chore(client/ts): port note_map

This commit is contained in:
Elian Doran 2025-01-17 21:25:36 +02:00
parent e7eb385b8f
commit dd3397bcbb
No known key found for this signature in database
3 changed files with 204 additions and 87 deletions

View File

@ -24,7 +24,7 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
notePath?: string | null; notePath?: string | null;
noteId?: string | null; noteId?: string | null;
private parentNoteId?: string | null; parentNoteId?: string | null;
viewScope?: ViewScope; viewScope?: ViewScope;
constructor(ntxId: string | null = null, hoistedNoteId: string = "root", mainNtxId: string | null = null) { constructor(ntxId: string | null = null, hoistedNoteId: string = "root", mainNtxId: string | null = null) {

View File

@ -3,7 +3,7 @@ import contextMenu from "./context_menu.js";
import appContext from "../components/app_context.js"; import appContext from "../components/app_context.js";
import type { ViewScope } from "../services/link.js"; import type { ViewScope } from "../services/link.js";
function openContextMenu(notePath: string, e: PointerEvent | JQuery.ContextMenuEvent, viewScope: ViewScope = {}, hoistedNoteId: string | null = null) { function openContextMenu(notePath: string, e: PointerEvent | MouseEvent | JQuery.ContextMenuEvent, viewScope: ViewScope = {}, hoistedNoteId: string | null = null) {
contextMenu.show({ contextMenu.show({
x: e.pageX, x: e.pageX,
y: e.pageY, y: e.pageY,

View File

@ -1,11 +1,14 @@
import server from "../services/server.js"; import server from "../services/server.js";
import attributeService from "../services/attributes.js"; import attributeService from "../services/attributes.js";
import hoistedNoteService from "../services/hoisted_note.js"; import hoistedNoteService from "../services/hoisted_note.js";
import appContext from "../components/app_context.js"; import appContext, { type EventData } from "../components/app_context.js";
import NoteContextAwareWidget from "./note_context_aware_widget.js"; import NoteContextAwareWidget from "./note_context_aware_widget.js";
import linkContextMenuService from "../menus/link_context_menu.js"; import linkContextMenuService from "../menus/link_context_menu.js";
import utils from "../services/utils.js"; import utils from "../services/utils.js";
import { t } from "../services/i18n.js"; import { t } from "../services/i18n.js";
import type ForceGraph from "force-graph";
import type { GraphData, LinkObject, NodeObject } from "force-graph";
import type FNote from "../entities/fnote.js";
const esc = utils.escapeHtml; const esc = utils.escapeHtml;
@ -92,8 +95,80 @@ const TPL = `<div class="note-map-widget" style="position: relative;">
<div class="note-map-container"></div> <div class="note-map-container"></div>
</div>`; </div>`;
type WidgetMode = "type" | "ribbon";
type MapType = "tree" | "link";
type Data = GraphData<NodeObject, LinkObject<NodeObject>>;
interface Node extends NodeObject {
id: string;
name: string;
type: string;
color: string;
}
interface Link extends LinkObject<NodeObject> {
id: string;
name: string;
x: number;
y: number;
source: Node;
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<string, number>;
}
interface GroupedLink {
id: string;
sourceNoteId: string;
targetNoteId: string;
names: string[]
}
interface CssData {
fontFamily: string;
textColor: string;
mutedTextColor: string;
}
export default class NoteMapWidget extends NoteContextAwareWidget { export default class NoteMapWidget extends NoteContextAwareWidget {
constructor(widgetMode) {
private fixNodes: boolean;
private widgetMode: WidgetMode;
private mapType?: MapType;
private cssData!: CssData;
private themeStyle!: string;
private $container!: JQuery<HTMLElement>;
private $styleResolver!: JQuery<HTMLElement>;
private graph!: ForceGraph;
private noteIdToSizeMap!: Record<string, number>;
private zoomLevel!: number;
private nodes!: Node[];
constructor(widgetMode: WidgetMode) {
super(); super();
this.fixNodes = false; // needed to save the status of the UI element. Is set later in the code this.fixNodes = false; // needed to save the status of the UI element. Is set later in the code
this.widgetMode = widgetMode; // 'type' or 'ribbon' this.widgetMode = widgetMode; // 'type' or 'ribbon'
@ -113,7 +188,7 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
this.$widget.find(".map-type-switcher button").on("click", async (e) => { this.$widget.find(".map-type-switcher button").on("click", async (e) => {
const type = $(e.target).closest("button").attr("data-type"); const type = $(e.target).closest("button").attr("data-type");
await attributeService.setLabel(this.noteId, "mapType", type); await attributeService.setLabel(this.noteId ?? "", "mapType", type);
}); });
// Reading the status of the Drag nodes Ui element. Changing it´s color when activated. Reading Force value of the link distance. // Reading the status of the Drag nodes Ui element. Changing it´s color when activated. Reading Force value of the link distance.
@ -134,30 +209,32 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
const $parent = this.$widget.parent(); const $parent = this.$widget.parent();
this.graph.height($parent.height()).width($parent.width()); this.graph
.height($parent.height() || 0)
.width($parent.width() || 0);
} }
async refreshWithNote(note) { async refreshWithNote(note: FNote) {
this.$widget.show(); this.$widget.show();
this.css = { this.cssData = {
fontFamily: this.$container.css("font-family"), fontFamily: this.$container.css("font-family"),
textColor: this.rgb2hex(this.$container.css("color")), textColor: this.rgb2hex(this.$container.css("color")),
mutedTextColor: this.rgb2hex(this.$styleResolver.css("color")) mutedTextColor: this.rgb2hex(this.$styleResolver.css("color"))
}; };
this.mapType = this.note.getLabelValue("mapType") === "tree" ? "tree" : "link"; 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 //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 = null; let hoverNode: NodeObject | null = null;
const highlightLinks = new Set(); const highlightLinks = new Set();
const neighbours = new Set(); const neighbours = new Set();
const ForceGraph = (await import("force-graph")).default; const ForceGraph = (await import("force-graph")).default;
this.graph = ForceGraph()(this.$container[0]) this.graph = new ForceGraph(this.$container[0])
.width(this.$container.width()) .width(this.$container.width() || 0)
.height(this.$container.height()) .height(this.$container.height() || 0)
.onZoom((zoom) => this.setZoomLevel(zoom.k)) .onZoom((zoom) => this.setZoomLevel(zoom.k))
.d3AlphaDecay(0.01) .d3AlphaDecay(0.01)
.d3VelocityDecay(0.08) .d3VelocityDecay(0.08)
@ -168,8 +245,8 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
node.fx = node.x; node.fx = node.x;
node.fy = node.y; node.fy = node.y;
} else { } else {
node.fx = null; node.fx = undefined;
node.fy = null; node.fy = undefined;
} }
}) })
//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 //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
@ -180,17 +257,19 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
// set link width to immitate a highlight effekt. Checking the condition if any links are saved in the previous defined set highlightlinks // set link width to immitate a highlight effekt. Checking the condition if any links are saved in the previous defined set highlightlinks
.linkWidth((link) => (highlightLinks.has(link) ? 3 : 0.4)) .linkWidth((link) => (highlightLinks.has(link) ? 3 : 0.4))
.linkColor((link) => (highlightLinks.has(link) ? "white" : this.css.mutedTextColor)) .linkColor((link) => (highlightLinks.has(link) ? "white" : this.cssData.mutedTextColor))
.linkDirectionalArrowLength(4) .linkDirectionalArrowLength(4)
.linkDirectionalArrowRelPos(0.95) .linkDirectionalArrowRelPos(0.95)
// main code for highlighting hovered nodes and neighbours. here we "style" the nodes. the nodes are rendered several hundred times per second. // main code for highlighting hovered nodes and neighbours. here we "style" the nodes. the nodes are rendered several hundred times per second.
.nodeCanvasObject((node, ctx) => { .nodeCanvasObject((_node, ctx) => {
const node = _node as Node;
if (hoverNode == node) { if (hoverNode == node) {
//paint only hovered node //paint only hovered node
this.paintNode(node, "#661822", ctx); this.paintNode(node, "#661822", ctx);
neighbours.clear(); //clearing neighbours or the effect would be maintained after hovering is over neighbours.clear(); //clearing neighbours or the effect would be maintained after hovering is over
for (const link of data.links) { for (const _link of data.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 //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) { if (link.source.id == node.id || link.target.id == node.id) {
neighbours.add(link.source); neighbours.add(link.source);
@ -207,23 +286,39 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
} }
}) })
.nodePointerAreaPaint((node, ctx) => this.paintNode(node, this.getColorForNode(node), ctx)) .nodePointerAreaPaint((node, _, ctx) => this.paintNode(node as Node, this.getColorForNode(node as Node), ctx))
.nodePointerAreaPaint((node, color, ctx) => { .nodePointerAreaPaint((node, color, ctx) => {
if (!node.id) {
return;
}
ctx.fillStyle = color; ctx.fillStyle = color;
ctx.beginPath(); ctx.beginPath();
ctx.arc(node.x, node.y, this.noteIdToSizeMap[node.id], 0, 2 * Math.PI, false); if (node.x && node.y) {
ctx.arc(node.x, node.y,
this.noteIdToSizeMap[node.id], 0,
2 * Math.PI, false);
}
ctx.fill(); ctx.fill();
}) })
.nodeLabel((node) => esc(node.name)) .nodeLabel((node) => esc((node as Node).name))
.maxZoom(7) .maxZoom(7)
.warmupTicks(30) .warmupTicks(30)
.onNodeClick((node) => appContext.tabManager.getActiveContext().setNote(node.id)) .onNodeClick((node) => {
.onNodeRightClick((node, e) => linkContextMenuService.openContextMenu(node.id, e)); if (node.id) {
appContext.tabManager.getActiveContext().setNote((node as Node).id);
}
})
.onNodeRightClick((node, e) => {
if (node.id) {
linkContextMenuService.openContextMenu((node as Node).id, e);
}
});
if (this.mapType === "link") { if (this.mapType === "link") {
this.graph this.graph
.linkLabel((l) => `${esc(l.source.name)} - <strong>${esc(l.name)}</strong> - ${esc(l.target.name)}`) .linkLabel((l) => `${esc((l as Link).source.name)} - <strong>${esc((l as Link).name)}</strong> - ${esc((l as Link).target.name)}`)
.linkCanvasObject((link, ctx) => this.paintLink(link, ctx)) .linkCanvasObject((link, ctx) => this.paintLink(link as Link, ctx))
.linkCanvasObjectMode(() => "after"); .linkCanvasObjectMode(() => "after");
} }
@ -237,25 +332,25 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
let distancevalue = 40; // default value for the link force of the nodes let distancevalue = 40; // default value for the link force of the nodes
this.$widget.find(".fixnodes-type-switcher input").on("change", async (e) => { this.$widget.find(".fixnodes-type-switcher input").on("change", async (e) => {
distancevalue = e.target.closest("input").value; distancevalue = parseInt(e.target.closest("input")?.value ?? "0");
this.graph.d3Force("link").distance(distancevalue); this.graph.d3Force("link")?.distance(distancevalue);
this.renderData(data); this.renderData(data);
}); });
this.graph.d3Force("center").strength(0.2); this.graph.d3Force("center")?.strength(0.2);
this.graph.d3Force("charge").strength(boundedCharge); this.graph.d3Force("charge")?.strength(boundedCharge);
this.graph.d3Force("charge").distanceMax(1000); this.graph.d3Force("charge")?.distanceMax(1000);
this.renderData(data); this.renderData(data);
} }
getMapRootNoteId() { getMapRootNoteId(): string {
if (this.widgetMode === "ribbon") { if (this.noteId && this.widgetMode === "ribbon") {
return this.noteId; return this.noteId;
} }
let mapRootNoteId = this.note.getLabelValue("mapRootNoteId"); let mapRootNoteId = this.note?.getLabelValue("mapRootNoteId");
if (mapRootNoteId === "hoisted") { if (mapRootNoteId === "hoisted") {
mapRootNoteId = hoistedNoteService.getHoistedNoteId(); mapRootNoteId = hoistedNoteService.getHoistedNoteId();
@ -263,10 +358,10 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
mapRootNoteId = appContext.tabManager.getActiveContext().parentNoteId; mapRootNoteId = appContext.tabManager.getActiveContext().parentNoteId;
} }
return mapRootNoteId; return mapRootNoteId ?? "";
} }
getColorForNode(node) { getColorForNode(node: Node) {
if (node.color) { if (node.color) {
return node.color; return node.color;
} else if (this.widgetMode === "ribbon" && node.id === this.noteId) { } else if (this.widgetMode === "ribbon" && node.id === this.noteId) {
@ -276,7 +371,7 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
} }
} }
generateColorFromString(str) { generateColorFromString(str: string) {
if (this.themeStyle === "dark") { if (this.themeStyle === "dark") {
str = `0${str}`; // magic lightning modifier str = `0${str}`; // magic lightning modifier
} }
@ -295,20 +390,22 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
return color; return color;
} }
rgb2hex(rgb) { rgb2hex(rgb: string) {
return `#${rgb return `#${(rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/) || [])
.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/)
.slice(1) .slice(1)
.map((n) => parseInt(n, 10).toString(16).padStart(2, "0")) .map((n) => parseInt(n, 10).toString(16).padStart(2, "0"))
.join("")}`; .join("")}`;
} }
setZoomLevel(level) { setZoomLevel(level: number) {
this.zoomLevel = level; this.zoomLevel = level;
} }
paintNode(node, color, ctx) { paintNode(node: Node, color: string, ctx: CanvasRenderingContext2D) {
const { x, y } = node; const { x, y } = node;
if (!x || !y) {
return;
}
const size = this.noteIdToSizeMap[node.id]; const size = this.noteIdToSizeMap[node.id];
ctx.fillStyle = color; ctx.fillStyle = color;
@ -322,8 +419,8 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
return; return;
} }
ctx.fillStyle = this.css.textColor; ctx.fillStyle = this.cssData.textColor;
ctx.font = `${size}px ${this.css.fontFamily}`; ctx.font = `${size}px ${this.cssData.fontFamily}`;
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.textBaseline = "middle"; ctx.textBaseline = "middle";
@ -336,26 +433,29 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
ctx.fillText(title, x, y + Math.round(size * 1.5)); ctx.fillText(title, x, y + Math.round(size * 1.5));
} }
paintLink(link, ctx) { paintLink(link: Link, ctx: CanvasRenderingContext2D) {
if (this.zoomLevel < 5) { if (this.zoomLevel < 5) {
return; return;
} }
ctx.font = `3px ${this.css.fontFamily}`; ctx.font = `3px ${this.cssData.fontFamily}`;
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.textBaseline = "middle"; ctx.textBaseline = "middle";
ctx.fillStyle = this.css.mutedTextColor; ctx.fillStyle = this.cssData.mutedTextColor;
const { source, target } = link; const { source, target } = link;
if (typeof source !== "object" || typeof target !== "object") {
return;
}
const x = (source.x + target.x) / 2; if (source.x && source.y && target.x && target.y) {
const y = (source.y + target.y) / 2; const x = ((source.x) + (target.x)) / 2;
const y = ((source.y) + (target.y)) / 2;
ctx.save(); ctx.save();
ctx.translate(x, y); ctx.translate(x, y);
const deltaY = source.y - target.y; const deltaY = (source.y) - (target.y);
const deltaX = source.x - target.x; const deltaX = (source.x) - (target.x);
let angle = Math.atan2(deltaY, deltaX); let angle = Math.atan2(deltaY, deltaX);
let moveY = 2; let moveY = 2;
@ -367,11 +467,13 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
ctx.rotate(angle); ctx.rotate(angle);
ctx.fillText(link.name, 0, moveY); ctx.fillText(link.name, 0, moveY);
}
ctx.restore(); ctx.restore();
} }
async loadNotesAndRelations(mapRootNoteId) { async loadNotesAndRelations(mapRootNoteId: string): Promise<NotesAndRelationsData> {
const resp = await server.post(`note-map/${mapRootNoteId}/${this.mapType}`); const resp = await server.post<PostNotesMapResponse>(`note-map/${mapRootNoteId}/${this.mapType}`);
this.calculateNodeSizes(resp); this.calculateNodeSizes(resp);
@ -395,8 +497,8 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
}; };
} }
getGroupedLinks(links) { getGroupedLinks(links: ResponseLink[]): GroupedLink[] {
const linksGroupedBySourceTarget = {}; const linksGroupedBySourceTarget: Record<string, GroupedLink> = {};
for (const link of links) { for (const link of links) {
const key = `${link.sourceNoteId}-${link.targetNoteId}`; const key = `${link.sourceNoteId}-${link.targetNoteId}`;
@ -418,7 +520,7 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
return Object.values(linksGroupedBySourceTarget); return Object.values(linksGroupedBySourceTarget);
} }
calculateNodeSizes(resp) { calculateNodeSizes(resp: PostNotesMapResponse) {
this.noteIdToSizeMap = {}; this.noteIdToSizeMap = {};
if (this.mapType === "tree") { if (this.mapType === "tree") {
@ -434,7 +536,7 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
} }
} }
} else if (this.mapType === "link") { } else if (this.mapType === "link") {
const noteIdToLinkCount = {}; const noteIdToLinkCount: Record<string, number> = {};
for (const link of resp.links) { for (const link of resp.links) {
noteIdToLinkCount[link.targetNoteId] = 1 + (noteIdToLinkCount[link.targetNoteId] || 0); noteIdToLinkCount[link.targetNoteId] = 1 + (noteIdToLinkCount[link.targetNoteId] || 0);
@ -450,7 +552,7 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
} }
} }
renderData(data) { renderData(data: Data) {
this.graph.graphData(data); this.graph.graphData(data);
if (this.widgetMode === "ribbon" && this.note?.type !== "search") { if (this.widgetMode === "ribbon" && this.note?.type !== "search") {
@ -473,7 +575,7 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
const noteIdsWithLinks = this.getNoteIdsWithLinks(data); const noteIdsWithLinks = this.getNoteIdsWithLinks(data);
if (noteIdsWithLinks.size > 0) { if (noteIdsWithLinks.size > 0) {
this.graph.zoomToFit(400, 30, (node) => noteIdsWithLinks.has(node.id)); this.graph.zoomToFit(400, 30, (node) => noteIdsWithLinks.has(node.id ?? ""));
} }
if (noteIdsWithLinks.size < 30) { if (noteIdsWithLinks.size < 30) {
@ -484,26 +586,36 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
} }
} }
getNoteIdsWithLinks(data) { getNoteIdsWithLinks(data: Data) {
const noteIds = new Set(); const noteIds = new Set<string | number>();
for (const link of data.links) { for (const link of data.links) {
if (typeof link.source === "object" && link.source.id) {
noteIds.add(link.source.id); noteIds.add(link.source.id);
}
if (typeof link.target === "object" && link.target.id) {
noteIds.add(link.target.id); noteIds.add(link.target.id);
} }
}
return noteIds; return noteIds;
} }
getSubGraphConnectedToCurrentNote(data) { getSubGraphConnectedToCurrentNote(data: Data) {
function getGroupedLinks(links, type) { function getGroupedLinks(links: LinkObject<NodeObject>[], type: "source" | "target") {
const map = {}; const map: Record<string | number, LinkObject<NodeObject>[]> = {};
for (const link of links) { for (const link of links) {
if (typeof link[type] !== "object") {
continue;
}
const key = link[type].id; const key = link[type].id;
if (key) {
map[key] = map[key] || []; map[key] = map[key] || [];
map[key].push(link); map[key].push(link);
} }
}
return map; return map;
} }
@ -513,19 +625,23 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
const subGraphNoteIds = new Set(); const subGraphNoteIds = new Set();
function traverseGraph(noteId) { function traverseGraph(noteId?: string | number) {
if (subGraphNoteIds.has(noteId)) { if (!noteId || subGraphNoteIds.has(noteId)) {
return; return;
} }
subGraphNoteIds.add(noteId); subGraphNoteIds.add(noteId);
for (const link of linksBySource[noteId] || []) { for (const link of linksBySource[noteId] || []) {
traverseGraph(link.target.id); if (typeof link.target === "object") {
traverseGraph(link.target?.id);
}
} }
for (const link of linksByTarget[noteId] || []) { for (const link of linksByTarget[noteId] || []) {
traverseGraph(link.source.id); if (typeof link.source === "object") {
traverseGraph(link.source?.id);
}
} }
} }
@ -537,8 +653,9 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
this.$container.html(""); this.$container.html("");
} }
entitiesReloadedEvent({ loadResults }) { entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (loadResults.getAttributeRows(this.componentId).find((attr) => attr.type === "label" && ["mapType", "mapRootNoteId"].includes(attr.name) && attributeService.isAffecting(attr, this.note))) { if (loadResults.getAttributeRows(this.componentId)
.find((attr) => attr.type === "label" && ["mapType", "mapRootNoteId"].includes(attr.name || "") && attributeService.isAffecting(attr, this.note))) {
this.refresh(); this.refresh();
} }
} }