Skip to content

Commit

Permalink
feat(general): Initial work on libary refactor, including SVG/Canvas …
Browse files Browse the repository at this point in the history
…rendering

BREAKING: changes to API and config parameters
  • Loading branch information
David Emory committed Jul 2, 2018
1 parent 0d14d2f commit 27c8905
Show file tree
Hide file tree
Showing 21 changed files with 3,459 additions and 451 deletions.
135 changes: 135 additions & 0 deletions lib/display/canvas-display.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@

import roundedRect from 'rounded-rect'

import Display from './display2'

export default class CanvasDisplay extends Display {
constructor (transitive) {
super(transitive)

const { el, canvas } = transitive.options

// Handle case of externally-provided canvas
if (canvas) {
// Set internal dimensions to match those of canvas
this.setDimensions(canvas.width, canvas.height)
this.setCanvas(canvas)

// We have a DOM element; create canvas
} else if (el) {
this.setDimensions(el.clientWidth, el.clientHeight)

const canvas = document.createElement('canvas')
canvas.width = el.clientWidth
canvas.height = el.clientHeight
el.appendChild(canvas)

// Check for Hi-PPI display
if (window.devicePixelRatio > 1) makeCanvasHiPPI(canvas)

this.setCanvas(canvas)
}
}

setCanvas (canvas) {
this.canvas = canvas
this.ctx = this.canvas.getContext('2d')
}

clear () {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
}

drawRect (upperLeft, attrs) {
this.ctx.strokeStyle = attrs['stroke']
this.ctx.lineWidth = attrs['stroke-width']
this.ctx.fillStyle = attrs['fill']

this.ctx.beginPath()
if (attrs.rx && attrs.ry && attrs.rx === attrs.ry) {
roundedRect(this.ctx, upperLeft.x, upperLeft.y, attrs.width, attrs.height, attrs.rx)
// TODO: handle case where rx != ry
} else { // ordinary rectangle
this.ctx.rect(upperLeft.x, upperLeft.y, attrs.width, attrs.height)
}
this.ctx.closePath()

if (attrs['fill']) this.ctx.fill()
if (attrs['stroke']) this.ctx.stroke()
}

drawCircle (center, attrs) {
this.ctx.beginPath()
this.ctx.arc(center.x, center.y, attrs.r, 0, Math.PI * 2, true)
this.ctx.closePath()

if (attrs['fill']) {
this.ctx.fillStyle = attrs['fill']
this.ctx.fill()
}
if (attrs['stroke']) {
this.ctx.strokeStyle = attrs['stroke']
this.ctx.lineWidth = attrs['stroke-width'] || 1
this.ctx.stroke()
}
}

drawEllipse (center, attrs) {
// TODO: implement
}

drawPath (pathStr, attrs) {
const path = new Path2D(pathStr)

this.ctx.strokeStyle = attrs['stroke']
this.ctx.lineWidth = attrs['stroke-width']

// dash array
if (attrs['stroke-dasharray']) {
const arr = attrs['stroke-dasharray'].split(',').map(str => parseFloat(str.trim()))
this.ctx.setLineDash(arr)
}
// linecap
this.ctx.lineCap = attrs['stroke-linecap'] || 'butt'

this.ctx.stroke(path)

if (attrs['stroke-dasharray']) this.ctx.setLineDash([])
}

drawText (text, anchor, attrs) {
// For equivalence w/ SVG text rendering
this.ctx.textBaseline = 'top'

this.ctx.font = `${attrs.fontSize || '14px'} ${attrs.fontFamily || 'sans-serif'}`
if (attrs['text-anchor']) this.ctx.textAlign = attrs['text-anchor']

if (attrs['stroke']) {
this.ctx.strokeStyle = attrs['stroke']
if (attrs['stroke-opacity']) this.ctx.globalAlpha = attrs['stroke-opacity']
this.ctx.lineWidth = attrs['stroke-width'] || 1
this.ctx.strokeText(text, anchor.x, anchor.y)
}
if (attrs['fill']) {
this.ctx.fillStyle = attrs['fill']
if (attrs['fill-opacity']) this.ctx.globalAlpha = attrs['fill-opacity']
this.ctx.fillText(text, anchor.x, anchor.y)
}

this.ctx.textAlign = 'start'
this.ctx.globalAlpha = 1
}
}

// Utility function to support HiPPI displays (e.g. Retina)
function makeCanvasHiPPI (canvas) {
const PIXEL_RATIO = 2
canvas.style.width = canvas.width + 'px'
canvas.style.height = canvas.height + 'px'

canvas.width *= PIXEL_RATIO
canvas.height *= PIXEL_RATIO

var context = canvas.getContext('2d')
context.scale(PIXEL_RATIO, PIXEL_RATIO)
}
223 changes: 223 additions & 0 deletions lib/display/display2.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import linearScale from 'simple-linear-scale'

