starting work on link map reimplementation using force-graph library

This commit is contained in:
zadam 2021-05-29 22:52:32 +02:00
parent 60f88574b0
commit f5573fcad4
8 changed files with 183 additions and 255 deletions

5
libraries/force-graph.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -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
}

View File

@ -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;
}
}
}

View File

@ -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);

View File

@ -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();

View File

@ -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;

View File

@ -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();

View File

@ -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 {