diff --git a/src/public/app/components/note_context.ts b/src/public/app/components/note_context.ts
index e5b649e56..cc3e368f3 100644
--- a/src/public/app/components/note_context.ts
+++ b/src/public/app/components/note_context.ts
@@ -24,7 +24,7 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
notePath?: string | null;
noteId?: string | null;
- private parentNoteId?: string | null;
+ parentNoteId?: string | null;
viewScope?: ViewScope;
constructor(ntxId: string | null = null, hoistedNoteId: string = "root", mainNtxId: string | null = null) {
diff --git a/src/public/app/menus/link_context_menu.ts b/src/public/app/menus/link_context_menu.ts
index b2eb973ec..3343ec85e 100644
--- a/src/public/app/menus/link_context_menu.ts
+++ b/src/public/app/menus/link_context_menu.ts
@@ -3,7 +3,7 @@ import contextMenu from "./context_menu.js";
import appContext from "../components/app_context.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({
x: e.pageX,
y: e.pageY,
diff --git a/src/public/app/widgets/note_map.js b/src/public/app/widgets/note_map.ts
similarity index 66%
rename from src/public/app/widgets/note_map.js
rename to src/public/app/widgets/note_map.ts
index cde0d07ee..51401294d 100644
--- a/src/public/app/widgets/note_map.js
+++ b/src/public/app/widgets/note_map.ts
@@ -1,11 +1,14 @@
import server from "../services/server.js";
import attributeService from "../services/attributes.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 linkContextMenuService from "../menus/link_context_menu.js";
import utils from "../services/utils.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;
@@ -92,8 +95,80 @@ const TPL = `
`;
+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;
+
+ 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;
+}
+
+interface GroupedLink {
+ id: string;
+ sourceNoteId: string;
+ targetNoteId: string;
+ names: string[]
+}
+
+interface CssData {
+ fontFamily: string;
+ textColor: string;
+ mutedTextColor: string;
+}
+
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;
+ private $styleResolver!: JQuery;
+ private graph!: ForceGraph;
+ private noteIdToSizeMap!: Record;
+ private zoomLevel!: number;
+ private nodes!: Node[];
+
+ constructor(widgetMode: WidgetMode) {
super();
this.fixNodes = false; // needed to save the status of the UI element. Is set later in the code
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) => {
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.
@@ -134,30 +209,32 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
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.css = {
+ this.cssData = {
fontFamily: this.$container.css("font-family"),
textColor: this.rgb2hex(this.$container.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
- let hoverNode = null;
+ let hoverNode: NodeObject | null = null;
const highlightLinks = new Set();
const neighbours = new Set();
const ForceGraph = (await import("force-graph")).default;
- this.graph = ForceGraph()(this.$container[0])
- .width(this.$container.width())
- .height(this.$container.height())
+ 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)
@@ -168,8 +245,8 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
node.fx = node.x;
node.fy = node.y;
} else {
- node.fx = null;
- node.fy = null;
+ node.fx = undefined;
+ 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
@@ -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
.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)
.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.
- .nodeCanvasObject((node, ctx) => {
+ .nodeCanvasObject((_node, ctx) => {
+ const node = _node as Node;
if (hoverNode == node) {
//paint only hovered node
this.paintNode(node, "#661822", ctx);
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
if (link.source.id == node.id || link.target.id == node.id) {
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) => {
+ if (!node.id) {
+ return;
+ }
+
ctx.fillStyle = color;
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();
})
- .nodeLabel((node) => esc(node.name))
+ .nodeLabel((node) => esc((node as Node).name))
.maxZoom(7)
.warmupTicks(30)
- .onNodeClick((node) => appContext.tabManager.getActiveContext().setNote(node.id))
- .onNodeRightClick((node, e) => linkContextMenuService.openContextMenu(node.id, e));
+ .onNodeClick((node) => {
+ 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") {
this.graph
- .linkLabel((l) => `${esc(l.source.name)} - ${esc(l.name)} - ${esc(l.target.name)}`)
- .linkCanvasObject((link, ctx) => this.paintLink(link, ctx))
+ .linkLabel((l) => `${esc((l as Link).source.name)} - ${esc((l as Link).name)} - ${esc((l as Link).target.name)}`)
+ .linkCanvasObject((link, ctx) => this.paintLink(link as Link, ctx))
.linkCanvasObjectMode(() => "after");
}
@@ -237,25 +332,25 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
let distancevalue = 40; // default value for the link force of the nodes
this.$widget.find(".fixnodes-type-switcher input").on("change", async (e) => {
- distancevalue = e.target.closest("input").value;
- this.graph.d3Force("link").distance(distancevalue);
+ distancevalue = parseInt(e.target.closest("input")?.value ?? "0");
+ this.graph.d3Force("link")?.distance(distancevalue);
this.renderData(data);
});
- this.graph.d3Force("center").strength(0.2);
- this.graph.d3Force("charge").strength(boundedCharge);
- this.graph.d3Force("charge").distanceMax(1000);
+ this.graph.d3Force("center")?.strength(0.2);
+ this.graph.d3Force("charge")?.strength(boundedCharge);
+ this.graph.d3Force("charge")?.distanceMax(1000);
this.renderData(data);
}
- getMapRootNoteId() {
- if (this.widgetMode === "ribbon") {
+ getMapRootNoteId(): string {
+ if (this.noteId && this.widgetMode === "ribbon") {
return this.noteId;
}
- let mapRootNoteId = this.note.getLabelValue("mapRootNoteId");
+ let mapRootNoteId = this.note?.getLabelValue("mapRootNoteId");
if (mapRootNoteId === "hoisted") {
mapRootNoteId = hoistedNoteService.getHoistedNoteId();
@@ -263,10 +358,10 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
mapRootNoteId = appContext.tabManager.getActiveContext().parentNoteId;
}
- return mapRootNoteId;
+ return mapRootNoteId ?? "";
}
- getColorForNode(node) {
+ getColorForNode(node: Node) {
if (node.color) {
return node.color;
} 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") {
str = `0${str}`; // magic lightning modifier
}
@@ -295,20 +390,22 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
return color;
}
- rgb2hex(rgb) {
- return `#${rgb
- .match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/)
+ 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("")}`;
}
- setZoomLevel(level) {
+ setZoomLevel(level: number) {
this.zoomLevel = level;
}
- paintNode(node, color, ctx) {
+ paintNode(node: Node, color: string, ctx: CanvasRenderingContext2D) {
const { x, y } = node;
+ if (!x || !y) {
+ return;
+ }
const size = this.noteIdToSizeMap[node.id];
ctx.fillStyle = color;
@@ -322,8 +419,8 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
return;
}
- ctx.fillStyle = this.css.textColor;
- ctx.font = `${size}px ${this.css.fontFamily}`;
+ ctx.fillStyle = this.cssData.textColor;
+ ctx.font = `${size}px ${this.cssData.fontFamily}`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
@@ -336,42 +433,47 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
ctx.fillText(title, x, y + Math.round(size * 1.5));
}
- paintLink(link, ctx) {
+ paintLink(link: Link, ctx: CanvasRenderingContext2D) {
if (this.zoomLevel < 5) {
return;
}
- ctx.font = `3px ${this.css.fontFamily}`;
+ ctx.font = `3px ${this.cssData.fontFamily}`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
- ctx.fillStyle = this.css.mutedTextColor;
+ ctx.fillStyle = this.cssData.mutedTextColor;
const { source, target } = link;
-
- const x = (source.x + target.x) / 2;
- const y = (source.y + target.y) / 2;
-
- ctx.save();
- ctx.translate(x, y);
-
- const deltaY = source.y - target.y;
- const deltaX = source.x - target.x;
-
- let angle = Math.atan2(deltaY, deltaX);
- let moveY = 2;
-
- if (angle < -Math.PI / 2 || angle > Math.PI / 2) {
- angle += Math.PI;
- moveY = -2;
+ if (typeof source !== "object" || typeof target !== "object") {
+ return;
+ }
+
+ if (source.x && source.y && target.x && target.y) {
+ const x = ((source.x) + (target.x)) / 2;
+ const y = ((source.y) + (target.y)) / 2;
+ ctx.save();
+ ctx.translate(x, y);
+
+ const deltaY = (source.y) - (target.y);
+ const deltaX = (source.x) - (target.x);
+
+ let angle = Math.atan2(deltaY, deltaX);
+ let moveY = 2;
+
+ if (angle < -Math.PI / 2 || angle > Math.PI / 2) {
+ angle += Math.PI;
+ moveY = -2;
+ }
+
+ ctx.rotate(angle);
+ ctx.fillText(link.name, 0, moveY);
}
- ctx.rotate(angle);
- ctx.fillText(link.name, 0, moveY);
ctx.restore();
}
- async loadNotesAndRelations(mapRootNoteId) {
- const resp = await server.post(`note-map/${mapRootNoteId}/${this.mapType}`);
+ async loadNotesAndRelations(mapRootNoteId: string): Promise {
+ const resp = await server.post(`note-map/${mapRootNoteId}/${this.mapType}`);
this.calculateNodeSizes(resp);
@@ -395,8 +497,8 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
};
}
- getGroupedLinks(links) {
- const linksGroupedBySourceTarget = {};
+ getGroupedLinks(links: ResponseLink[]): GroupedLink[] {
+ const linksGroupedBySourceTarget: Record = {};
for (const link of links) {
const key = `${link.sourceNoteId}-${link.targetNoteId}`;
@@ -418,7 +520,7 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
return Object.values(linksGroupedBySourceTarget);
}
- calculateNodeSizes(resp) {
+ calculateNodeSizes(resp: PostNotesMapResponse) {
this.noteIdToSizeMap = {};
if (this.mapType === "tree") {
@@ -434,7 +536,7 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
}
}
} else if (this.mapType === "link") {
- const noteIdToLinkCount = {};
+ const noteIdToLinkCount: Record = {};
for (const link of resp.links) {
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);
if (this.widgetMode === "ribbon" && this.note?.type !== "search") {
@@ -473,7 +575,7 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
const noteIdsWithLinks = this.getNoteIdsWithLinks(data);
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) {
@@ -484,25 +586,35 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
}
}
- getNoteIdsWithLinks(data) {
- const noteIds = new Set();
+ getNoteIdsWithLinks(data: Data) {
+ const noteIds = new Set();
for (const link of data.links) {
- noteIds.add(link.source.id);
- noteIds.add(link.target.id);
+ if (typeof link.source === "object" && link.source.id) {
+ noteIds.add(link.source.id);
+ }
+ if (typeof link.target === "object" && link.target.id) {
+ noteIds.add(link.target.id);
+ }
}
return noteIds;
}
- getSubGraphConnectedToCurrentNote(data) {
- function getGroupedLinks(links, type) {
- const map = {};
+ getSubGraphConnectedToCurrentNote(data: Data) {
+ function getGroupedLinks(links: LinkObject[], type: "source" | "target") {
+ const map: Record[]> = {};
for (const link of links) {
+ if (typeof link[type] !== "object") {
+ continue;
+ }
+
const key = link[type].id;
- map[key] = map[key] || [];
- map[key].push(link);
+ if (key) {
+ map[key] = map[key] || [];
+ map[key].push(link);
+ }
}
return map;
@@ -513,19 +625,23 @@ export default class NoteMapWidget extends NoteContextAwareWidget {
const subGraphNoteIds = new Set();
- function traverseGraph(noteId) {
- if (subGraphNoteIds.has(noteId)) {
+ function traverseGraph(noteId?: string | number) {
+ if (!noteId || subGraphNoteIds.has(noteId)) {
return;
}
subGraphNoteIds.add(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] || []) {
- 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("");
}
- entitiesReloadedEvent({ loadResults }) {
- if (loadResults.getAttributeRows(this.componentId).find((attr) => attr.type === "label" && ["mapType", "mapRootNoteId"].includes(attr.name) && attributeService.isAffecting(attr, this.note))) {
+ entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
+ if (loadResults.getAttributeRows(this.componentId)
+ .find((attr) => attr.type === "label" && ["mapType", "mapRootNoteId"].includes(attr.name || "") && attributeService.isAffecting(attr, this.note))) {
this.refresh();
}
}