import server from "../services/server.js"; import attributeService from "../services/attributes.js"; import hoistedNoteService from "../services/hoisted_note.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; const TPL = /*html*/`
`; type WidgetMode = "type" | "ribbon"; type Data = GraphData>; export default class NoteMapWidget extends NoteContextAwareWidget { private fixNodes: boolean; private widgetMode: WidgetMode; private themeStyle!: string; private $container!: JQuery; private $fixNodesButton!: JQuery; graph!: ForceGraph; private noteIdToSizeMap!: Record; 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' } doRender() { this.$widget = $(TPL); this.$container = this.$widget.find(".note-map-container"); this.$styleResolver = this.$widget.find(".style-resolver"); this.$fixNodesButton = this.$widget.find(".fixnodes-type-switcher > button"); new ResizeObserver(() => this.setDimensions()).observe(this.$container[0]); // 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) => { this.fixNodes = !this.fixNodes; this.$fixNodesButton.toggleClass("toggled", this.fixNodes); }); super.doRender(); } async refreshWithNote(note: FNote) { this.$widget.show(); const ForceGraph = (await import("force-graph")).default; this.graph = new ForceGraph(this.$container[0]) //Code to fixate nodes when dragged .onNodeDragEnd((node) => { if (this.fixNodes) { node.fx = node.x; node.fy = node.y; } else { node.fx = undefined; node.fy = undefined; } }) // Rendering code was here const nodeLinkRatio = data.nodes.length / data.links.length; const magnifiedRatio = Math.pow(nodeLinkRatio, 1.5); const charge = -20 / magnifiedRatio; const boundedCharge = Math.min(-3, charge); let distancevalue = 40; // default value for the link force of the nodes this.$widget.find(".fixnodes-type-switcher input").on("change", async (e) => { 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.renderData(data); } renderData(data: Data) { if (this.widgetMode === "ribbon" && this.note?.type !== "search") { setTimeout(() => { this.setDimensions(); const subGraphNoteIds = this.getSubGraphConnectedToCurrentNote(data); this.graph.zoomToFit(400, 50, (node) => subGraphNoteIds.has(node.id)); if (subGraphNoteIds.size < 30) { this.graph.d3VelocityDecay(0.4); } }, 1000); } else { if (data.nodes.length > 1) { setTimeout(() => { this.setDimensions(); const noteIdsWithLinks = this.getNoteIdsWithLinks(data); if (noteIdsWithLinks.size > 0) { this.graph.zoomToFit(400, 30, (node) => noteIdsWithLinks.has(node.id ?? "")); } if (noteIdsWithLinks.size < 30) { this.graph.d3VelocityDecay(0.4); } }, 1000); } } } getNoteIdsWithLinks(data: Data) { const noteIds = new Set(); for (const link of data.links) { 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: 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; if (key) { map[key] = map[key] || []; map[key].push(link); } } return map; } const linksBySource = getGroupedLinks(data.links, "source"); const linksByTarget = getGroupedLinks(data.links, "target"); const subGraphNoteIds = new Set(); function traverseGraph(noteId?: string | number) { if (!noteId || subGraphNoteIds.has(noteId)) { return; } subGraphNoteIds.add(noteId); for (const link of linksBySource[noteId] || []) { if (typeof link.target === "object") { traverseGraph(link.target?.id); } } for (const link of linksByTarget[noteId] || []) { if (typeof link.source === "object") { traverseGraph(link.source?.id); } } } traverseGraph(this.noteId); return subGraphNoteIds; } cleanup() { this.$container.html(""); } 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(); } } }