((acc, link) => {
return {
...acc,
[link.source]: [...(acc[link.source] ?? []), link],
[link.target]: [...(acc[link.target] ?? []), link],
}
}, {})
// 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(orgRoamGraphData))
setGraphData(orgRoamGraphDataClone)
})
}, [])
const [threeDim, setThreeDim] = useState(false)
const [showTweeks, setShowTweeks] = useState(true)
if (!graphData) {
return null
}
return (
{showTweeks && (
{
setShowTweeks(false)
}}
/>
)}
)
}
export interface InfoTooltipProps {
infoText?: string | boolean
}
export const InfoTooltip = (props: InfoTooltipProps) => {
const { infoText } = props
return (
)
}
export interface SliderWithInfoProps {
min?: number
max?: number
step?: number
value: number
onChange: (arg0: number) => void
label: string
infoText?: string
}
export const SliderWithInfo = ({
min = 0,
max = 10,
step = 0.1,
value = 1,
...rest
}: SliderWithInfoProps) => {
const { onChange, label, infoText } = rest
return (
{label}
{infoText && }
)
}
export interface EnableSectionProps {
label: string
value: boolean | number
onChange: () => void
infoText?: string
children: React.ReactNode
}
export const EnableSection = (props: EnableSectionProps) => {
const { value, onChange, label, infoText, children } = props
return (
{label}
{infoText && }
{value && children}
)
}
export interface TweakProps {
physics: typeof initialPhysics
setPhysics: any
threeDim: boolean
onClose: () => void
}
export const Tweaks = function (props: TweakProps) {
const { physics, setPhysics, threeDim, onClose } = props
return (
}
onClick={() => setPhysics(initialPhysics)}
/>
Physics
setPhysics({ ...physics, enabled: !physics.enabled })}
isChecked={physics.enabled}
/>
}
align="stretch"
>
setPhysics({ ...physics, gravityOn: !physics.gravityOn })}
>
setPhysics({ ...physics, gravity: v / 10 })}
/>
setPhysics({ ...physics, charge: -100 * value })}
label="Repulsive Force"
/>
setPhysics({ ...physics, collision: !physics.collision })}
>
setPhysics({ ...physics, collisionStrength: value / 10 })}
label="Strength"
/>
setPhysics({ ...physics, linkStrength: value / 5 })}
label="Link Force"
/>
setPhysics({ ...physics, linkIts: value })}
min={0}
max={6}
step={1}
infoText="How many links down the line the physics of a single node affects (Slow)"
/>
setPhysics({ ...physics, velocityDecay: value / 10 })}
/>
Advanced
}
align="stretch"
>
setPhysics({ ...physics, iterations: v })}
infoText="Number of times the physics simulation iterates per simulation step"
/>
setPhysics({ ...physics, alphaDecay: value / 50 })}
/>
{/* */}
Visual
}
align="stretch"
>
setPhysics({ ...physics, nodeRel: value })}
/>
setPhysics({ ...physics, linkWidth: value })}
/>
setPhysics({ ...physics, labels: !physics.labels })}
>
setPhysics({ ...physics, labelScale: value / 5 })}
/>
setPhysics({ ...physics, particles: !physics.particles })}
>
setPhysics({ ...physics, particlesNumber: value })}
/>
setPhysics({ ...physics, particleWidth: value })}
/>
setPhysics({ ...physics, highlight: !physics.highlight })}
value={physics.highlight}
>
setPhysics({ ...physics, highlightLinkSize: value })}
/>
setPhysics({ ...physics, highlightNodeSize: value })}
/>
Behavior
}
align="stretch"
>
Hover Higlight
Click
Double-click
)
}
export interface GraphProps {
nodeById: NodeById
linksByNodeId: LinksByNodeId
graphData: GraphData
physics: typeof initialPhysics
threeDim: boolean
emacsNodeId: string | null
}
export const Graph = function (props: GraphProps) {
const { physics, graphData, threeDim, linksByNodeId, emacsNodeId } = props
const graph2dRef = useRef(null)
const graph3dRef = useRef(null)
// 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 [scope, setScope] = useState({ nodeIds: [] })
useEffect(() => {
if (!emacsNodeId) {
return
}
setScope({
nodeIds: [emacsNodeId],
})
}, [emacsNodeId])
const centralHighlightedNode = hoverNode
const highlightedNodes = (() => {
if (!centralHighlightedNode) {
return {}
}
const links = linksByNodeId[centralHighlightedNode.id!]
if (!links) {
return {}
}
return Object.fromEntries(
[
centralHighlightedNode.id! as string,
...links.flatMap((link) => [link.source, link.target]),
].map((nodeId) => [nodeId, {}]),
)
})()
const scopedNodes = graphData.nodes.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 scopedNodeIds = scopedNodes.map((node) => node.id as string)
const scopedLinks = graphData.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 = typeof link.source === 'object' ? link.source.id! : (link.source as string)
const targetId = typeof link.target === 'object' ? link.target.id! : (link.target as string)
return scopedNodeIds.includes(sourceId as string) && scopedNodeIds.includes(targetId as string)
})
const scopedGraphData =
scope.nodeIds.length === 0
? graphData
: {
nodes: scopedNodes,
links: scopedLinks,
}
useEffect(() => {
;(async () => {
const fg = threeDim ? graph3dRef.current : graph2dRef.current
const d3 = await d3promise
if (physics.gravityOn) {
fg.d3Force('x', d3.forceX().strength(physics.gravity))
fg.d3Force('y', d3.forceY().strength(physics.gravity))
if (threeDim) {
if (physics.galaxy) {
fg.d3Force('x', d3.forceX().strength(physics.gravity / 5))
fg.d3Force('z', d3.forceZ().strength(physics.gravity / 5))
} else {
fg.d3Force('x', d3.forceX().strength(physics.gravity))
fg.d3Force('z', d3.forceZ().strength(physics.gravity))
}
} else {
fg.d3Force('z', null)
}
} else {
fg.d3Force('x', null)
fg.d3Force('y', null)
threeDim ? fg.d3Force('z', null) : null
}
fg.d3Force('link').strength(physics.linkStrength)
fg.d3Force('link').iterations(physics.linkIts)
physics.collision
? fg.d3Force('collide', d3.forceCollide().radius(20))
: fg.d3Force('collide', null)
fg.d3Force('charge').strength(physics.charge)
})()
})
// 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(() => {
graph2dRef.current?.d3ReheatSimulation()
}, [physics])
//shitty handler to check for doubleClicks
const lastNodeClickRef = useRef(0)
const onNodeClick = (node: NodeObject, event: any) => {
const isDoubleClick = event.timeStamp - lastNodeClickRef.current < 400
lastNodeClickRef.current = event.timeStamp
if (isDoubleClick) {
window.open('org-protocol://roam-node?node=' + node.id, '_self')
return
}
setScope((currentScope) => ({
...currentScope,
nodeIds: [...currentScope.nodeIds, node.id as string],
}))
return
}
const graphCommonProps: ComponentPropsWithoutRef = {
graphData: scopedGraphData,
width: windowWidth,
height: windowHeight,
backgroundColor: '#242730',
nodeLabel: (node) => (node as OrgRoamNode).title,
nodeColor: (node) => {
if (!physics.colorful) {
if (Object.keys(highlightedNodes).length === 0) {
return 'rgb(100, 100, 100)'
}
return highlightedNodes[node.id!] ? '#a991f1' : 'rgb(50, 50, 50)'
}
const palette = [
'#ff665c',
'#e69055',
'#7bc275',
'#4db5bd',
'#FCCE7B',
'#51afef',
'#1f5582',
'#C57BDB',
'#a991f1',
'#5cEfFF',
'#6A8FBF',
]
return palette[
numbereWithinRange(linksByNodeId[node.id!]?.length ?? 0, 0, palette.length - 1)
]
},
nodeRelSize: physics.nodeRel,
nodeVal: (node) => {
const links = linksByNodeId[node.id!] ?? []
const basicSize = 3 + links.length
const highlightSize = highlightedNodes[node.id!] ? physics.highlightNodeSize : 1
return basicSize * highlightSize
},
nodeCanvasObject: (node, ctx, globalScale) => {
if (!physics.labels) {
return
}
if (globalScale <= physics.labelScale && !highlightedNodes[node.id!]) {
return
}
const nodeTitle = (node as OrgRoamNode).title
const label = nodeTitle.substring(0, Math.min(nodeTitle.length, 30))
// const label = 'label'
const fontSize = 12 / globalScale
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 - physics.labelScale)) / physics.labelScale, 1)
const opacity =
highlightedNodes.length === 0
? 0.5 * fadeFactor
: highlightedNodes[node.id!]
? 0.5
: 0.15 * fadeFactor
// draw label background
ctx.fillStyle = `rgba(20, 20, 20, ${opacity})`
ctx.fillRect(
node.x! - bckgDimensions[0] / 2,
node.y! - bckgDimensions[1] / 2,
...bckgDimensions,
)
// draw label text
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillStyle =
'rgb(255, 255, 255, ' +
(highlightedNodes.length === 0
? fadeFactor
: highlightedNodes[node.id!]
? 1
: 0.3 * fadeFactor) +
')'
ctx.font = `${fontSize}px Sans-Serif`
ctx.fillText(label, node.x!, node.y!)
},
nodeCanvasObjectMode: () => 'after',
linkDirectionalParticles: physics.particles ? physics.particlesNumber : undefined,
linkColor: (link) => {
const linkIsHighlighted =
(link.source as NodeObject).id! === centralHighlightedNode?.id! ||
(link.target as NodeObject).id! === centralHighlightedNode?.id!
return linkIsHighlighted ? '#a991f1' : '#666666'
},
linkWidth: () => {
return physics.linkWidth
},
linkDirectionalParticleWidth: physics.particlesWidth,
d3AlphaDecay: physics.alphaDecay,
d3AlphaMin: physics.alphaMin,
d3VelocityDecay: physics.velocityDecay,
onNodeClick: onNodeClick,
onBackgroundClick: () => {
setScope((currentScope) => ({
...currentScope,
nodeIds: [],
}))
},
onNodeHover: (node) => {
if (!physics.hover) {
return
}
setHoverNode(node)
},
}
return (
{threeDim ? (
) : (
)}
)
}
function numbereWithinRange(num: number, min: number, max: number) {
return Math.min(Math.max(num, min), max)
}