import { useEffect, useRef, useState } from "preact/hooks"; import "./NoteMap.css"; import { getMapRootNoteId, getThemeStyle, MapType, NoteMapWidgetMode, rgb2hex } from "./utils"; import { RefObject } from "preact"; import FNote from "../../entities/fnote"; import { useElementSize, useNoteContext, useNoteLabel } from "../react/hooks"; import ForceGraph, { LinkObject, NodeObject } from "force-graph"; import { loadNotesAndRelations, Node, NotesAndRelationsData } from "./data"; import { CssData, setupRendering } from "./rendering"; import ActionButton from "../react/ActionButton"; import { t } from "../../services/i18n"; import link_context_menu from "../../menus/link_context_menu"; import appContext from "../../components/app_context"; import Slider from "../react/Slider"; interface NoteMapProps { note: FNote; widgetMode: NoteMapWidgetMode; parentRef: RefObject; } export default function NoteMap({ note, widgetMode, parentRef }: NoteMapProps) { const containerRef = useRef(null); const styleResolverRef = useRef(null); const [ mapTypeRaw, setMapType ] = useNoteLabel(note, "mapType"); const mapType: MapType = mapTypeRaw === "tree" ? "tree" : "link"; const graphRef = useRef>>(); const containerSize = useElementSize(parentRef); const [ fixNodes, setFixNodes ] = useState(false); const [ linkDistance, setLinkDistance ] = useState(40); const notesAndRelationsRef = useRef(); // Build the note graph instance. useEffect(() => { const container = containerRef.current; if (!container) return; const graph = new ForceGraph(container); graphRef.current = graph; const mapRootId = getMapRootNoteId(note.noteId, note, widgetMode); if (!mapRootId) return; const labelValues = (name: string) => note.getLabels(name).map(l => l.value) ?? []; const excludeRelations = labelValues("mapExcludeRelation"); const includeRelations = labelValues("mapIncludeRelation"); loadNotesAndRelations(mapRootId, excludeRelations, includeRelations, mapType).then((notesAndRelations) => { if (!containerRef.current || !styleResolverRef.current) return; const cssData = getCssData(containerRef.current, styleResolverRef.current); // Configure rendering properties. setupRendering(graph, { note, noteId: note.noteId, noteIdToSizeMap: notesAndRelations.noteIdToSizeMap, cssData, notesAndRelations, themeStyle: getThemeStyle(), widgetMode, mapType }); // Interaction graph .onNodeClick((node) => { if (!node.id) return; appContext.tabManager.getActiveContext()?.setNote((node as Node).id); }) .onNodeRightClick((node, e) => { if (!node.id) return; link_context_menu.openContextMenu((node as Node).id, e); }); // Set data graph.graphData(notesAndRelations); notesAndRelationsRef.current = notesAndRelations; }); return () => container.replaceChildren(); }, [ note, mapType ]); useEffect(() => { if (!graphRef.current || !notesAndRelationsRef.current) return; graphRef.current.d3Force("link")?.distance(linkDistance); graphRef.current.graphData(notesAndRelationsRef.current); }, [ linkDistance ]); // React to container size useEffect(() => { if (!containerSize || !graphRef.current) return; graphRef.current.width(containerSize.width).height(containerSize.height); }, [ containerSize?.width, containerSize?.height ]); // Fixing nodes when dragged. useEffect(() => { graphRef.current?.onNodeDragEnd((node) => { if (fixNodes) { node.fx = node.x; node.fy = node.y; } else { node.fx = undefined; node.fy = undefined; } }) }, [ fixNodes ]); return (
setFixNodes(!fixNodes)} frame />
) } function MapTypeSwitcher({ icon, text, type, currentMapType, setMapType }: { icon: string; text: string; type: MapType; currentMapType: MapType; setMapType: (type: MapType) => void; }) { return ( setMapType(type)} frame /> ) } function getCssData(container: HTMLElement, styleResolver: HTMLElement): CssData { const containerStyle = window.getComputedStyle(container); const styleResolverStyle = window.getComputedStyle(styleResolver); return { fontFamily: containerStyle.fontFamily, textColor: rgb2hex(containerStyle.color), mutedTextColor: rgb2hex(styleResolverStyle.color) } }