mirror of
https://github.com/zadam/trilium.git
synced 2025-06-06 09:58:32 +02:00
starting work on link map reimplementation using force-graph library
This commit is contained in:
parent
60f88574b0
commit
f5573fcad4
5
libraries/force-graph.min.js
vendored
Normal file
5
libraries/force-graph.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -57,6 +57,10 @@ const WHEEL_ZOOM = {
|
||||
js: [ "libraries/wheel-zoom.min.js"]
|
||||
};
|
||||
|
||||
const FORCE_GRAPH = {
|
||||
js: [ "libraries/force-graph.min.js"]
|
||||
};
|
||||
|
||||
async function requireLibrary(library) {
|
||||
if (library.css) {
|
||||
library.css.map(cssUrl => requireCss(cssUrl));
|
||||
@ -106,5 +110,6 @@ export default {
|
||||
PRINT_THIS,
|
||||
CALENDAR_WIDGET,
|
||||
KATEX,
|
||||
WHEEL_ZOOM
|
||||
WHEEL_ZOOM,
|
||||
FORCE_GRAPH
|
||||
}
|
||||
|
@ -1,57 +1,88 @@
|
||||
import libraryLoader from "./library_loader.js";
|
||||
import server from "./server.js";
|
||||
import froca from "./froca.js";
|
||||
import linkService from "./link.js";
|
||||
|
||||
const linkOverlays = [
|
||||
[ "Arrow", {
|
||||
location: 1,
|
||||
id: "arrow",
|
||||
length: 10,
|
||||
width: 10,
|
||||
foldback: 0.7
|
||||
} ]
|
||||
];
|
||||
|
||||
export default class LinkMap {
|
||||
constructor(note, $linkMapContainer, options = {}) {
|
||||
this.note = note;
|
||||
this.options = Object.assign({
|
||||
maxDepth: 10,
|
||||
maxNotes: 30,
|
||||
zoom: 1.0,
|
||||
stopCheckerCallback: () => false
|
||||
maxNotes: 100,
|
||||
zoom: 1.0
|
||||
}, options);
|
||||
|
||||
this.$linkMapContainer = $linkMapContainer;
|
||||
this.linkMapContainerId = this.$linkMapContainer.attr("id");
|
||||
|
||||
this.zoomLevel = 1;
|
||||
}
|
||||
|
||||
setZoomLevel(level) {
|
||||
this.zoomLevel = level;
|
||||
}
|
||||
|
||||
async render() {
|
||||
await libraryLoader.requireLibrary(libraryLoader.LINK_MAP);
|
||||
await libraryLoader.requireLibrary(libraryLoader.FORCE_GRAPH);
|
||||
|
||||
jsPlumb.ready(() => {
|
||||
if (this.options.stopCheckerCallback()) {
|
||||
return;
|
||||
}
|
||||
this.graph = ForceGraph()(this.$linkMapContainer[0])
|
||||
.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 => this.getNodeLabel(node))
|
||||
.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 => `${this.getNodeLabel(l.source)} - <strong>${l.type}</strong> - ${this.getNodeLabel(l.target)}`)
|
||||
.linkCanvasObject((link, ctx) => this.paintLink(link, ctx))
|
||||
.linkCanvasObjectMode("after")
|
||||
.linkDirectionalArrowLength(4)
|
||||
.linkDirectionalArrowRelPos(1)
|
||||
.linkWidth(2)
|
||||
.linkColor("#ddd")
|
||||
.d3VelocityDecay(0.2)
|
||||
.onNodeClick(node => this.nodeClicked(node));
|
||||
|
||||
this.initJsPlumbInstance();
|
||||
this.graph.d3Force('link').distance(50);
|
||||
|
||||
this.initPanZoom();
|
||||
this.graph.d3Force('center').strength(0.9);
|
||||
|
||||
this.loadNotesAndRelations();
|
||||
});
|
||||
this.graph.d3Force('charge').strength(-30);
|
||||
this.graph.d3Force('charge').distanceMax(400);
|
||||
|
||||
this.renderData(await this.loadNotesAndRelations());
|
||||
}
|
||||
|
||||
renderData(data, zoomToFit = true, zoomPadding = 10) {
|
||||
this.graph.graphData(data);
|
||||
|
||||
if (zoomToFit) {
|
||||
setTimeout(() => this.graph.zoomToFit(400, zoomPadding), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
centerOnNode(node) {
|
||||
this.nodeClicked(node);
|
||||
|
||||
this.graph.centerAt(node.x, node.y, 1000);
|
||||
this.graph.zoom(6, 2000);
|
||||
}
|
||||
|
||||
async nodeClicked(node) {
|
||||
if (!node.expanded) {
|
||||
const neighborGraph = await fetchNeighborGraph(node.id);
|
||||
|
||||
addToTasGraph(neighborGraph);
|
||||
|
||||
renderData(getTasGraph(), false);
|
||||
}
|
||||
}
|
||||
|
||||
async loadNotesAndRelations(options = {}) {
|
||||
this.options = Object.assign(this.options, options);
|
||||
|
||||
this.cleanup();
|
||||
|
||||
if (this.options.stopCheckerCallback()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const links = await server.post(`notes/${this.note.noteId}/link-map`, {
|
||||
maxNotes: this.options.maxNotes,
|
||||
maxDepth: this.options.maxDepth
|
||||
@ -63,233 +94,121 @@ export default class LinkMap {
|
||||
noteIds.add(this.note.noteId);
|
||||
}
|
||||
|
||||
await froca.getNotes(Array.from(noteIds));
|
||||
|
||||
// pre-fetch the link titles, it's important to have the construction afterwards synchronous
|
||||
// since jsPlumb caculates width of the element
|
||||
const $linkTitles = {};
|
||||
|
||||
for (const noteId of noteIds) {
|
||||
const note = await froca.getNote(noteId);
|
||||
|
||||
$linkTitles[noteId] = await linkService.createNoteLink(noteId, {title: note.title});
|
||||
|
||||
$linkTitles[noteId].on('click', e => {
|
||||
try {
|
||||
$linkTitles[noteId].tooltip('dispose');
|
||||
}
|
||||
catch (e) {}
|
||||
|
||||
linkService.goToLink(e);
|
||||
})
|
||||
}
|
||||
|
||||
// preload all notes
|
||||
const notes = await froca.getNotes(Array.from(noteIds), true);
|
||||
|
||||
const graph = new Springy.Graph();
|
||||
graph.addNodes(...noteIds);
|
||||
graph.addEdges(...links.map(l => [l.noteId, l.targetNoteId]));
|
||||
|
||||
const layout = new Springy.Layout.ForceDirected(
|
||||
graph,
|
||||
this.options.stopCheckerCallback,
|
||||
// param explanation here: https://github.com/dhotson/springy/issues/58
|
||||
400.0, // Spring stiffness
|
||||
600.0, // Node repulsion
|
||||
0.15, // Damping
|
||||
0.1 // min energy threshold
|
||||
);
|
||||
|
||||
const getNoteBox = noteId => {
|
||||
const noteBoxId = this.noteIdToId(noteId);
|
||||
const $existingNoteBox = $("#" + noteBoxId);
|
||||
|
||||
if ($existingNoteBox.length > 0) {
|
||||
return $existingNoteBox;
|
||||
}
|
||||
|
||||
const note = notes.find(n => n.noteId === noteId);
|
||||
|
||||
if (!note) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const $noteBox = $("<div>")
|
||||
.addClass("note-box")
|
||||
.prop("id", noteBoxId)
|
||||
.addClass(note.getCssClass());
|
||||
|
||||
const $link = $linkTitles[noteId];
|
||||
|
||||
$noteBox.append(
|
||||
$("<span>")
|
||||
.addClass(note.getIcon()),
|
||||
$("<span>")
|
||||
.addClass("title")
|
||||
.append($link)
|
||||
);
|
||||
|
||||
if (noteId === this.note.noteId) {
|
||||
$noteBox.addClass("link-map-active-note");
|
||||
}
|
||||
|
||||
$noteBox
|
||||
.mouseover(() => this.$linkMapContainer.find(".link-" + noteId).addClass("jsplumb-connection-hover"))
|
||||
.mouseout(() => this.$linkMapContainer.find(".link-" + noteId).removeClass("jsplumb-connection-hover"));
|
||||
|
||||
this.$linkMapContainer.append($noteBox);
|
||||
|
||||
this.jsPlumbInstance.draggable($noteBox[0], {
|
||||
start: params => {
|
||||
this.renderer.stop();
|
||||
},
|
||||
drag: params => {},
|
||||
stop: params => {}
|
||||
});
|
||||
|
||||
return $noteBox;
|
||||
return {
|
||||
nodes: notes.map(note => ({
|
||||
id: note.noteId,
|
||||
name: note.title,
|
||||
type: note.type
|
||||
})),
|
||||
links: links.map(link => ({
|
||||
id: link.noteId + "-" + link.name + "-" + link.targetNoteId,
|
||||
source: link.noteId,
|
||||
target: link.targetNoteId,
|
||||
name: link.name
|
||||
}))
|
||||
};
|
||||
|
||||
if (this.options.stopCheckerCallback()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.renderer = new Springy.Renderer(layout);
|
||||
await this.renderer.start(500);
|
||||
|
||||
layout.eachNode((node, point) => {
|
||||
const $noteBox = getNoteBox(node.id);
|
||||
const middleW = this.$linkMapContainer.width() / 2;
|
||||
const middleH = this.$linkMapContainer.height() / 2;
|
||||
|
||||
$noteBox
|
||||
.css("left", (middleW + point.p.x * 100) + "px")
|
||||
.css("top", (middleH + point.p.y * 100) + "px");
|
||||
|
||||
if ($noteBox.hasClass("link-map-active-note")) {
|
||||
this.moveToCenterOfElement($noteBox[0]);
|
||||
}
|
||||
});
|
||||
|
||||
layout.eachEdge(edge => {
|
||||
const connectionId = this.linkMapContainerId + '-' + edge.source.id + '-' + edge.target.id;
|
||||
|
||||
if ($("#" + connectionId).length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
getNoteBox(edge.source.id);
|
||||
getNoteBox(edge.target.id);
|
||||
|
||||
const connection = this.jsPlumbInstance.connect({
|
||||
source: this.noteIdToId(edge.source.id),
|
||||
target: this.noteIdToId(edge.target.id),
|
||||
type: 'link'
|
||||
});
|
||||
|
||||
if (connection) {
|
||||
$(connection.canvas)
|
||||
.prop("id", connectionId)
|
||||
.addClass('link-' + edge.source.id)
|
||||
.addClass('link-' + edge.target.id);
|
||||
}
|
||||
else {
|
||||
//console.debug(`connection not created for`, edge);
|
||||
}
|
||||
});
|
||||
|
||||
this.jsPlumbInstance.repaintEverything();
|
||||
}
|
||||
|
||||
moveToCenterOfElement(element) {
|
||||
const owner = this.pzInstance.getOwner();
|
||||
|
||||
const center = () => {
|
||||
const elemBounds = element.getBoundingClientRect();
|
||||
const containerBounds = owner.getBoundingClientRect();
|
||||
|
||||
const centerX = -elemBounds.left + containerBounds.left + (containerBounds.width / 2) - (elemBounds.width / 2);
|
||||
const centerY = -elemBounds.top + containerBounds.top + (containerBounds.height / 2) - (elemBounds.height / 2);
|
||||
|
||||
const transform = this.pzInstance.getTransform();
|
||||
|
||||
const newX = transform.x + centerX;
|
||||
const newY = transform.y + centerY;
|
||||
|
||||
this.pzInstance.moveTo(newX, newY);
|
||||
};
|
||||
|
||||
let shown = false;
|
||||
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
if (!shown && entries[0].isIntersecting) {
|
||||
shown = true;
|
||||
center();
|
||||
}
|
||||
}, {
|
||||
rootMargin: '0px',
|
||||
threshold: 0.1
|
||||
});
|
||||
|
||||
observer.observe(owner);
|
||||
}
|
||||
|
||||
initPanZoom() {
|
||||
if (this.pzInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pzInstance = panzoom(this.$linkMapContainer[0], {
|
||||
maxZoom: 2,
|
||||
minZoom: 0.3,
|
||||
smoothScroll: false,
|
||||
filterKey: function (e, dx, dy, dz) {
|
||||
// if ALT is pressed then panzoom should bubble the event up
|
||||
// this is to preserve ALT-LEFT, ALT-RIGHT navigation working
|
||||
return e.altKey;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if (this.renderer) {
|
||||
this.renderer.stop();
|
||||
}
|
||||
|
||||
if (this.jsPlumbInstance) {
|
||||
// delete all endpoints and connections
|
||||
// this is done at this point (after async operations) to reduce flicker to the minimum
|
||||
this.jsPlumbInstance.deleteEveryEndpoint();
|
||||
|
||||
// without this we still end up with note boxes remaining in the canvas
|
||||
this.$linkMapContainer.empty();
|
||||
|
||||
// reset zoom/pan
|
||||
this.pzInstance.zoomAbs(0, 0, this.options.zoom);
|
||||
this.pzInstance.moveTo(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
initJsPlumbInstance() {
|
||||
if (this.jsPlumbInstance) {
|
||||
this.cleanup();
|
||||
|
||||
paintLink(link, ctx) {
|
||||
if (this.zoomLevel < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.jsPlumbInstance = jsPlumb.getInstance({
|
||||
Endpoint: ["Blank", {}],
|
||||
ConnectionOverlays: linkOverlays,
|
||||
PaintStyle: { stroke: "var(--muted-text-color)", strokeWidth: 1 },
|
||||
HoverPaintStyle: { stroke: "var(--main-text-color)", strokeWidth: 1 },
|
||||
Container: this.$linkMapContainer.attr("id")
|
||||
});
|
||||
ctx.font = '3px Sans-Serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = "grey";
|
||||
|
||||
this.jsPlumbInstance.registerConnectionType("link", { anchor: "Continuous", connector: "Straight", overlays: linkOverlays });
|
||||
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);
|
||||
|
||||
if (angle < -Math.PI / 2 || angle > Math.PI / 2) {
|
||||
angle += Math.PI;
|
||||
}
|
||||
|
||||
ctx.rotate(angle);
|
||||
ctx.fillText(link.name, 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
noteIdToId(noteId) {
|
||||
return this.linkMapContainerId + "-note-" + noteId;
|
||||
paintNode(node, color, ctx) {
|
||||
const {x, y} = node;
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 5, 0, 2 * Math.PI, false);
|
||||
ctx.fill();
|
||||
|
||||
if (this.zoomLevel < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!node.expanded) {
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = 10 + 'px Sans-Serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText("+", x, y + 1);
|
||||
}
|
||||
|
||||
ctx.fillStyle = "#555";
|
||||
ctx.font = 3 + 'px Sans-Serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
let title = node.name;
|
||||
|
||||
if (title.length > 10) {
|
||||
title = title.substr(0, 10) + "...";
|
||||
}
|
||||
|
||||
ctx.fillText(title, x, y + 8);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
getNodeLabel(node) {
|
||||
if (node.type === node.name) {
|
||||
return node.type;
|
||||
}
|
||||
else {
|
||||
return `${node.type}: ${node.name}`;
|
||||
}
|
||||
}
|
||||
|
||||
shuffleArray(array) {
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
const temp = array[i];
|
||||
array[i] = array[j];
|
||||
array[j] = temp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -100,8 +100,6 @@ export default class TabManager extends Component {
|
||||
filteredTabs[0].active = true;
|
||||
}
|
||||
|
||||
console.log("filteredTabs", filteredTabs);
|
||||
|
||||
await this.tabsUpdate.allowUpdateWithoutChange(async () => {
|
||||
for (const tab of filteredTabs) {
|
||||
await this.openContextWithNote(tab.notePath, tab.active, tab.ntxId, tab.hoistedNoteId, tab.mainNtxId);
|
||||
|
@ -79,8 +79,7 @@ export default class LinkMapWidget extends CollapsibleWidget {
|
||||
|
||||
this.linkMapService = new LinkMapServiceClass(note, $linkMapContainer, {
|
||||
maxDepth: 1,
|
||||
zoom: 0.6,
|
||||
stopCheckerCallback: () => this.noteId !== note.noteId // stop when current note is not what was originally requested
|
||||
zoom: 0.6
|
||||
});
|
||||
|
||||
await this.linkMapService.render();
|
||||
|
@ -159,14 +159,14 @@ export default class CollapsibleSectionContainer extends NoteContextAwareWidget
|
||||
|
||||
this.lastActiveComponentId = sectionComponentId;
|
||||
|
||||
this.$titleContainer.find(`.section-title-real[data-section-component-id="${sectionComponentId}"]`).addClass("active");
|
||||
this.$bodyContainer.find(`.section-body[data-section-component-id="${sectionComponentId}"]`).addClass("active");
|
||||
|
||||
const activeChild = this.getActiveSectionWidget();
|
||||
|
||||
if (activeChild) {
|
||||
activeChild.handleEvent('noteSwitched', {noteContext: this.noteContext, notePath: this.notePath});
|
||||
}
|
||||
|
||||
this.$titleContainer.find(`.section-title-real[data-section-component-id="${sectionComponentId}"]`).addClass("active");
|
||||
this.$bodyContainer.find(`.section-body[data-section-component-id="${sectionComponentId}"]`).addClass("active");
|
||||
}
|
||||
else {
|
||||
this.lastActiveComponentId = null;
|
||||
|
@ -7,6 +7,10 @@ const TPL = `
|
||||
.link-map-widget {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.link-map-container {
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.open-full-dialog-button {
|
||||
position: absolute;
|
||||
@ -46,14 +50,12 @@ export default class LinkMapWidget extends NoteContextAwareWidget {
|
||||
this.$widget.find(".link-map-container").empty();
|
||||
|
||||
const $linkMapContainer = this.$widget.find('.link-map-container');
|
||||
$linkMapContainer.attr("id", "link-map-container-" + linkMapContainerIdCtr++);
|
||||
|
||||
const LinkMapServiceClass = (await import('../../services/link_map.js')).default;
|
||||
|
||||
this.linkMapService = new LinkMapServiceClass(note, $linkMapContainer, {
|
||||
maxDepth: 3,
|
||||
zoom: 0.6,
|
||||
stopCheckerCallback: () => this.noteId !== note.noteId // stop when current note is not what was originally requested
|
||||
zoom: 0.6
|
||||
});
|
||||
|
||||
await this.linkMapService.render();
|
||||
|
@ -599,7 +599,7 @@ a.external:not(.no-arrow):after, a[href^="http://"]:not(.no-arrow):after, a[href
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 0.7rem 1rem !important; /* make modal header padding slightly smaller */
|
||||
padding: 0.7rem 1rem 0 1rem !important; /* make modal header padding slightly smaller */
|
||||
}
|
||||
|
||||
.floating-button {
|
||||
|
Loading…
x
Reference in New Issue
Block a user