mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 10:26:08 +01:00 
			
		
		
		
	unified note map with ribbon map
This commit is contained in:
		| @@ -29,16 +29,25 @@ const TPL = `<div class="note-map-widget" style="position: relative;"> | ||||
|       <button type="button" class="btn btn-secondary" title="Tree map" data-type="tree"><span class="bx bx-sitemap"></span></button> | ||||
|     </div> | ||||
|  | ||||
|     <div class="style-resolver"></div> | ||||
|  | ||||
|     <div class="note-map-container"></div> | ||||
| </div>`; | ||||
|  | ||||
| export default class NoteMapWidget extends NoteContextAwareWidget { | ||||
|     constructor(widgetMode) { | ||||
|         super(); | ||||
|  | ||||
|         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'); | ||||
|  | ||||
|         window.addEventListener('resize', () => this.setFullHeight(), false); | ||||
|         window.addEventListener('resize', () => this.setHeight(), false); | ||||
|  | ||||
|         this.$widget.find(".map-type-switcher button").on("click",  async e => { | ||||
|             const type = $(e.target).closest("button").attr("data-type"); | ||||
| @@ -49,31 +58,30 @@ export default class NoteMapWidget extends NoteContextAwareWidget { | ||||
|         super.doRender(); | ||||
|     } | ||||
|  | ||||
|     setFullHeight() { | ||||
|     setHeight() { | ||||
|         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('.note-map-container') | ||||
|             .css("height", height) | ||||
|             .css("width", this.$widget.width()); | ||||
|         const $parent = this.$widget.parent(); | ||||
|  | ||||
|         this.graph | ||||
|             .height(height) | ||||
|             .width(width); | ||||
|             .height($parent.height()) | ||||
|             .width($parent.width()); | ||||
|     } | ||||
|  | ||||
|     async refreshWithNote() { | ||||
|         this.$widget.show(); | ||||
|  | ||||
|         this.css = { | ||||
|             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.setFullHeight(); | ||||
|         this.setHeight(); | ||||
|  | ||||
|         await libraryLoader.requireLibrary(libraryLoader.FORCE_GRAPH); | ||||
|  | ||||
| @@ -98,7 +106,7 @@ export default class NoteMapWidget extends NoteContextAwareWidget { | ||||
|             .linkDirectionalArrowRelPos(1) | ||||
|             .linkWidth(1) | ||||
|             .linkColor(() => this.css.mutedTextColor) | ||||
|             .onNodeClick(node => this.nodeClicked(node)); | ||||
|             .onNodeClick(node => appContext.tabManager.getActiveContext().setNote(node.id)); | ||||
|  | ||||
|         if (this.mapType === 'link') { | ||||
|             this.graph | ||||
| @@ -112,20 +120,29 @@ export default class NoteMapWidget extends NoteContextAwareWidget { | ||||
|         this.graph.d3Force('charge').strength(-30); | ||||
|         this.graph.d3Force('charge').distanceMax(1000); | ||||
|  | ||||
|         let mapRootNoteId = this.note.getLabelValue("mapRootNoteId"); | ||||
|  | ||||
|         if (mapRootNoteId === 'hoisted') { | ||||
|             mapRootNoteId = hoistedNoteService.getHoistedNoteId(); | ||||
|         } | ||||
|         else if (!mapRootNoteId) { | ||||
|             mapRootNoteId = appContext.tabManager.getActiveContext().parentNoteId; | ||||
|         } | ||||
|         let mapRootNoteId = this.getMapRootNoteId(); | ||||
|  | ||||
|         const data = await this.loadNotesAndRelations(mapRootNoteId); | ||||
|  | ||||
|         this.renderData(data); | ||||
|     } | ||||
|  | ||||
|     getMapRootNoteId() { | ||||
|         if (this.widgetMode === 'ribbon') { | ||||
|             return this.noteId; | ||||
|         } | ||||
|  | ||||
|         let mapRootNoteId = this.note.getLabelValue("mapRootNoteId"); | ||||
|  | ||||
|         if (mapRootNoteId === 'hoisted') { | ||||
|             mapRootNoteId = hoistedNoteService.getHoistedNoteId(); | ||||
|         } else if (!mapRootNoteId) { | ||||
|             mapRootNoteId = appContext.tabManager.getActiveContext().parentNoteId; | ||||
|         } | ||||
|  | ||||
|         return mapRootNoteId; | ||||
|     } | ||||
|  | ||||
|     stringToColor(str) { | ||||
|         let hash = 0; | ||||
|         for (let i = 0; i < str.length; i++) { | ||||
| @@ -167,14 +184,6 @@ export default class NoteMapWidget extends NoteContextAwareWidget { | ||||
|             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 = size + 'px ' + this.css.fontFamily; | ||||
|         ctx.textAlign = 'center'; | ||||
| @@ -265,13 +274,14 @@ export default class NoteMapWidget extends NoteContextAwareWidget { | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return { | ||||
|             nodes: resp.notes.map(([noteId, title, type]) => ({ | ||||
|         this.nodes = resp.notes.map(([noteId, title, type]) => ({ | ||||
|             id: noteId, | ||||
|             name: title, | ||||
|             type: type, | ||||
|                 expanded: true | ||||
|             })), | ||||
|         })); | ||||
|  | ||||
|         return { | ||||
|             nodes: this.nodes, | ||||
|             links: Object.values(linksGroupedBySourceTarget).map(link => ({ | ||||
|                 id: link.id, | ||||
|                 source: link.sourceNoteId, | ||||
| @@ -295,11 +305,20 @@ export default class NoteMapWidget extends NoteContextAwareWidget { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     renderData(data, zoomToFit = true, zoomPadding = 10) { | ||||
|     renderData(data) { | ||||
|         this.graph.graphData(data); | ||||
|  | ||||
|         if (zoomToFit && data.nodes.length > 1) { | ||||
|             setTimeout(() => this.graph.zoomToFit(400, zoomPadding), 1000); | ||||
|         if (this.widgetMode === 'ribbon') { | ||||
|             setTimeout(() => { | ||||
|                 const node = this.nodes.find(node => node.id === this.noteId); | ||||
|  | ||||
|                 this.graph.centerAt(node.x, node.y, 500); | ||||
|             }, 1000); | ||||
|         } | ||||
|         else if (this.widgetMode === 'type') { | ||||
|             if (data.nodes.length > 1) { | ||||
|                 setTimeout(() => this.graph.zoomToFit(400, 10), 1000); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -1,8 +1,5 @@ | ||||
| import NoteContextAwareWidget from "../note_context_aware_widget.js"; | ||||
| import froca from "../../services/froca.js"; | ||||
| import libraryLoader from "../../services/library_loader.js"; | ||||
| import server from "../../services/server.js"; | ||||
| import appContext from "../../services/app_context.js"; | ||||
| import NoteMapWidget from "../note_map.js"; | ||||
|  | ||||
| const TPL = ` | ||||
| <div class="note-map-ribbon-widget"> | ||||
| @@ -33,11 +30,16 @@ const TPL = ` | ||||
|     <button class="bx bx-arrow-to-top icon-action collapse-button" style="display: none;" title="Collapse to normal size"></button> | ||||
|  | ||||
|     <div class="note-map-container"></div> | ||||
|      | ||||
|     <div class="style-resolver"></div> | ||||
| </div>`; | ||||
|  | ||||
| export default class NoteMapRibbonWidget extends NoteContextAwareWidget { | ||||
|     constructor() { | ||||
|         super(); | ||||
|  | ||||
|         this.noteMapWidget = new NoteMapWidget('ribbon'); | ||||
|         this.child(this.noteMapWidget); | ||||
|     } | ||||
|  | ||||
|     get name() { | ||||
|         return "noteMap"; | ||||
|     } | ||||
| @@ -62,6 +64,7 @@ export default class NoteMapRibbonWidget extends NoteContextAwareWidget { | ||||
|         this.$widget = $(TPL); | ||||
|         this.contentSized(); | ||||
|         this.$container = this.$widget.find(".note-map-container"); | ||||
|         this.$container.append(this.noteMapWidget.render()); | ||||
|  | ||||
|         this.openState = 'small'; | ||||
|  | ||||
| @@ -73,6 +76,8 @@ export default class NoteMapRibbonWidget extends NoteContextAwareWidget { | ||||
|             this.$collapseButton.show(); | ||||
|  | ||||
|             this.openState = 'full'; | ||||
|  | ||||
|             this.noteMapWidget.setHeight(); | ||||
|         }); | ||||
|  | ||||
|         this.$collapseButton = this.$widget.find('.collapse-button'); | ||||
| @@ -83,11 +88,10 @@ export default class NoteMapRibbonWidget extends NoteContextAwareWidget { | ||||
|             this.$collapseButton.hide(); | ||||
|  | ||||
|             this.openState = 'small'; | ||||
|  | ||||
|             this.noteMapWidget.setHeight(); | ||||
|         }); | ||||
|  | ||||
|         this.$styleResolver = this.$widget.find('.style-resolver'); | ||||
|  | ||||
|  | ||||
|         window.addEventListener('resize', () => { | ||||
|             if (!this.graph) { // no graph has been even rendered | ||||
|                 return; | ||||
| @@ -107,10 +111,6 @@ export default class NoteMapRibbonWidget extends NoteContextAwareWidget { | ||||
|         const width = this.$widget.width(); | ||||
|  | ||||
|         this.$widget.find('.note-map-container') | ||||
|             .css("height", SMALL_SIZE_HEIGHT) | ||||
|             .css("width", width); | ||||
|  | ||||
|         this.graph | ||||
|             .height(SMALL_SIZE_HEIGHT) | ||||
|             .width(width); | ||||
|     } | ||||
| @@ -122,237 +122,7 @@ export default class NoteMapRibbonWidget extends NoteContextAwareWidget { | ||||
|         const width = this.$widget.width(); | ||||
|  | ||||
|         this.$widget.find('.note-map-container') | ||||
|             .css("height", height) | ||||
|             .css("width", this.$widget.width()); | ||||
|  | ||||
|         this.graph | ||||
|             .height(height) | ||||
|             .width(width); | ||||
|     } | ||||
|  | ||||
|     setZoomLevel(level) { | ||||
|         this.zoomLevel = level; | ||||
|     } | ||||
|  | ||||
|     async refreshWithNote(note) { | ||||
|         this.linkIdToLinkMap = {}; | ||||
|         this.noteIdToLinkCountMap = {}; | ||||
|  | ||||
|         this.$container.empty(); | ||||
|  | ||||
|         this.css = { | ||||
|             fontFamily: this.$container.css("font-family"), | ||||
|             textColor: this.rgb2hex(this.$container.css("color")), | ||||
|             mutedTextColor: this.rgb2hex(this.$styleResolver.css("color")) | ||||
|         }; | ||||
|  | ||||
|         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} - <strong>${l.name}</strong> - ${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(this.noteId,2)); | ||||
|     } | ||||
|  | ||||
|     renderData(data, zoomToFit = true, zoomPadding = 10) { | ||||
|         this.graph.graphData(data); | ||||
|  | ||||
|         if (zoomToFit && data.nodes.length > 1) { | ||||
|             setTimeout(() => this.graph.zoomToFit(400, zoomPadding), 1000); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async nodeClicked(node) { | ||||
|         if (!node.expanded) { | ||||
|             this.renderData( | ||||
|                 await this.loadNotesAndRelations(node.id,1), | ||||
|                 false | ||||
|             ); | ||||
|         } | ||||
|         else { | ||||
|             await appContext.tabManager.getActiveContext().setNote(node.id); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async loadNotesAndRelations(noteId, maxDepth) { | ||||
|         const resp = await server.post(`notes/${noteId}/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(", ") | ||||
|             })) | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     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(); | ||||
|     } | ||||
|  | ||||
|     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)); | ||||
|     } | ||||
|  | ||||
|     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('')}` | ||||
|     } | ||||
|  | ||||
|     entitiesReloadedEvent({loadResults}) { | ||||
|         if (loadResults.getAttributes().find(attr => attr.type === 'relation' && (attr.noteId === this.noteId || attr.value === this.noteId))) { | ||||
|             this.refresh(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -9,7 +9,7 @@ export default class NoteMapTypeWidget extends TypeWidget { | ||||
|     constructor() { | ||||
|         super(); | ||||
|  | ||||
|         this.noteMapWidget = new NoteMapWidget(); | ||||
|         this.noteMapWidget = new NoteMapWidget('type'); | ||||
|         this.child(this.noteMapWidget); | ||||
|     } | ||||
|  | ||||
| @@ -21,8 +21,6 @@ export default class NoteMapTypeWidget extends TypeWidget { | ||||
|     } | ||||
|  | ||||
|     async doRefresh(note) { | ||||
|         console.log("isEnabled", this.noteMapWidget.isEnabled()); | ||||
|  | ||||
|         await this.noteMapWidget.refresh(); | ||||
|     } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user