musicvis-lib
Version:
Music analysis and visualization library
715 lines (685 loc) • 22.2 kB
JavaScript
import * as d3 from 'd3'
/**
* @module graphics/Canvas
* @todo combine multiple canvases into one, by drawing over common background
* @todo save canvas as file https://www.digitalocean.com/community/tutorials/js-canvas-toblob
*/
/**
* Sets up a canvas rescaled to device pixel ratio
*
* @param {HTMLCanvasElement} canvas canvas element
* @returns {CanvasRenderingContext2D} canvas rendering context
*/
export function setupCanvas (canvas) {
// Fix issues when importing musicvis-lib in Node.js
if (!window) { return }
// Get the device pixel ratio, falling back to 1.
const dpr = window.devicePixelRatio || 1
// Get the size of the canvas in CSS pixels.
const rect = canvas.getBoundingClientRect()
// Give the canvas pixel dimensions of their CSS
// sizes times the device pixel ratio.
canvas.width = rect.width * dpr
canvas.height = rect.height * dpr
const context = canvas.getContext('2d')
// Scale all drawing operations by the dpr
context.scale(dpr, dpr)
return context
}
/**
* Draws a stroked straight line.
*
* @param {CanvasRenderingContext2D} context canvas rendering context
* @param {number} x1 x coordinate of the start
* @param {number} y1 y coordinate of the start
* @param {number} x2 x coordinate of end
* @param {number} y2 y coordinate of end
* @returns {void}
* @example
* // Set the strokeStyle first
* context.strokeStyle = 'black';
* // Let's draw an X
* Canvas.drawLine(context, 0, 0, 50, 50);
* Canvas.drawLine(context, 0, 50, 50, 0);
*/
export function drawLine (context, x1, y1, x2, y2) {
context.beginPath()
context.moveTo(x1, y1)
context.lineTo(x2, y2)
context.stroke()
}
/**
* Draws a stroked straight horizontal line.
*
* @deprecated Use context.fillRect(x1, y, x2-x1, strokeWidth)
* @param {CanvasRenderingContext2D} context canvas rendering context
* @param {number} x1 x coordinate of the start
* @param {number} y y coordinate of the start
* @param {number} x2 x coordinate of end
* @returns {void}
*/
export function drawHLine (context, x1, y, x2) {
context.beginPath()
context.moveTo(x1, y)
context.lineTo(x2, y)
context.stroke()
}
/**
* Draws a stroked straight vertical line.
*
* @deprecated Use context.fillRect(x1, y1, strokeWidth, y2-y1)
* @param {CanvasRenderingContext2D} context canvas rendering context
* @param {number} x x coordinate of the start
* @param {number} y1 y coordinate of the start
* @param {number} y2 y coordinate of end
* @returns {void}
*/
export function drawVLine (context, x, y1, y2) {
context.beginPath()
context.moveTo(x, y1)
context.lineTo(x, y2)
context.stroke()
}
/**
* Draws a line that bows to the right in the direction of travel (looks like a
* left turn), thereby encoding direction. Useful for node-link graphs.
*
* @param {CanvasRenderingContext2D} context canvas rendering context
* @param {number} x1 x coordinate of the start
* @param {number} y1 y coordinate of the start
* @param {number} x2 x coordinate of end
* @param {number} y2 y coordinate of end
* @param {number} [strength=0.5] how much the bow deviates from a straight line
* towards the right, negative values will make bows to the left
*/
export function drawBowRight (context, x1, y1, x2, y2, strength = 0.5) {
const middleX = (x1 + x2) / 2
const middleY = (y1 + y2) / 2
const dx = x2 - x1
const dy = y2 - y1
const normalX = -dy
const normalY = dx
const cx = middleX + strength * normalX
const cy = middleY + strength * normalY
context.beginPath()
context.moveTo(x1, y1)
context.bezierCurveTo(cx, cy, cx, cy, x2, y2)
context.stroke()
}
/**
* Draws a stroked circle.
*
* @param {CanvasRenderingContext2D} context canvas rendering context
* @param {number} x x coordinate of center
* @param {number} y y coordinate of center
* @param {number} radius radius
* @returns {void}
*/
export function drawCircle (context, x, y, radius) {
context.beginPath()
context.arc(x, y, radius, 0, 2 * Math.PI)
context.stroke()
}
/**
* Draws a filled circle.
*
* @param {CanvasRenderingContext2D} context canvas rendering context
* @param {number} x x coordinate of center
* @param {number} y y coordinate of center
* @param {number} radius radius
* @returns {void}
*/
export function drawFilledCircle (context, x, y, radius) {
context.beginPath()
context.arc(x, y, radius, 0, 2 * Math.PI)
context.fill()
}
/**
* Draws a filled triangle like this: /\
*
* @param {CanvasRenderingContext2D} context canvas rendering context
* @param {number} x x coordinate of center
* @param {number} y y coordinate of center
* @param {number} halfSize half of the size
* @returns {void}
*/
export function drawTriangle (context, x, y, halfSize) {
context.beginPath()
context.moveTo(x - halfSize, y + halfSize)
context.lineTo(x + halfSize, y + halfSize)
context.lineTo(x, y - halfSize)
context.closePath()
context.fill()
}
/**
* Draws a diamond like this: <>
*
* @param {CanvasRenderingContext2D} context canvas rendering context
* @param {number} x x coordinate of center
* @param {number} y y coordinate of center
* @param {number} halfSize half of the size
* @returns {void}
*/
export function drawDiamond (context, x, y, halfSize) {
context.beginPath()
context.moveTo(x - halfSize, y)
context.lineTo(x, y - halfSize)
context.lineTo(x + halfSize, y)
context.lineTo(x, y + halfSize)
context.closePath()
context.fill()
}
/**
* Draws an X
*
* @param {CanvasRenderingContext2D} context canvas rendering context
* @param {number} x x coordinate of center
* @param {number} y y coordinate of center
* @param {number} halfSize half of the size
* @returns {void}
*/
export function drawX (context, x, y, halfSize) {
context.save()
context.lineWidth = 2
context.beginPath()
context.moveTo(x - halfSize, y - halfSize)
context.lineTo(x + halfSize, y + halfSize)
context.moveTo(x - halfSize, y + halfSize)
context.lineTo(x + halfSize, y - halfSize)
context.stroke()
context.restore()
}
/**
* Draws a trapezoid that looks like a rectangle but gets narrower at the right
* end, so better show where one ends and the next begins.
*
* @param {CanvasRenderingContext2D} context canvas rendering context
* @param {number} x x coordinate of top left
* @param {number} y y coordinate of top left
* @param {number} width width
* @param {number} height height (of left side)
* @param {number} height2 height (of right side)
* @returns {void}
*/
export function drawNoteTrapezoid (context, x, y, width, height, height2) {
context.beginPath()
context.moveTo(x, y)
context.lineTo(x, y + height)
context.lineTo(x + width, y + (height / 2 + height2 / 2))
context.lineTo(x + width, y + (height / 2 - height2 / 2))
context.closePath()
context.fill()
}
/**
* Draws a trapezoid that looks like a rectangle but gets narrower at the top
* end, so better show where one ends and the next begins.
*
* @param {CanvasRenderingContext2D} context canvas rendering context
* @param {number} x x coordinate of bounding rect's top left
* @param {number} y y coordinate of bounding rect's top left
* @param {number} width width (of bounding rect / bottom side)
* @param {number} height height
* @param {number} width2 width (of top side)
* @returns {void}
*/
export function drawNoteTrapezoidUpwards (context, x, y, width, height, width2) {
context.beginPath()
context.lineTo(x, y + height)
context.lineTo(x + width, y + height)
context.lineTo(x + (width / 2 + width2 / 2), y)
context.lineTo(x + (width / 2 - width2 / 2), y)
context.closePath()
context.fill()
}
/**
* Draws a rectangle with rounded corners, does not fill or stroke by itself
*
* @param {CanvasRenderingContext2D} context canvas rendering context
* @param {number} x x coordinate of bounding rect's top left
* @param {number} y y coordinate of bounding rect's top left
* @param {number} width width
* @param {number} height height
* @param {number} radius rounding radius
* @returns {void}
*/
export function drawRoundedRect (context, x, y, width, height, radius) {
if (width < 0) { return }
context.beginPath()
context.moveTo(x + radius, y)
context.lineTo(x + width - radius, y)
context.quadraticCurveTo(x + width, y, x + width, y + radius)
context.lineTo(x + width, y + height - radius)
context.quadraticCurveTo(x + width, y + height, x + width - radius, y + height)
context.lineTo(x + radius, y + height)
context.quadraticCurveTo(x, y + height, x, y + height - radius)
context.lineTo(x, y + radius)
context.quadraticCurveTo(x, y, x + radius, y)
context.closePath()
}
/**
* Draws a horizontal, then vertical line to connect two points (or the other
* way round when xFirst == false)
*
* @param {CanvasRenderingContext2D} context canvas rendering context
* @param {number} x1 x coordinate of start
* @param {number} y1 y coordinate of start
* @param {number} x2 x coordinate of end
* @param {number} y2 y coordinate of end
* @param {boolean} [xFirst=true] controls whether to go first in x or y direction
*/
export function drawCornerLine (context, x1, y1, x2, y2, xFirst = true) {
context.beginPath()
context.moveTo(x1, y1)
xFirst
? context.lineTo(x2, y1)
: context.lineTo(x1, y2)
context.lineTo(x2, y2)
context.stroke()
}
/**
* Draws a rounded version of drawCornerLine().
* Only works for dendrograms drawn from top-dowm, use
* drawRoundedCornerLineRightLeft for right-to-left dendrograms.
*
* @param {CanvasRenderingContext2D} context canvas rendering context
* @param {number} x1 x coordinate of start
* @param {number} y1 y coordinate of start
* @param {number} x2 x coordinate of end
* @param {number} y2 y coordinate of end
* @param {number} [maxRadius=25] maximum radius, fixes possible overlaps
*/
export function drawRoundedCornerLine (context, x1, y1, x2, y2, maxRadius = 25) {
const xDist = Math.abs(x2 - x1)
const yDist = Math.abs(y2 - y1)
const radius = Math.min(xDist, yDist, maxRadius)
const cx = x1 < x2 ? x2 - radius : x2 + radius
const cy = y1 < y2 ? y1 + radius : y1 - radius
context.beginPath()
context.moveTo(x1, y1)
if (x1 < x2) {
context.arc(cx, cy, radius, 1.5 * Math.PI, 2 * Math.PI)
} else {
context.arc(cx, cy, radius, 1.5 * Math.PI, Math.PI, true)
}
context.lineTo(x2, y2)
context.stroke()
}
/**
* Draws a rounded version of drawRoundedCornerLine for right-to-left
* dendrograms.
*
* @param {CanvasRenderingContext2D} context canvas rendering context
* @param {number} x1 x coordinate of start
* @param {number} y1 y coordinate of start
* @param {number} x2 x coordinate of end
* @param {number} y2 y coordinate of end
* @param {number} [maxRadius=25] maximum radius, fixes possible overlaps
*/
export function drawRoundedCornerLineRightLeft (
context,
x1,
y1,
x2,
y2,
maxRadius = 25
) {
const xDist = Math.abs(x2 - x1)
const yDist = Math.abs(y2 - y1)
const radius = Math.min(xDist, yDist, maxRadius)
const cx = x1 < x2 ? x1 + radius : x1 - radius
const cy = y1 < y2 ? y2 - radius : y2 + radius
context.beginPath()
context.moveTo(x1, y1)
if (y1 < y2) {
context.arc(cx, cy, radius, 0, 0.5 * Math.PI)
} else {
context.arc(cx, cy, radius, 0, 1.5 * Math.PI, true)
}
context.lineTo(x2, y2)
context.stroke()
}
/**
* Draws a hexagon, call context.fill() or context.stroke() afterwards.
*
* @param {CanvasRenderingContext2D} context canvas rendering context
* @param {number} cx center x
* @param {number} cy center y
* @param {number} radius radius of the circle on which the points are placed
*/
export function drawHexagon (context, cx, cy, radius) {
context.beginPath()
for (let index = 0; index < 6; index++) {
// Start at 30° TODO: allow to specify
const angle = (60 * index + 30) / 180 * Math.PI
const x = cx + Math.cos(angle) * radius
const y = cy + Math.sin(angle) * radius
if (index === 0) {
context.moveTo(x, y)
} else {
context.lineTo(x, y)
}
}
context.closePath()
}
/**
* Draws a Bezier curve to connect to points in X direction.
*
* @param {CanvasRenderingContext2D} context canvas rendering context
* @param {number} x1 x coordinate of the first point
* @param {number} y1 y coordinate of the first point
* @param {number} x2 x coordinate of the second point
* @param {number} y2 y coordinate of the second point
*/
export function drawBezierConnectorX (context, x1, y1, x2, y2) {
const deltaX = (x2 - x1) / 2
context.beginPath()
context.moveTo(x1, y1)
context.bezierCurveTo(x1 + deltaX, y1, x1 + deltaX, y2, x2, y2)
context.stroke()
}
/**
* Draws a Bezier curve to connect to points in Y direction.
*
* @param {CanvasRenderingContext2D} context canvas rendering context
* @param {number} x1 x coordinate of the first point
* @param {number} y1 y coordinate of the first point
* @param {number} x2 x coordinate of the second point
* @param {number} y2 y coordinate of the second point
*/
export function drawBezierConnectorY (context, x1, y1, x2, y2) {
const deltaY = (y2 - y1) / 2
context.beginPath()
context.moveTo(x1, y1)
context.bezierCurveTo(x1, y1 + deltaY, x2, y1 + deltaY, x2, y2)
context.stroke()
}
/**
* Draws a funnel to indicate that horizontal span relates to another one below
* Will fill() the shape, call context.stroke() afterwards to also stroke.
*
* @todo add X version
* @param {CanvasRenderingContext2D} context canvas rendering context
* @param {number} y1 top y position
* @param {number} y2 bottom y position
* @param {number} x1left top left x position
* @param {number} x1right top right x position
* @param {number} x2left bottom left x position
* @param {number} x2right bottom right x position
*/
export function drawBezierFunnelY (
context,
y1,
y2,
x1left,
x1right,
x2left,
x2right
) {
const deltaY = (y2 - y1) / 2
context.beginPath()
context.moveTo(x1left, y1)
context.bezierCurveTo(x1left, y1 + deltaY, x2left, y1 + deltaY, x2left, y2)
context.lineTo(x2right, y2)
context.bezierCurveTo(
x2right,
y1 + deltaY,
x1right,
y1 + deltaY,
x1right,
y1
)
context.closePath()
context.fill()
}
/**
* Draws a rounded corner, requires x and y distances between points to be
* equal.
*
* @param {CanvasRenderingContext2D} context canvas rendering context
* @param {number} x1 x coordinate of the first point
* @param {number} y1 y coordinate of the first point
* @param {number} x2 x coordinate of the second point
* @param {number} y2 y coordinate of the second point
* @param {boolean} turnLeft true for left turn, false for right turn
* @param {number} roundness corner roundness between 0 (sharp) and 1 (round)
*/
export function drawRoundedCorner (context, x1, y1, x2, y2, turnLeft = true, roundness = 1) {
context.beginPath()
context.moveTo(x1, y1)
if (x1 === x2 || y1 === y2) {
context.lineTo(x2, y2)
context.stroke()
return
}
const radius = Math.abs(x2 - x1) * roundness
let cx
let cy
if (turnLeft) {
if (x1 < x2 && y1 < y2) {
cx = x1 + radius
cy = y2 - radius
context.arc(cx, cy, radius, 1 * Math.PI, 0.5 * Math.PI, true)
} else if (x1 > x2 && y1 < y2) {
cx = x2 + radius
cy = y1 + radius
context.arc(cx, cy, radius, 1.5 * Math.PI, 1 * Math.PI, true)
} else if (x1 > x2 && y1 > y2) {
cx = x1 - radius
cy = y2 + radius
context.arc(cx, cy, radius, 0, 1.5 * Math.PI, true)
} else {
cx = x2 - radius
cy = y1 - radius
context.arc(cx, cy, radius, 0.5 * Math.PI, 0, true)
}
} else {
if (x1 < x2 && y1 < y2) {
cx = x2 - radius
cy = y1 + radius
context.arc(cx, cy, radius, 1.5 * Math.PI, 0)
} else if (x1 > x2 && y1 < y2) {
cx = x1 - radius
cy = y2 - radius
context.arc(cx, cy, radius, 0, 0.5 * Math.PI)
} else if (x1 > x2 && y1 > y2) {
cx = x2 + radius
cy = y1 - radius
context.arc(cx, cy, radius, 0.5 * Math.PI, 1 * Math.PI, false)
} else {
cx = x1 + radius
cy = y2 + radius
context.arc(cx, cy, radius, Math.PI, 1.5 * Math.PI, false)
}
}
context.lineTo(x2, y2)
context.stroke()
}
/**
* Draws an arc that connects similar parts.
* Both parts must have the same width in pixels.
*
* @param {CanvasRenderingContext2D} context canvas rendering context
* @param {number} startX1 x coordinate of the start of the first part
* @param {number} startX2 x coordinate of the start of the second part
* @param {number} length length in pixels of the parts
* @param {number} yBottom bottom baseline y coordinate
*/
export function drawArc (context, startX1, startX2, length, yBottom) {
// Get center and radius
const radius = (startX2 - startX1) / 2
const cx = startX1 + radius + length / 2
context.lineWidth = length
context.beginPath()
context.arc(cx, yBottom, radius, Math.PI, 2 * Math.PI)
context.stroke()
}
/**
* Draws a more complex path and fills it.
* Two arcs: One from startX1 to endX2 on the top, one from endX1 to startX2
* below it.
*
* @param {CanvasRenderingContext2D} context canvas rendering context
* @param {number} startX1 x coordinate of the start of the first part
* @param {number} endX1 x coordinate of the end of the first part
* @param {number} startX2 x coordinate of the start of the second part
* @param {number} endX2 x coordinate of the end of the second part
* @param {number} yBottom bottom baseline y coordinate
*/
export function drawAssymetricArc (context, startX1, endX1, startX2, endX2, yBottom) {
// Get center and radius
const radiusTop = (endX2 - startX1) / 2
if (radiusTop < 0) {
return
}
let radiusBottom = (startX2 - endX1) / 2
if (radiusBottom < 0) {
radiusBottom = 0
}
const cxTop = startX1 + radiusTop
const cxBottom = endX1 + radiusBottom
context.beginPath()
context.moveTo(startX1, yBottom)
context.arc(cxTop, yBottom, radiusTop, Math.PI, 2 * Math.PI)
context.lineTo(startX2, yBottom)
context.arc(cxBottom, yBottom, radiusBottom, 2 * Math.PI, Math.PI, true)
context.closePath()
context.fill()
}
/**
* Draws a horizontal bracket like this |_____| (bottom)
* or this |""""""| (top).
* Use a positive h for bottom and a negative one for top.
*
* @param {CanvasRenderingContext2D} context canvas rendering context
* @param {number} x x position of the bracket's horizontal lines
* @param {number} y y position of the bracket's horizontal lines
* @param {number} w width of the bracket's horizontal lines
* @param {number} h height of the bracket's vertical ticks
*/
export function drawBracketH (context, x, y, w, h) {
context.beginPath()
context.moveTo(x, y + h)
context.lineTo(x, y)
context.lineTo(x + w, y)
context.lineTo(x + w, y + h)
context.stroke()
}
/**
* Draws a quadratic matrix onto a canvas
*
* @param {CanvasRenderingContext2D} context canvas rendering context
* @param {number[][]} matrix matrix
* @param {number} [x=0] x position of the top left corner
* @param {number} [y=0] y position of the top left corner
* @param {number} [size=400] width and height in pixel
* @param {Function} colorMap colormap from [min, max] to a color
*/
export function drawMatrix (
context,
matrix,
x = 0,
y = 0,
size = 400,
colorScale,
colorMap = d3.interpolateViridis
) {
const cellSize = size / matrix.length
const paddedSize = cellSize * 1.01
colorScale = colorScale || d3
.scaleLinear()
.domain(d3.extent(matrix.flat()))
.range([1, 0])
for (let row = 0; row < matrix.length; row++) {
for (let col = 0; col < matrix.length; col++) {
context.fillStyle = colorMap(colorScale(matrix[row][col]))
context.fillRect(x, y, paddedSize, paddedSize)
x += cellSize
}
y += cellSize
}
}
/**
* Draws a color ramp
*
* @param {CanvasRenderingContext2D} context canvas rendering context
* @param {Function} colorMap colormap from [min, max] to a color
*/
export function drawColorRamp (context, w = 100, h = 10, colorMap = d3.interpolateRainbow) {
const scaleColor = d3.scaleLinear().domain([0, w])
for (let x = 0; x < w; ++x) {
context.fillStyle = colorMap(scaleColor(x))
context.fillRect(x, 0, 1.1, h)
}
}
/**
* Draws a color map as small rectanlges below the notes C, C#, ...
* @todo test
* @param {CanvasRenderingContext2D} context canvas rendering context
* @param {string[]} colors colors, e.g., from NoteColorUtils
* @param {number} w
* @param {number} h
* @param {number} [x=0]
* @param {number} [y=0]
* @param {number} [fontSize=12]
*/
export function drawNoteColorMap (
context,
colors,
w,
h,
bgColor,
textColor = '#222',
fontSize = 12,
x = 0,
y = 0
) {
const notes = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
context.save()
if (bgColor) {
context.fillStyle = bgColor
context.fillRect(0, 0, w, h)
}
const mWidth = w / 12
const mWidthInner = mWidth * 0.5
context.font = `${fontSize}px sans-serif`
context.textAlign = 'center'
for (const [index, note] of notes.entries()) {
const col = index
const nX = x + col * mWidth
context.fillStyle = colors[index]
context.fillRect(nX, y + fontSize, mWidthInner, h - fontSize)
context.fillStyle = textColor
context.fillText(note, nX + mWidthInner / 2, 0)
}
context.restore()
}
/**
* Draws text horizontally rotated 90 degrees clock-wise
* @todo use the one from mvlib
* @param {*} context
* @param {number} x x position
* @param {number} y y position
* @param {string} text text
* @param {string} [color='black'] HTML color string
* @param {string} [font='12px sans-serif'] font string, e.g, '12px sans-serif'
* @param {boolean} [centered=false] center text?
*/
export function drawVerticalText (
context,
x,
y,
text,
color = 'black',
font = '12px sans-serif',
centered = false
) {
context.save()
context.rotate((90 * Math.PI) / 180)
if (centered) {
context.textAlign = 'center'
}
context.fillStyle = color
context.font = font
context.fillText(text, y, -x)
context.restore()
}