-
Notifications
You must be signed in to change notification settings - Fork 67
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(general): Initial work on libary refactor, including SVG/Canvas …
…rendering BREAKING: changes to API and config parameters
- Loading branch information
David Emory
committed
Jul 2, 2018
1 parent
0d14d2f
commit 27c8905
Showing
21 changed files
with
3,459 additions
and
451 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) { } | ||
} |
Oops, something went wrong.