mirror of
https://github.com/zadam/trilium.git
synced 2025-03-01 14:22:32 +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 BasicPropertiesWidget from "../widgets/ribbon_widgets/basic_properties.js";
|
||||||
import NoteInfoWidget from "../widgets/ribbon_widgets/note_info_widget.js";
|
import NoteInfoWidget from "../widgets/ribbon_widgets/note_info_widget.js";
|
||||||
import BookPropertiesWidget from "../widgets/ribbon_widgets/book_properties.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 NotePathsWidget from "../widgets/ribbon_widgets/note_paths.js";
|
||||||
import SimilarNotesWidget from "../widgets/ribbon_widgets/similar_notes.js";
|
import SimilarNotesWidget from "../widgets/ribbon_widgets/similar_notes.js";
|
||||||
import RightPaneContainer from "../widgets/containers/right_pane_container.js";
|
import RightPaneContainer from "../widgets/containers/right_pane_container.js";
|
||||||
@ -135,7 +135,7 @@ export default class DesktopLayout {
|
|||||||
.ribbon(new OwnedAttributeListWidget())
|
.ribbon(new OwnedAttributeListWidget())
|
||||||
.ribbon(new InheritedAttributesWidget())
|
.ribbon(new InheritedAttributesWidget())
|
||||||
.ribbon(new NotePathsWidget())
|
.ribbon(new NotePathsWidget())
|
||||||
.ribbon(new LinkMapWidget())
|
.ribbon(new NoteMapRibbonWidget())
|
||||||
.ribbon(new SimilarNotesWidget())
|
.ribbon(new SimilarNotesWidget())
|
||||||
.ribbon(new NoteInfoWidget())
|
.ribbon(new NoteInfoWidget())
|
||||||
.button(new EditButton())
|
.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";
|
import appContext from "../../services/app_context.js";
|
||||||
|
|
||||||
const TPL = `
|
const TPL = `
|
||||||
<div class="link-map-widget">
|
<div class="note-map-ribbon-widget">
|
||||||
<style>
|
<style>
|
||||||
.link-map-widget {
|
.note-map-ribbon-widget {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-map-widget .link-map-container {
|
.note-map-ribbon-widget .note-map-container {
|
||||||
height: 300px;
|
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-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>
|
<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 class="style-resolver"></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
export default class LinkMapWidget extends NoteContextAwareWidget {
|
export default class NoteMapRibbonWidget extends NoteContextAwareWidget {
|
||||||
get name() {
|
get name() {
|
||||||
return "linkMap";
|
return "noteMap";
|
||||||
}
|
}
|
||||||
|
|
||||||
get toggleCommand() {
|
get toggleCommand() {
|
||||||
return "toggleRibbonTabLinkMap";
|
return "toggleRibbonTabNoteMap";
|
||||||
}
|
}
|
||||||
|
|
||||||
isEnabled() {
|
isEnabled() {
|
||||||
@ -53,15 +53,15 @@ export default class LinkMapWidget extends NoteContextAwareWidget {
|
|||||||
getTitle() {
|
getTitle() {
|
||||||
return {
|
return {
|
||||||
show: this.isEnabled(),
|
show: this.isEnabled(),
|
||||||
title: 'Link Map',
|
title: 'Note Map',
|
||||||
icon: 'bx bx-network-chart'
|
icon: 'bx bx-map-alt'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
doRender() {
|
doRender() {
|
||||||
this.$widget = $(TPL);
|
this.$widget = $(TPL);
|
||||||
this.contentSized();
|
this.contentSized();
|
||||||
this.$container = this.$widget.find(".link-map-container");
|
this.$container = this.$widget.find(".note-map-container");
|
||||||
|
|
||||||
this.openState = 'small';
|
this.openState = 'small';
|
||||||
|
|
||||||
@ -106,7 +106,7 @@ export default class LinkMapWidget extends NoteContextAwareWidget {
|
|||||||
const SMALL_SIZE_HEIGHT = 300;
|
const SMALL_SIZE_HEIGHT = 300;
|
||||||
const width = this.$widget.width();
|
const width = this.$widget.width();
|
||||||
|
|
||||||
this.$widget.find('.link-map-container')
|
this.$widget.find('.note-map-container')
|
||||||
.css("height", SMALL_SIZE_HEIGHT)
|
.css("height", SMALL_SIZE_HEIGHT)
|
||||||
.css("width", width);
|
.css("width", width);
|
||||||
|
|
||||||
@ -121,7 +121,7 @@ export default class LinkMapWidget extends NoteContextAwareWidget {
|
|||||||
const height = $(window).height() - top;
|
const height = $(window).height() - top;
|
||||||
const width = this.$widget.width();
|
const width = this.$widget.width();
|
||||||
|
|
||||||
this.$widget.find('.link-map-container')
|
this.$widget.find('.note-map-container')
|
||||||
.css("height", height)
|
.css("height", height)
|
||||||
.css("width", this.$widget.width());
|
.css("width", this.$widget.width());
|
||||||
|
|
@ -1,317 +1,28 @@
|
|||||||
import TypeWidget from "./type_widget.js";
|
import TypeWidget from "./type_widget.js";
|
||||||
import libraryLoader from "../../services/library_loader.js";
|
import NoteMapWidget from "../note_map.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";
|
|
||||||
|
|
||||||
const TPL = `<div class="note-detail-note-map note-detail-printable" style="position: relative;">
|
const TPL = `<div class="note-detail-note-map note-detail-printable"></div>`;
|
||||||
<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 NoteMapTypeWidget extends TypeWidget {
|
export default class NoteMapTypeWidget extends TypeWidget {
|
||||||
static getType() { return "note-map"; }
|
static getType() { return "note-map"; }
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.noteMapWidget = new NoteMapWidget();
|
||||||
|
this.child(this.noteMapWidget);
|
||||||
|
}
|
||||||
|
|
||||||
doRender() {
|
doRender() {
|
||||||
this.$widget = $(TPL);
|
this.$widget = $(TPL);
|
||||||
|
this.$widget.append(this.noteMapWidget.render());
|
||||||
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();
|
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) {
|
async doRefresh(note) {
|
||||||
this.$widget.show();
|
console.log("isEnabled", this.noteMapWidget.isEnabled());
|
||||||
|
|
||||||
this.mapType = this.note.getLabelValue("mapType") === "tree" ? "tree" : "link";
|
await this.noteMapWidget.refresh();
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -384,7 +384,7 @@ const DEFAULT_KEYBOARD_ACTIONS = [
|
|||||||
scope: "window"
|
scope: "window"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
actionName: "toggleRibbonTabLinkMap",
|
actionName: "toggleRibbonTabNoteMap",
|
||||||
defaultShortcuts: [],
|
defaultShortcuts: [],
|
||||||
description: "Toggle Link Map",
|
description: "Toggle Link Map",
|
||||||
scope: "window"
|
scope: "window"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user