import React, {
ComponentPropsWithoutRef,
useEffect,
useRef,
useState,
useMemo,
useContext,
forwardRef,
} from 'react'
import { usePersistantState } from '../util/persistant-state'
const d3promise = import('d3-force-3d')
import * as d3int from 'd3-interpolate'
import type {
ForceGraph2D as TForceGraph2D,
ForceGraph3D as TForceGraph3D,
} from 'react-force-graph'
import { OrgRoamGraphReponse, OrgRoamLink, OrgRoamNode } from '../api'
import { GraphData, NodeObject, LinkObject } from 'force-graph'
import { useWindowSize } from '@react-hook/window-size'
import { useAnimation } from '@lilib/hooks'
import { Box, useDisclosure, useTheme } from '@chakra-ui/react'
import {
initialPhysics,
initialFilter,
initialVisuals,
initialBehavior,
initialMouse,
algos,
TagColors,
colorList,
} from '../components/config'
import { Tweaks } from '../components/tweaks'
import { ContextMenu } from '../components/contextmenu'
import { ThemeContext, ThemeContextProps } from '../util/themecontext'
import SpriteText from 'three-spritetext'
import ReconnectingWebSocket from 'reconnecting-websocket'
// react-force-graph fails on import when server-rendered
// https://github.com/vasturiano/react-force-graph/issues/155
const ForceGraph2D = (
!!global.window ? require('react-force-graph').ForceGraph2D : null
) as typeof TForceGraph2D
const ForceGraph3D = (
!!global.window ? require('react-force-graph').ForceGraph3D : null
) as typeof TForceGraph3D
export type NodeById = { [nodeId: string]: OrgRoamNode | undefined }
export type LinksByNodeId = { [nodeId: string]: OrgRoamLink[] | undefined }
export type NodesByFile = { [file: string]: OrgRoamNode[] | undefined }
export type Tags = string[]
export type Scope = {
nodeIds: string[]
}
export default function Home() {
// only render on the client
const [showPage, setShowPage] = useState(false)
useEffect(() => {
setShowPage(true)
}, [])
if (!showPage) {
return null
}
return
}
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)
const [graphData, setGraphData] = useState(null)
const [emacsNodeId, setEmacsNodeId] = useState(null)
const [behavior, setBehavior] = usePersistantState('behavior', initialBehavior)
const [mouse, setMouse] = usePersistantState('mouse', initialMouse)
const nodeByIdRef = useRef({})
const linksByNodeIdRef = useRef({})
const tagsRef = useRef([])
const graphRef = useRef(null)
const currentGraphDataRef = useRef({ nodes: [], links: [] })
const updateGraphData = (orgRoamGraphData: OrgRoamGraphReponse) => {
const oldNodeById = nodeByIdRef.current
tagsRef.current = orgRoamGraphData.tags ?? []
const nodesByFile = orgRoamGraphData.nodes.reduce((acc, node) => {
return {
...acc,
[node.file]: [...(acc[node.file] ?? []), node],
}
}, {})
const headingLinks: OrgRoamLink[] = Object.keys(nodesByFile).flatMap((file) => {
const nodesInFile = nodesByFile[file] ?? []
// "file node" as opposed to "heading node"
const fileNode = nodesInFile.find((node) => node.level === 0)
const headingNodes = nodesInFile.filter((node) => node.level !== 0)
if (!fileNode) {
return []
}
return headingNodes.map((headingNode) => {
const smallerHeadings = nodesInFile.filter((node) => {
if (
node.level >= headingNode.level ||
node.pos >= headingNode.pos ||
!headingNode.olp?.includes(node.title) ||
node.level >= headingNode.level - headingNode.olp.reverse().indexOf(node.title)
) {
return false
}
return true
})
const target = smallerHeadings.slice(-1)[0]
return {
source: headingNode.id,
target: target?.id || fileNode.id,
type: 'heading',
}
})
})
// we want to support both linking to only the file node and to the next heading
// to do this we need both links, as we can't really toggle between them without
// recalculating the entire graph otherwise
const fileLinks: OrgRoamLink[] = Object.keys(nodesByFile).flatMap((file) => {
const nodesInFile = nodesByFile[file] ?? []
// "file node" as opposed to "heading node"
const fileNode = nodesInFile.find((node) => node.level === 0)
const headingNodes = nodesInFile.filter((node) => node.level !== 0)
if (!fileNode) {
return []
}
return headingNodes.map((headingNode) => {
return {
source: headingNode.id,
target: fileNode.id,
type: 'parent',
}
})
})
nodeByIdRef.current = Object.fromEntries(orgRoamGraphData.nodes.map((node) => [node.id, node]))
const dirtyLinks = [...orgRoamGraphData.links, ...headingLinks, ...fileLinks]
const nonExistantNodes: OrgRoamNode[] = []
const links = dirtyLinks.map((link) => {
const sourceId = link.source as string
const targetId = link.target as string
if (!nodeByIdRef.current[sourceId]) {
nonExistantNodes.push({
id: sourceId,
tags: ['bad'],
properties: { FILELESS: 'yes', bad: 'yes' },
file: '',
title: sourceId,
level: 0,
pos: 0,
olp: null,
})
return { ...link, type: 'bad' }
}
if (!nodeByIdRef.current[targetId]) {
nonExistantNodes.push({
id: targetId,
tags: ['bad'],
properties: { FILELESS: 'yes', bad: 'yes' },
file: '',
title: targetId,
level: 0,
pos: 0,
olp: null,
})
return { ...link, type: 'bad' }
}
return link
})
nodeByIdRef.current = {
...nodeByIdRef.current,
...Object.fromEntries(nonExistantNodes.map((node) => [node.id, node])),
}
linksByNodeIdRef.current = links.reduce((acc, link) => {
return {
...acc,
[link.source]: [...(acc[link.source] ?? []), link],
[link.target]: [...(acc[link.target] ?? []), link],
}
}, {})
const nodes = [...orgRoamGraphData.nodes, ...nonExistantNodes]
const orgRoamGraphDataProcessed = {
nodes,
links,
}
const currentGraphData = currentGraphDataRef.current
if (currentGraphData.nodes.length === 0) {
// react-force-graph modifies the graph data implicitly,
// so we make sure there's no overlap between the objects we pass it and
// nodeByIdRef, linksByNodeIdRef
const orgRoamGraphDataClone = JSON.parse(JSON.stringify(orgRoamGraphDataProcessed))
currentGraphDataRef.current = orgRoamGraphDataClone
setGraphData(orgRoamGraphDataClone)
return
}
const newNodes = [
...currentGraphData.nodes.flatMap((node: NodeObject) => {
const newNode = nodeByIdRef.current[node?.id!] ?? false
if (!newNode) {
return []
}
return [{ ...node, ...newNode }]
}),
...Object.keys(nodeByIdRef.current)
.filter((id) => !oldNodeById[id])
.map((id) => {
return nodeByIdRef.current[id] as NodeObject
}),
]
const nodeIndex = newNodes.reduce<{ [id: string]: number }>((acc, node, index) => {
const id = node?.id as string
return {
...acc,
[id]: index,
}
}, {})
const newerLinks = links.map((link) => {
const [source, target] = normalizeLinkEnds(link)
return {
...link,
source: newNodes[nodeIndex![source]],
target: newNodes[nodeIndex![target]],
}
})
setGraphData({ nodes: newNodes as NodeObject[], links: newerLinks })
}
useEffect(() => {
if (!graphData) {
return
}
currentGraphDataRef.current = graphData
}, [graphData])
const { setEmacsTheme } = useContext(ThemeContext)
const scopeRef = useRef({ nodeIds: [] })
const behaviorRef = useRef(initialBehavior)
behaviorRef.current = behavior
const WebSocketRef = useRef(null)
scopeRef.current = scope
const followBehavior = (
command: string,
emacsNode: string,
speed: number = 2000,
padding: number = 200,
) => {
if (command === 'color') {
return
}
const fg = graphRef.current
const sr = scopeRef.current
const bh = behaviorRef.current
const links = linksByNodeIdRef.current[emacsNode] ?? []
const nodes = Object.fromEntries(
[emacsNode as string, ...links.flatMap((link) => [link.source, link.target])].map(
(nodeId) => [nodeId, {}],
),
)
if (command === 'zoom') {
if (sr.nodeIds.length) {
setScope({ nodeIds: [] })
}
setTimeout(
() => fg.zoomToFit(speed, padding, (node: NodeObject) => nodes[node.id as string]),
50,
)
return
}
if (!sr.nodeIds.length) {
setScope({ nodeIds: [emacsNode] })
setTimeout(() => {
fg.centerAt(0, 0, 10)
fg.zoomToFit(1, padding)
}, 50)
return
}
if (bh.localSame !== 'add') {
setScope({ nodeIds: [emacsNode] })
setTimeout(() => {
fg.centerAt(0, 0, 10)
fg.zoomToFit(1, padding)
}, 50)
return
}
// if the node is in the scoped nodes, add it to scope instead of replacing it
if (
!sr.nodeIds.includes(emacsNode) ||
!sr.nodeIds.some((scopeId: string) => {
return nodes[scopeId]
})
) {
setScope({ nodeIds: [emacsNode] })
setTimeout(() => {
fg.centerAt(0, 0, 10)
fg.zoomToFit(1, padding)
}, 50)
return
}
setScope((currentScope: Scope) => ({
...currentScope,
nodeIds: [...currentScope.nodeIds, emacsNode as string],
}))
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', () => {
console.log('Connection with Emacs established')
})
WebSocketRef.current.addEventListener('message', (event: any) => {
const bh = behaviorRef.current
const message = JSON.parse(event.data)
switch (message.type) {
case 'graphdata':
return updateGraphData(message.data)
case 'theme':
return setEmacsTheme(message.data)
case 'command':
switch (message.data.commandName) {
case 'local':
const speed = behavior.zoomSpeed
const padding = behavior.zoomPadding
followBehavior('local', message.data.id, speed, padding)
setEmacsNodeId(message.data.id)
break
case 'zoom': {
const speed = message?.data?.speed || bh.zoomSpeed
const padding = message?.data?.padding || bh.zoomPadding
followBehavior('zoom', message.data.id, speed, padding)
setEmacsNodeId(message.data.id)
break
}
case 'follow': {
followBehavior(bh.follow, message.data.id, bh.zoomSpeed, bh.zoomPadding)
setEmacsNodeId(message.data.id)
break
}
default:
return console.error('unknown message type', message.type)
}
}
})
}, [])
useEffect(() => {
const fg = graphRef.current
if (!fg || scope.nodeIds.length > 1) {
return
}
if (!scope.nodeIds.length && physics.gravityOn) {
fg.zoomToFit()
return
}
setTimeout(() => {
fg.zoomToFit(5, 200)
}, 50)
}, [scope.nodeIds])
if (!graphData) {
return null
}
return (
)
}
export interface GraphProps {
nodeById: NodeById
linksByNodeId: LinksByNodeId
graphData: GraphData
physics: typeof initialPhysics
threeDim: boolean
filter: typeof initialFilter
emacsNodeId: string | null
visuals: typeof initialVisuals
behavior: typeof initialBehavior
mouse: typeof initialMouse
scope: Scope
setScope: any
webSocket: any
tagColors: { [tag: string]: string }
}
export const Graph = forwardRef(function (props: GraphProps, graphRef: any) {
const {
physics,
graphData,
threeDim,
linksByNodeId,
filter,
emacsNodeId,
nodeById,
visuals,
behavior,
mouse,
scope,
setScope,
webSocket,
tagColors,
} = props
// react-force-graph does not track window size
// https://github.com/vasturiano/react-force-graph/issues/233
// does not work below a certain width
const [windowWidth, windowHeight] = useWindowSize()
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 (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],
}))
return
}
const sendMessageToEmacs = (command: string, data: {}) => {
webSocket.send(JSON.stringify({ command: command, data: data }))
}
const openNodeInEmacs = (node: OrgRoamNode) => {
sendMessageToEmacs('open', { id: node.id })
}
const deleteNodeInEmacs = (node: OrgRoamNode) => {
if (node.level !== 0) {
return
}
sendMessageToEmacs('delete', { id: node.id, file: node.file })
}
const createNodeInEmacs = (node: OrgRoamNode) => {
sendMessageToEmacs('create', { id: node.id, title: node.title, ref: node.properties.ROAM_REFS })
}
const contextMenu = useDisclosure()
const openContextMenu = (node: OrgRoamNode, event: any) => {
setContextPos([event.pageX, event.pageY])
setRightClickedNode(node)
contextMenu.onOpen()
}
const handleClick = (click: string, node: OrgRoamNode, event: any) => {
switch (click) {
case mouse.local: {
handleLocal(node, behavior.localSame)
break
}
case mouse.follow: {
openNodeInEmacs(node)
break
}
case mouse.context: {
openContextMenu(node, event)
}
default:
break
}
}
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)
useEffect(() => {
if (!emacsNodeId) {
return
}
setHoverNode(nodeById[emacsNodeId] as NodeObject)
}, [emacsNodeId])
const filteredLinksByNodeIdRef = useRef({})
const hiddenNodeIdsRef = useRef({})
const filteredGraphData = useMemo(() => {
hiddenNodeIdsRef.current = {}
const filteredNodes = graphData?.nodes
?.filter((nodeArg) => {
const node = nodeArg as OrgRoamNode
if (
filter.tagsBlacklist.length &&
filter.tagsBlacklist.some((tag) => node.tags.indexOf(tag) > -1)
) {
hiddenNodeIdsRef.current = { ...hiddenNodeIdsRef.current, [node.id]: node }
return false
}
if (
filter.tagsWhitelist.length > 0 &&
!filter.tagsWhitelist.some((tag) => node.tags.indexOf(tag) > -1)
) {
hiddenNodeIdsRef.current = { ...hiddenNodeIdsRef.current, [node.id]: node }
return false
}
if (filter.filelessCites && node.properties.FILELESS) {
hiddenNodeIdsRef.current = { ...hiddenNodeIdsRef.current, [node.id]: node }
return false
}
if (filter.bad && node.properties.bad) {
hiddenNodeIdsRef.current = { ...hiddenNodeIdsRef.current, [node.id]: node }
return false
}
return true
})
.filter((node) => {
const links = linksByNodeId[node?.id as string] ?? []
const unhiddenLinks = links.filter(
(link) =>
!hiddenNodeIdsRef.current[link.source] && !hiddenNodeIdsRef.current[link.target],
)
if (!filter.orphans) {
return true
}
if (filter.parent) {
return unhiddenLinks.length !== 0
}
if (unhiddenLinks.length === 0) {
return false
}
return unhiddenLinks.some((link) => !['parent', 'heading'].includes(link.type))
})
const filteredNodeIds = filteredNodes.map((node) => node.id as string)
const filteredLinks = graphData.links.filter((link) => {
const [sourceId, targetId] = normalizeLinkEnds(link)
if (
!filteredNodeIds.includes(sourceId as string) ||
!filteredNodeIds.includes(targetId as string)
) {
return false
}
const linkRoam = link as OrgRoamLink
if (!filter.parent) {
return !['parent', 'heading'].includes(linkRoam.type)
}
return linkRoam.type !== ['parent', 'heading'].find((type) => type !== filter.parent)
})
filteredLinksByNodeIdRef.current = filteredLinks.reduce((acc, linkArg) => {
const link = linkArg as OrgRoamLink
const [sourceId, targetId] = normalizeLinkEnds(link)
return {
...acc,
[sourceId]: [...(acc[sourceId] ?? []), link],
[targetId]: [...(acc[targetId] ?? []), link],
}
}, {})
return { nodes: filteredNodes, links: filteredLinks }
}, [filter, graphData])
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 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])
centralHighlightedNode.current = hoverNode
const highlightedNodes = useMemo(() => {
if (!centralHighlightedNode.current) {
return {}
}
const links = filteredLinksByNodeIdRef.current[centralHighlightedNode.current.id!]
if (!links) {
return {}
}
return Object.fromEntries(
[
centralHighlightedNode.current.id! as string,
...links.flatMap((link) => [link.source, link.target]),
].map((nodeId) => [nodeId, {}]),
)
}, [centralHighlightedNode.current, linksByNodeId])
useEffect(() => {
;(async () => {
const fg = graphRef.current
const d3 = await d3promise
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))
} else {
fg.d3Force('x', null)
fg.d3Force('y', null)
threeDim && fg.d3Force('z', null)
}
physics.centering
? fg.d3Force('center', d3.forceCenter().strength(physics.centeringStrength))
: fg.d3Force('center', null)
physics.linkStrength && fg.d3Force('link').strength(physics.linkStrength)
physics.linkIts && fg.d3Force('link').iterations(physics.linkIts)
physics.charge && fg.d3Force('charge').strength(physics.charge)
fg.d3Force(
'collide',
physics.collision ? d3.forceCollide().radius(physics.collisionStrength) : null,
)
})()
}, [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, scope.nodeIds.length])
// shitty handler to check for doubleClicks
const lastNodeClickRef = useRef(0)
const [opacity, setOpacity] = useState(1)
const [fadeIn, cancel] = useAnimation((x) => setOpacity(x), {
duration: visuals.animationSpeed,
algorithm: algos[visuals.algorithmName],
})
const [fadeOut, fadeOutCancel] = useAnimation(
(x) => setOpacity(Math.min(opacity, -1 * (x - 1))),
{
duration: visuals.animationSpeed,
algorithm: algos[visuals.algorithmName],
},
)
const lastHoverNode = useRef(null)
useEffect(() => {
if (hoverNode) {
lastHoverNode.current = hoverNode as OrgRoamNode
}
if (!visuals.highlightAnim) {
return hoverNode ? setOpacity(1) : setOpacity(0)
}
if (hoverNode) {
fadeIn()
} else {
// to prevent fadeout animation from starting at 1
// when quickly moving away from a hovered node
cancel()
opacity > 0.5 ? fadeOut() : setOpacity(0)
}
}, [hoverNode])
const getThemeColor = (name: string) => {
if (!theme) {
return
}
return name.split('.').reduce((o, i) => o[i], theme.colors)
}
const highlightColors = useMemo(() => {
return Object.fromEntries(
colorList.map((color) => {
const color1 = getThemeColor(color)
const crisscross = colorList.map((color2) => [
color2,
d3int.interpolate(color1, getThemeColor(color2)),
])
return [color, Object.fromEntries(crisscross)]
}),
)
}, [emacsTheme])
const previouslyHighlightedNodes = useMemo(() => {
const previouslyHighlightedLinks = linksByNodeId[lastHoverNode.current?.id!] ?? []
return Object.fromEntries(
[
lastHoverNode.current?.id! as string,
...previouslyHighlightedLinks.flatMap((link) => [link.source, link.target]),
].map((nodeId) => [nodeId, {}]),
)
}, [JSON.stringify(hoverNode), lastHoverNode.current])
const getNodeColorById = (id: string) => {
const linklen = filteredLinksByNodeIdRef.current[id!]?.length ?? 0
/* const parentCiteNeighbors = linklen
* ? linksByNodeId[id!]?.filter((link) => ['parent', 'heading', 'cite', 'ref'].includes(link.type)).length
* : 0
* const neighbors = filter.parent ? linklen : linklen - parentCiteNeighbors! */
return visuals.nodeColorScheme[
numberWithinRange(linklen, 0, visuals.nodeColorScheme.length - 1)
]
}
const getLinkNodeColor = (sourceId: string, targetId: string) => {
return filteredLinksByNodeIdRef.current[sourceId]!.length >
filteredLinksByNodeIdRef.current[targetId]!.length
? getNodeColorById(sourceId)
: getNodeColorById(targetId)
}
const getLinkColor = (sourceId: string, targetId: string, needsHighlighting: boolean) => {
if (!visuals.linkHighlight && !visuals.linkColorScheme && !needsHighlighting) {
const nodeColor = getLinkNodeColor(sourceId, targetId)
return getThemeColor(nodeColor)
}
if (!needsHighlighting && !visuals.linkColorScheme) {
const nodeColor = getLinkNodeColor(sourceId, targetId)
return highlightColors[nodeColor][visuals.backgroundColor](visuals.highlightFade * opacity)
}
if (!needsHighlighting) {
return highlightColors[visuals.linkColorScheme][visuals.backgroundColor](
visuals.highlightFade * opacity,
)
}
if (!visuals.linkHighlight && !visuals.linkColorScheme) {
const nodeColor = getLinkNodeColor(sourceId, targetId)
return getThemeColor(nodeColor)
}
if (!visuals.linkHighlight) {
return getThemeColor(visuals.linkColorScheme)
}
if (!visuals.linkColorScheme) {
return highlightColors[getLinkNodeColor(sourceId, targetId)][visuals.linkHighlight](opacity)
}
return highlightColors[visuals.linkColorScheme][visuals.linkHighlight](opacity)
}
const getNodeColor = (node: OrgRoamNode) => {
const needsHighlighting = highlightedNodes[node.id!] || previouslyHighlightedNodes[node.id!]
// if we are matching the node color and don't have a highlight color
// or we don't have our own scheme and we're not being highlighted
if (visuals.emacsNodeColor && node.id === emacsNodeId) {
return getThemeColor(visuals.emacsNodeColor)
}
if (tagColors && node.tags.some((tag) => tagColors[tag])) {
const tagColor = tagColors[node.tags.filter((tag) => tagColors[tag])[0]]
return highlightColors[tagColor][visuals.backgroundColor](visuals.highlightFade * opacity)
}
if (visuals.citeNodeColor && node.properties.ROAM_REFS && node.properties.FILELESS) {
return needsHighlighting
? getThemeColor(visuals.citeNodeColor)
: highlightColors[visuals.citeNodeColor][visuals.backgroundColor](
visuals.highlightFade * opacity,
)
}
if (visuals.refNodeColor && node.properties.ROAM_REFS) {
return needsHighlighting
? getThemeColor(visuals.refNodeColor)
: highlightColors[visuals.refNodeColor][visuals.backgroundColor](
visuals.highlightFade * opacity,
)
}
if (!needsHighlighting) {
return highlightColors[getNodeColorById(node.id as string)][visuals.backgroundColor](
visuals.highlightFade * opacity,
)
}
if (!visuals.nodeHighlight) {
return getThemeColor(getNodeColorById(node.id as string))
}
return highlightColors[getNodeColorById(node.id as string)][visuals.nodeHighlight](opacity)
}
const labelTextColor = useMemo(
() => getThemeColor(visuals.labelTextColor),
[visuals.labelTextColor, emacsTheme],
)
const labelBackgroundColor = useMemo(
() => getThemeColor(visuals.labelBackgroundColor),
[visuals.labelBackgroundColor, emacsTheme],
)
const nodeSize = (node: NodeObject) => {
const links = filteredLinksByNodeIdRef.current[node.id!] ?? []
const parentNeighbors = links.length ? links.filter((link) => link.type === 'parent').length : 0
const basicSize =
3 + links.length * visuals.nodeSizeLinks - (!filter.parent ? parentNeighbors : 0)
if (visuals.highlightNodeSize === 1) {
return basicSize
}
const highlightSize =
highlightedNodes[node.id!] || previouslyHighlightedNodes[node.id!]
? 1 + opacity * (visuals.highlightNodeSize - 1)
: 1
return basicSize * highlightSize
}
const [dragging, setDragging] = useState(false)
const graphCommonProps: ComponentPropsWithoutRef = {
graphData: scope.nodeIds.length ? scopedGraphData : filteredGraphData,
width: windowWidth,
height: windowHeight,
backgroundColor: theme.colors.gray[visuals.backgroundColor],
warmupTicks: scope.nodeIds.length === 1 ? 100 : scope.nodeIds.length > 1 ? 20 : 0,
nodeLabel: (node) => (node as OrgRoamNode).title,
nodeColor: (node) => {
return getNodeColor(node as OrgRoamNode)
},
nodeRelSize: visuals.nodeRel,
nodeVal: (node) => {
return nodeSize(node)
},
nodeCanvasObject: (node, ctx, globalScale) => {
if (!node) {
return
}
if (dragging) {
return
}
if (!visuals.labels) {
return
}
const wasHighlightedNode = previouslyHighlightedNodes[node.id!]
if (
(globalScale <= visuals.labelScale || visuals.labels === 1) &&
!highlightedNodes[node.id!] &&
!wasHighlightedNode
) {
return
}
const nodeTitle = (node as OrgRoamNode).title!
const label =
nodeTitle.length > visuals.labelLength
? nodeTitle.substring(0, visuals.labelLength) + '...'
: nodeTitle
const fontSize = visuals.labelFontSize / (0.75 * Math.min(Math.max(0.5, globalScale), 3))
const textWidth = ctx.measureText(label).width
const bckgDimensions = [textWidth * 1.1, fontSize].map((n) => n + fontSize * 0.5) as [
number,
number,
] // some padding
const fadeFactor = Math.min((3 * (globalScale - visuals.labelScale)) / visuals.labelScale, 1)
// draw label background
const getLabelOpacity = () => {
if (visuals.labels === 1) {
return opacity
}
if (globalScale <= visuals.labelScale) {
return opacity
}
return highlightedNodes[node.id!] || previouslyHighlightedNodes[node.id!]
? Math.max(fadeFactor, opacity)
: 1 * fadeFactor * (-1 * (visuals.highlightFade * opacity - 1))
}
const nodeS = 8 * Math.cbrt(nodeSize(node) * visuals.nodeRel)
if (visuals.labelBackgroundColor && visuals.labelBackgroundOpacity) {
const backgroundOpacity = getLabelOpacity() * visuals.labelBackgroundOpacity
const labelBackground = hexToRGBA(labelBackgroundColor, backgroundOpacity)
ctx.fillStyle = labelBackground
ctx.fillRect(
node.x! - bckgDimensions[0] / 2,
node.y! - bckgDimensions[1] / 2 + nodeS,
...bckgDimensions,
)
}
// draw label text
const textOpacity = getLabelOpacity()
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const labelText = hexToRGBA(labelTextColor, textOpacity)
ctx.fillStyle = labelText
ctx.font = `${fontSize}px Sans-Serif`
ctx.fillText(label, node.x!, node.y! + nodeS)
},
nodeCanvasObjectMode: () => 'after',
linkDirectionalParticles: visuals.particles ? visuals.particlesNumber : undefined,
linkDirectionalArrowLength: visuals.arrows ? visuals.arrowsLength : undefined,
linkDirectionalArrowRelPos: visuals.arrowsPos,
linkDirectionalArrowColor: visuals.arrowsColor
? () => getThemeColor(visuals.arrowsColor)
: undefined,
linkColor: (link) => {
const sourceId = typeof link.source === 'object' ? link.source.id! : (link.source as string)
const targetId = typeof link.target === 'object' ? link.target.id! : (link.target as string)
const linkIsHighlighted = isLinkRelatedToNode(link, centralHighlightedNode.current)
const linkWasHighlighted = isLinkRelatedToNode(link, lastHoverNode.current)
const needsHighlighting = linkIsHighlighted || linkWasHighlighted
const roamLink = link as OrgRoamLink
if (visuals.refLinkColor && roamLink.type === 'ref') {
return needsHighlighting && (visuals.refLinkHighlightColor || visuals.linkHighlight)
? highlightColors[visuals.refLinkColor][
visuals.refLinkHighlightColor || visuals.linkHighlight
](opacity)
: highlightColors[visuals.refLinkColor][visuals.backgroundColor](
visuals.highlightFade * opacity,
)
}
if (visuals.citeLinkColor && roamLink.type?.includes('cite')) {
return needsHighlighting && (visuals.citeLinkHighlightColor || visuals.linkHighlight)
? highlightColors[visuals.citeLinkColor][
visuals.citeLinkHighlightColor || visuals.linkHighlight
](opacity)
: highlightColors[visuals.citeLinkColor][visuals.backgroundColor](
visuals.highlightFade * opacity,
)
}
return getLinkColor(sourceId as string, targetId as string, needsHighlighting)
},
linkWidth: (link) => {
if (visuals.highlightLinkSize === 1) {
return visuals.linkWidth
}
const linkIsHighlighted = isLinkRelatedToNode(link, centralHighlightedNode.current)
const linkWasHighlighted = isLinkRelatedToNode(link, lastHoverNode.current)
return linkIsHighlighted || linkWasHighlighted
? visuals.linkWidth * (1 + opacity * (visuals.highlightLinkSize - 1))
: visuals.linkWidth
},
linkDirectionalParticleWidth: visuals.particlesWidth,
d3AlphaDecay: physics.alphaDecay,
d3AlphaMin: physics.alphaMin,
d3VelocityDecay: physics.velocityDecay,
onNodeClick: (nodeArg: NodeObject, event: any) => {
const node = nodeArg as OrgRoamNode
contextMenu.onClose()
const doubleClickTimeBuffer = 200
const isDoubleClick = event.timeStamp - lastNodeClickRef.current < doubleClickTimeBuffer
lastNodeClickRef.current = event.timeStamp
if (isDoubleClick) {
return handleClick('double', node, event)
}
const prevNodeClickTime = lastNodeClickRef.current
return setTimeout(() => {
if (lastNodeClickRef.current !== prevNodeClickTime) {
return
}
return handleClick('click', node, event)
}, doubleClickTimeBuffer)
},
onBackgroundClick: () => {
contextMenu.onClose()
setHoverNode(null)
if (scope.nodeIds.length === 0) {
return
}
setScope((currentScope: Scope) => ({
...currentScope,
nodeIds: [],
}))
},
onNodeHover: (node) => {
if (!visuals.highlight) {
return
}
if (!hoverNode) {
fadeOutCancel()
setOpacity(0)
}
setHoverNode(node)
},
onNodeRightClick: (nodeArg, event) => {
const node = nodeArg as OrgRoamNode
handleClick('right', node, event)
},
onNodeDrag: (node) => {
contextMenu.onClose()
setHoverNode(node)
setDragging(true)
},
onNodeDragEnd: () => {
setHoverNode(null)
setDragging(false)
},
}
return (
{contextMenu.isOpen && (
)}
{threeDim ? (
{
if (!visuals.labels) {
return
}
if (visuals.labels < 3 && !highlightedNodes[node.id!]) {
return
}
const sprite = new SpriteText(node.title.substring(0, 40))
sprite.color = getThemeColor(visuals.labelTextColor)
sprite.backgroundColor = getThemeColor(visuals.labelBackgroundColor)
sprite.padding = 2
sprite.textHeight = 8
return sprite
}}
/>
) : (
{
const linkArg = link as OrgRoamLink
if (visuals.citeDashes && linkArg.type?.includes('cite')) {
return [visuals.citeDashLength, visuals.citeGapLength]
}
if (visuals.refDashes && linkArg.type == 'ref') {
return [visuals.refDashLength, visuals.refGapLength]
}
return null
}}
/>
)}
)
})
function isLinkRelatedToNode(link: LinkObject, node: NodeObject | null) {
return (
(link.source as NodeObject)?.id! === node?.id! || (link.target as NodeObject)?.id! === node?.id!
)
}
function numberWithinRange(num: number, min: number, max: number) {
return Math.min(Math.max(num, min), max)
}
function normalizeLinkEnds(link: OrgRoamLink | LinkObject): [string, string] {
// 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 =
typeof link.source === 'object' ? (link.source.id! as string) : (link.source as string)
const targetId =
typeof link.target === 'object' ? (link.target.id! as string) : (link.target as string)
return [sourceId, targetId]
}
function hexToRGBA(hex: string, opacity: number) {
return (
'rgba(' +
(hex = hex.replace('#', ''))
.match(new RegExp('(.{' + hex.length / 3 + '})', 'g'))!
.map(function (l) {
return parseInt(hex.length % 2 ? l + l : l, 16)
})
.concat(isFinite(opacity) ? opacity : 1)
.join(',') +
')'
)
}