From f870028fad3f03e4dfd3c54656b16cc49af00ee7 Mon Sep 17 00:00:00 2001 From: Adam Procter <adamprocter@researchnot.es> Date: Tue, 25 Feb 2020 00:38:03 +0000 Subject: [PATCH] start of links --- canvas-10-feb/src/components/LinksLayer.vue | 157 ++++++++++++ canvas-10-feb/src/components/mixins/canvas.js | 58 +++++ canvas-10-feb/src/components/mixins/color.js | 42 ++++ canvas-10-feb/src/components/utils/nodes.js | 235 ++++++++++++++++++ canvas-10-feb/src/components/utils/numbers.js | 64 +++++ canvas-10-feb/src/views/Home.vue | 16 +- 6 files changed, 568 insertions(+), 4 deletions(-) create mode 100644 canvas-10-feb/src/components/LinksLayer.vue create mode 100644 canvas-10-feb/src/components/mixins/canvas.js create mode 100644 canvas-10-feb/src/components/mixins/color.js create mode 100644 canvas-10-feb/src/components/utils/nodes.js create mode 100644 canvas-10-feb/src/components/utils/numbers.js diff --git a/canvas-10-feb/src/components/LinksLayer.vue b/canvas-10-feb/src/components/LinksLayer.vue new file mode 100644 index 0000000..e2fa2ca --- /dev/null +++ b/canvas-10-feb/src/components/LinksLayer.vue @@ -0,0 +1,157 @@ +<template> + <canvas ref="canvas" :width="width" :height="height"></canvas> +</template> + +<script> +import { getPalette } from './mixins/color.js' +import { drawLinkBezierCurve, drawArrowHead } from './mixins/canvas.js' + +export default { + name: 'LinksLayer', + mixins: [getPalette, drawLinkBezierCurve, drawArrowHead], + props: { + width: { + type: Number + }, + height: { + type: Number + }, + nodes: { + type: Array + }, + links: { + type: Array + } + }, + mounted() { + this.canvas = this.$refs.canvas + this.context = this.canvas.getContext('2d') + this.draw() + }, + data() { + return { + canvas: {}, + context: {}, + defaultLinkProps: { + // these are our initial 'default' settings for each link + hue: 'dark', + tension: 0.25, + lineWidth: 3, + lineDash: [0, 0] + } + } + }, + // Watch the props for changes and, if necessary, redraw canvas + watch: { + width() { + this.draw() + }, + height() { + this.draw() + }, + nodes() { + this.draw() + }, + links() { + this.draw() + } + }, + methods: { + /** + * Clears the context, renders links and nodes on initial load + * and when new data has been provided + **/ + draw() { + this.clear() + this.context.save() + this.renderAllLinks() + this.renderAllNodes() + this.context.restore() + }, + + /** + * Clears drawing context + **/ + clear() { + this.context.clearRect(0, 0, this.width, this.height) + }, + + /** + * Where a node can be rendered + * @param {Node} node - Target node + **/ + renderNode({ x, y }) { + this.context.setTransform(1, 0, 0, 1, x, y) + // this renders nothing, but if we wanted to we + // could render nodes in the canvas as well + this.context.setTransform(1, 0, 0, 1, 0, 0) + }, + + /** + * Renders a link between two nodes + **/ + renderLink({ from, to, color, lineDash, lineWidth, hue, tension, arrow }) { + // fetch the nodes based on their id reference provided in the link + const fromNode = this.findNode(from) + const toNode = this.findNode(to) + + if (fromNode && toNode) { + // apply the link color, falling back to defaultLinkProps + this.context.strokeStyle = getPalette( + color, + hue || this.defaultLinkProps.hue + ) + + // apply link styling, falling back to defaultLinkProps + this.context.setLineDash(lineDash || this.defaultLinkProps.lineDash) + this.context.lineWidth = lineWidth || this.defaultLinkProps.lineWidth + + // establish link tension, falling back to defaultLinkProps + const curveTension = !isNaN(tension) || this.defaultLinkProps.tension + + // add the curve to our drawing context + drawLinkBezierCurve(this.context, fromNode, toNode, curveTension) + if (arrow) drawArrowHead(this.context, fromNode, toNode) + + // render the curve + this.context.stroke() + } + }, + + /** + * Helper to fetch a single node based on its id + * + * @param {string} id - Reference to node id + * @returns {Node} + **/ + findNode(id) { + return [...this.nodes].find(pt => pt.id === id) + }, + + /** + * Iterate through the array of links, rendering each one + **/ + renderAllLinks() { + for (let link of this.links) { + this.renderLink(link) + } + }, + + /** + * Included for demo only, doesn't actually render anything + * iterate through the array of nodes, rendering each one + **/ + renderAllNodes() { + for (let node of this.nodes) { + this.renderNode(node) + } + } + } +} +</script> +<style scoped> +canvas { + z-index: 1; + position: absolute; +} +</style> diff --git a/canvas-10-feb/src/components/mixins/canvas.js b/canvas-10-feb/src/components/mixins/canvas.js new file mode 100644 index 0000000..6f5798e --- /dev/null +++ b/canvas-10-feb/src/components/mixins/canvas.js @@ -0,0 +1,58 @@ +import { generateLinkHandles } from '../utils/nodes.js' +import { mapRange, distance } from '../utils/numbers.js' + +////////////////////////////////////////////////////////////////////// +// CANVAS UTILITIES +////////////////////////////////////////////////////////////////////// +// Because these helpers directly manipulate the drawing context, +// their methods don't need to return anything + +/** + * Adds a bezier curve to the drawing context + * + * @param {CanvasRenderingContext2D} context - 2D rendering context + * @param {Node} from - Origin node + * @param {Node} to - Target node + * @param {number} tension - Tension of the curve to be drawn + * + * */ +export const drawLinkBezierCurve = (context, from, to, tension) => { + const [fromHandle, toHandle] = generateLinkHandles(from, to) + + // This is a simple way to adjust the link tension depending + // on the distance the link covers + const adjustedTension = mapRange( + distance(fromHandle, toHandle), + 0, + 1000, + tension * 0.01, + tension * 2 + ) + + context.beginPath() + context.moveTo(fromHandle.x, fromHandle.y) + context.bezierCurveTo( + fromHandle.x * (1 + adjustedTension), + fromHandle.y, + toHandle.x * (1 - adjustedTension), + toHandle.y, + toHandle.x, + toHandle.y + ) +} + +export const drawArrowHead = (context, from, to, size = 10) => { + const [fromHandle, toHandle] = generateLinkHandles(from, to) + var angleBetween = null + const angle = angleBetween(fromHandle, toHandle) + + context.setLineDash([0, 0]) + context.setTransform(1, 0, 0, 1, toHandle.x - size, toHandle.y) + + context.rotate(angle * 0.65) + + context.moveTo(-size, -size / 1.25) + context.lineTo(0, 0) + context.lineTo(-size, size / 1.25) + context.setTransform(1, 0, 0, 1, 0, 0) +} diff --git a/canvas-10-feb/src/components/mixins/color.js b/canvas-10-feb/src/components/mixins/color.js new file mode 100644 index 0000000..44438cb --- /dev/null +++ b/canvas-10-feb/src/components/mixins/color.js @@ -0,0 +1,42 @@ +export const palette = { + coral: { + light: '#FFD8E1', + dark: '#F56789' + }, + lime: { + light: '#EEFFBC', + dark: '#B9DF4E' + }, + blue: { + light: '#CDEAFF', + dark: '#4EB4FF' + }, + purple: { + light: '#DAD9FE', + dark: '#8A80F6' + }, + pink: { + light: '#FBE9FF', + dark: '#E47EFD' + }, + yellow: { + light: '#FFF3CA', + dark: '#FFD84F' + }, + mono: { + light: '#FFFFFF', + dark: '#A3A3A3' + } +} + +/** + * Selects a HEX color value depending on a supplied key reference, + * providing a default (mono) value if none is found. + * + * @param {string} color - Target palette (from above) + * @param {string} type - Target color type (light or dark) + * @returns {string} hex color value + * + * */ +export const getPalette = (color = 'mono', type) => + palette[color] ? palette[color][type] : palette.mono[type] diff --git a/canvas-10-feb/src/components/utils/nodes.js b/canvas-10-feb/src/components/utils/nodes.js new file mode 100644 index 0000000..11ff05b --- /dev/null +++ b/canvas-10-feb/src/components/utils/nodes.js @@ -0,0 +1,235 @@ +////////////////////////////////////////////////////////////////////// +// TYPE DEFINITIONS +////////////////////////////////////////////////////////////////////// + +/** + * A representation of a point + * @typedef {Object} Point + * @property {number} x - Horizontal (x) position in pixels + * @property {number} y - Vertical (y) position in pixels + */ + +/** + * A representation of an area + * @typedef {Object} Area + * @property {number} x1 - Top left (x) position in pixels + * @property {number} y1 - Top left (y) position in pixels + * @property {number} x2 - Bottom right (x) position in pixels + * @property {number} y2 - Bottom right (y) position in pixels + */ + +/** + * A representation of a node + * @typedef {Object} Node + * @property {number} x - Horizontal (x) position in pixels + * @property {number} y - Vertical (y) position in pixels + * @property {number} width - Node width in pixels + * @property {number} height - Node height in pixels + */ + +////////////////////////////////////////////////////////////////////// +// NODES +////////////////////////////////////////////////////////////////////// +// Helpers for working with nodes, points and areas + +/** + * Converts a @Node into the @Area it covers + * + * @param {Node} node - Origin node + * @param {number} threshold - Additional threshold area (optional) + * @return {Area} Area covered by box, including threshold + * + * */ +export const nodeToArea = (node, threshold = 0.0) => { + return { + x1: node.x * (1.0 - threshold), + y1: node.y * (1.0 - threshold), + x2: (node.x + node.width) * (1.0 + threshold), + y2: (node.y + node.height) * (1.0 + threshold) + } +} + +/** + * Converts an @Area into a node + * + * @param {Area} area - Origin node + * @return {Node} + * + * */ +export const areaToNode = area => { + return { + x: area.x1, + y: area.y1, + width: Math.abs(area.x2 - area.x1), + height: Math.abs(area.y2 - area.y1) + } +} + +/** + * Returns whether one @Area completely contains another @Area + * + * @param {Area} a + * @param {Area} b + * @return {boolean} + * + * */ +export const areaContains = (a, b) => + !(b.x1 < a.x1 || b.y1 < a.y1 || b.x2 > a.x2 || b.y2 > a.y2) + +/** + * Returns whether one @Area overlaps another @Area + * + * @param {Area} a + * @param {Area} b + * @return {boolean} + * + * */ +export const areaOverlaps = (a, b) => { + if (a.x1 >= b.x2 || b.x1 >= a.x2) return false + if (a.y1 >= b.y2 || b.y1 >= a.y2) return false + + return true +} + +/** + * Returns anchor positions for drawing links between two Nodes. + * + * @param {Node} from - Origin node + * @param {Node} from - Target node + * @return {array} Array of {x,y} positions + * + * */ +export const generateLinkHandles = (from, to) => { + // const toLeft = to.x > from.x + return [ + { + x: from.x + from.width, + y: from.y + from.height / 2 + }, + { + // x: to.x + to.width / 2, + x: to.x + 10, + y: to.y + to.height / 2 + } + ] +} + +/** + * Returns a list of nodes which overlap with a given {x1,y1,x2,y2} @Area + * + * @param {Area} area - Origin node + * @param {array} node - List of nodes to check against + * @param {number} threshold - Additional threshold area (optional) + * @return {array} Array of @Node objects which match + * + * */ +export const areaNodeIntersections = (targetNode, nodes, threshold = 0) => { + return nodes + .filter(node => + areaOverlaps(nodeToArea(targetNode), nodeToArea(node, threshold)) + ) + .map(({ id }) => id) +} + +/** + * Returns a list of nodes which overlap with a given {x,y} @Point + * + * @param {Point} point + * @param {array} node - List of nodes to check against + * @return {array} Array of @Node objects which match + * + * */ +export const pointNodeIntersections = (point, nodes) => { + return nodes.filter(node => pointWithinNode(point, node)).map(({ id }) => id) +} + +/** + * Checks whether a @Point is within a @Node + * + * @param {Point} point + * @param {Node} node + * @return {boolean} + * + * */ +export const pointWithinNode = (point, node) => { + const { x1, y1, x2, y2 } = nodeToArea(node) + return x1 <= point.x && point.x <= x2 && y1 <= point.y && point.y <= y2 +} + +/** + * Converts an array of nodes into a single large @Area + * + * @param {array} node - List of nodes + * @return {Area} + * + * */ +export const generateAreaFromNodes = nodes => { + const sum = {} + + for (let node of nodes) { + const area = nodeToArea(node) + if (!sum.x1 || area.x1 < sum.x1) { + sum.x1 = area.x1 + } + if (!sum.y1 || area.y1 < sum.y1) { + sum.y1 = area.y1 + } + if (!sum.x2 || area.x2 > sum.x2) { + sum.x2 = area.x2 + } + if (!sum.y2 || area.y2 > sum.y2) { + sum.y2 = area.y2 + } + } + return sum +} + +/** + * Generates a node shape from two given points + * */ +export const generateNode = (origin, point) => { + const minusX = origin.x > point.x + const minusY = origin.y > point.y + const width = Math.abs(point.x - origin.x) + const height = Math.abs(point.y - origin.y) + + return { + x: minusX ? origin.x - width : origin.x, + y: minusY ? origin.y - height : origin.y, + width, + height + } +} + +/** + * Transforms a grouped set of nodes based on a base transformation + * + * @param {Node[]} nodes - List of nodes + * @param {Point} transform – transform + * @return {Node[]} + * */ +export const transformNodeSelection = (nodes, transform) => { + const baseCoordinates = {} + + // iterate through all nodes to get underlying coordinates of group + for (let node of nodes) { + if (!baseCoordinates.x || node.x < baseCoordinates.x) { + baseCoordinates.x = node.x + } + if (!baseCoordinates.y || node.y < baseCoordinates.y) { + baseCoordinates.y = node.y + } + } + + return nodes.map(node => { + const relativeTransform = { + x: node.x - baseCoordinates.x, + y: node.y - baseCoordinates.y + } + + return Object.assign({}, node, { + x: transform.x + relativeTransform.x, + y: transform.y + relativeTransform.y + }) + }) +} diff --git a/canvas-10-feb/src/components/utils/numbers.js b/canvas-10-feb/src/components/utils/numbers.js new file mode 100644 index 0000000..edd9906 --- /dev/null +++ b/canvas-10-feb/src/components/utils/numbers.js @@ -0,0 +1,64 @@ +////////////////////////////////////////////////////////////////////// +// MATHS AND NUMBERS +////////////////////////////////////////////////////////////////////// + +/** + * Returns the distance between two {x,y} points + * + * @param {Node} from - Origin node + * @param {Node} from - Target node + * @return {number} Distance between points + * + * */ +export const distance = (from, to) => Math.hypot(to.x - from.x, to.y - from.y) + +/** + * Returns an interpolated value between two numbers + * + * @param {number} from + * @param {number} to + * @param {number} extent - between 0.0 and 1.0 + * @return {number} + * + * */ +export const lerp = (from, to, extent) => from * (1 - extent) + to * extent + +/** + * Returns an interpolated value between two points + * + * @param {Point} from + * @param {Point} to + * @param {number} extent - between 0.0 and 1.0 + * @return {Point} + * + * */ +export const lerpPoint = (from, to, extent) => { + return { + x: lerp(from.x, to.x, extent) + } +} + +/** + * Map a @number from one range to another + * + * @param {number} value - value to map + * @param {number} from1 + * @param {number} to1 + * @param {number} from2 + * @param {number} to2 + * @return {number} + * + * */ +export const mapRange = (value, from1, to1, from2, to2) => + ((value - from1) * (to2 - from2)) / (to1 - from1) + from2 + +/** + * Calculates the angle between two points + * + * @param {Point} point1 + * @param {Point} point2 + * @return {number} + * + * */ +export const angleBetween = (point1, point2) => + Math.atan2(point2.y - point1.y, point2.x - point1.x) diff --git a/canvas-10-feb/src/views/Home.vue b/canvas-10-feb/src/views/Home.vue index da2ed08..351ecef 100644 --- a/canvas-10-feb/src/views/Home.vue +++ b/canvas-10-feb/src/views/Home.vue @@ -15,7 +15,13 @@ v-bind:nodetext="value.nodetext" /> - <CanvasLayer /> + <!-- <CanvasLayer /> --> + <LinksLayer + v-bind:width="width" + v-bind:height="height" + v-bind:nodes="nodes" + v-bind:links="links" + /> <DeBug /> <ControlsLayer /> </div> @@ -26,7 +32,7 @@ <script> // @ is an alias to /src import OnBoard from '@/components/OnBoard.vue' -import CanvasLayer from '@/components/CanvasLayer.vue' +import LinksLayer from '@/components/LinksLayer.vue' import NodesLayer from '@/components/NodesLayer.vue' import OtherNodeslayer from '@/components/OtherNodeslayer.vue' import DeBug from '@/components/DeBug.vue' @@ -40,13 +46,15 @@ export default { data: function() { return { clientset: false, - offline: false + offline: false, + width: 1200, + height: 800 } }, components: { OnBoard, - CanvasLayer, + LinksLayer, NodesLayer, OtherNodeslayer, DeBug, -- GitLab