diff --git a/apps/client/src/widgets/note_map.ts b/apps/client/src/widgets/note_map.ts index 6259157c6..c06e50465 100644 --- a/apps/client/src/widgets/note_map.ts +++ b/apps/client/src/widgets/note_map.ts @@ -30,16 +30,8 @@ const TPL = /*html*/`
`; type WidgetMode = "type" | "ribbon"; -type MapType = "tree" | "link"; type Data = GraphData>; -interface Node extends NodeObject { - id: string; - name: string; - type: string; - color: string; -} - interface Link extends LinkObject { id: string; name: string; @@ -50,43 +42,10 @@ interface Link extends LinkObject { target: Node; } -interface NotesAndRelationsData { - nodes: Node[]; - links: { - id: string; - source: string; - target: string; - name: string; - }[]; -} - -// Replace -interface ResponseLink { - key: string; - sourceNoteId: string; - targetNoteId: string; - name: string; -} - -interface PostNotesMapResponse { - notes: string[]; - links: ResponseLink[]; - noteIdToDescendantCountMap: Record; -} - -interface GroupedLink { - id: string; - sourceNoteId: string; - targetNoteId: string; - names: string[]; -} - export default class NoteMapWidget extends NoteContextAwareWidget { private fixNodes: boolean; private widgetMode: WidgetMode; - private mapType?: MapType; - private cssData!: CssData; private themeStyle!: string; private $container!: JQuery; @@ -147,8 +106,6 @@ export default class NoteMapWidget extends NoteContextAwareWidget { async refreshWithNote(note: FNote) { this.$widget.show(); - this.mapType = note.getLabelValue("mapType") === "tree" ? "tree" : "link"; - //variables for the hover effekt. We have to save the neighbours of a hovered node in a set. Also we need to save the links as well as the hovered node itself let hoverNode: NodeObject | null = null; @@ -157,8 +114,6 @@ export default class NoteMapWidget extends NoteContextAwareWidget { const ForceGraph = (await import("force-graph")).default; this.graph = new ForceGraph(this.$container[0]) - .width(this.$container.width() || 0) - .height(this.$container.height() || 0) .onZoom((zoom) => this.setZoomLevel(zoom.k)) .d3AlphaDecay(0.01) .d3VelocityDecay(0.08) @@ -244,15 +199,6 @@ export default class NoteMapWidget extends NoteContextAwareWidget { .linkCanvasObjectMode(() => "after"); } - const mapRootNoteId = this.getMapRootNoteId(); - - const labelValues = (name: string) => this.note?.getLabels(name).map(l => l.value) ?? []; - - const excludeRelations = labelValues("mapExcludeRelation"); - const includeRelations = labelValues("mapIncludeRelation"); - - const data = await this.loadNotesAndRelations(mapRootNoteId, excludeRelations, includeRelations); - const nodeLinkRatio = data.nodes.length / data.links.length; const magnifiedRatio = Math.pow(nodeLinkRatio, 1.5); const charge = -20 / magnifiedRatio; @@ -273,22 +219,6 @@ export default class NoteMapWidget extends NoteContextAwareWidget { this.renderData(data); } - getMapRootNoteId(): string { - if (this.noteId && this.widgetMode === "ribbon") { - return this.noteId; - } - - let mapRootNoteId = this.note?.getLabelValue("mapRootNoteId"); - - if (mapRootNoteId === "hoisted") { - mapRootNoteId = hoistedNoteService.getHoistedNoteId(); - } else if (!mapRootNoteId) { - mapRootNoteId = appContext.tabManager.getActiveContext()?.parentNoteId; - } - - return mapRootNoteId ?? ""; - } - getColorForNode(node: Node) { if (node.color) { return node.color; @@ -393,91 +323,7 @@ export default class NoteMapWidget extends NoteContextAwareWidget { ctx.restore(); } - async loadNotesAndRelations(mapRootNoteId: string, excludeRelations: string[], includeRelations: string[]): Promise { - const resp = await server.post(`note-map/${mapRootNoteId}/${this.mapType}`, { - excludeRelations, includeRelations - }); - - this.calculateNodeSizes(resp); - - const links = this.getGroupedLinks(resp.links); - - this.nodes = resp.notes.map(([noteId, title, type, color]) => ({ - id: noteId, - name: title, - type: type, - color: color - })); - - return { - nodes: this.nodes, - links: links.map((link) => ({ - id: `${link.sourceNoteId}-${link.targetNoteId}`, - source: link.sourceNoteId, - target: link.targetNoteId, - name: link.names.join(", ") - })) - }; - } - - getGroupedLinks(links: ResponseLink[]): GroupedLink[] { - const linksGroupedBySourceTarget: Record = {}; - - for (const link of links) { - const key = `${link.sourceNoteId}-${link.targetNoteId}`; - - if (key in linksGroupedBySourceTarget) { - if (!linksGroupedBySourceTarget[key].names.includes(link.name)) { - linksGroupedBySourceTarget[key].names.push(link.name); - } - } else { - linksGroupedBySourceTarget[key] = { - id: key, - sourceNoteId: link.sourceNoteId, - targetNoteId: link.targetNoteId, - names: [link.name] - }; - } - } - - return Object.values(linksGroupedBySourceTarget); - } - - calculateNodeSizes(resp: PostNotesMapResponse) { - this.noteIdToSizeMap = {}; - - if (this.mapType === "tree") { - const { noteIdToDescendantCountMap } = resp; - - for (const noteId in noteIdToDescendantCountMap) { - this.noteIdToSizeMap[noteId] = 4; - - const count = noteIdToDescendantCountMap[noteId]; - - if (count > 0) { - this.noteIdToSizeMap[noteId] += 1 + Math.round(Math.log(count) / Math.log(1.5)); - } - } - } else if (this.mapType === "link") { - const noteIdToLinkCount: Record = {}; - - for (const link of resp.links) { - noteIdToLinkCount[link.targetNoteId] = 1 + (noteIdToLinkCount[link.targetNoteId] || 0); - } - - for (const [noteId] of resp.notes) { - this.noteIdToSizeMap[noteId] = 4; - - if (noteId in noteIdToLinkCount) { - this.noteIdToSizeMap[noteId] += Math.min(Math.pow(noteIdToLinkCount[noteId], 0.5), 15); - } - } - } - } - renderData(data: Data) { - this.graph.graphData(data); - if (this.widgetMode === "ribbon" && this.note?.type !== "search") { setTimeout(() => { this.setDimensions(); diff --git a/apps/client/src/widgets/note_map/NoteMap.tsx b/apps/client/src/widgets/note_map/NoteMap.tsx index a53893cb3..bf9e2b7b1 100644 --- a/apps/client/src/widgets/note_map/NoteMap.tsx +++ b/apps/client/src/widgets/note_map/NoteMap.tsx @@ -1,6 +1,11 @@ import { useEffect, useRef, useState } from "preact/hooks"; import "./NoteMap.css"; -import { rgb2hex } from "./utils"; +import { getMapRootNoteId, NoteMapWidgetMode, rgb2hex } from "./utils"; +import { RefObject } from "preact"; +import FNote from "../../entities/fnote"; +import { useNoteContext, useNoteLabel } from "../react/hooks"; +import ForceGraph, { LinkObject, NodeObject } from "force-graph"; +import { loadNotesAndRelations, NotesAndRelationsData } from "./data"; interface CssData { fontFamily: string; @@ -8,11 +13,20 @@ interface CssData { mutedTextColor: string; } -export default function NoteMap() { +interface NoteMapProps { + note: FNote; + widgetMode: NoteMapWidgetMode; +} + +type MapType = "tree" | "link"; + +export default function NoteMap({ note, widgetMode }: NoteMapProps) { + console.log("Got note", note); const containerRef = useRef(null); const styleResolverRef = useRef(null); const [ cssData, setCssData ] = useState(); - console.log("Got CSS ", cssData); + const [ mapTypeRaw ] = useNoteLabel(note, "mapType"); + const mapType: MapType = mapTypeRaw === "tree" ? "tree" : "link"; useEffect(() => { if (!containerRef.current || !styleResolverRef.current) return; @@ -22,14 +36,54 @@ export default function NoteMap() { return (
- -
- Container goes here. -
+
) } +function NoteGraph({ containerRef, note, widgetMode, mapType }: { + containerRef: RefObject; + note: FNote; + widgetMode: NoteMapWidgetMode; + mapType: MapType; +}) { + const graphRef = useRef>>(); + const [ data, setData ] = useState(); + console.log("Got data ", data); + + // Build the note graph instance. + useEffect(() => { + const container = containerRef.current; + if (!container) return; + const { width, height } = container.getBoundingClientRect(); + const graph = new ForceGraph(container) + .width(width) + .height(height); + 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((data) => { + console.log("Got data ", data); + }); + + return () => container.replaceChildren(); + }, [ note ]); + + // Render the data. + useEffect(() => { + if (!graphRef.current || !data) return; + graphRef.current.graphData(data); + }, [ data ]); + + + return
; +} + function getCssData(container: HTMLElement, styleResolver: HTMLElement): CssData { const containerStyle = window.getComputedStyle(container); const styleResolverStyle = window.getComputedStyle(styleResolver); diff --git a/apps/client/src/widgets/note_map/data.ts b/apps/client/src/widgets/note_map/data.ts new file mode 100644 index 000000000..1909f1d49 --- /dev/null +++ b/apps/client/src/widgets/note_map/data.ts @@ -0,0 +1,113 @@ +import { NoteMapLink, NoteMapPostResponse } from "@triliumnext/commons"; +import server from "../../services/server"; +import { NodeObject } from "force-graph"; + +type MapType = "tree" | "link"; + +interface GroupedLink { + id: string; + sourceNoteId: string; + targetNoteId: string; + names: string[]; +} + +interface Node extends NodeObject { + id: string; + name: string; + type: string; + color: string; +} + +export interface NotesAndRelationsData { + nodes: Node[]; + links: { + id: string; + source: string; + target: string; + name: string; + }[]; + noteIdToSizeMap: Record; +} + +export async function loadNotesAndRelations(mapRootNoteId: string, excludeRelations: string[], includeRelations: string[], mapType: MapType): Promise { + const resp = await server.post(`note-map/${mapRootNoteId}/${mapType}`, { + excludeRelations, includeRelations + }); + + const noteIdToSizeMap = calculateNodeSizes(resp, mapType); + const links = getGroupedLinks(resp.links); + const nodes = resp.notes.map(([noteId, title, type, color]) => ({ + id: noteId, + name: title, + type: type, + color: color + })); + + return { + noteIdToSizeMap, + nodes, + links: links.map((link) => ({ + id: `${link.sourceNoteId}-${link.targetNoteId}`, + source: link.sourceNoteId, + target: link.targetNoteId, + name: link.names.join(", ") + })) + }; +} + +function calculateNodeSizes(resp: NoteMapPostResponse, mapType: MapType) { + const noteIdToSizeMap: Record = {}; + + if (mapType === "tree") { + const { noteIdToDescendantCountMap } = resp; + + for (const noteId in noteIdToDescendantCountMap) { + noteIdToSizeMap[noteId] = 4; + + const count = noteIdToDescendantCountMap[noteId]; + + if (count > 0) { + noteIdToSizeMap[noteId] += 1 + Math.round(Math.log(count) / Math.log(1.5)); + } + } + } else if (mapType === "link") { + const noteIdToLinkCount: Record = {}; + + for (const link of resp.links) { + noteIdToLinkCount[link.targetNoteId] = 1 + (noteIdToLinkCount[link.targetNoteId] || 0); + } + + for (const [noteId] of resp.notes) { + noteIdToSizeMap[noteId] = 4; + + if (noteId in noteIdToLinkCount) { + noteIdToSizeMap[noteId] += Math.min(Math.pow(noteIdToLinkCount[noteId], 0.5), 15); + } + } + } + + return noteIdToSizeMap; +} + +function getGroupedLinks(links: NoteMapLink[]): GroupedLink[] { + const linksGroupedBySourceTarget: Record = {}; + + for (const link of links) { + const key = `${link.sourceNoteId}-${link.targetNoteId}`; + + if (key in linksGroupedBySourceTarget) { + if (!linksGroupedBySourceTarget[key].names.includes(link.name)) { + linksGroupedBySourceTarget[key].names.push(link.name); + } + } else { + linksGroupedBySourceTarget[key] = { + id: key, + sourceNoteId: link.sourceNoteId, + targetNoteId: link.targetNoteId, + names: [link.name] + }; + } + } + + return Object.values(linksGroupedBySourceTarget); +} diff --git a/apps/client/src/widgets/note_map/utils.ts b/apps/client/src/widgets/note_map/utils.ts index 70d1caab6..8014df414 100644 --- a/apps/client/src/widgets/note_map/utils.ts +++ b/apps/client/src/widgets/note_map/utils.ts @@ -1,6 +1,28 @@ +import appContext from "../../components/app_context"; +import FNote from "../../entities/fnote"; +import hoisted_note from "../../services/hoisted_note"; + +export type NoteMapWidgetMode = "ribbon" | "hoisted"; + export function rgb2hex(rgb: string) { return `#${(rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/) || []) .slice(1) .map((n) => parseInt(n, 10).toString(16).padStart(2, "0")) .join("")}`; } + +export function getMapRootNoteId(noteId: string, note: FNote, widgetMode: NoteMapWidgetMode): string | null { + if (noteId && widgetMode === "ribbon") { + return noteId; + } + + let mapRootNoteId = note?.getLabelValue("mapRootNoteId"); + + if (mapRootNoteId === "hoisted") { + mapRootNoteId = hoisted_note.getHoistedNoteId(); + } else if (!mapRootNoteId) { + mapRootNoteId = appContext.tabManager.getActiveContext()?.parentNoteId ?? null; + } + + return mapRootNoteId; +} diff --git a/apps/client/src/widgets/ribbon/NoteMapTab.tsx b/apps/client/src/widgets/ribbon/NoteMapTab.tsx index 93317bef7..88ee108ad 100644 --- a/apps/client/src/widgets/ribbon/NoteMapTab.tsx +++ b/apps/client/src/widgets/ribbon/NoteMapTab.tsx @@ -7,7 +7,7 @@ import NoteMap from "../note_map/NoteMap"; const SMALL_SIZE_HEIGHT = "300px"; -export default function NoteMapTab({ noteContext }: TabContext) { +export default function NoteMapTab({ note }: TabContext) { const [ isExpanded, setExpanded ] = useState(false); const [ height, setHeight ] = useState(SMALL_SIZE_HEIGHT); const containerRef = useRef(null); @@ -26,7 +26,7 @@ export default function NoteMapTab({ noteContext }: TabContext) { return (
- + {note && } {!isExpanded ? ( ; } + +export interface NoteMapLink { + key: string; + sourceNoteId: string; + targetNoteId: string; + name: string; +} + +export interface NoteMapPostResponse { + notes: string[]; + links: NoteMapLink[]; + noteIdToDescendantCountMap: Record; +}