mirror of
				https://github.com/zadam/trilium.git
				synced 2025-11-04 05:28:59 +01:00 
			
		
		
		
	split of note map widget from type widget
This commit is contained in:
		
							parent
							
								
									208492bee1
								
							
						
					
					
						commit
						0f693dae5e
					
				@ -35,7 +35,7 @@ import ClosePaneButton from "../widgets/buttons/close_pane_button.js";
 | 
			
		||||
import BasicPropertiesWidget from "../widgets/ribbon_widgets/basic_properties.js";
 | 
			
		||||
import NoteInfoWidget from "../widgets/ribbon_widgets/note_info_widget.js";
 | 
			
		||||
import BookPropertiesWidget from "../widgets/ribbon_widgets/book_properties.js";
 | 
			
		||||
import LinkMapWidget from "../widgets/ribbon_widgets/link_map.js";
 | 
			
		||||
import NoteMapRibbonWidget from "../widgets/ribbon_widgets/note_map.js";
 | 
			
		||||
import NotePathsWidget from "../widgets/ribbon_widgets/note_paths.js";
 | 
			
		||||
import SimilarNotesWidget from "../widgets/ribbon_widgets/similar_notes.js";
 | 
			
		||||
import RightPaneContainer from "../widgets/containers/right_pane_container.js";
 | 
			
		||||
