chore(react): port data part of server API

This commit is contained in:
Elian Doran
2025-10-04 11:40:31 +03:00
parent b41042fec4
commit 09811d23f6
7 changed files with 212 additions and 163 deletions

View File

@@ -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<HTMLDivElement>(null);
const styleResolverRef = useRef<HTMLDivElement>(null);
const [ cssData, setCssData ] = useState<CssData>();
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 (
<div className="note-map-widget">
<div ref={styleResolverRef} class="style-resolver" />
<div ref={containerRef} className="note-map-container">
Container goes here.
</div>
<NoteGraph containerRef={containerRef} note={note} widgetMode={widgetMode} mapType={mapType} />
</div>
)
}
function NoteGraph({ containerRef, note, widgetMode, mapType }: {
containerRef: RefObject<HTMLDivElement>;
note: FNote;
widgetMode: NoteMapWidgetMode;
mapType: MapType;
}) {
const graphRef = useRef<ForceGraph<NodeObject, LinkObject<NodeObject>>>();
const [ data, setData ] = useState<NotesAndRelationsData>();
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 <div ref={containerRef} className="note-map-container" />;
}
function getCssData(container: HTMLElement, styleResolver: HTMLElement): CssData {
const containerStyle = window.getComputedStyle(container);
const styleResolverStyle = window.getComputedStyle(styleResolver);

View File

@@ -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<string, number>;
}
export async function loadNotesAndRelations(mapRootNoteId: string, excludeRelations: string[], includeRelations: string[], mapType: MapType): Promise<NotesAndRelationsData> {
const resp = await server.post<NoteMapPostResponse>(`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<string, number> = {};
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<string, number> = {};
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<string, GroupedLink> = {};
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);
}

View File

@@ -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;
}