note-graph
Version:
a generic visualization tool designed to show the structure of the document space and the relations between each doc
737 lines (700 loc) • 28.1 kB
JavaScript
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
var d3Color = require('d3-color');
var d3Force = require('d3-force');
var d3Scale = require('d3-scale');
var ForceGraph = require('force-graph');
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
var ForceGraph__default = /*#__PURE__*/_interopDefaultLegacy(ForceGraph);
/**
* Can generate GraphViewModel by `toGraphViewModel`
*/
class NoteGraphModel {
constructor(notes) {
this.subscribers = [];
this.notes = notes;
this.updateCache();
}
updateCache() {
const nodes = [];
const links = [];
const nodeInfos = {};
const linkMap = new Map();
this.notes.forEach((note) => {
nodes.push({ id: note.id, data: { note } });
const nodeInfo = {
title: note.title,
linkIds: [],
neighbors: [],
};
if (note.linkTo) {
note.linkTo.forEach((linkedNodeId) => {
const link = {
id: this.formLinkId(note.id, linkedNodeId),
source: note.id,
target: linkedNodeId,
};
links.push(link);
linkMap.set(link.id, link);
nodeInfo.linkIds.push(link.id);
nodeInfo.neighbors.push(linkedNodeId);
});
}
if (note.referencedBy) {
note.referencedBy.forEach((refererId) => {
nodeInfo.linkIds.push(this.formLinkId(refererId, note.id));
nodeInfo.neighbors.push(refererId);
});
}
nodeInfos[note.id] = nodeInfo;
});
const cache = this.cache || {};
cache.nodeInfos = nodeInfos;
cache.links = links;
cache.linkMap = linkMap;
this.cache = cache;
}
getNodeInfoById(id) {
return this.cache.nodeInfos[id];
}
getLinkById(id) {
return this.cache.linkMap.get(id);
}
/**
* A link's id is a combination of source node and target node's id
*/
formLinkId(sourceId, targetId) {
return `${sourceId}-${targetId}`;
}
toGraphViewData() {
const vm = {
graphData: {
nodes: this.notes,
links: this.cache.links,
},
nodeInfos: this.cache.nodeInfos,
};
return vm;
}
publishChange() {
this.subscribers.forEach((subscriber) => {
subscriber(this);
});
}
subscribe(subscriber) {
this.subscribers.push(subscriber);
return () => {
const pos = this.subscribers.indexOf(subscriber);
if (pos > -1) {
this.subscribers.splice(pos, 1);
}
};
}
}
var cjs = {};
Object.defineProperty(cjs, '__esModule', { value: true });
/* eslint-disable no-undefined,no-param-reassign,no-shadow */
/**
* Throttle execution of a function. Especially useful for rate limiting
* execution of handlers on events like resize and scroll.
*
* @param {number} delay - A zero-or-greater delay in milliseconds. For event callbacks, values around 100 or 250 (or even higher) are most useful.
* @param {boolean} [noTrailing] - Optional, defaults to false. If noTrailing is true, callback will only execute every `delay` milliseconds while the
* throttled-function is being called. If noTrailing is false or unspecified, callback will be executed one final time
* after the last throttled-function call. (After the throttled-function has not been called for `delay` milliseconds,
* the internal counter is reset).
* @param {Function} callback - A function to be executed after delay milliseconds. The `this` context and all arguments are passed through, as-is,
* to `callback` when the throttled-function is executed.
* @param {boolean} [debounceMode] - If `debounceMode` is true (at begin), schedule `clear` to execute after `delay` ms. If `debounceMode` is false (at end),
* schedule `callback` to execute after `delay` ms.
*
* @returns {Function} A new, throttled, function.
*/
function throttle (delay, noTrailing, callback, debounceMode) {
/*
* After wrapper has stopped being called, this timeout ensures that
* `callback` is executed at the proper times in `throttle` and `end`
* debounce modes.
*/
var timeoutID;
var cancelled = false; // Keep track of the last time `callback` was executed.
var lastExec = 0; // Function to clear existing timeout
function clearExistingTimeout() {
if (timeoutID) {
clearTimeout(timeoutID);
}
} // Function to cancel next exec
function cancel() {
clearExistingTimeout();
cancelled = true;
} // `noTrailing` defaults to falsy.
if (typeof noTrailing !== 'boolean') {
debounceMode = callback;
callback = noTrailing;
noTrailing = undefined;
}
/*
* The `wrapper` function encapsulates all of the throttling / debouncing
* functionality and when executed will limit the rate at which `callback`
* is executed.
*/
function wrapper() {
for (var _len = arguments.length, arguments_ = new Array(_len), _key = 0; _key < _len; _key++) {
arguments_[_key] = arguments[_key];
}
var self = this;
var elapsed = Date.now() - lastExec;
if (cancelled) {
return;
} // Execute `callback` and update the `lastExec` timestamp.
function exec() {
lastExec = Date.now();
callback.apply(self, arguments_);
}
/*
* If `debounceMode` is true (at begin) this is used to clear the flag
* to allow future `callback` executions.
*/
function clear() {
timeoutID = undefined;
}
if (debounceMode && !timeoutID) {
/*
* Since `wrapper` is being called for the first time and
* `debounceMode` is true (at begin), execute `callback`.
*/
exec();
}
clearExistingTimeout();
if (debounceMode === undefined && elapsed > delay) {
/*
* In throttle mode, if `delay` time has been exceeded, execute
* `callback`.
*/
exec();
} else if (noTrailing !== true) {
/*
* In trailing throttle mode, since `delay` time has not been
* exceeded, schedule `callback` to execute `delay` ms after most
* recent execution.
*
* If `debounceMode` is true (at begin), schedule `clear` to execute
* after `delay` ms.
*
* If `debounceMode` is false (at end), schedule `callback` to
* execute after `delay` ms.
*/
timeoutID = setTimeout(debounceMode ? clear : exec, debounceMode === undefined ? delay - elapsed : delay);
}
}
wrapper.cancel = cancel; // Return the wrapper function.
return wrapper;
}
/* eslint-disable no-undefined */
/**
* Debounce execution of a function. Debouncing, unlike throttling,
* guarantees that a function is only executed a single time, either at the
* very beginning of a series of calls, or at the very end.
*
* @param {number} delay - A zero-or-greater delay in milliseconds. For event callbacks, values around 100 or 250 (or even higher) are most useful.
* @param {boolean} [atBegin] - Optional, defaults to false. If atBegin is false or unspecified, callback will only be executed `delay` milliseconds
* after the last debounced-function call. If atBegin is true, callback will be executed only at the first debounced-function call.
* (After the throttled-function has not been called for `delay` milliseconds, the internal counter is reset).
* @param {Function} callback - A function to be executed after delay milliseconds. The `this` context and all arguments are passed through, as-is,
* to `callback` when the debounced-function is executed.
*
* @returns {Function} A new, debounced function.
*/
function debounce (delay, atBegin, callback) {
return callback === undefined ? throttle(delay, atBegin, false) : throttle(delay, callback, atBegin !== false);
}
var debounce_1 = cjs.debounce = debounce;
cjs.throttle = throttle;
const mergeObjects = (target, ...sources) => {
if (!sources.length) {
return target;
}
const source = sources.shift();
if (source === undefined) {
return target;
}
if (isMergebleObject(target) && isMergebleObject(source)) {
Object.keys(source).forEach(function (key) {
if (isMergebleObject(source[key])) {
if (!target[key]) {
target[key] = {};
}
mergeObjects(target[key], source[key]);
}
else {
target[key] = source[key];
}
});
}
return mergeObjects(target, ...sources);
};
const isObject = (item) => {
return item !== null && typeof item === 'object';
};
const isMergebleObject = (item) => {
return isObject(item) && !Array.isArray(item);
};
function getColorOnContainer(container, name, fallback) {
return getComputedStyle(container).getPropertyValue(name) || fallback;
}
function getDefaultColorOf(opts = {}) {
const container = opts.container || document.body;
const highlightedForeground = getColorOnContainer(container, '--notegraph-highlighted-foreground-color', '#f9c74f');
return {
background: getColorOnContainer(container, `--notegraph-background`, '#f7f7f7'),
fontSize: parseInt(getColorOnContainer(container, `--notegraph-font-size`, 12)),
highlightedForeground,
node: {
note: {
regular: getColorOnContainer(container, '--notegraph-note-color-regular', '#5f76e7'),
},
unknown: getColorOnContainer(container, '--notegraph-unkown-node-color', '#f94144'),
},
link: {
regular: getColorOnContainer(container, '--notegraph-link-color-regular', '#ccc'),
highlighted: getColorOnContainer(container, '--notegraph-link-color-highlighted', highlightedForeground),
},
hoverNodeLink: {
highlightedDirection: {
inbound: '#3078cd',
outbound: highlightedForeground,
},
},
};
}
// function mixColorFieldss<T extends HSLColor | RGBColor, K extends keyof T>(color1: T, color2: T, fields: K[], amount=0.5) {
// const results = fields.map((k: any) => {
// return color1[k] * amount + color2[k] * (1 - amount)
// })
// return results
// }
function mixRgb(color1, color2, amount = 0.5) {
const r = color1.r * amount + color2.r * (1 - amount);
const g = color1.g * amount + color2.g * (1 - amount);
const b = color1.b * amount + color2.b * (1 - amount);
return d3Color.rgb(r, g, b);
}
const makeDrawWrapper = (ctx) => ({
circle: function (x, y, radius, color) {
ctx.beginPath();
ctx.arc(x, y, radius, 0, 2 * Math.PI, false);
ctx.fillStyle = color;
ctx.fill();
ctx.closePath();
return this;
},
text: function (text, x, y, size, color) {
ctx.font = `${size}px Sans-Serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillStyle = color;
ctx.fillText(text, x, y);
return this;
},
});
/**
* The view of the graph.
* Wraps a d3 force-graph inside
*/
class NoteGraphView {
constructor(opts) {
this.hasEngineStopped = false;
this.engineStopSizingFn = null;
this.sizeScaler = d3Scale.scaleLinear()
.domain([0, 20])
.range([1, 4])
.clamp(true);
this.labelAlphaScaler = d3Scale.scaleLinear()
.domain([1.2, 2])
.range([0, 1])
.clamp(true);
this.interactionCallbacks = {};
this.hasInitialZoomToFit = false;
this.actions = {
selectNode(model, nodeId, isAppend) {
if (!isAppend) {
model.selectedNodes.clear();
}
if (nodeId != null) {
model.selectedNodes.add(nodeId);
}
},
highlightNode(model, nodeId) {
model.hoverNode = nodeId;
},
};
this.shouldDebugColor = false;
this.options = opts;
this.container = opts.container;
this.model = {
graphData: {
nodes: [],
links: [],
},
nodeInfos: {},
selectedNodes: new Set(),
focusNodes: new Set(),
focusLinks: new Set(),
hoverNode: null,
};
this.initStyle();
if (opts.graphModel) {
this.linkWithGraphModel(opts.graphModel);
if (!opts.lazyInitView) {
this.initView();
}
}
}
initStyle() {
if (!this.style) {
this.style = getDefaultColorOf({ container: this.container });
}
mergeObjects(this.style, this.options.style);
}
updateStyle(style) {
this.options.style = mergeObjects(this.options.style || {}, style);
this.initStyle();
this.refreshByStyle();
}
refreshByStyle() {
if (!this.forceGraph)
return;
const backgroundRgb = d3Color.rgb(this.style.background);
const getNodeColor = (nodeId, model) => {
const info = model.nodeInfos[nodeId];
const noteStyle = this.style.node.note;
const typeFill = this.style.node.note[info.type || 'regular'] || this.style.node.unknown;
if (this.shouldDebugColor) {
console.log('node fill', typeFill);
}
switch (this.getNodeState(nodeId, model)) {
case 'regular':
return { fill: typeFill, border: typeFill };
case 'lessened':
let color = noteStyle.lessened;
if (!color) {
const c = d3Color.rgb(typeFill);
// use mixing instead of opacity
const mixedColor = mixRgb(c, backgroundRgb, 0.2);
color = mixedColor;
}
return { fill: color, border: color };
case 'highlighted':
return {
fill: typeFill,
border: this.style.highlightedForeground,
};
default:
throw new Error(`Unknown type for node ${nodeId}`);
}
};
this.forceGraph
.backgroundColor(this.style.background)
.nodeCanvasObject((node, ctx, globalScale) => {
if (!node.id)
return;
const info = this.model.nodeInfos[node.id];
const size = this.sizeScaler(info.neighbors ? info.neighbors.length : 1);
const { fill, border } = getNodeColor(node.id, this.model);
const fontSize = this.style.fontSize / globalScale;
let textColor = d3Color.rgb(fill);
const nodeState = this.getNodeState(node.id, this.model);
const alphaByDistance = this.labelAlphaScaler(globalScale);
textColor.opacity =
nodeState === 'highlighted'
? 1
: nodeState === 'lessened'
? Math.min(0.2, alphaByDistance)
: alphaByDistance;
const label = info.title;
makeDrawWrapper(ctx)
.circle(node.x, node.y, size + 0.5, border)
.circle(node.x, node.y, size, fill)
.text(label, node.x, node.y + size + 1, fontSize, textColor);
})
.linkColor((link) => {
return this.getLinkColor(link, this.model);
});
}
linkWithGraphModel(graphModel) {
if (this.currentDataModelEntry) {
this.currentDataModelEntry.unsub();
}
this.updateViewData(graphModel.toGraphViewData());
const unsub = graphModel.subscribe(() => {
this.updateViewData(graphModel.toGraphViewData());
});
this.currentDataModelEntry = {
graphModel,
unsub,
};
}
getColorOnContainer(name, fallback) {
return getComputedStyle(this.container).getPropertyValue(name) || fallback;
}
updateViewData(dataInput) {
Object.assign(this.model, dataInput);
if (dataInput.focusedNode) {
this.model.hoverNode = dataInput.focusedNode;
}
}
updateCanvasSize(size) {
if (!this.forceGraph)
return;
if ('width' in size) {
this.forceGraph.width(size.width);
}
if ('height' in size) {
this.forceGraph.height(size.height);
}
}
initView() {
const { options, model, actions } = this;
// this runtime dependency may not be ready when this umd file excutes,
// so we will retrieve it from the global scope
const forceGraphFactory = ForceGraph__default['default'] || window.ForceGraph;
const forceGraph = this.forceGraph || forceGraphFactory();
const width = options.width || window.innerWidth - this.container.offsetLeft - 20;
const height = options.height || window.innerHeight - this.container.offsetTop - 20;
// const randomId = Math.floor(Math.random() * 1000)
// console.log('initView', randomId)
forceGraph(this.container)
.height(height)
.width(width)
.graphData(model.graphData)
.linkHoverPrecision(8)
.enableNodeDrag(!!options.enableNodeDrag)
.cooldownTime(200)
.d3Force('x', d3Force.forceX())
.d3Force('y', d3Force.forceY())
.d3Force('collide', d3Force.forceCollide(forceGraph.nodeRelSize()))
.linkWidth(1)
.linkDirectionalParticles(1)
.linkDirectionalParticleWidth((link) => this.getLinkState(link, model) === 'highlighted' ? 2 : 0)
.onEngineStop(() => {
this.hasEngineStopped = true;
if (this.engineStopSizingFn) {
this.engineStopSizingFn();
}
else {
if (!this.hasInitialZoomToFit) {
this.hasInitialZoomToFit = true;
forceGraph.zoomToFit(1000, 20);
}
}
})
.onNodeHover((node) => {
actions.highlightNode(this.model, node ? node.id : null);
this.updateViewModeInteractiveState();
})
.onNodeClick((node, event) => {
actions.selectNode(this.model, node.id, event.getModifierState('Shift'));
this.updateViewModeInteractiveState();
this.fireInteraction('nodeClick', { node, event });
})
.onLinkClick((link, event) => {
this.fireInteraction('linkClick', { link, event });
})
.onBackgroundClick((event) => {
actions.selectNode(this.model, null, event.getModifierState('Shift'));
this.updateViewModeInteractiveState();
this.fireInteraction('backgroundClick', { event });
})
.onBackgroundRightClick((event) => {
forceGraph.zoomToFit(1000, 20);
this.fireInteraction('backgroundRightClick', { event });
});
if (options.enableSmartZooming !== false) {
this.initGraphSmartZooming(forceGraph);
}
this.forceGraph = forceGraph;
this.refreshByStyle();
}
initGraphSmartZooming(forceGraph) {
let isAdjustingZoom = false;
const debouncedZoomHandler = debounce_1(200, (event) => {
if (isAdjustingZoom)
return;
const { x: xb, y: yb } = this.forceGraph.getGraphBbox();
// x/y here is translate, k is scale
const { k, x, y } = event;
const scaledBoundL = k * xb[0];
const scaledBoundR = k * xb[1];
const scaledBoundT = k * yb[0];
const scaledBoundB = k * yb[1];
const graphCanvasW = this.forceGraph.width();
const graphCanvasH = this.forceGraph.height();
const oldCenter = this.forceGraph.centerAt();
const currentCenter = oldCenter; // TODO: this is more like the center before zoom, rather than current zooming one ?
let newCenterX;
let newCenterY;
// should calculate proper center (because that's force-graph's only method...) to make the viewport fit the graphBbox
if (scaledBoundR + x < 0) {
// console.log('is out of right')
isAdjustingZoom = false;
newCenterX = xb[1];
}
else if (scaledBoundL + x > graphCanvasW) {
// console.log('is out of left')
newCenterX = xb[0];
}
if (scaledBoundT + y > graphCanvasH) {
// is out of top
newCenterY = yb[0];
}
else if (scaledBoundB + y < 0) {
// console.log('is out of bottom')
newCenterY = yb[1];
}
if (typeof newCenterX === 'number' || typeof newCenterY === 'number') {
// console.log('new centerX', newCenterX, newCenterY, 'old center', oldCenter)
this.forceGraph.centerAt(newCenterX !== undefined ? newCenterX : currentCenter.x, newCenterY !== undefined ? newCenterY : currentCenter.y, 2000);
}
});
forceGraph
.onZoom((event) => {
if (!this.hasInitialZoomToFit)
return;
debouncedZoomHandler(event);
})
.onZoomEnd(() => {
setTimeout(() => {
isAdjustingZoom = false;
}, 20);
});
}
getLinkNodeId(v) {
const t = typeof v;
return t === 'string' || t === 'number' ? v : v.id;
}
getNodeState(nodeId, model = this.model) {
return model.selectedNodes.has(nodeId) || model.hoverNode === nodeId
? 'highlighted'
: model.focusNodes.size === 0
? 'regular'
: model.focusNodes.has(nodeId)
? 'regular'
: 'lessened';
}
getLinkState(link, model = this.model) {
return model.focusNodes.size === 0
? 'regular'
: model.focusLinks.has(link.id)
? 'highlighted'
: 'lessened';
}
getLinkColor(link, model) {
var _a, _b;
const style = this.style;
const linkStyle = style.link;
switch (this.getLinkState(link, model)) {
case 'regular':
return linkStyle.regular;
case 'highlighted':
// inbound/outbound link is a little bit different with hoverNode
let linkColorByDirection;
const hoverNodeLinkStyle = style.hoverNodeLink;
if (model.hoverNode === this.getLinkNodeId(link.source)) {
linkColorByDirection = (_a = hoverNodeLinkStyle.highlightedDirection) === null || _a === void 0 ? void 0 : _a.outbound;
}
else if (model.hoverNode === this.getLinkNodeId(link.target)) {
linkColorByDirection = (_b = hoverNodeLinkStyle.highlightedDirection) === null || _b === void 0 ? void 0 : _b.inbound;
}
return (linkColorByDirection ||
linkStyle.highlighted ||
style.highlightedForeground);
case 'lessened':
let color = linkStyle.lessened;
if (!color) {
const c = d3Color.hsl(style.node.note.lessened);
c.opacity = 0.2;
color = c;
}
return color;
default:
throw new Error(`Unknown type for link ${link}`);
}
}
updateViewModeInteractiveState() {
var _a, _b;
const { model } = this;
// compute highlighted elements
const focusNodes = new Set();
const focusLinks = new Set();
if (model.hoverNode) {
focusNodes.add(model.hoverNode);
const info = model.nodeInfos[model.hoverNode];
(_a = info.neighbors) === null || _a === void 0 ? void 0 : _a.forEach((neighborId) => focusNodes.add(neighborId));
(_b = info.linkIds) === null || _b === void 0 ? void 0 : _b.forEach((link) => focusLinks.add(link));
}
if (model.selectedNodes) {
model.selectedNodes.forEach((nodeId) => {
var _a, _b;
focusNodes.add(nodeId);
const info = model.nodeInfos[nodeId];
(_a = info.neighbors) === null || _a === void 0 ? void 0 : _a.forEach((neighborId) => focusNodes.add(neighborId));
(_b = info.linkIds) === null || _b === void 0 ? void 0 : _b.forEach((link) => focusLinks.add(link));
});
}
model.focusNodes = focusNodes;
model.focusLinks = focusLinks;
}
/**
* Select nodes to gain more initial attention
*/
setSelectedNodes(nodeIds, opts = {}) {
const { isAppend, shouldZoomToFit } = opts;
if (!isAppend)
this.model.selectedNodes.clear();
nodeIds.forEach(nodeId => this.actions.selectNode(this.model, nodeId, true));
this.updateViewModeInteractiveState();
if (shouldZoomToFit) {
const doZoomToFitFocusedNodes = () => {
this.forceGraph.zoomToFit(1000, 20, (node) => {
return this.model.focusNodes.has(node.id);
});
};
if (this.hasEngineStopped) {
doZoomToFitFocusedNodes();
}
else {
this.engineStopSizingFn = () => {
doZoomToFitFocusedNodes();
this.engineStopSizingFn = null;
};
}
}
}
onInteraction(name, cb) {
if (!this.interactionCallbacks[name])
this.interactionCallbacks[name] = [];
const callbackList = this.interactionCallbacks[name];
callbackList.push(cb);
return () => {
const pos = callbackList.indexOf(cb);
if (pos > -1) {
callbackList.splice(pos, 1);
}
};
}
fireInteraction(name, payload) {
const callbackList = this.interactionCallbacks[name];
if (callbackList) {
callbackList.forEach((cb) => cb(payload));
}
}
dispose() {
if (this.forceGraph) {
this.forceGraph.pauseAnimation();
}
}
}
exports.NoteGraphModel = NoteGraphModel;
exports.NoteGraphView = NoteGraphView;
exports.getColorOnContainer = getColorOnContainer;
exports.getDefaultColorOf = getDefaultColorOf;