@ -135,7 +135,7 @@ export default class DesktopLayout {
 | 
			
		||||
                                        .ribbon(new OwnedAttributeListWidget())
 | 
			
		||||
                                        .ribbon(new InheritedAttributesWidget())
 | 
			
		||||
                                        .ribbon(new NotePathsWidget())
 | 
			
		||||
                                        .ribbon(new LinkMapWidget())
 | 
			
		||||
                                        .ribbon(new NoteMapRibbonWidget())
 | 
			
		||||
                                        .ribbon(new SimilarNotesWidget())
 | 
			
		||||
                                        .ribbon(new NoteInfoWidget())
 | 
			
		||||
                                        .button(new EditButton())
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										315
									
								
								src/public/app/widgets/note_map.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										315
									
								
								src/public/app/widgets/note_map.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,315 @@
 | 
			
		||||
import libraryLoader from "../services/library_loader.js";
 | 
			
		||||
import server from "../services/server.js";
 | 
			
		||||
import attributeService from "../services/attributes.js";
 | 
			
		||||
import hoistedNoteService from "../services/hoisted_note.js";
 | 
			
		||||
import appContext from "../services/app_context.js";
 | 
			
		||||
import NoteContextAwareWidget from "./note_context_aware_widget.js";
 | 
			
		||||
 | 
			
		||||
const TPL = `<div class="note-map-widget" style="position: relative;">
 | 
			
		||||
    <style>
 | 
			
		||||
        .type-special .note-detail, .note-detail-note-map {
 | 
			
		||||
            height: 100%;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        .map-type-switcher {
 | 
			
		||||
            position: absolute; 
 | 
			
		||||
            top: 10px; 
 | 
			
		||||
            right: 10px; 
 | 
			
		||||
            background-color: var(--accented-background-color);
 | 
			
		||||
            z-index: 1000;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        .map-type-switcher .bx {
 | 
			
		||||
            font-size: x-large;
 | 
			
		||||
        }
 | 
			
		||||
    </style>
 | 
			
		||||
    
 | 
			
		||||
    <div class="btn-group btn-group-sm map-type-switcher" role="group">
 | 
			
		||||
      <button type="button" class="btn btn-secondary" title="Link Map" data-type="link"><span class="bx bx-network-chart"></span></button>
 | 
			
		||||
      <button type="button" class="btn btn-secondary" title="Tree map" data-type="tree"><span class="bx bx-sitemap"></span></button>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="note-map-container"></div>
 | 
			
		||||
</div>`;
 | 
			
		||||
 | 
			
		||||
export default class NoteMapWidget extends NoteContextAwareWidget {
 | 
			
		||||
    doRender() {
 | 
			
		||||
        this.$widget = $(TPL);
 | 
			
		||||
 | 
			
		||||
        this.$container = this.$widget.find(".note-map-container");
 | 
			
		||||
 | 
			
		||||
        window.addEventListener('resize', () => this.setFullHeight(), false);
 | 
			
		||||
 | 
			
		||||
        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);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        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('.note-map-container')
 | 
			
		||||
            .css("height", height)
 | 
			
		||||
            .css("width", this.$widget.width());
 | 
			
		||||
 | 
			
		||||
        this.graph
 | 
			
		||||
            .height(height)
 | 
			
		||||
            .width(width);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async refreshWithNote() {
 | 
			
		||||
        this.$widget.show();
 | 
			
		||||
 | 
			
		||||
        this.mapType = this.note.getLabelValue("mapType") === "tree" ? "tree" : "link";
 | 
			
		||||
 | 
			
		||||
        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))
 | 
			
		||||
            .d3AlphaDecay(0.01)
 | 
			
		||||
            .d3VelocityDecay(0.08)
 | 
			
		||||
            .nodeCanvasObject((node, ctx) => this.paintNode(node, this.stringToColor(node.type), ctx))
 | 
			
		||||
            .nodePointerAreaPaint((node, ctx) => this.paintNode(node, this.stringToColor(node.type), ctx))
 | 
			
		||||
            .nodePointerAreaPaint((node, color, ctx) => {
 | 
			
		||||
                ctx.fillStyle = color;
 | 
			
		||||
                ctx.beginPath();
 | 
			
		||||
                ctx.arc(node.x, node.y, this.noteIdToSizeMap[node.id], 0, 2 * Math.PI, false);
 | 
			
		||||
                ctx.fill();
 | 
			
		||||
            })
 | 
			
		||||
            .nodeLabel(node => node.name)
 | 
			
		||||
            .maxZoom(7)
 | 
			
		||||
            .warmupTicks(10)
 | 
			
		||||
            .linkDirectionalArrowLength(5)
 | 
			
		||||
            .linkDirectionalArrowRelPos(1)
 | 
			
		||||
            .linkWidth(1)
 | 
			
		||||
            .linkColor(() => this.css.mutedTextColor)
 | 
			
		||||
            .onNodeClick(node => this.nodeClicked(node));
 | 
			
		||||
 | 
			
		||||
        if (this.mapType === 'link') {
 | 
			
		||||
            this.graph
 | 
			
		||||
                .linkLabel(l => `${l.source.name} - <strong>${l.name}</strong> - ${l.target.name}`)
 | 
			
		||||
                .linkCanvasObject((link, ctx) => this.paintLink(link, ctx))
 | 
			
		||||
                .linkCanvasObjectMode(() => "after");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.graph.d3Force('link').distance(40);
 | 
			
		||||
        this.graph.d3Force('center').strength(0.01);
 | 
			
		||||
        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;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const data = await this.loadNotesAndRelations(mapRootNoteId);
 | 
			
		||||
 | 
			
		||||
        this.renderData(data);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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;
 | 
			
		||||
        const size = this.noteIdToSizeMap[node.id];
 | 
			
		||||
 | 
			
		||||
        ctx.fillStyle = node.id === this.noteId ? 'red' : color;
 | 
			
		||||
        ctx.beginPath();
 | 
			
		||||
        ctx.arc(x, y, size, 0, 2 * Math.PI, false);
 | 
			
		||||
        ctx.fill();
 | 
			
		||||
 | 
			
		||||
        const toRender = this.zoomLevel > 2
 | 
			
		||||
            || (this.zoomLevel > 1 && size > 6)
 | 
			
		||||
            || (this.zoomLevel > 0.3 && size > 10);
 | 
			
		||||
 | 
			
		||||
        if (!toRender) {
 | 
			
		||||
            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';
 | 
			
		||||
        ctx.textBaseline = 'middle';
 | 
			
		||||
 | 
			
		||||
        let title = node.name;
 | 
			
		||||
 | 
			
		||||
        if (title.length > 15) {
 | 
			
		||||
            title = title.substr(0, 15) + "...";
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ctx.fillText(title, x, y + Math.round(size * 1.5));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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(mapRootNoteId) {
 | 
			
		||||
        this.linkIdToLinkMap = {};
 | 
			
		||||
        this.noteIdToLinkCountMap = {};
 | 
			
		||||
 | 
			
		||||
        const resp = await server.post(`note-map/${mapRootNoteId}/${this.mapType}`);
 | 
			
		||||
 | 
			
		||||
        this.noteIdToLinkCountMap = resp.noteIdToLinkCountMap;
 | 
			
		||||
 | 
			
		||||
        this.calculateSizes(resp.noteIdToDescendantCountMap);
 | 
			
		||||
 | 
			
		||||
        for (const link of resp.links) {
 | 
			
		||||
            this.linkIdToLinkMap[link.id] = link;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        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: resp.notes.map(([noteId, title, type]) => ({
 | 
			
		||||
                id: noteId,
 | 
			
		||||
                name: title,
 | 
			
		||||
                type: type,
 | 
			
		||||
                expanded: true
 | 
			
		||||
            })),
 | 
			
		||||
            links: Object.values(linksGroupedBySourceTarget).map(link => ({
 | 
			
		||||
                id: link.id,
 | 
			
		||||
                source: link.sourceNoteId,
 | 
			
		||||
                target: link.targetNoteId,
 | 
			
		||||
                name: link.names.join(", ")
 | 
			
		||||
            }))
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    calculateSizes(noteIdToDescendantCountMap) {
 | 
			
		||||
        this.noteIdToSizeMap = {};
 | 
			
		||||
 | 
			
		||||
        for (const noteId in noteIdToDescendantCountMap) {
 | 
			
		||||
            this.noteIdToSizeMap[noteId] = 4;
 | 
			
		||||
 | 
			
		||||
            const count = noteIdToDescendantCountMap[noteId];
 | 
			
		||||
 | 
			
		||||
            if (count > 0) {
 | 
			
		||||
                this.noteIdToSizeMap[noteId] += 1 + Math.round(Math.log(count) / Math.log(1.5));
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    renderData(data, zoomToFit = true, zoomPadding = 10) {
 | 
			
		||||
        this.graph.graphData(data);
 | 
			
		||||
 | 
			
		||||
        if (zoomToFit && data.nodes.length > 1) {
 | 
			
		||||
            setTimeout(() => this.graph.zoomToFit(400, zoomPadding), 1000);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    cleanup() {
 | 
			
		||||
        this.$container.html('');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    entitiesReloadedEvent({loadResults}) {
 | 
			
		||||
        if (loadResults.getAttributes(this.componentId).find(attr => attr.name === 'mapType' && attributeService.isAffecting(attr, this.note))) {
 | 
			
		||||
            this.refresh();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@ -5,13 +5,13 @@ import server from "../../services/server.js";
 | 
			
		||||
import appContext from "../../services/app_context.js";
 | 
			
		||||
 | 
			
		||||
const TPL = `
 | 
			
		||||
<div class="link-map-widget">
 | 
			
		||||
<div class="note-map-ribbon-widget">
 | 
			
		||||
    <style>
 | 
			
		||||
        .link-map-widget {
 | 
			
		||||
        .note-map-ribbon-widget {
 | 
			
		||||
            position: relative;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        .link-map-widget .link-map-container {
 | 
			
		||||
        .note-map-ribbon-widget .note-map-container {
 | 
			
		||||
            height: 300px;
 | 
			
		||||
        }
 | 
			
		||||
    
 | 
			
		||||
@ -32,18 +32,18 @@ const TPL = `
 | 
			
		||||
    <button class="bx bx-arrow-to-bottom icon-action open-full-button" title="Open full"></button>
 | 
			
		||||
    <button class="bx bx-arrow-to-top icon-action collapse-button" style="display: none;" title="Collapse to normal size"></button>
 | 
			
		||||
 | 
			
		||||
    <div class="link-map-container"></div>
 | 
			
		||||
    <div class="note-map-container"></div>
 | 
			
		||||
    
 | 
			
		||||
    <div class="style-resolver"></div>
 | 
			
		||||
</div>`;
 | 
			
		||||
 | 
			
		||||
export default class LinkMapWidget extends NoteContextAwareWidget {
 | 
			
		||||
export default class NoteMapRibbonWidget extends NoteContextAwareWidget {
 | 
			
		||||
    get name() {
 | 
			
		||||
        return "linkMap";
 | 
			
		||||
        return "noteMap";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    get toggleCommand() {
 | 
			
		||||
        return "toggleRibbonTabLinkMap";
 | 
			
		||||
        return "toggleRibbonTabNoteMap";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    isEnabled() {
 | 
			
		||||
@ -53,15 +53,15 @@ export default class LinkMapWidget extends NoteContextAwareWidget {
 | 
			
		||||
    getTitle() {
 | 
			
		||||
        return {
 | 
			
		||||
            show: this.isEnabled(),
 | 
			
		||||
            title: 'Link Map',
 | 
			
		||||
            icon: 'bx bx-network-chart'
 | 
			
		||||
            title: 'Note Map',
 | 
			
		||||
            icon: 'bx bx-map-alt'
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    doRender() {
 | 
			
		||||
        this.$widget = $(TPL);
 | 
			
		||||
        this.contentSized();
 | 
			
		||||
        this.$container = this.$widget.find(".link-map-container");
 | 
			
		||||
        this.$container = this.$widget.find(".note-map-container");
 | 
			
		||||
 | 
			
		||||
        this.openState = 'small';
 | 
			
		||||
 | 
			
		||||
@ -106,7 +106,7 @@ export default class LinkMapWidget extends NoteContextAwareWidget {
 | 
			
		||||
        const SMALL_SIZE_HEIGHT = 300;
 | 
			
		||||
        const width = this.$widget.width();
 | 
			
		||||
 | 
			
		||||
        this.$widget.find('.link-map-container')
 | 
			
		||||
        this.$widget.find('.note-map-container')
 | 
			
		||||
            .css("height", SMALL_SIZE_HEIGHT)
 | 
			
		||||
            .css("width", width);
 | 
			
		||||
 | 
			
		||||
@ -121,7 +121,7 @@ export default class LinkMapWidget extends NoteContextAwareWidget {
 | 
			
		||||
        const height = $(window).height() - top;
 | 
			
		||||
        const width = this.$widget.width();
 | 
			
		||||
 | 
			
		||||
        this.$widget.find('.link-map-container')
 | 
			
		||||
        this.$widget.find('.note-map-container')
 | 
			
		||||
            .css("height", height)
 | 
			
		||||
            .css("width", this.$widget.width());
 | 
			
		||||
 | 
			
		||||
@ -1,317 +1,28 @@
 | 
			
		||||
import TypeWidget from "./type_widget.js";
 | 
			
		||||
import libraryLoader from "../../services/library_loader.js";
 | 
			
		||||
import server from "../../services/server.js";
 | 
			
		||||
import attributeService from "../../services/attributes.js";
 | 
			
		||||
import hoistedNoteService from "../../services/hoisted_note.js";
 | 
			
		||||
import appContext from "../../services/app_context.js";
 | 
			
		||||
import NoteMapWidget from "../note_map.js";
 | 
			
		||||
 | 
			
		||||
const TPL = `<div class="note-detail-note-map note-detail-printable" style="position: relative;">
 | 
			
		||||
    <style>
 | 
			
		||||
        .type-special .note-detail, .note-detail-note-map {
 | 
			
		||||
            height: 100%;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        .map-type-switcher {
 | 
			
		||||
            position: absolute; 
 | 
			
		||||
            top: 10px; 
 | 
			
		||||
            right: 10px; 
 | 
			
		||||
            background-color: var(--accented-background-color);
 | 
			
		||||
            z-index: 1000;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        .map-type-switcher .bx {
 | 
			
		||||
            font-size: x-large;
 | 
			
		||||
        }
 | 
			
		||||
    </style>
 | 
			
		||||
    
 | 
			
		||||
    <div class="btn-group btn-group-sm map-type-switcher" role="group">
 | 
			
		||||
      <button type="button" class="btn btn-secondary" title="Link Map" data-type="link"><span class="bx bx-network-chart"></span></button>
 | 
			
		||||
      <button type="button" class="btn btn-secondary" title="Tree map" data-type="tree"><span class="bx bx-sitemap"></span></button>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="note-map-container"></div>
 | 
			
		||||
</div>`;
 | 
			
		||||
const TPL = `<div class="note-detail-note-map note-detail-printable"></div>`;
 | 
			
		||||
 | 
			
		||||
export default class NoteMapTypeWidget extends TypeWidget {
 | 
			
		||||
    static getType() { return "note-map"; }
 | 
			
		||||
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super();
 | 
			
		||||
 | 
			
		||||
        this.noteMapWidget = new NoteMapWidget();
 | 
			
		||||
        this.child(this.noteMapWidget);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    doRender() {
 | 
			
		||||
        this.$widget = $(TPL);
 | 
			
		||||
 | 
			
		||||
        this.$container = this.$widget.find(".note-map-container");
 | 
			
		||||
 | 
			
		||||
        window.addEventListener('resize', () => this.setFullHeight(), false);
 | 
			
		||||
 | 
			
		||||
        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);
 | 
			
		||||
        });
 | 
			
		||||
        this.$widget.append(this.noteMapWidget.render());
 | 
			
		||||
 | 
			
		||||
        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('.note-map-container')
 | 
			
		||||
            .css("height", height)
 | 
			
		||||
            .css("width", this.$widget.width());
 | 
			
		||||
 | 
			
		||||
        this.graph
 | 
			
		||||
            .height(height)
 | 
			
		||||
            .width(width);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async doRefresh(note) {
 | 
			
		||||
        this.$widget.show();
 | 
			
		||||
        console.log("isEnabled", this.noteMapWidget.isEnabled());
 | 
			
		||||
 | 
			
		||||
        this.mapType = this.note.getLabelValue("mapType") === "tree" ? "tree" : "link";
 | 
			
		||||
 | 
			
		||||
        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))
 | 
			
		||||
            .d3AlphaDecay(0.01)
 | 
			
		||||
            .d3VelocityDecay(0.08)
 | 
			
		||||
            .nodeCanvasObject((node, ctx) => this.paintNode(node, this.stringToColor(node.type), ctx))
 | 
			
		||||
            .nodePointerAreaPaint((node, ctx) => this.paintNode(node, this.stringToColor(node.type), ctx))
 | 
			
		||||
            .nodePointerAreaPaint((node, color, ctx) => {
 | 
			
		||||
                ctx.fillStyle = color;
 | 
			
		||||
                ctx.beginPath();
 | 
			
		||||
                ctx.arc(node.x, node.y, this.noteIdToSizeMap[node.id], 0, 2 * Math.PI, false);
 | 
			
		||||
                ctx.fill();
 | 
			
		||||
            })
 | 
			
		||||
            .nodeLabel(node => node.name)
 | 
			
		||||
            .maxZoom(7)
 | 
			
		||||
            .warmupTicks(10)
 | 
			
		||||
            .linkDirectionalArrowLength(5)
 | 
			
		||||
            .linkDirectionalArrowRelPos(1)
 | 
			
		||||
            .linkWidth(1)
 | 
			
		||||
            .linkColor(() => this.css.mutedTextColor)
 | 
			
		||||
            .onNodeClick(node => this.nodeClicked(node));
 | 
			
		||||
 | 
			
		||||
        if (this.mapType === 'link') {
 | 
			
		||||
            this.graph
 | 
			
		||||
                .linkLabel(l => `${l.source.name} - <strong>${l.name}</strong> - ${l.target.name}`)
 | 
			
		||||
                .linkCanvasObject((link, ctx) => this.paintLink(link, ctx))
 | 
			
		||||
                .linkCanvasObjectMode(() => "after");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.graph.d3Force('link').distance(40);
 | 
			
		||||
        this.graph.d3Force('center').strength(0.01);
 | 
			
		||||
        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;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const data = await this.loadNotesAndRelations(mapRootNoteId);
 | 
			
		||||
 | 
			
		||||
        this.renderData(data);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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;
 | 
			
		||||
        const size = this.noteIdToSizeMap[node.id];
 | 
			
		||||
 | 
			
		||||
        ctx.fillStyle = node.id === this.noteId ? 'red' : color;
 | 
			
		||||
        ctx.beginPath();
 | 
			
		||||
        ctx.arc(x, y, size, 0, 2 * Math.PI, false);
 | 
			
		||||
        ctx.fill();
 | 
			
		||||
 | 
			
		||||
        const toRender = this.zoomLevel > 2
 | 
			
		||||
            || (this.zoomLevel > 1 && size > 6)
 | 
			
		||||
            || (this.zoomLevel > 0.3 && size > 10);
 | 
			
		||||
 | 
			
		||||
        if (!toRender) {
 | 
			
		||||
            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';
 | 
			
		||||
        ctx.textBaseline = 'middle';
 | 
			
		||||
 | 
			
		||||
        let title = node.name;
 | 
			
		||||
 | 
			
		||||
        if (title.length > 15) {
 | 
			
		||||
            title = title.substr(0, 15) + "...";
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ctx.fillText(title, x, y + Math.round(size * 1.5));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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(mapRootNoteId) {
 | 
			
		||||
        this.linkIdToLinkMap = {};
 | 
			
		||||
        this.noteIdToLinkCountMap = {};
 | 
			
		||||
 | 
			
		||||
        const resp = await server.post(`note-map/${mapRootNoteId}/${this.mapType}`);
 | 
			
		||||
 | 
			
		||||
        this.noteIdToLinkCountMap = resp.noteIdToLinkCountMap;
 | 
			
		||||
 | 
			
		||||
        this.calculateSizes(resp.noteIdToDescendantCountMap);
 | 
			
		||||
 | 
			
		||||
        for (const link of resp.links) {
 | 
			
		||||
            this.linkIdToLinkMap[link.id] = link;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        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: resp.notes.map(([noteId, title, type]) => ({
 | 
			
		||||
                id: noteId,
 | 
			
		||||
                name: title,
 | 
			
		||||
                type: type,
 | 
			
		||||
                expanded: true
 | 
			
		||||
            })),
 | 
			
		||||
            links: Object.values(linksGroupedBySourceTarget).map(link => ({
 | 
			
		||||
                id: link.id,
 | 
			
		||||
                source: link.sourceNoteId,
 | 
			
		||||
                target: link.targetNoteId,
 | 
			
		||||
                name: link.names.join(", ")
 | 
			
		||||
            }))
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    calculateSizes(noteIdToDescendantCountMap) {
 | 
			
		||||
        this.noteIdToSizeMap = {};
 | 
			
		||||
 | 
			
		||||
        for (const noteId in noteIdToDescendantCountMap) {
 | 
			
		||||
            this.noteIdToSizeMap[noteId] = 4;
 | 
			
		||||
 | 
			
		||||
            const count = noteIdToDescendantCountMap[noteId];
 | 
			
		||||
 | 
			
		||||
            if (count > 0) {
 | 
			
		||||
                this.noteIdToSizeMap[noteId] += 1 + Math.round(Math.log(count) / Math.log(1.5));
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    renderData(data, zoomToFit = true, zoomPadding = 10) {
 | 
			
		||||
        this.graph.graphData(data);
 | 
			
		||||
 | 
			
		||||
        if (zoomToFit && data.nodes.length > 1) {
 | 
			
		||||
            setTimeout(() => this.graph.zoomToFit(400, zoomPadding), 1000);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    cleanup() {
 | 
			
		||||
        this.$container.html('');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    entitiesReloadedEvent({loadResults}) {
 | 
			
		||||
        if (loadResults.getAttributes(this.componentId).find(attr => attr.name === 'mapType' && attributeService.isAffecting(attr, this.note))) {
 | 
			
		||||
            this.refresh();
 | 
			
		||||
        }
 | 
			
		||||
        await this.noteMapWidget.refresh();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -384,7 +384,7 @@ const DEFAULT_KEYBOARD_ACTIONS = [
 | 
			
		||||
        scope: "window"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        actionName: "toggleRibbonTabLinkMap",
 | 
			
		||||
        actionName: "toggleRibbonTabNoteMap",
 | 
			
		||||
        defaultShortcuts: [],
 | 
			
		||||
        description: "Toggle Link Map",
 | 
			
		||||
        scope: "window"
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user