diff --git a/NAMESPACE b/NAMESPACE index b3b665d..32d6908 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -4,6 +4,7 @@ export("%>%") export(as.yearmon) export(as.yearqtr) export(dyAnnotation) +export(dyArrow) export(dyAxis) export(dyBarChart) export(dyBarSeries) @@ -17,7 +18,9 @@ export(dyDependency) export(dyErrorFill) export(dyEvent) export(dyFilledLine) +export(dyGetPluginOptions) export(dyGroup) +export(dyHasPlugin) export(dyHighlight) export(dyLegend) export(dyLimit) @@ -32,6 +35,7 @@ export(dyRibbon) export(dyRoller) export(dySeries) export(dySeriesData) +export(dySetPluginOptions) export(dyShading) export(dyShadow) export(dyStackedBarChart) diff --git a/R/arrow.R b/R/arrow.R new file mode 100644 index 0000000..4792e32 --- /dev/null +++ b/R/arrow.R @@ -0,0 +1,91 @@ +#' dygraph trade arrow +#' +#' Add an arrow to a dygraph +#' +#' @param dygraph Dygraph to add arrow to +#' @param seriesName Name of series within data set. +#' @param x Either numeric or date/time for the event, depending on the format of the +#' x-axis of the dygraph. (For date/time must be a \code{POSIXct} object or another +#' object convertible to \code{POSIXct} via \code{as.POSIXct}) +#' @param label Label for event. Defaults to blank +#' @param direction Direction of arrow (up or down) +#' @param fillColor Fill color of arrow. This can be of the form "#AABBCC" or +#' "rgb(255,100,200)" or "yellow". Defaults to white. +#' @param strokeColor Stroke color of arrow. This can be of the form "#AABBCC" or +#' "rgb(255,100,200)" or "yellow". Defaults to black. +#' +#' @return A dygraph with the specified trade arrow. +#' +#' @examples +#' library(dygraphs) +#' dygraph(presidents, main = "Quarterly Presidential Approval Ratings") %>% +#' dyArrow("1950-6-30", "Korea", direction = "down", fillColor = "red") %>% +#' dyArrow("1965-2-09", "Vietnam", direction = "down", fillColor = "red") +#' +#' dygraph(presidents, main = "Quarterly Presidential Approval Ratings") %>% +#' dyArrow(c("1950-6-30", "1965-2-09"), +#' c("Korea", "Vietnam"), +#' direction = "down", +#' fillColor = "red") +#' +#' @export +dyArrow <- function(dygraph, + x, + text = NULL, + direction = c("up", "down", "left", "right", "ne", "se", "sw", "nw"), + fillColor = "white", + strokeColor = "black", + series = NULL) { + + # create arrows + if (!is.null(text) && length(x) != length(text)) { + stop("Length of 'x' and 'text' does not match") + } + + # validate series if specified + if (!is.null(series) && ! series %in% dygraph$x$attrs$labels) { + stop("The specified series was not found. Valid series names are: ", + paste(dygraph$x$attrs$labels[-1], collapse = ", ")) + } + + # default the series if necessary + if (is.null(series)) + series <- dygraph$x$attrs$labels[length(dygraph$x$attrs$labels)] + + fixedtz <- ifelse(is.null(dygraph$x$fixedtz), FALSE, dygraph$x$fixedtz) + scale <- dygraph$x$scale + + direction <- match.arg(direction) + arrows <- lapply(seq_along(x), + function(i) { + list(xval = ifelse(dygraph$x$format == "date", + asISO8601Time(x[i]), x[i]), + text = ifelse(is.null(text), NULL, text[i]), + direction = direction, + fillColor = fillColor, + strokeColor = strokeColor, + series = series, + fixedtz = fixedtz, + scale = scale) + }) + + pluginName <- "Arrow" + if (dyHasPlugin(dygraph, pluginName)) { + dygraph <- dySetPluginOptions(dygraph, + pluginName, + append(dyGetPluginOptions(dygraph, pluginName), + arrows)) + } else { + dygraph <- dyPlugin(dygraph = dygraph, + name = pluginName, + path = system.file("plugins/arrow.js", + package = "dygraphs"), + options = arrows) + } + + # add necessary CSS styles + dygraph <- dyCSS(dygraph, system.file("plugins/arrow.css", package = "dygraphs")) + + # return dygraph + dygraph +} diff --git a/R/dependency.R b/R/dependency.R index 27764ba..55fa1f5 100644 --- a/R/dependency.R +++ b/R/dependency.R @@ -71,6 +71,58 @@ dyPlugin <- function(dygraph, name, path, options = list(), version = "1.0") { dygraph } +#' Check if plugin was included +#' +#' @param dygraph Dygraph to check plugin existance in +#' @param name Name of plugin +#' +#' @return TRUE if plugin was included in dygraph, FALSE otherwise. +#' +#' @export +dyHasPlugin <- function(dygraph, name) { + name %in% names(dygraph$x$plugins) +} + +#' Get options of included plugin +#' +#' @param dygraph Dygraph with plugin to get options of +#' @param name Name of plugin +#' +#' @return A list with plugin options. +#' +#' @export +dyGetPluginOptions <- function(dygraph, name) { + if (!dyHasPlugin(dygraph, name)) { + stop(paste0("Can't get options of not loaded plugin: ", name)) + } + + # return options list + dygraph$x$plugins[[name]] +} + +#' Set options to included plugin +#' +#' @param dygraph Dygraph with plugin to set options to +#' @param name Name of plugin +#' @param options New options to set +#' +#' @return A dygraph with plugin with updated options. +#' +#' @export +dySetPluginOptions <- function(dygraph, name, options = list()) { + if (!dyHasPlugin(dygraph, name)) { + stop(paste0("Can't set options for not loaded plugin: ", name)) + } + + if (length(options) == 0) { + options <- JS("{}") + } + dygraph$x$plugins[[name]] <- options + + # return dygraph + dygraph +} + #' Include a dygraph plotter #' #' @param dygraph Dygraph to add plotter to diff --git a/docs/gallery-arrow.Rmd b/docs/gallery-arrow.Rmd new file mode 100644 index 0000000..906078f --- /dev/null +++ b/docs/gallery-arrow.Rmd @@ -0,0 +1,41 @@ +--- +title: (Trade) Arrows with Optional Annotations +--- + +```{r setup, include=FALSE} +library(dygraphs) +``` + +```{r} +dygraph(presidents, main = "Quarterly Presidential Approval Ratings") %>% + dyArrow("1950-7-1", + "Korea", + direction = "se", + fillColor = "#d9534f") %>% + dyArrow("1965-1-1", + "Vietnam", + direction = "sw", + fillColor = "#5cb85c") +``` + +```{r} +library(quantmod) + +getSymbols("SPY", from = "2018-01-01", to = "2018-02-01", auto.assign=TRUE) + +difference <- SPY[, "SPY.Open"] - SPY[, "SPY.Close"] +decreasing <- which(difference < 0) +increasing <- which(difference > 0) + +dyData <- SPY[, "SPY.Close"] + +dygraph(dyData) %>% + dyArrow(index(dyData[increasing]), + as.vector(dyData[increasing]), + direction = "up", + fillColor = "green") %>% + dyArrow(index(dyData[decreasing]), + as.vector(dyData[decreasing]), + direction = "down", + fillColor = "red") +``` diff --git a/inst/htmlwidgets/dygraphs.js b/inst/htmlwidgets/dygraphs.js index dcc4f23..403e8e2 100644 --- a/inst/htmlwidgets/dygraphs.js +++ b/inst/htmlwidgets/dygraphs.js @@ -50,7 +50,7 @@ HTMLWidgets.widget({ attrs.file = x.data; // disable zoom interaction except for clicks - if (attrs.disableZoom !== false) { + if (!!attrs.disableZoom) { attrs.interactionModel = Dygraph.Interaction.nonInteractiveModel_; } diff --git a/inst/plugins/arrow.css b/inst/plugins/arrow.css new file mode 100644 index 0000000..d653af6 --- /dev/null +++ b/inst/plugins/arrow.css @@ -0,0 +1,12 @@ +.arrow-popup { + position: absolute; + z-index: 10; + border: 1px solid black; + background: white; + pointer-events: none; +} + +.arrow-popup--hidden { + top: -9999px; + left: -9999px; +} diff --git a/inst/plugins/arrow.js b/inst/plugins/arrow.js new file mode 100644 index 0000000..2aabd5f --- /dev/null +++ b/inst/plugins/arrow.js @@ -0,0 +1,324 @@ +/** + * (Trade) Arrows Dygraph Plugin. + */ +Dygraph.Plugins.Arrow = (function() { + 'use strict'; + + var arrow = function(data) { + this.arrowPoints = []; // Dygraph points with arrows + this.arrows = []; // Arrows DOM elements attached to chart + this.data = data; // Raw arrow data + this.dygraph = null; // Dygraph reference + this.popup = null; // Popup div + }; + + var isNumeric = function(v) { + var obj = {}; + return (typeof v === 'number' || + obj.toString.call(v) === '[object Number]') && !isNaN(v); + }; + + var normalizeDateValue = function(scale, value, fixedtz) { + var date = new Date(value); + if (scale !== 'minute' && + scale !== 'hourly' && + scale !== 'seconds' && + !fixedtz) { + var localAsUTC = date.getTime() + (date.getTimezoneOffset() * 60000); + date = new Date(localAsUTC); + } + return date; + }; + + arrow.prototype.toString = function() { + return 'Arrow Plugin'; + }; + + arrow.prototype.activate = function(g) { + this.dygraph = g; + + this.popup = this.initPopup(); + + return { + didDrawChart: this.didDrawChart, + clearChart: this.clearChart, + select: this.select, + deselect: this.deselect, + }; + }; + + arrow.prototype.initPopup = function() { + var div = document.createElement('div'); + div.className = 'arrow-popup arrow-popup--hidden'; + this.dygraph.graphDiv.appendChild(div); + return div; + }; + + arrow.prototype.didDrawChart = function() { + // Early out in the (common) case of no data. + if (this.data.length === 0) return; + + this.attachArrowsToChart(); + + var canvasx; + for (var i = 0; i < this.data.length; i++) { + var item = this.data[i]; + if (isNumeric(item.xval)) { + canvasx = this.dygraph.toDomXCoord(item.xval); + } else { + canvasx = normalizeDateValue(item.scale, item.xval, item.fixedtz) + .getTime(); + canvasx = this.dygraph.toDomXCoord(canvasx); + } + } + }; + + arrow.prototype.clearChart = function() { + // Early out in the (common) case of zero arrows. + if (this.arrows.length === 0) return; + + this.detachArrows(); + }; + + arrow.prototype.attachArrowsToChart = function() { + this.evaluateArrows(); + + for (var i = 0; i < this.arrowPoints.length; i++) { + var point = this.arrowPoints[i]; + if (this.pointInvisible(point)) continue; + + this.attachArrow(point); + } + }; + + arrow.prototype.evaluateArrows = function() { + var arrows = {}; + var i; + + for (i = 0; i < this.data.length; i++) { + var a = this.data[i]; + var xval = isNumeric(a.xval) ? + a.xval : normalizeDateValue(a.scale, a.xval, a.fixedtz).getTime(); + arrows[xval + ',' + a.series] = a; + } + + this.arrowPoints = []; + + for (i = 0; i < this.dygraph.layout_.points.length; i++) { + var points = this.dygraph.layout_.points[i]; + for (var j = 0; j < points.length; j++) { + var p = points[j]; + var k = p.xval + ',' + p.name; + if (k in arrows) { + p.arrow = arrows[k]; + this.arrowPoints.push(p); + } + } + } + }; + + arrow.prototype.pointInvisible = function(point) { + var area = this.dygraph.plotter_.area; + + if (point.canvasx < area.x || point.canvasx > area.x + area.w || + point.canvasy < area.y || point.canvasy > area.y + area.h) { + return true; + } + return false; + }; + + arrow.prototype.attachArrow = function(point) { + var tickHeight = 10; + + var canvas = this.makeCanvas(point.arrow, tickHeight); + var position = + this.calcPosition(canvas, + point.canvasx, + point.canvasy, + tickHeight); + this.setCanvasPosition(canvas, position); + + this.dygraph.graphDiv.appendChild(canvas); + this.arrows.push(canvas); + }; + + arrow.prototype.makeCanvas = function(arrow, tickHeight) { + var size = 11; + var width = (size + tickHeight) * 4; + var height = width; + var canvas = document.createElement('canvas'); + var ctx = canvas.getContext('2d'); + canvas.width = width; + canvas.height = height; + canvas.style.width = width + 'px'; // for IE + canvas.style.height = height + 'px'; // for IE + canvas.style.position = 'absolute'; + canvas.style.pointerEvents = 'none'; + + var cx = width / 2; + var cy = width / 2; + this.rotateCanvas(ctx, arrow.direction, tickHeight); + ctx.translate(-cx, -cy + tickHeight); + + this.shape(ctx, size, arrow.strokeColor, arrow.fillColor); + + return canvas; + }; + + arrow.prototype.calcPosition = function(canvas, x, y, tickHeight) { + return { + 'left': Math.ceil(x - canvas.width / 2), + 'top': y - canvas.height / 2 + tickHeight + }; + }; + + arrow.prototype.setCanvasPosition = function(canvas, position) { + canvas.style.left = position.left + 'px'; + canvas.style.top = position.top + 'px'; + }; + + arrow.prototype.rotateCanvas = function(ctx, direction, tickHeight) { + var directions = { + 'up': 0, + 'down': 180, + 'left': 270, + 'right': 90, + 'ne': 45, + 'se': 135, + 'sw': 225, + 'nw': 315 + }; + var rotation = directions[direction] || 0; + var cx = ctx.canvas.width / 2; + var cy = cx; + ctx.translate(cx, cy - tickHeight); + ctx.rotate(Math.PI / 180 * rotation); + }; + + arrow.prototype.shape = function(ctx, size, stroke, fill) { + var cx = ctx.canvas.width / 2; + var cy = cx; + + ctx.strokeStyle = stroke; + ctx.fillStyle = fill; + ctx.lineWidth = 0.6; + + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.lineTo(cx + size / 2, cy + size); + ctx.lineTo(cx + size / 6, cy + size * 0.8); + ctx.lineTo(cx + size / 6, cy + size * 2); + ctx.lineTo(cx - size / 6, cy + size * 2); + ctx.lineTo(cx - size / 6, cy + size * 0.8); + ctx.lineTo(cx - size / 2, cy + size); + ctx.lineTo(cx, cy); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + }; + + arrow.prototype.detachArrows = function() { + for (var i = 0; i < this.arrows.length; i++) { + var a = this.arrows[i]; + if (a.parentNode) { + a.parentNode.removeChild(a); + } + this.arrows[i] = null; + } + this.arrows = []; + }; + + arrow.prototype.select = function(e) { + for (var i = 0; i < e.selectedPoints.length; i++) { + var p = e.selectedPoints[i]; + if (!p.hasOwnProperty('arrow')) { + this.hidePopup(); + continue; + } + this.showPopup(p); + } + }; + + arrow.prototype.showPopup = function(p) { + this.popup.innerText = p.arrow.text; + this.setPopupPosition(p); + + this.popup.classList.remove("arrow-popup--hidden"); + for (var i = 0; i < this.popup.classList.length; i++) { + var oldClass = this.popup.classList.item(i); + var newClass = "arrow-popup--direction-" + p.arrow.direction; + if (oldClass === newClass) { + break; + } + if (oldClass.startsWith("arrow-popup--direction-")) { + this.popup.classList.replace(oldClass, newClass); + break; + } + this.popup.classList.add(newClass); + } + }; + + arrow.prototype.setPopupPosition = function(p) { + var popupWidth = this.popup.offsetWidth; + var popupHeight = this.popup.offsetHeight; + var offset = 10; + var leftPopup, topPopup; + + if (p.arrow.direction === "up") { + leftPopup = p.canvasx - popupWidth / 2; + topPopup = p.canvasy - popupHeight - offset; + } else if (p.arrow.direction === "down") { + leftPopup = p.canvasx - popupWidth / 2; + topPopup = p.canvasy + offset; + } else if (p.arrow.direction === "left") { + leftPopup = p.canvasx - popupWidth - offset; + topPopup = p.canvasy - popupHeight / 2; + } else if (p.arrow.direction === "right") { + leftPopup = p.canvasx + offset; + topPopup = p.canvasy - popupHeight / 2; + } else if (p.arrow.direction === "se") { + leftPopup = p.canvasx + offset; + topPopup = p.canvasy + offset; + } else if (p.arrow.direction === "sw") { + leftPopup = p.canvasx - popupWidth - offset; + topPopup = p.canvasy + offset; + } else if (p.arrow.direction === "ne") { + leftPopup = p.canvasx + offset; + topPopup = p.canvasy - popupHeight - offset; + } else if (p.arrow.direction === "nw") { + leftPopup = p.canvasx - popupWidth - offset; + topPopup = p.canvasy - popupHeight - offset; + } + + // Strip redundant CSS classes + this.popup.classList.remove("arrow-popup--left-bound", "arrow-popup--right-bound"); + + var xLabelWidth = this.dygraph.getOptionForAxis('axisLabelWidth', 'y'); + if (leftPopup < xLabelWidth) { + leftPopup = xLabelWidth; + this.popup.classList.add("arrow-popup--left-bound"); + } + + var area = this.dygraph.plotter_.area; + var diff = (leftPopup + popupWidth) - area.w - xLabelWidth - offset; + if (diff > 0) { + leftPopup = leftPopup - diff; + this.popup.classList.add("arrow-popup--right-bound"); + } + + this.popup.style.left = leftPopup + "px"; + this.popup.style.top = topPopup + "px"; + }; + + arrow.prototype.hidePopup = function() { + this.popup.style.left = ""; + this.popup.style.top = ""; + this.popup.classList.add("arrow-popup--hidden"); + }; + + arrow.prototype.deselect = function(e) { + this.hidePopup(); + }; + + return arrow; +})(); diff --git a/man/dyArrow.Rd b/man/dyArrow.Rd new file mode 100644 index 0000000..38a9fdf --- /dev/null +++ b/man/dyArrow.Rd @@ -0,0 +1,49 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/arrow.R +\name{dyArrow} +\alias{dyArrow} +\title{dygraph trade arrow} +\usage{ +dyArrow(dygraph, x, text = NULL, tooltip = NULL, direction = c("up", + "down", "left", "right", "ne", "se", "sw", "nw"), fillColor = "white", + strokeColor = "black", series = NULL) +} +\arguments{ +\item{dygraph}{Dygraph to add arrow to} + +\item{x}{Either numeric or date/time for the event, depending on the format of the +x-axis of the dygraph. (For date/time must be a \code{POSIXct} object or another +object convertible to \code{POSIXct} via \code{as.POSIXct})} + +\item{direction}{Direction of arrow (up or down)} + +\item{fillColor}{Fill color of arrow. This can be of the form "#AABBCC" or +"rgb(255,100,200)" or "yellow". Defaults to white.} + +\item{strokeColor}{Stroke color of arrow. This can be of the form "#AABBCC" or +"rgb(255,100,200)" or "yellow". Defaults to black.} + +\item{seriesName}{Name of series within data set.} + +\item{label}{Label for event. Defaults to blank} +} +\value{ +A dygraph with the specified trade arrow. +} +\description{ +Add an arrow to a dygraph +} +\examples{ +library(dygraphs) +dygraph(presidents, main = "Quarterly Presidential Approval Ratings") \%>\% + dyArrow("1950-6-30", "Korea", direction = "down", fillColor = "red") \%>\% + dyArrow("1965-2-09", "Vietnam", direction = "down", fillColor = "red") + +dygraph(presidents, main = "Quarterly Presidential Approval Ratings") \%>\% + dyArrow(c("1950-6-30", "1965-2-09"), + c("Korea", "Vietnam"), + direction = "down", + fillColor = "red") + +} + diff --git a/man/dyGetPluginOptions.Rd b/man/dyGetPluginOptions.Rd new file mode 100644 index 0000000..54910f5 --- /dev/null +++ b/man/dyGetPluginOptions.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/dependency.R +\name{dyGetPluginOptions} +\alias{dyGetPluginOptions} +\title{Get options of included plugin} +\usage{ +dyGetPluginOptions(dygraph, name) +} +\arguments{ +\item{dygraph}{Dygraph with plugin to get options of} + +\item{name}{Name of plugin} +} +\value{ +A list with plugin options. +} +\description{ +Get options of included plugin +} + diff --git a/man/dyHasPlugin.Rd b/man/dyHasPlugin.Rd new file mode 100644 index 0000000..c2d839d --- /dev/null +++ b/man/dyHasPlugin.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/dependency.R +\name{dyHasPlugin} +\alias{dyHasPlugin} +\title{Check if plugin was included} +\usage{ +dyHasPlugin(dygraph, name) +} +\arguments{ +\item{dygraph}{Dygraph to check plugin existance in} + +\item{name}{Name of plugin} +} +\value{ +TRUE if plugin was included in dygraph, FALSE otherwise. +} +\description{ +Check if plugin was included +} + diff --git a/man/dySetPluginOptions.Rd b/man/dySetPluginOptions.Rd new file mode 100644 index 0000000..d5e83f1 --- /dev/null +++ b/man/dySetPluginOptions.Rd @@ -0,0 +1,22 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/dependency.R +\name{dySetPluginOptions} +\alias{dySetPluginOptions} +\title{Set options to included plugin} +\usage{ +dySetPluginOptions(dygraph, name, options = list()) +} +\arguments{ +\item{dygraph}{Dygraph with plugin to set options to} + +\item{name}{Name of plugin} + +\item{options}{New options to set} +} +\value{ +A dygraph with plugin with updated options. +} +\description{ +Set options to included plugin +} +