UNPKG

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