import React, { ComponentPropsWithoutRef, useEffect, useRef, useState, useMemo } from 'react'
import { usePersistantState } from '../util/persistant-state'
const d3promise = import('d3-force-3d')
import type {
ForceGraph2D as TForceGraph2D,
ForceGraph3D as TForceGraph3D,
} from 'react-force-graph'
import { OrgRoamGraphReponse, OrgRoamLink, OrgRoamNode } from '../api'
import { GraphData, NodeObject } from 'force-graph'
import { useWindowSize } from '@react-hook/window-size'
import { Scrollbars } from 'react-custom-scrollbars-2'
import { Easing } from '@tweenjs/tween.js'
import { useAnimation } from '@lilib/hooks'
import {
Accordion,
AccordionButton,
AccordionItem,
AccordionIcon,
AccordionPanel,
Text,
Heading,
VStack,
StackDivider,
Button,
CloseButton,
Slider,
SliderThumb,
SliderTrack,
SliderFilledTrack,
Switch,
FormControl,
FormLabel,
Box,
Container,
Icon,
IconButton,
Tooltip,
Menu,
MenuList,
MenuButton,
MenuItem,
MenuGroup,
MenuDivider,
MenuOptionGroup,
MenuItemOption,
Flex,
useTheme,
Select,
} from '@chakra-ui/react'
import { InfoOutlineIcon, RepeatClockIcon, ChevronDownIcon, SettingsIcon } from '@chakra-ui/icons'
// 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 ParentNodesByFile = {
[adultness: string]: { [nodeFile: string]: string[] } | undefined
}
export type LinksByNodeId = { [nodeId: string]: OrgRoamLink[] | undefined }
export type Scope = {
nodeIds: string[]
}
const getAlgos = (option?: boolean) => {
const options: string[] = []
const algorithms: { [name: string]: (percent: number) => number } = {}
for (let type in Easing) {
for (let mode in (Easing as any)[type]) {
let name = type + mode
if (name === 'LinearNone') {
name = 'Linear'
}
option ? options.push(name) : (algorithms[name] = (Easing as any)[type][mode])
}
}
return option ? options : algorithms
}
const initialPhysics = {
enabled: true,
charge: -350,
collision: true,
collisionStrength: 0,
linkStrength: 0.1,
linkIts: 1,
particles: false,
particlesNumber: 0,
particlesWidth: 4,
linkOpacity: 0.4,
linkWidth: 1,
nodeRel: 4,
labels: true,
labelScale: 1.5,
alphaDecay: 0.02,
alphaTarget: 0,
alphaMin: 0,
velocityDecay: 0.25,
gravity: 0.5,
gravityOn: true,
colorful: true,
galaxy: true,
ticks: 1,
hover: 'highlight',
click: 'select',
doubleClick: 'local',
iterations: 0,
highlight: true,
highlightNodeSize: 2,
highlightLinkSize: 2,
highlightAnim: false,
animationSpeed: 250,
algorithms: getAlgos(false),
algorithmOptions: getAlgos(true),
algorithmName: 'CubicOut',
orphans: false,
follow: 'Local',
}
const initialFilter = {
orphans: false,
parents: true,
tags: [],
nodes: [],
links: [],
date: [],
}
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 [physics, setPhysics] = usePersistantState('physics', initialPhysics)
const [filter, setFilter] = usePersistantState('filter', initialFilter)
// const [theme, setTheme] = useState(initialTheme)
const [graphData, setGraphData] = useState(null)
const [emacsNodeId, setEmacsNodeId] = useState(null)
const nodeByIdRef = useRef({})
const parentNodesByFileRef = useRef({}) //useRef({})
const linksByNodeIdRef = useRef({})
const updateGraphData = () => {
return fetch('http://localhost:35901/graph')
.then((res) => res.json())
.then((orgRoamGraphData: OrgRoamGraphReponse) => {
nodeByIdRef.current = Object.fromEntries(
orgRoamGraphData.nodes.map((node) => [node.id, node]),
)
linksByNodeIdRef.current = orgRoamGraphData.links.reduce((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)
})
}
useEffect(() => {
const trackEmacs = new EventSource('http://127.0.0.1:35901/current-node-id')
trackEmacs.addEventListener('message', (e) => {
const emacsNodeId = e.data
setEmacsNodeId(emacsNodeId)
})
updateGraphData()
}, [])
useEffect(() => {
if (!emacsNodeId) {
return
}
updateGraphData()
}, [emacsNodeId])
const [threeDim, setThreeDim] = useState(false)
const [showTweaks, setShowTweaks] = useState(true)
if (!graphData) {
return null
}
return (
{showTweaks ? (
{
setShowTweaks(false)
}}
/>
) : (
}
onClick={() => setShowTweaks(true)}
/>
)}
)
}
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 DropDownMenuProps {
textArray: string[]
onClickArray: any
displayValue: string
}
export const DropDownMenu = (props: DropDownMenuProps) => {
const { textArray, onClickArray, displayValue } = props
return (
)
}
/* style={{
* position: "absolute",
* zIndex: 2000,
* width: 400,
* maxHeight: "70%",
* background: "alt.100",
* marginTop: "2%",
* marginLeft: "2%"
* }} */
export interface TweakProps {
physics: typeof initialPhysics
setPhysics: any
threeDim: boolean
setThreedim: (boolean) => void
filter: typeof initialFilter
setFilter: any
onClose: () => void
}
export const Tweaks = (props: TweakProps) => {
const { physics, setPhysics, threeDim, filter, setFilter, onClose } = props
return (
}
onClick={() => setPhysics(initialPhysics)}
colorScheme="purple"
/>
(
)}
>
Filter
Kill orphans
{
setFilter({ ...filter, orphans: !filter.orphans })
}}
isChecked={filter.orphans}
>
Link nodes with parent file
{
setFilter({ ...filter, parents: !filter.parents })
}}
isChecked={filter.parents}
>
Physics
setPhysics({ ...physics, enabled: !physics.enabled })}
isChecked={physics.enabled}
colorScheme="purple"
/>
}
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, colorful: !physics.colorful })}
value={physics.colorful}
>
Child
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, particlesWidth: value })}
/>
{
setPhysics({ ...physics, highlightAnim: !physics.highlightAnim })
}}
value={physics.highlightAnim}
>
setPhysics({ ...physics, animationSpeed: v })}
value={physics.animationSpeed}
infoText="Slower speed has a chance of being buggy"
min={50}
max={1000}
step={10}
/>
{/*
setPhysics({ ...physics, algorithmName: { option } }),
)}
/> */}
setPhysics({ ...physics, highlight: !physics.highlight })}
value={physics.highlight}
>
setPhysics({ ...physics, highlightLinkSize: value })}
/>
setPhysics({ ...physics, highlightNodeSize: value })}
/>
Highlight node color
Highlight link color
Behavior
}
align="stretch"
>
Hover Higlight
Click
Double-click
)
}
export interface GraphProps {
nodeById: NodeById
linksByNodeId: LinksByNodeId
graphData: GraphData
physics: typeof initialPhysics
threeDim: boolean
filter: typeof initialFilter
emacsNodeId: string | null
}
export const Graph = function (props: GraphProps) {
const { physics, graphData, threeDim, linksByNodeId, filter, emacsNodeId, nodeById } = 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
}
switch (physics.follow) {
case 'Local':
setScope({ nodeIds: [emacsNodeId] })
break
case 'Zoom':
default:
}
}, [emacsNodeId])
const centralHighlightedNode = hoverNode
const highlightedNodes = useMemo(() => {
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, {}]),
)
}, [centralHighlightedNode, linksByNodeId])
const filteredNodes = useMemo(() => {
return graphData.nodes.filter((node) => {
const links = linksByNodeId[node.id as string] ?? []
let showNode = true
if (filter.orphans) {
if (filter.parents) {
showNode = links.length !== 0
} else {
if (links.length === 0) {
showNode = false
} else {
if (
links.length -
links.filter((link) => link.type === 'parent' || link.type === 'cite').length ===
0
) {
showNode = false
}
}
}
}
return showNode
})
}, [filter, graphData.nodes, linksByNodeId])
const filteredLinks = useMemo(() => {
return 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)
let showNode = true
if (!filter.parents && link.type === 'parent') {
showNode = false
}
return link.type !== 'cite' && showNode
})
}, [filter, JSON.stringify(graphData.links)])
const scopedNodes = useMemo(() => {
return filteredNodes.filter((node) => {
const links = linksByNodeId[node.id as string] ?? []
/* if (physics.orphans && links.length === 0) {
* return false
* } */
return (
scope.nodeIds.includes(node.id as string) ||
links.some((link) => {
return scope.nodeIds.includes(link.source) || scope.nodeIds.includes(link.target)
})
)
})
}, [filteredNodes, linksByNodeId, scope.nodeIds])
const scopedNodeIds = scopedNodes.map((node) => node.id as string)
const scopedLinks = useMemo(() => {
return 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 = 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)
)
})
}, [filteredLinks, scopedNodes])
const scopedGraphData = useMemo(
() =>
scope.nodeIds.length === 0
? { nodes: filteredNodes, links: filteredLinks }
: {
nodes: scopedNodes,
links: scopedLinks,
},
[filter, scope, JSON.stringify(Object.keys(nodeById))],
)
// make sure the camera position and zoom or fine when the list of nodes to render is changed
useEffect(() => {
// this setTimeout was added holistically because the behavior is better when we put
// zoomToFit off a little bit
setTimeout(() => {
const fg = threeDim ? graph3dRef.current : graph2dRef.current
fg?.zoomToFit(0, 200)
}, 1)
}, [JSON.stringify(scopedNodeIds)])
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
}
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(20) : null)
})()
})
// 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
}
// easing algorithms
const [opacity, setOpacity] = useState(1)
const [fadeIn, cancel] = useAnimation((x) => setOpacity(x), {
duration: physics.animationSpeed,
algorithm: physics.algorithms[physics.algorithmName],
})
const [fadeOut, fadeOutCancel] = useAnimation(
(x) => setOpacity(Math.min(opacity, -1 * (x - 1))),
{
duration: physics.animationSpeed,
algorithm: physics.algorithms[physics.algorithmName],
},
)
const lastHoverNode = useRef()
useEffect(() => {
hoverNode && (lastHoverNode.current = hoverNode)
if (!physics.highlightAnim) {
return
}
if (hoverNode) {
fadeIn()
} else {
cancel()
opacity > 0.5 ? fadeOut() : setOpacity(0)
}
}, [hoverNode])
const theme = useTheme()
const graphCommonProps: ComponentPropsWithoutRef = {
graphData: scopedGraphData,
width: windowWidth,
height: windowHeight,
backgroundColor: theme.white,
nodeLabel: (node) => (node as OrgRoamNode).title,
nodeColor: (node) => {
if (!physics.colorful) {
if (!physics.highlightAnim) {
return Object.keys(highlightedNodes).length === 0
? highlightedNodes[node.id!]
? theme.colors.purple[500]
: theme.colors.gray[400]
: theme.colors.gray[500]
}
return Object.keys(highlightedNodes).length === 0
? lastHoverNode.current?.id! === node.id!
? theme.colors.purple['inter'](opacity)
: theme.colors.gray['inter'](opacity)
: highlightedNodes[node.id!]
? theme.colors.purple['inter'](opacity)
: theme.colors.gray['inter'](opacity)
}
const palette = ['pink', 'purple', 'blue', 'cyan', 'teal', 'green', 'yellow', 'orange', 'red']
// otherwise links with parents get shown as having one note
const linklen = linksByNodeId[node.id!]?.length ?? 0
const parentCiteNeighbors = linklen
? linksByNodeId[node.id]?.filter((link) => link.type === 'parent' || link.type === 'cite')
.length
: 0
const neighbors = filter.parents ? linklen : linklen - parentCiteNeighbors
return theme.colors[palette[numbereWithinRange(neighbors, 0, palette.length - 1)]][500]
},
nodeRelSize: physics.nodeRel,
nodeVal: (node) => {
const links = linksByNodeId[node.id!] ?? []
const parentNeighbors = links.length
? links.filter((link) => link.type === 'parent' || link.type === 'cite').length
: 0
const basicSize = 3 + links.length - (!filter.parents ? parentNeighbors : 0)
if (physics.highlightAnim) {
const wasNeighbor = (link) =>
link.source === lastHoverNode.current?.id! || link.target === lastHoverNode.current?.id!
const wasHighlightedNode = links.some(wasNeighbor)
const highlightSize = highlightedNodes[node.id!]
? 1 + opacity * (physics.highlightNodeSize - 1)
: lastHoverNode.current?.id! === node.id!
? 1 + opacity * (physics.highlightNodeSize - 1)
: wasHighlightedNode
? 1 + opacity * (physics.highlightNodeSize - 1)
: 1
return basicSize * highlightSize
} else {
const highlightSize = highlightedNodes[node.id!] ? physics.highlightNodeSize : 1
return basicSize * highlightSize
}
},
nodeCanvasObject: (node, ctx, globalScale) => {
if (!physics.labels) {
return
}
const links = linksByNodeId[node.id!] ?? []
const wasHighlightedNode =
physics.highlightAnim &&
links.some(
(link) =>
link.source === lastHoverNode.current?.id! ||
link.target === lastHoverNode.current?.id!,
)
if (globalScale <= physics.labelScale && !highlightedNodes[node.id!] && !wasHighlightedNode) {
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)
// draw label background
const getLabelOpacity = () => {
if (physics.highlightAnim) {
if (globalScale <= physics.labelScale) {
return opacity
}
return Object.keys(highlightedNodes).length === 0
? lastHoverNode.current?.id! === node.id
? 1
: 1 * fadeFactor * (-1 * (0.5 * opacity - 1))
: highlightedNodes[node.id!] || wasHighlightedNode
? 1
: 1 * fadeFactor * (-1 * (0.5 * opacity - 1))
} else {
return Object.keys(highlightedNodes).length === 0
? 1 * fadeFactor
: highlightedNodes[node.id!]
? 1
: 1 * fadeFactor
}
}
const backgroundOpacity = 0.5 * getLabelOpacity()
ctx.fillStyle = `rgba(20, 20, 20, ${backgroundOpacity})`
ctx.fillRect(
node.x! - bckgDimensions[0] / 2,
node.y! - bckgDimensions[1] / 2,
...bckgDimensions,
)
// draw label text
const textOpacity = 2 * backgroundOpacity
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillStyle = `rgb(255, 255, 255, ${textOpacity})`
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!
if (physics.highlightAnim) {
const linkWasHighlighted =
(link.source as NodeObject).id! === lastHoverNode.current?.id! ||
(link.target as NodeObject).id! === lastHoverNode.current?.id!
return linkIsHighlighted
? theme.colors.purple['inter'](opacity) /*the.colors.purple[500]*/
: linkWasHighlighted
? theme.colors.purple['inter'](opacity) /*the.colors.purple[500]*/
: theme.colors.gray[500]
} else {
return linkIsHighlighted ? theme.colors.purple[500] : theme.colors.gray[500]
}
},
linkWidth: (link) => {
const linkIsHighlighted =
(link.source as NodeObject).id! === centralHighlightedNode?.id! ||
(link.target as NodeObject).id! === centralHighlightedNode?.id!
if (!physics.highlightAnim) {
return linkIsHighlighted ? physics.linkWidth * physics.highlightLinkSize : physics.linkWidth
}
const linkWasHighlighted =
(link.source as NodeObject).id! === lastHoverNode.current?.id! ||
(link.target as NodeObject).id! === lastHoverNode.current?.id!
return linkIsHighlighted
? physics.linkWidth * (1 + opacity * (physics.highlightLinkSize - 1))
: linkWasHighlighted
? physics.linkWidth * (1 + opacity * (physics.highlightLinkSize - 1))
: physics.linkWidth
},
linkDirectionalParticleWidth: physics.particlesWidth,
d3AlphaDecay: physics.alphaDecay,
d3AlphaMin: physics.alphaMin,
d3VelocityDecay: physics.velocityDecay,
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)
}