2024-11-30 19:57:06 +01:00
|
|
|
|
import server from "../services/server.js";
|
|
|
|
|
|
import attributeService from "../services/attributes.js";
|
|
|
|
|
|
import hoistedNoteService from "../services/hoisted_note.js";
|
2025-01-17 21:25:36 +02:00
|
|
|
|
import appContext, { type EventData } from "../components/app_context.js";
|
2024-11-30 19:57:06 +01:00
|
|
|
|
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";
|
2025-01-17 21:25:36 +02:00
|
|
|
|
import type ForceGraph from "force-graph";
|
|
|
|
|
|
import type { GraphData, LinkObject, NodeObject } from "force-graph";
|
|
|
|
|
|
import type FNote from "../entities/fnote.js";
|
2022-12-26 10:52:28 +01:00
|
|
|
|
|
|
|
|
|
|
const esc = utils.escapeHtml;
|
2021-09-22 21:11:36 +02:00
|
|
|
|
|
2025-04-01 23:24:21 +03:00
|
|
|
|
const TPL = /*html*/`<div class="note-map-widget">
|
2025-03-22 13:06:00 +02:00
|
|
|
|
<div class="btn-group btn-group-sm map-type-switcher content-floating-buttons top-left" role="group">
|
2025-02-23 06:17:57 +02:00
|
|
|
|
<button type="button" class="btn bx bx-network-chart tn-tool-button" title="${t("note-map.button-link-map")}" data-type="link"></button>
|
|
|
|
|
|
<button type="button" class="btn bx bx-sitemap tn-tool-button" title="${t("note-map.button-tree-map")}" data-type="tree"></button>
|
2024-11-30 19:20:07 +01:00
|
|
|
|
</div>
|
2024-11-30 19:57:06 +01:00
|
|
|
|
|
2025-10-04 11:07:16 +03:00
|
|
|
|
<!-- UI for dragging Notes and link force -->
|
2024-11-30 19:57:06 +01:00
|
|
|
|
|
2025-03-22 13:06:00 +02:00
|
|
|
|
<div class="btn-group-sm fixnodes-type-switcher content-floating-buttons bottom-left" role="group">
|
2025-02-23 06:17:57 +02:00
|
|
|
|
<button type="button" data-toggle="button" class="btn bx bx-lock-alt tn-tool-button" title="${t("note_map.fix-nodes")}" data-type="moveable"></button>
|
|
|
|
|
|
<input type="range" class="slider" min="1" title="${t("note_map.link-distance")}" max="100" value="40" >
|
2021-09-22 21:11:36 +02:00
|
|
|
|
</div>
|
2021-06-27 12:53:05 +02:00
|
|
|
|
|
2021-09-22 22:25:39 +02:00
|
|
|
|
|
2021-09-22 21:11:36 +02:00
|
|
|
|
<div class="note-map-container"></div>
|
|
|
|
|
|
</div>`;
|
2021-05-28 23:19:11 +02:00
|
|
|
|
|
2025-01-17 21:25:36 +02:00
|
|
|
|
type WidgetMode = "type" | "ribbon";
|
|
|
|
|
|
type Data = GraphData<NodeObject, LinkObject<NodeObject>>;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2021-09-22 21:11:36 +02:00
|
|
|
|
export default class NoteMapWidget extends NoteContextAwareWidget {
|
2025-01-17 21:25:36 +02:00
|
|
|
|
|
|
|
|
|
|
private fixNodes: boolean;
|
|
|
|
|
|
private widgetMode: WidgetMode;
|
|
|
|
|
|
|
|
|
|
|
|
private themeStyle!: string;
|
|
|
|
|
|
private $container!: JQuery<HTMLElement>;
|
|
|
|
|
|
private $styleResolver!: JQuery<HTMLElement>;
|
2025-02-23 06:17:57 +02:00
|
|
|
|
private $fixNodesButton!: JQuery<HTMLElement>;
|
2025-01-19 21:21:13 +02:00
|
|
|
|
graph!: ForceGraph;
|
2025-01-17 21:25:36 +02:00
|
|
|
|
private noteIdToSizeMap!: Record<string, number>;
|
|
|
|
|
|
private zoomLevel!: number;
|
|
|
|
|
|
private nodes!: Node[];
|
|
|
|
|
|
|
|
|
|
|
|
constructor(widgetMode: WidgetMode) {
|
2021-09-22 22:25:39 +02:00
|
|
|
|
super();
|
2024-11-30 19:57:06 +01:00
|
|
|
|
this.fixNodes = false; // needed to save the status of the UI element. Is set later in the code
|
2021-09-22 22:25:39 +02:00
|
|
|
|
this.widgetMode = widgetMode; // 'type' or 'ribbon'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2021-05-28 23:19:11 +02:00
|
|
|
|
doRender() {
|
|
|
|
|
|
this.$widget = $(TPL);
|
2021-05-31 21:20:30 +02:00
|
|
|
|
|
2024-11-30 19:57:06 +01:00
|
|
|
|
this.$container = this.$widget.find(".note-map-container");
|
2025-01-09 18:07:02 +02:00
|
|
|
|
this.$styleResolver = this.$widget.find(".style-resolver");
|
2025-02-23 06:17:57 +02:00
|
|
|
|
this.$fixNodesButton = this.$widget.find(".fixnodes-type-switcher > button");
|
2021-05-31 23:38:47 +02:00
|
|
|
|
|
2023-08-15 22:50:13 +02:00
|
|
|
|
new ResizeObserver(() => this.setDimensions()).observe(this.$container[0]);
|
2021-05-31 21:20:30 +02:00
|
|
|
|
|
2025-01-09 18:07:02 +02:00
|
|
|
|
this.$widget.find(".map-type-switcher button").on("click", async (e) => {
|
2024-11-30 19:57:06 +01:00
|
|
|
|
const type = $(e.target).closest("button").attr("data-type");
|
|
|
|
|
|
|
2025-01-17 21:25:36 +02:00
|
|
|
|
await attributeService.setLabel(this.noteId ?? "", "mapType", type);
|
2021-05-31 21:20:30 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
2025-02-23 06:17:57 +02:00
|
|
|
|
// Reading the status of the Drag nodes Ui element. Changing it´s color when activated.
|
|
|
|
|
|
// Reading Force value of the link distance.
|
|
|
|
|
|
this.$fixNodesButton.on("click", async (event) => {
|
2024-11-30 19:20:07 +01:00
|
|
|
|
this.fixNodes = !this.fixNodes;
|
2025-02-23 06:17:57 +02:00
|
|
|
|
this.$fixNodesButton.toggleClass("toggled", this.fixNodes);
|
2024-11-30 19:20:07 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
2021-09-22 21:11:36 +02:00
|
|
|
|
super.doRender();
|
2021-05-31 23:38:47 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-01-17 21:25:36 +02:00
|
|
|
|
async refreshWithNote(note: FNote) {
|
2021-09-22 21:11:36 +02:00
|
|
|
|
this.$widget.show();
|
2021-05-31 21:31:07 +02:00
|
|
|
|
|
2025-01-17 20:21:31 +02:00
|
|
|
|
const ForceGraph = (await import("force-graph")).default;
|
2025-01-17 21:25:36 +02:00
|
|
|
|
this.graph = new ForceGraph(this.$container[0])
|
2024-11-30 19:20:07 +01:00
|
|
|
|
//Code to fixate nodes when dragged
|
2025-01-09 18:07:02 +02:00
|
|
|
|
.onNodeDragEnd((node) => {
|
2024-11-30 19:20:07 +01:00
|
|
|
|
if (this.fixNodes) {
|
|
|
|
|
|
node.fx = node.x;
|
|
|
|
|
|
node.fy = node.y;
|
|
|
|
|
|
} else {
|
2025-01-17 21:25:36 +02:00
|
|
|
|
node.fx = undefined;
|
|
|
|
|
|
node.fy = undefined;
|
2024-11-30 19:20:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
})
|
2025-01-09 00:04:09 +02:00
|
|
|
|
|
2024-11-30 19:57:06 +01:00
|
|
|
|
// set link width to immitate a highlight effekt. Checking the condition if any links are saved in the previous defined set highlightlinks
|
2025-01-09 18:07:02 +02:00
|
|
|
|
.linkWidth((link) => (highlightLinks.has(link) ? 3 : 0.4))
|
2025-01-17 21:25:36 +02:00
|
|
|
|
.linkColor((link) => (highlightLinks.has(link) ? "white" : this.cssData.mutedTextColor))
|
2024-11-30 19:57:06 +01:00
|
|
|
|
.linkDirectionalArrowLength(4)
|
|
|
|
|
|
.linkDirectionalArrowRelPos(0.95)
|
|
|
|
|
|
|
2025-10-04 12:29:37 +03:00
|
|
|
|
// Rendering code was here
|
2024-11-30 19:57:06 +01:00
|
|
|
|
|
2025-01-17 21:25:36 +02:00
|
|
|
|
.nodePointerAreaPaint((node, _, ctx) => this.paintNode(node as Node, this.getColorForNode(node as Node), ctx))
|
2021-05-31 21:31:07 +02:00
|
|
|
|
.nodePointerAreaPaint((node, color, ctx) => {
|
2025-01-17 21:25:36 +02:00
|
|
|
|
if (!node.id) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2021-05-31 21:31:07 +02:00
|
|
|
|
ctx.fillStyle = color;
|
|
|
|
|
|
ctx.beginPath();
|
2025-01-17 21:25:36 +02:00
|
|
|
|
if (node.x && node.y) {
|
2025-03-02 20:47:57 +01:00
|
|
|
|
ctx.arc(node.x, node.y, this.noteIdToSizeMap[node.id], 0, 2 * Math.PI, false);
|
2025-01-17 21:25:36 +02:00
|
|
|
|
}
|
2021-05-31 21:31:07 +02:00
|
|
|
|
ctx.fill();
|
|
|
|
|
|
})
|
2025-01-17 21:25:36 +02:00
|
|
|
|
.nodeLabel((node) => esc((node as Node).name))
|
|
|
|
|
|
.onNodeClick((node) => {
|
|
|
|
|
|
if (node.id) {
|
2025-03-03 21:02:18 +01:00
|
|
|
|
appContext.tabManager.getActiveContext()?.setNote((node as Node).id);
|
2025-01-17 21:25:36 +02:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.onNodeRightClick((node, e) => {
|
|
|
|
|
|
if (node.id) {
|
|
|
|
|
|
linkContextMenuService.openContextMenu((node as Node).id, e);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2021-05-31 21:31:07 +02:00
|
|
|
|
|
2025-01-09 18:07:02 +02:00
|
|
|
|
if (this.mapType === "link") {
|
2021-09-22 21:11:36 +02:00
|
|
|
|
this.graph
|
2025-01-17 21:25:36 +02:00
|
|
|
|
.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 as Link, ctx))
|
2024-11-30 19:57:06 +01:00
|
|
|
|
.linkCanvasObjectMode(() => "after");
|
2021-09-22 21:11:36 +02:00
|
|
|
|
}
|
2021-05-31 21:31:07 +02:00
|
|
|
|
|
2021-09-28 13:27:21 +02:00
|
|
|
|
const nodeLinkRatio = data.nodes.length / data.links.length;
|
2021-10-03 11:04:56 +02:00
|
|
|
|
const magnifiedRatio = Math.pow(nodeLinkRatio, 1.5);
|
|
|
|
|
|
const charge = -20 / magnifiedRatio;
|
|
|
|
|
|
const boundedCharge = Math.min(-3, charge);
|
2024-11-30 19:57:06 +01:00
|
|
|
|
let distancevalue = 40; // default value for the link force of the nodes
|
2024-11-30 19:20:07 +01:00
|
|
|
|
|
2025-01-09 18:07:02 +02:00
|
|
|
|
this.$widget.find(".fixnodes-type-switcher input").on("change", async (e) => {
|
2025-01-17 21:25:36 +02:00
|
|
|
|
distancevalue = parseInt(e.target.closest("input")?.value ?? "0");
|
|
|
|
|
|
this.graph.d3Force("link")?.distance(distancevalue);
|
2024-11-30 19:20:07 +01:00
|
|
|
|
|
|
|
|
|
|
this.renderData(data);
|
|
|
|
|
|
});
|
2021-09-28 13:27:21 +02:00
|
|
|
|
|
2025-01-17 21:25:36 +02:00
|
|
|
|
this.graph.d3Force("center")?.strength(0.2);
|
|
|
|
|
|
this.graph.d3Force("charge")?.strength(boundedCharge);
|
|
|
|
|
|
this.graph.d3Force("charge")?.distanceMax(1000);
|
2024-11-30 19:57:06 +01:00
|
|
|
|
|
2021-09-22 22:25:39 +02:00
|
|
|
|
this.renderData(data);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-01-17 21:25:36 +02:00
|
|
|
|
setZoomLevel(level: number) {
|
2021-09-22 21:11:36 +02:00
|
|
|
|
this.zoomLevel = level;
|
|
|
|
|
|
}
|
2021-06-01 22:03:38 +02:00
|
|
|
|
|
2025-01-17 21:25:36 +02:00
|
|
|
|
paintLink(link: Link, ctx: CanvasRenderingContext2D) {
|
2021-06-02 21:39:18 +02:00
|
|
|
|
if (this.zoomLevel < 5) {
|
2021-05-31 21:31:07 +02:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-01-17 21:25:36 +02:00
|
|
|
|
ctx.font = `3px ${this.cssData.fontFamily}`;
|
2025-01-09 18:07:02 +02:00
|
|
|
|
ctx.textAlign = "center";
|
|
|
|
|
|
ctx.textBaseline = "middle";
|
2025-01-17 21:25:36 +02:00
|
|
|
|
ctx.fillStyle = this.cssData.mutedTextColor;
|
2021-05-31 21:31:07 +02:00
|
|
|
|
|
2025-01-09 18:07:02 +02:00
|
|
|
|
const { source, target } = link;
|
2025-01-17 21:25:36 +02:00
|
|
|
|
if (typeof source !== "object" || typeof target !== "object") {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2021-05-31 21:31:07 +02:00
|
|
|
|
|
2025-01-17 21:25:36 +02:00
|
|
|
|
if (source.x && source.y && target.x && target.y) {
|
2025-03-02 20:47:57 +01:00
|
|
|
|
const x = (source.x + target.x) / 2;
|
|
|
|
|
|
const y = (source.y + target.y) / 2;
|
2025-01-17 21:25:36 +02:00
|
|
|
|
ctx.save();
|
|
|
|
|
|
ctx.translate(x, y);
|
2024-11-30 19:57:06 +01:00
|
|
|
|
|
2025-03-02 20:47:57 +01:00
|
|
|
|
const deltaY = source.y - target.y;
|
|
|
|
|
|
const deltaX = source.x - target.x;
|
2021-05-31 21:31:07 +02:00
|
|
|
|
|
2025-01-17 21:25:36 +02:00
|
|
|
|
let angle = Math.atan2(deltaY, deltaX);
|
|
|
|
|
|
let moveY = 2;
|
2021-05-31 21:31:07 +02:00
|
|
|
|
|
2025-01-17 21:25:36 +02:00
|
|
|
|
if (angle < -Math.PI / 2 || angle > Math.PI / 2) {
|
|
|
|
|
|
angle += Math.PI;
|
|
|
|
|
|
moveY = -2;
|
|
|
|
|
|
}
|
2021-05-31 21:31:07 +02:00
|
|
|
|
|
2025-01-17 21:25:36 +02:00
|
|
|
|
ctx.rotate(angle);
|
|
|
|
|
|
ctx.fillText(link.name, 0, moveY);
|
2021-05-31 21:31:07 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ctx.restore();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-01-17 21:25:36 +02:00
|
|
|
|
renderData(data: Data) {
|
2025-01-09 18:07:02 +02:00
|
|
|
|
if (this.widgetMode === "ribbon" && this.note?.type !== "search") {
|
2021-09-22 22:25:39 +02:00
|
|
|
|
setTimeout(() => {
|
2022-11-05 22:32:50 +01:00
|
|
|
|
this.setDimensions();
|
|
|
|
|
|
|
2021-10-21 22:52:52 +02:00
|
|
|
|
const subGraphNoteIds = this.getSubGraphConnectedToCurrentNote(data);
|
2021-09-22 22:25:39 +02:00
|
|
|
|
|
2025-01-09 18:07:02 +02:00
|
|
|
|
this.graph.zoomToFit(400, 50, (node) => subGraphNoteIds.has(node.id));
|
2021-11-13 22:48:30 +01:00
|
|
|
|
|
|
|
|
|
|
if (subGraphNoteIds.size < 30) {
|
|
|
|
|
|
this.graph.d3VelocityDecay(0.4);
|
|
|
|
|
|
}
|
2021-09-22 22:25:39 +02:00
|
|
|
|
}, 1000);
|
2025-01-09 18:07:02 +02:00
|
|
|
|
} else {
|
2021-09-22 22:25:39 +02:00
|
|
|
|
if (data.nodes.length > 1) {
|
2021-11-13 22:48:30 +01:00
|
|
|
|
setTimeout(() => {
|
2022-11-05 22:32:50 +01:00
|
|
|
|
this.setDimensions();
|
|
|
|
|
|
|
2022-11-07 23:19:38 +01:00
|
|
|
|
const noteIdsWithLinks = this.getNoteIdsWithLinks(data);
|
|
|
|
|
|
|
|
|
|
|
|
if (noteIdsWithLinks.size > 0) {
|
2025-01-17 21:25:36 +02:00
|
|
|
|
this.graph.zoomToFit(400, 30, (node) => noteIdsWithLinks.has(node.id ?? ""));
|
2022-11-07 23:19:38 +01:00
|
|
|
|
}
|
2021-11-13 22:48:30 +01:00
|
|
|
|
|
2022-11-07 23:19:38 +01:00
|
|
|
|
if (noteIdsWithLinks.size < 30) {
|
2021-11-13 22:48:30 +01:00
|
|
|
|
this.graph.d3VelocityDecay(0.4);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 1000);
|
2021-09-22 22:25:39 +02:00
|
|
|
|
}
|
2021-05-28 23:19:11 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-01-17 21:25:36 +02:00
|
|
|
|
getNoteIdsWithLinks(data: Data) {
|
|
|
|
|
|
const noteIds = new Set<string | number>();
|
2022-11-07 23:19:38 +01:00
|
|
|
|
|
|
|
|
|
|
for (const link of data.links) {
|
2025-01-17 21:25:36 +02:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2022-11-07 23:19:38 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return noteIds;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-01-17 21:25:36 +02:00
|
|
|
|
getSubGraphConnectedToCurrentNote(data: Data) {
|
|
|
|
|
|
function getGroupedLinks(links: LinkObject<NodeObject>[], type: "source" | "target") {
|
|
|
|
|
|
const map: Record<string | number, LinkObject<NodeObject>[]> = {};
|
2021-10-21 22:52:52 +02:00
|
|
|
|
|
|
|
|
|
|
for (const link of links) {
|
2025-01-17 21:25:36 +02:00
|
|
|
|
if (typeof link[type] !== "object") {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2021-11-13 22:48:30 +01:00
|
|
|
|
const key = link[type].id;
|
2025-01-17 21:25:36 +02:00
|
|
|
|
if (key) {
|
|
|
|
|
|
map[key] = map[key] || [];
|
|
|
|
|
|
map[key].push(link);
|
|
|
|
|
|
}
|
2021-10-21 22:52:52 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return map;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-11-30 19:57:06 +01:00
|
|
|
|
const linksBySource = getGroupedLinks(data.links, "source");
|
|
|
|
|
|
const linksByTarget = getGroupedLinks(data.links, "target");
|
2021-10-21 22:52:52 +02:00
|
|
|
|
|
|
|
|
|
|
const subGraphNoteIds = new Set();
|
|
|
|
|
|
|
2025-01-17 21:25:36 +02:00
|
|
|
|
function traverseGraph(noteId?: string | number) {
|
|
|
|
|
|
if (!noteId || subGraphNoteIds.has(noteId)) {
|
2021-10-21 22:52:52 +02:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
subGraphNoteIds.add(noteId);
|
|
|
|
|
|
|
|
|
|
|
|
for (const link of linksBySource[noteId] || []) {
|
2025-01-17 21:25:36 +02:00
|
|
|
|
if (typeof link.target === "object") {
|
|
|
|
|
|
traverseGraph(link.target?.id);
|
|
|
|
|
|
}
|
2021-10-21 22:52:52 +02:00
|
|
|
|
}
|
2021-11-13 22:48:30 +01:00
|
|
|
|
|
|
|
|
|
|
for (const link of linksByTarget[noteId] || []) {
|
2025-01-17 21:25:36 +02:00
|
|
|
|
if (typeof link.source === "object") {
|
|
|
|
|
|
traverseGraph(link.source?.id);
|
|
|
|
|
|
}
|
2021-11-13 22:48:30 +01:00
|
|
|
|
}
|
2021-10-21 22:52:52 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
traverseGraph(this.noteId);
|
|
|
|
|
|
return subGraphNoteIds;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2021-09-22 21:11:36 +02:00
|
|
|
|
cleanup() {
|
2025-01-09 18:07:02 +02:00
|
|
|
|
this.$container.html("");
|
2021-06-02 21:23:40 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-01-17 21:25:36 +02:00
|
|
|
|
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
|
|
|
|
|
if (loadResults.getAttributeRows(this.componentId)
|
|
|
|
|
|
.find((attr) => attr.type === "label" && ["mapType", "mapRootNoteId"].includes(attr.name || "") && attributeService.isAffecting(attr, this.note))) {
|
2021-06-01 22:03:38 +02:00
|
|
|
|
this.refresh();
|
2021-05-28 23:19:11 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|