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 { 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, } 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 LinksByNodeId = { [nodeId: string]: OrgRoamLink[] | undefined } export type Scope = { nodeIds: string[] } 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, } 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 [theme, setTheme] = useState(initialTheme) const [graphData, setGraphData] = useState(null) const [emacsNodeId, setEmacsNodeId] = useState(null) const nodeByIdRef = 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} ) } /* 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 onClose: () => void } export const Tweaks = function (props: TweakProps) { const { physics, setPhysics, threeDim, onClose } = props return ( } onClick={() => setPhysics(initialPhysics)} colorScheme="purple" /> 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, 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 })} /> Highlight node color Highlight link color Behavior } align="stretch" > Hover Higlight }> {physics.hover} Off On 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, 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 } 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 = useMemo( () => scope.nodeIds.length === 0 ? graphData : { nodes: scopedNodes, links: scopedLinks, }, [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 // fg.d3Force('x', null) // fg.d3Force('y', null) fg.d3Force('collide', d3.forceCollide().radius(10)) // fg.d3Force('charge').strength(1) })() }) // 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 theme = useTheme() console.log(theme) const graphCommonProps: ComponentPropsWithoutRef = { graphData: scopedGraphData, width: windowWidth, height: windowHeight, backgroundColor: theme.white, 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!] ? theme.blue['500'] : 'rgb(50, 50, 50)' } const palette = ['pink', 'purple', 'blue', 'cyan', 'teal', 'green', 'yellow', 'orange', 'red'] return theme.colors[ palette[numbereWithinRange(linksByNodeId[node.id!]?.length ?? 0, 0, palette.length - 1)] ][500] }, nodeRelSize: physics.nodeRel, nodeVal: (node) => { const links = linksByNodeId[node.id!] ?? [] return links.length }, 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) // draw label background const backgroundOpacity = Object.keys(highlightedNodes).length === 0 ? 0.5 * fadeFactor : highlightedNodes[node.id!] ? 0.5 : 0.15 * fadeFactor 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 = Object.keys(highlightedNodes).length === 0 ? fadeFactor : highlightedNodes[node.id!] ? 1 : 0.3 * fadeFactor 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! return linkIsHighlighted ? theme.colors.purple[500] : theme.colors.gray[400] }, linkWidth: 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) }