mirror of
https://github.com/zadam/trilium.git
synced 2025-11-10 15:25:51 +01:00
chore(react): start rendering nodes in note map
This commit is contained in:
@@ -1,27 +1,22 @@
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import "./NoteMap.css";
|
||||
import { getMapRootNoteId, NoteMapWidgetMode, rgb2hex } from "./utils";
|
||||
import { getMapRootNoteId, getThemeStyle, NoteMapWidgetMode, rgb2hex } from "./utils";
|
||||
import { RefObject } from "preact";
|
||||
import FNote from "../../entities/fnote";
|
||||
import { useNoteContext, useNoteLabel } from "../react/hooks";
|
||||
import { useElementSize, useNoteContext, useNoteLabel } from "../react/hooks";
|
||||
import ForceGraph, { LinkObject, NodeObject } from "force-graph";
|
||||
import { loadNotesAndRelations, NotesAndRelationsData } from "./data";
|
||||
|
||||
interface CssData {
|
||||
fontFamily: string;
|
||||
textColor: string;
|
||||
mutedTextColor: string;
|
||||
}
|
||||
import { CssData, setupRendering } from "./rendering";
|
||||
|
||||
interface NoteMapProps {
|
||||
note: FNote;
|
||||
widgetMode: NoteMapWidgetMode;
|
||||
parentRef: RefObject<HTMLElement>;
|
||||
}
|
||||
|
||||
type MapType = "tree" | "link";
|
||||
|
||||
export default function NoteMap({ note, widgetMode }: NoteMapProps) {
|
||||
console.log("Got note", note);
|
||||
export default function NoteMap({ note, widgetMode, parentRef }: NoteMapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const styleResolverRef = useRef<HTMLDivElement>(null);
|
||||
const [ cssData, setCssData ] = useState<CssData>();
|
||||
@@ -36,50 +31,57 @@ export default function NoteMap({ note, widgetMode }: NoteMapProps) {
|
||||
return (
|
||||
<div className="note-map-widget">
|
||||
<div ref={styleResolverRef} class="style-resolver" />
|
||||
<NoteGraph containerRef={containerRef} note={note} widgetMode={widgetMode} mapType={mapType} />
|
||||
<NoteGraph parentRef={parentRef} containerRef={containerRef} note={note} widgetMode={widgetMode} mapType={mapType} cssData={cssData} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NoteGraph({ containerRef, note, widgetMode, mapType }: {
|
||||
function NoteGraph({ containerRef, parentRef, note, widgetMode, mapType, cssData }: {
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
parentRef: RefObject<HTMLElement>;
|
||||
note: FNote;
|
||||
widgetMode: NoteMapWidgetMode;
|
||||
mapType: MapType;
|
||||
cssData: CssData;
|
||||
}) {
|
||||
const graphRef = useRef<ForceGraph<NodeObject, LinkObject<NodeObject>>>();
|
||||
const [ data, setData ] = useState<NotesAndRelationsData>();
|
||||
console.log("Got data ", data);
|
||||
const containerSize = useElementSize(parentRef);
|
||||
|
||||
// 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);
|
||||
const graph = new ForceGraph(container);
|
||||
|
||||
graphRef.current = graph;
|
||||
|
||||
const mapRootId = getMapRootNoteId(note.noteId, note, widgetMode);
|
||||
console.log("Map root ID ", mapRootId);
|
||||
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);
|
||||
loadNotesAndRelations(mapRootId, excludeRelations, includeRelations, mapType).then((notesAndRelations) => {
|
||||
setupRendering(graph, {
|
||||
cssData,
|
||||
noteId: note.noteId,
|
||||
noteIdToSizeMap: notesAndRelations.noteIdToSizeMap,
|
||||
notesAndRelations,
|
||||
themeStyle: getThemeStyle(),
|
||||
widgetMode
|
||||
});
|
||||
graph.graphData(notesAndRelations);
|
||||
});
|
||||
|
||||
return () => container.replaceChildren();
|
||||
}, [ note ]);
|
||||
|
||||
// Render the data.
|
||||
// React to container size
|
||||
useEffect(() => {
|
||||
if (!graphRef.current || !data) return;
|
||||
graphRef.current.graphData(data);
|
||||
}, [ data ]);
|
||||
|
||||
if (!containerSize || !graphRef.current) return;
|
||||
graphRef.current.width(containerSize.width).height(containerSize.height);
|
||||
}, [ containerSize?.width, containerSize?.height ]);
|
||||
|
||||
return <div ref={containerRef} className="note-map-container" />;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NoteMapLink, NoteMapPostResponse } from "@triliumnext/commons";
|
||||
import server from "../../services/server";
|
||||
import { NodeObject } from "force-graph";
|
||||
import { LinkObject, NodeObject } from "force-graph";
|
||||
|
||||
type MapType = "tree" | "link";
|
||||
|
||||
@@ -11,13 +11,22 @@ interface GroupedLink {
|
||||
names: string[];
|
||||
}
|
||||
|
||||
interface Node extends NodeObject {
|
||||
export interface Node extends NodeObject {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface Link extends LinkObject<NodeObject> {
|
||||
id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
source: Node;
|
||||
target: Node;
|
||||
}
|
||||
|
||||
export interface NotesAndRelationsData {
|
||||
nodes: Node[];
|
||||
links: {
|
||||
|
||||
96
apps/client/src/widgets/note_map/rendering.ts
Normal file
96
apps/client/src/widgets/note_map/rendering.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type ForceGraph from "force-graph";
|
||||
import { Link, Node, NotesAndRelationsData } from "./data";
|
||||
import { NodeObject } from "force-graph";
|
||||
import { getColorForNode, NoteMapWidgetMode } from "./utils";
|
||||
|
||||
export interface CssData {
|
||||
fontFamily: string;
|
||||
textColor: string;
|
||||
mutedTextColor: string;
|
||||
}
|
||||
|
||||
interface RenderData {
|
||||
noteIdToSizeMap: Record<string, number>;
|
||||
cssData: CssData;
|
||||
noteId: string;
|
||||
themeStyle: "light" | "dark";
|
||||
widgetMode: NoteMapWidgetMode;
|
||||
notesAndRelations: NotesAndRelationsData;
|
||||
}
|
||||
|
||||
export function setupRendering(graph: ForceGraph, { noteId, themeStyle, widgetMode, noteIdToSizeMap, notesAndRelations, cssData }: RenderData) {
|
||||
// variables for the hover effect. 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
|
||||
const neighbours = new Set();
|
||||
const highlightLinks = new Set();
|
||||
let hoverNode: NodeObject | null = null;
|
||||
let zoomLevel: number;
|
||||
|
||||
function paintNode(node: Node, color: string, ctx: CanvasRenderingContext2D) {
|
||||
const { x, y } = node;
|
||||
if (!x || !y) {
|
||||
return;
|
||||
}
|
||||
const size = noteIdToSizeMap[node.id];
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, size * 0.8, 0, 2 * Math.PI, false);
|
||||
ctx.fill();
|
||||
|
||||
const toRender = zoomLevel > 2 || (zoomLevel > 1 && size > 6) || (zoomLevel > 0.3 && size > 10);
|
||||
|
||||
if (!toRender) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.fillStyle = cssData.textColor;
|
||||
ctx.font = `${size}px ${cssData.fontFamily}`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
|
||||
let title = node.name;
|
||||
|
||||
if (title.length > 15) {
|
||||
title = `${title.substr(0, 15)}...`;
|
||||
}
|
||||
|
||||
ctx.fillText(title, x, y + Math.round(size * 1.5));
|
||||
}
|
||||
|
||||
// main code for highlighting hovered nodes and neighbours. here we "style" the nodes. the nodes are rendered several hundred times per second.
|
||||
graph
|
||||
.d3AlphaDecay(0.01)
|
||||
.d3VelocityDecay(0.08)
|
||||
.maxZoom(7)
|
||||
.warmupTicks(30)
|
||||
.nodeCanvasObject((_node, ctx) => {
|
||||
const node: Node = _node as Node;
|
||||
if (hoverNode == node) {
|
||||
//paint only hovered node
|
||||
paintNode(node, "#661822", ctx);
|
||||
neighbours.clear(); //clearing neighbours or the effect would be maintained after hovering is over
|
||||
for (const _link of notesAndRelations.links) {
|
||||
const link = _link as unknown as Link;
|
||||
//check if node is part of a link in the canvas, if so add it´s neighbours and related links to the previous defined variables to paint the nodes
|
||||
if (link.source.id == node.id || link.target.id == node.id) {
|
||||
neighbours.add(link.source);
|
||||
neighbours.add(link.target);
|
||||
highlightLinks.add(link);
|
||||
neighbours.delete(node);
|
||||
}
|
||||
}
|
||||
} else if (neighbours.has(node) && hoverNode != null) {
|
||||
//paint neighbours
|
||||
paintNode(node, "#9d6363", ctx);
|
||||
} else {
|
||||
paintNode(node, getColorForNode(node, noteId, themeStyle, widgetMode), ctx); //paint rest of nodes in canvas
|
||||
}
|
||||
})
|
||||
//check if hovered and set the hovernode variable, saving the hovered node object into it. Clear links variable everytime you hover. Without clearing links will stay highlighted
|
||||
.onNodeHover((node) => {
|
||||
hoverNode = node || null;
|
||||
highlightLinks.clear();
|
||||
})
|
||||
.onZoom((zoom) => zoomLevel = zoom.k);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import appContext from "../../components/app_context";
|
||||
import FNote from "../../entities/fnote";
|
||||
import hoisted_note from "../../services/hoisted_note";
|
||||
import { Node } from "./data";
|
||||
|
||||
export type NoteMapWidgetMode = "ribbon" | "hoisted";
|
||||
|
||||
@@ -26,3 +27,37 @@ export function getMapRootNoteId(noteId: string, note: FNote, widgetMode: NoteMa
|
||||
|
||||
return mapRootNoteId;
|
||||
}
|
||||
|
||||
export function getColorForNode(node: Node, noteId: string, themeStyle: "light" | "dark", widgetMode: NoteMapWidgetMode) {
|
||||
if (node.color) {
|
||||
return node.color;
|
||||
} else if (widgetMode === "ribbon" && node.id === noteId) {
|
||||
return "red"; // subtree root mark as red
|
||||
} else {
|
||||
return generateColorFromString(node.type, themeStyle);
|
||||
}
|
||||
}
|
||||
|
||||
function generateColorFromString(str: string, themeStyle: "light" | "dark") {
|
||||
if (themeStyle === "dark") {
|
||||
str = `0${str}`; // magic lightning modifier
|
||||
}
|
||||
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
|
||||
let color = "#";
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const value = (hash >> (i * 8)) & 0xff;
|
||||
|
||||
color += `00${value.toString(16)}`.substr(-2);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
export function getThemeStyle() {
|
||||
const documentStyle = window.getComputedStyle(document.documentElement);
|
||||
return documentStyle.getPropertyValue("--theme-style")?.trim() as "light" | "dark";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user