From 43e829ca9920045d0e6ce61b34556d76cf98f715 Mon Sep 17 00:00:00 2001 From: zadam Date: Fri, 17 Sep 2021 10:09:42 +0200 Subject: [PATCH] global link map setup --- package.json | 2 +- .../buttons/open_note_button_widget.js | 2 + .../app/widgets/ribbon_widgets/link_map.js | 2 +- .../widgets/type_widgets/global_link_map.js | 240 +++++++++++++++++- 4 files changed, 243 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 109dd5066..658302368 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ "jsdoc": "3.6.7", "lorem-ipsum": "2.0.3", "rcedit": "3.0.1", - "webpack": "5.52.1", + "webpack": "5.53.0", "webpack-cli": "4.8.0" }, "optionalDependencies": { diff --git a/src/public/app/widgets/buttons/open_note_button_widget.js b/src/public/app/widgets/buttons/open_note_button_widget.js index 05f3b8244..9e25eb2a6 100644 --- a/src/public/app/widgets/buttons/open_note_button_widget.js +++ b/src/public/app/widgets/buttons/open_note_button_widget.js @@ -1,6 +1,8 @@ import ButtonWidget from "./button_widget.js"; import appContext from "../../services/app_context.js"; +// TODO: here we could read icon and title of the target note and use it for tooltip and displayed icon + export default class OpenNoteButtonWidget extends ButtonWidget { targetNote(noteId) { this.onClick(() => appContext.tabManager.openTabWithNoteWithHoisting(noteId, true)); diff --git a/src/public/app/widgets/ribbon_widgets/link_map.js b/src/public/app/widgets/ribbon_widgets/link_map.js index bba5b978f..8d493dd0c 100644 --- a/src/public/app/widgets/ribbon_widgets/link_map.js +++ b/src/public/app/widgets/ribbon_widgets/link_map.js @@ -11,7 +11,7 @@ const TPL = ` position: relative; } - .link-map-container { + .link-map-widget .link-map-container { height: 300px; } diff --git a/src/public/app/widgets/type_widgets/global_link_map.js b/src/public/app/widgets/type_widgets/global_link_map.js index 6d7161b5f..1f5242d1c 100644 --- a/src/public/app/widgets/type_widgets/global_link_map.js +++ b/src/public/app/widgets/type_widgets/global_link_map.js @@ -1,6 +1,17 @@ import TypeWidget from "./type_widget.js"; +import libraryLoader from "../../services/library_loader.js"; +import server from "../../services/server.js"; +import froca from "../../services/froca.js"; -const TPL = ``; +const TPL = ``; export default class GlobalLinkMapTypeWidget extends TypeWidget { static getType() { return "globallinkmap"; } @@ -8,6 +19,233 @@ export default class GlobalLinkMapTypeWidget extends TypeWidget { doRender() { this.$widget = $(TPL); + this.$container = this.$widget.find(".link-map-container"); + + window.addEventListener('resize', () => this.setFullHeight(), false); + super.doRender(); } + + setFullHeight() { + if (!this.graph) { // no graph has been even rendered + return; + } + + const {top} = this.$widget[0].getBoundingClientRect(); + + const height = $(window).height() - top; + const width = this.$widget.width(); + + this.$widget.find('.link-map-container') + .css("height", height) + .css("width", this.$widget.width()); + + this.graph + .height(height) + .width(width); + } + + async doRefresh(note) { + this.$widget.show(); + + this.setFullHeight(); + + await libraryLoader.requireLibrary(libraryLoader.FORCE_GRAPH); + + this.graph = ForceGraph()(this.$container[0]) + .width(this.$container.width()) + .height(this.$container.height()) + .onZoom(zoom => this.setZoomLevel(zoom.k)) + .nodeRelSize(7) + .nodeCanvasObject((node, ctx) => this.paintNode(node, this.stringToColor(node.type), ctx)) + .nodePointerAreaPaint((node, ctx) => this.paintNode(node, this.stringToColor(node.type), ctx)) + .nodeLabel(node => node.name) + .maxZoom(7) + .nodePointerAreaPaint((node, color, ctx) => { + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(node.x, node.y, 5, 0, 2 * Math.PI, false); + ctx.fill(); + }) + .linkLabel(l => `${l.source.name} - ${l.name} - ${l.target.name}`) + .linkCanvasObject((link, ctx) => this.paintLink(link, ctx)) + .linkCanvasObjectMode(() => "after") + .linkDirectionalArrowLength(4) + .linkDirectionalArrowRelPos(1) + .linkWidth(2) + .linkColor(() => this.css.mutedTextColor) + .d3VelocityDecay(0.2) + .onNodeClick(node => this.nodeClicked(node)); + + this.graph.d3Force('link').distance(50); + + this.graph.d3Force('center').strength(0.9); + + this.graph.d3Force('charge').strength(-30); + this.graph.d3Force('charge').distanceMax(400); + + this.renderData(await this.loadNotesAndRelations()); + } + + stringToColor(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + let colour = '#'; + for (let i = 0; i < 3; i++) { + const value = (hash >> (i * 8)) & 0xFF; + colour += ('00' + value.toString(16)).substr(-2); + } + return colour; + } + + rgb2hex(rgb) { + return `#${rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/) + .slice(1) + .map(n => parseInt(n, 10).toString(16).padStart(2, '0')) + .join('')}` + } + + setZoomLevel(level) { + this.zoomLevel = level; + } + + paintNode(node, color, ctx) { + const {x, y} = node; + + ctx.fillStyle = node.id === this.noteId ? 'red' : color; + ctx.beginPath(); + ctx.arc(x, y, node.id === this.noteId ? 8 : 4, 0, 2 * Math.PI, false); + ctx.fill(); + + if (this.zoomLevel < 2) { + return; + } + + if (!node.expanded) { + ctx.fillStyle = this.css.textColor; + ctx.font = 10 + 'px ' + this.css.fontFamily; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText("+", x, y + 0.5); + } + + ctx.fillStyle = this.css.textColor; + ctx.font = 5 + 'px ' + this.css.fontFamily; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + let title = node.name; + + if (title.length > 15) { + title = title.substr(0, 15) + "..."; + } + + ctx.fillText(title, x, y + (node.id === this.noteId ? 11 : 7)); + } + + paintLink(link, ctx) { + if (this.zoomLevel < 5) { + return; + } + + ctx.font = '3px ' + this.css.fontFamily; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = this.css.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; + } + + ctx.rotate(angle); + ctx.fillText(link.name, 0, moveY); + ctx.restore(); + } + + async loadNotesAndRelations(noteId, maxDepth) { + this.linkIdToLinkMap = {}; + this.noteIdToLinkCountMap = {}; + + const resp = await server.post(`notes/root/link-map`, { + maxNotes: 1000, + maxDepth + }); + + this.noteIdToLinkCountMap = {...this.noteIdToLinkCountMap, ...resp.noteIdToLinkCountMap}; + + for (const link of resp.links) { + this.linkIdToLinkMap[link.id] = link; + } + + // preload all notes + const notes = await froca.getNotes(Object.keys(this.noteIdToLinkCountMap), true); + + const noteIdToLinkIdMap = {}; + noteIdToLinkIdMap[this.noteId] = new Set(); // for case there are no relations + const linksGroupedBySourceTarget = {}; + + for (const link of Object.values(this.linkIdToLinkMap)) { + noteIdToLinkIdMap[link.sourceNoteId] = noteIdToLinkIdMap[link.sourceNoteId] || new Set(); + noteIdToLinkIdMap[link.sourceNoteId].add(link.id); + + noteIdToLinkIdMap[link.targetNoteId] = noteIdToLinkIdMap[link.targetNoteId] || new Set(); + noteIdToLinkIdMap[link.targetNoteId].add(link.id); + + 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 { + nodes: notes.map(note => ({ + id: note.noteId, + name: note.title, + type: note.type, + expanded: this.noteIdToLinkCountMap[note.noteId] === noteIdToLinkIdMap[note.noteId].size + })), + links: Object.values(linksGroupedBySourceTarget).map(link => ({ + id: link.id, + source: link.sourceNoteId, + target: link.targetNoteId, + name: link.names.join(", ") + })) + }; + } + + renderData(data, zoomToFit = true, zoomPadding = 10) { + this.graph.graphData(data); + + if (zoomToFit && data.nodes.length > 1) { + setTimeout(() => this.graph.zoomToFit(400, zoomPadding), 1000); + } + } }