mirror of
https://github.com/zadam/trilium.git
synced 2025-06-06 18:08:33 +02:00
unified note map with ribbon map
This commit is contained in:
parent
0f693dae5e
commit
a766374bf4
@ -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 {
|
||||
}
|
||||
}
|
||||
|
||||
this.nodes = resp.notes.map(([noteId, title, type]) => ({
|
||||
id: noteId,
|
||||
name: title,
|
||||
type: type,
|
||||
}));
|
||||
|
||||
return {
|
||||
nodes: resp.notes.map(([noteId, title, type]) => ({
|
||||
id: noteId,
|
||||
name: title,
|
||||
type: type,
|
||||
expanded: true
|
||||
})),
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user