export default class Display {
constructor (transitive) {
this.transitive = transitive

this.zoomFactors = transitive.options.zoomFactors || this.getDefaultZoomFactors()

this.updateActiveZoomFactors(1)
}

setDimensions (width, height) {
this.width = width
this.height = height
}

setXDomain (domain) { // [minX , maxX]
this.xDomain = domain
this.xScale = linearScale(domain, [0, this.width])
if (!this.initialXDomain) {
this.initialXDomain = domain
this.initialXRes = (domain[1] - domain[0]) / this.width
}
}

setYDomain (domain) { // [minY , maxY]
this.yDomain = domain
this.yScale = linearScale(domain, [this.height, 0])
if (!this.initialYDomain) this.initialYDomain = domain
}

fitToWorldBounds (bounds) {
const domains = this.computeDomainsFromBounds(bounds)
this.setXDomain(domains[0])
this.setYDomain(domains[1])
this.computeScale()
}

reset () {
this.initialXDomain = null
this.initialYDomain = null
this.scaleSet = false
this.lastScale = undefined
}

/**
* Apply a transformation {x, y, k} to the *initial* state of the map, where
* (x, y) is the pixel offset and k is a scale factor relative to an initial
* zoom level of 1.0. Intended primarily to support D3-style panning/zooming.
*/

applyTransform (transform) {
const { x, y, k } = transform

let xMin = this.initialXDomain[0]
let xMax = this.initialXDomain[1]
let yMin = this.initialYDomain[0]
let yMax = this.initialYDomain[1]

// Apply the scale factor
xMax = xMin + (xMax - xMin) / k
yMin = yMax - (yMax - yMin) / k

// Apply the translation
const xOffset = -x * (xMax - xMin) / this.width
xMin += xOffset
xMax += xOffset
const yOffset = y * (yMax - yMin) / this.height
yMin += yOffset
yMax += yOffset

// Update the scale functions and recompute the internal scale factor
this.setXDomain([xMin, xMax])
this.setYDomain([yMin, yMax])
this.computeScale()
}

getDefaultZoomFactors (data) {
return [{
minScale: 0,
gridCellSize: 25,
internalVertexFactor: 1000000,
angleConstraint: 45,
mergeVertexThreshold: 200
}, {
minScale: 1.5,
gridCellSize: 0,
internalVertexFactor: 0,
angleConstraint: 5,
mergeVertexThreshold: 0
}]
}

updateActiveZoomFactors (scale) {
var updated = false
for (var i = 0; i < this.zoomFactors.length; i++) {
var min = this.zoomFactors[i].minScale
var max = (i < this.zoomFactors.length - 1)
? this.zoomFactors[i + 1].minScale
: Number.MAX_VALUE

// check if we've crossed into a new zoomFactor partition
if ((!this.lastScale || this.lastScale < min || this.lastScale >= max) &&
scale >= min && scale < max) {
this.activeZoomFactors = this.zoomFactors[i]
updated = true
}
}
return updated
}

computeScale () {
this.lastScale = this.scale
this.scaleSet = true
const newXRes = (this.xDomain[1] - this.xDomain[0]) / this.width
this.scale = this.initialXRes / newXRes
if (this.lastScale !== this.scale) this.scaleChanged()
}

scaleChanged () {
const zoomFactorsChanged = this.updateActiveZoomFactors(this.scale)
if (zoomFactorsChanged) {
this.transitive.network = null
this.transitive.render()
}
}

/**
* Compute the x/y coordinate space domains to fit the graph.
*/

computeDomainsFromBounds (bounds) {
var xmin = bounds[0][0]
var xmax = bounds[1][0]
var ymin = bounds[0][1]
var ymax = bounds[1][1]
var xRange = xmax - xmin
var yRange = ymax - ymin

const { options } = this.transitive

var paddingFactor = (options && options.paddingFactor)
? options.paddingFactor
: 0.1

var margins = this.getMargins()

var usableHeight = this.height - margins.top - margins.bottom
var usableWidth = this.width - margins.left - margins.right
var displayAspect = this.width / this.height
var usableDisplayAspect = usableWidth / usableHeight
var graphAspect = xRange / (yRange === 0 ? -Infinity : yRange)

var padding
var dispX1, dispX2, dispY1, dispY2
var dispXRange, dispYRange

if (usableDisplayAspect > graphAspect) { // y-axis is limiting
padding = paddingFactor * yRange
dispY1 = ymin - padding
dispY2 = ymax + padding
dispYRange = yRange + 2 * padding
var addedYRange = (this.height / usableHeight * dispYRange) - dispYRange
if (margins.top > 0 || margins.bottom > 0) {
dispY1 -= margins.bottom / (margins.bottom + margins.top) * addedYRange
dispY2 += margins.top / (margins.bottom + margins.top) * addedYRange
}
dispXRange = (dispY2 - dispY1) * displayAspect
var xOffset = (margins.left - margins.right) / this.width
var xMidpoint = (xmax + xmin - dispXRange * xOffset) / 2
dispX1 = xMidpoint - dispXRange / 2
dispX2 = xMidpoint + dispXRange / 2
} else { // x-axis limiting
padding = paddingFactor * xRange
dispX1 = xmin - padding
dispX2 = xmax + padding
dispXRange = xRange + 2 * padding
var addedXRange = (this.width / usableWidth * dispXRange) - dispXRange
if (margins.left > 0 || margins.right > 0) {
dispX1 -= margins.left / (margins.left + margins.right) * addedXRange
dispX2 += margins.right / (margins.left + margins.right) * addedXRange
}

dispYRange = (dispX2 - dispX1) / displayAspect
var yOffset = (margins.bottom - margins.top) / this.height
var yMidpoint = (ymax + ymin - dispYRange * yOffset) / 2
dispY1 = yMidpoint - dispYRange / 2
dispY2 = yMidpoint + dispYRange / 2
}

return [
[dispX1, dispX2],
[dispY1, dispY2]
]
}

getMargins () {
return Object.assign({
left: 0,
right: 0,
top: 0,
bottom: 0
}, this.transitive.options.displayMargins)
}

isInRange (x, y) {
return x >= 0 && x <= this.width && y >= 0 && y <= this.height
}

/** Methods to be defined by subclasses **/

clear () { }

drawCircle (coord, attrs) { }

drawEllipse (coord, attrs) { }

drawRect (upperLeft, attrs) { }

drawText (text, anchor, attrs) { }

drawPath (pathStr, attrs) { }
}
Loading

0 comments on commit 27c8905

Please sign in to comment.