You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
705 lines
24 KiB
705 lines
24 KiB
/*
|
|
* heatmap.js v2.0.5 | JavaScript Heatmap Library
|
|
*
|
|
* Copyright 2008-2016 Patrick Wied <heatmapjs@patrick-wied.at> - All rights reserved.
|
|
* Dual licensed under MIT and Beerware license
|
|
*
|
|
* :: 2016-09-05 01:16
|
|
*/
|
|
(function (name, context, factory) {
|
|
// Supports UMD. AMD, CommonJS/Node.js and browser context
|
|
if (typeof module !== "undefined" && module.exports) {
|
|
module.exports = factory();
|
|
} else if (typeof define === "function" && define.amd) {
|
|
define(factory);
|
|
} else {
|
|
context[name] = factory();
|
|
}
|
|
})("h337", this, () => {
|
|
// Heatmap Config stores default values and will be merged with instance config
|
|
let HeatmapConfig = {
|
|
defaultRadius: 40,
|
|
defaultRenderer: "canvas2d",
|
|
defaultGradient: {
|
|
0.25: "rgb(0,0,255)",
|
|
0.55: "rgb(0,255,0)",
|
|
0.85: "yellow",
|
|
1.0: "rgb(255,0,0)",
|
|
},
|
|
defaultMaxOpacity: 1,
|
|
defaultMinOpacity: 0,
|
|
defaultBlur: 0.85,
|
|
defaultXField: "x",
|
|
defaultYField: "y",
|
|
defaultValueField: "value",
|
|
plugins: {},
|
|
};
|
|
let Store = (function StoreClosure() {
|
|
let Store = function Store(config) {
|
|
this._coordinator = {};
|
|
this._data = [];
|
|
this._radi = [];
|
|
this._min = 10;
|
|
this._max = 1;
|
|
this._xField = config["xField"] || config.defaultXField;
|
|
this._yField = config["yField"] || config.defaultYField;
|
|
this._valueField = config["valueField"] || config.defaultValueField;
|
|
|
|
if (config["radius"]) {
|
|
this._cfgRadius = config["radius"];
|
|
}
|
|
};
|
|
|
|
let defaultRadius = HeatmapConfig.defaultRadius;
|
|
|
|
Store.prototype = {
|
|
// when forceRender = false -> called from setData, omits renderall event
|
|
_organiseData(dataPoint, forceRender) {
|
|
let x = dataPoint[this._xField];
|
|
let y = dataPoint[this._yField];
|
|
let radi = this._radi;
|
|
let store = this._data;
|
|
let max = this._max;
|
|
let min = this._min;
|
|
let value = dataPoint[this._valueField] || 1;
|
|
let radius = dataPoint.radius || this._cfgRadius || defaultRadius;
|
|
|
|
if (!store[x]) {
|
|
store[x] = [];
|
|
radi[x] = [];
|
|
}
|
|
|
|
if (!store[x][y]) {
|
|
store[x][y] = value;
|
|
radi[x][y] = radius;
|
|
} else {
|
|
store[x][y] += value;
|
|
}
|
|
let storedVal = store[x][y];
|
|
|
|
if (storedVal > max) {
|
|
if (!forceRender) {
|
|
this._max = storedVal;
|
|
} else {
|
|
this.setDataMax(storedVal);
|
|
}
|
|
return false;
|
|
} else if (storedVal < min) {
|
|
if (!forceRender) {
|
|
this._min = storedVal;
|
|
} else {
|
|
this.setDataMin(storedVal);
|
|
}
|
|
return false;
|
|
}
|
|
return {
|
|
x,
|
|
y,
|
|
value,
|
|
radius,
|
|
min,
|
|
max,
|
|
};
|
|
},
|
|
_unOrganizeData() {
|
|
let unorganizedData = [];
|
|
let data = this._data;
|
|
let radi = this._radi;
|
|
|
|
for (let x in data) {
|
|
for (let y in data[x]) {
|
|
unorganizedData.push({
|
|
x,
|
|
y,
|
|
radius: radi[x][y],
|
|
value: data[x][y],
|
|
});
|
|
}
|
|
}
|
|
return {
|
|
min: this._min,
|
|
max: this._max,
|
|
data: unorganizedData,
|
|
};
|
|
},
|
|
_onExtremaChange() {
|
|
this._coordinator.emit("extremachange", {
|
|
min: this._min,
|
|
max: this._max,
|
|
});
|
|
},
|
|
addData() {
|
|
if (arguments[0].length > 0) {
|
|
let dataArr = arguments[0];
|
|
let dataLen = dataArr.length;
|
|
while (dataLen--) {
|
|
this.addData.call(this, dataArr[dataLen]);
|
|
}
|
|
} else {
|
|
// add to store
|
|
let organisedEntry = this._organiseData(arguments[0], true);
|
|
if (organisedEntry) {
|
|
// if it's the first datapoint initialize the extremas with it
|
|
if (this._data.length === 0) {
|
|
this._min = this._max = organisedEntry.value;
|
|
}
|
|
this._coordinator.emit("renderpartial", {
|
|
min: this._min,
|
|
max: this._max,
|
|
data: [organisedEntry],
|
|
});
|
|
}
|
|
}
|
|
return this;
|
|
},
|
|
setData(data) {
|
|
let dataPoints = data.data;
|
|
let pointsLen = dataPoints.length;
|
|
|
|
// reset data arrays
|
|
this._data = [];
|
|
this._radi = [];
|
|
|
|
for (let i = 0; i < pointsLen; i++) {
|
|
this._organiseData(dataPoints[i], false);
|
|
}
|
|
this._max = data.max;
|
|
this._min = data.min || 0;
|
|
|
|
this._onExtremaChange();
|
|
this._coordinator.emit("renderall", this._getInternalData());
|
|
return this;
|
|
},
|
|
removeData() {
|
|
// TODO: implement
|
|
},
|
|
setDataMax(max) {
|
|
this._max = max;
|
|
this._onExtremaChange();
|
|
this._coordinator.emit("renderall", this._getInternalData());
|
|
return this;
|
|
},
|
|
setDataMin(min) {
|
|
this._min = min;
|
|
this._onExtremaChange();
|
|
this._coordinator.emit("renderall", this._getInternalData());
|
|
return this;
|
|
},
|
|
setCoordinator(coordinator) {
|
|
this._coordinator = coordinator;
|
|
},
|
|
_getInternalData() {
|
|
return {
|
|
max: this._max,
|
|
min: this._min,
|
|
data: this._data,
|
|
radi: this._radi,
|
|
};
|
|
},
|
|
getData() {
|
|
return this._unOrganizeData();
|
|
} /*,
|
|
|
|
TODO: rethink.
|
|
|
|
getValueAt: function(point) {
|
|
var value;
|
|
var radius = 100;
|
|
var x = point.x;
|
|
var y = point.y;
|
|
var data = this._data;
|
|
|
|
if (data[x] && data[x][y]) {
|
|
return data[x][y];
|
|
} else {
|
|
var values = [];
|
|
// radial search for datapoints based on default radius
|
|
for(var distance = 1; distance < radius; distance++) {
|
|
var neighbors = distance * 2 +1;
|
|
var startX = x - distance;
|
|
var startY = y - distance;
|
|
|
|
for(var i = 0; i < neighbors; i++) {
|
|
for (var o = 0; o < neighbors; o++) {
|
|
if ((i == 0 || i == neighbors-1) || (o == 0 || o == neighbors-1)) {
|
|
if (data[startY+i] && data[startY+i][startX+o]) {
|
|
values.push(data[startY+i][startX+o]);
|
|
}
|
|
} else {
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (values.length > 0) {
|
|
return Math.max.apply(Math, values);
|
|
}
|
|
}
|
|
return false;
|
|
}*/,
|
|
};
|
|
|
|
return Store;
|
|
})();
|
|
|
|
let Canvas2dRenderer = (function Canvas2dRendererClosure() {
|
|
let _getColorPalette = function (config) {
|
|
let gradientConfig = config.gradient || config.defaultGradient;
|
|
let paletteCanvas = document.createElement("canvas");
|
|
let paletteCtx = paletteCanvas.getContext("2d");
|
|
|
|
paletteCanvas.width = 256;
|
|
paletteCanvas.height = 1;
|
|
|
|
let gradient = paletteCtx.createLinearGradient(0, 0, 256, 1);
|
|
for (let key in gradientConfig) {
|
|
gradient.addColorStop(key, gradientConfig[key]);
|
|
}
|
|
|
|
paletteCtx.fillStyle = gradient;
|
|
paletteCtx.fillRect(0, 0, 256, 1);
|
|
|
|
return paletteCtx.getImageData(0, 0, 256, 1).data;
|
|
};
|
|
|
|
let _getPointTemplate = function (radius, blurFactor) {
|
|
let tplCanvas = document.createElement("canvas");
|
|
let tplCtx = tplCanvas.getContext("2d");
|
|
let x = radius;
|
|
let y = radius;
|
|
tplCanvas.width = tplCanvas.height = radius * 2;
|
|
|
|
if (blurFactor == 1) {
|
|
tplCtx.beginPath();
|
|
tplCtx.arc(x, y, radius, 0, 2 * Math.PI, false);
|
|
tplCtx.fillStyle = "rgba(0,0,0,1)";
|
|
tplCtx.fill();
|
|
} else {
|
|
let gradient = tplCtx.createRadialGradient(x, y, radius * blurFactor, x, y, radius);
|
|
gradient.addColorStop(0, "rgba(0,0,0,1)");
|
|
gradient.addColorStop(1, "rgba(0,0,0,0)");
|
|
tplCtx.fillStyle = gradient;
|
|
tplCtx.fillRect(0, 0, 2 * radius, 2 * radius);
|
|
}
|
|
|
|
return tplCanvas;
|
|
};
|
|
|
|
let _prepareData = function (data) {
|
|
let renderData = [];
|
|
let min = data.min;
|
|
let max = data.max;
|
|
let radi = data.radi;
|
|
var data = data.data;
|
|
|
|
let xValues = Object.keys(data);
|
|
let xValuesLen = xValues.length;
|
|
|
|
while (xValuesLen--) {
|
|
let xValue = xValues[xValuesLen];
|
|
let yValues = Object.keys(data[xValue]);
|
|
let yValuesLen = yValues.length;
|
|
while (yValuesLen--) {
|
|
let yValue = yValues[yValuesLen];
|
|
let value = data[xValue][yValue];
|
|
let radius = radi[xValue][yValue];
|
|
renderData.push({
|
|
x: xValue,
|
|
y: yValue,
|
|
value,
|
|
radius,
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
min,
|
|
max,
|
|
data: renderData,
|
|
};
|
|
};
|
|
|
|
function Canvas2dRenderer(config) {
|
|
let container = config.container;
|
|
let shadowCanvas = (this.shadowCanvas = document.createElement("canvas"));
|
|
let canvas = (this.canvas = config.canvas || document.createElement("canvas"));
|
|
let renderBoundaries = (this._renderBoundaries = [10000, 10000, 0, 0]);
|
|
|
|
let computed = getComputedStyle(config.container) || {};
|
|
|
|
canvas.className = "heatmap-canvas";
|
|
|
|
this._width =
|
|
canvas.width =
|
|
shadowCanvas.width =
|
|
config.width || +computed.width.replace(/px/, "");
|
|
this._height =
|
|
canvas.height =
|
|
shadowCanvas.height =
|
|
config.height || +computed.height.replace(/px/, "");
|
|
|
|
this.shadowCtx = shadowCanvas.getContext("2d");
|
|
this.ctx = canvas.getContext("2d");
|
|
|
|
// @TODO:
|
|
// conditional wrapper
|
|
|
|
canvas.style.cssText = shadowCanvas.style.cssText = "position:absolute;left:0;top:0;";
|
|
|
|
container.style.position = "relative";
|
|
container.appendChild(canvas);
|
|
|
|
this._palette = _getColorPalette(config);
|
|
this._templates = {};
|
|
|
|
this._setStyles(config);
|
|
}
|
|
|
|
Canvas2dRenderer.prototype = {
|
|
renderPartial(data) {
|
|
if (data.data.length > 0) {
|
|
this._drawAlpha(data);
|
|
this._colorize();
|
|
}
|
|
},
|
|
renderAll(data) {
|
|
// reset render boundaries
|
|
this._clear();
|
|
if (data.data.length > 0) {
|
|
this._drawAlpha(_prepareData(data));
|
|
this._colorize();
|
|
}
|
|
},
|
|
_updateGradient(config) {
|
|
this._palette = _getColorPalette(config);
|
|
},
|
|
updateConfig(config) {
|
|
if (config["gradient"]) {
|
|
this._updateGradient(config);
|
|
}
|
|
this._setStyles(config);
|
|
},
|
|
setDimensions(width, height) {
|
|
this._width = width;
|
|
this._height = height;
|
|
this.canvas.width = this.shadowCanvas.width = width;
|
|
this.canvas.height = this.shadowCanvas.height = height;
|
|
},
|
|
_clear() {
|
|
this.shadowCtx.clearRect(0, 0, this._width, this._height);
|
|
this.ctx.clearRect(0, 0, this._width, this._height);
|
|
},
|
|
_setStyles(config) {
|
|
this._blur = config.blur == 0 ? 0 : config.blur || config.defaultBlur;
|
|
|
|
if (config.backgroundColor) {
|
|
this.canvas.style.backgroundColor = config.backgroundColor;
|
|
}
|
|
|
|
this._width =
|
|
this.canvas.width =
|
|
this.shadowCanvas.width =
|
|
config.width || this._width;
|
|
this._height =
|
|
this.canvas.height =
|
|
this.shadowCanvas.height =
|
|
config.height || this._height;
|
|
|
|
this._opacity = (config.opacity || 0) * 255;
|
|
this._maxOpacity = (config.maxOpacity || config.defaultMaxOpacity) * 255;
|
|
this._minOpacity = (config.minOpacity || config.defaultMinOpacity) * 255;
|
|
this._useGradientOpacity = !!config.useGradientOpacity;
|
|
},
|
|
_drawAlpha(data) {
|
|
let min = (this._min = data.min);
|
|
let max = (this._max = data.max);
|
|
var data = data.data || [];
|
|
let dataLen = data.length;
|
|
// on a point basis?
|
|
let blur = 1 - this._blur;
|
|
|
|
while (dataLen--) {
|
|
let point = data[dataLen];
|
|
|
|
let x = point.x;
|
|
let y = point.y;
|
|
let radius = point.radius;
|
|
// if value is bigger than max
|
|
// use max as value
|
|
let value = Math.min(point.value, max);
|
|
let rectX = x - radius;
|
|
let rectY = y - radius;
|
|
let shadowCtx = this.shadowCtx;
|
|
|
|
var tpl;
|
|
if (!this._templates[radius]) {
|
|
this._templates[radius] = tpl = _getPointTemplate(radius, blur);
|
|
} else {
|
|
tpl = this._templates[radius];
|
|
}
|
|
// value from minimum / value range
|
|
// => [0, 1]
|
|
let templateAlpha = (value - min) / (max - min);
|
|
// this fixes #176: small values are not visible because globalAlpha < .01 cannot be read from imageData
|
|
shadowCtx.globalAlpha = templateAlpha < 0.01 ? 0.01 : templateAlpha;
|
|
|
|
shadowCtx.drawImage(tpl, rectX, rectY);
|
|
|
|
// update renderBoundaries
|
|
if (rectX < this._renderBoundaries[0]) {
|
|
this._renderBoundaries[0] = rectX;
|
|
}
|
|
if (rectY < this._renderBoundaries[1]) {
|
|
this._renderBoundaries[1] = rectY;
|
|
}
|
|
if (rectX + 2 * radius > this._renderBoundaries[2]) {
|
|
this._renderBoundaries[2] = rectX + 2 * radius;
|
|
}
|
|
if (rectY + 2 * radius > this._renderBoundaries[3]) {
|
|
this._renderBoundaries[3] = rectY + 2 * radius;
|
|
}
|
|
}
|
|
},
|
|
_colorize() {
|
|
let x = this._renderBoundaries[0];
|
|
let y = this._renderBoundaries[1];
|
|
let width = this._renderBoundaries[2] - x;
|
|
let height = this._renderBoundaries[3] - y;
|
|
let maxWidth = this._width;
|
|
let maxHeight = this._height;
|
|
let opacity = this._opacity;
|
|
let maxOpacity = this._maxOpacity;
|
|
let minOpacity = this._minOpacity;
|
|
let useGradientOpacity = this._useGradientOpacity;
|
|
|
|
if (x < 0) {
|
|
x = 0;
|
|
}
|
|
if (y < 0) {
|
|
y = 0;
|
|
}
|
|
if (x + width > maxWidth) {
|
|
width = maxWidth - x;
|
|
}
|
|
if (y + height > maxHeight) {
|
|
height = maxHeight - y;
|
|
}
|
|
|
|
let img = this.shadowCtx.getImageData(x, y, width, height);
|
|
let imgData = img.data;
|
|
let len = imgData.length;
|
|
let palette = this._palette;
|
|
|
|
for (let i = 3; i < len; i += 4) {
|
|
let alpha = imgData[i];
|
|
let offset = alpha * 4;
|
|
|
|
if (!offset) {
|
|
continue;
|
|
}
|
|
|
|
var finalAlpha;
|
|
if (opacity > 0) {
|
|
finalAlpha = opacity;
|
|
} else if (alpha < maxOpacity) {
|
|
if (alpha < minOpacity) {
|
|
finalAlpha = minOpacity;
|
|
} else {
|
|
finalAlpha = alpha;
|
|
}
|
|
} else {
|
|
finalAlpha = maxOpacity;
|
|
}
|
|
|
|
imgData[i - 3] = palette[offset];
|
|
imgData[i - 2] = palette[offset + 1];
|
|
imgData[i - 1] = palette[offset + 2];
|
|
imgData[i] = useGradientOpacity ? palette[offset + 3] : finalAlpha;
|
|
}
|
|
|
|
// img.data = imgData;
|
|
this.ctx.putImageData(img, x, y);
|
|
|
|
this._renderBoundaries = [1000, 1000, 0, 0];
|
|
},
|
|
getValueAt(point) {
|
|
let value;
|
|
let shadowCtx = this.shadowCtx;
|
|
let img = shadowCtx.getImageData(point.x, point.y, 1, 1);
|
|
let data = img.data[3];
|
|
let max = this._max;
|
|
let min = this._min;
|
|
|
|
value = (Math.abs(max - min) * (data / 255)) >> 0;
|
|
|
|
return value;
|
|
},
|
|
getDataURL() {
|
|
return this.canvas.toDataURL();
|
|
},
|
|
};
|
|
|
|
return Canvas2dRenderer;
|
|
})();
|
|
|
|
let Renderer = (function RendererClosure() {
|
|
let rendererFn = false;
|
|
|
|
if (HeatmapConfig["defaultRenderer"] === "canvas2d") {
|
|
rendererFn = Canvas2dRenderer;
|
|
}
|
|
|
|
return rendererFn;
|
|
})();
|
|
|
|
let Util = {
|
|
merge() {
|
|
let merged = {};
|
|
let argsLen = arguments.length;
|
|
for (let i = 0; i < argsLen; i++) {
|
|
let obj = arguments[i];
|
|
for (let key in obj) {
|
|
merged[key] = obj[key];
|
|
}
|
|
}
|
|
return merged;
|
|
},
|
|
};
|
|
// Heatmap Constructor
|
|
let Heatmap = (function HeatmapClosure() {
|
|
let Coordinator = (function CoordinatorClosure() {
|
|
function Coordinator() {
|
|
this.cStore = {};
|
|
}
|
|
|
|
Coordinator.prototype = {
|
|
on(evtName, callback, scope) {
|
|
let cStore = this.cStore;
|
|
|
|
if (!cStore[evtName]) {
|
|
cStore[evtName] = [];
|
|
}
|
|
cStore[evtName].push((data) => {
|
|
return callback.call(scope, data);
|
|
});
|
|
},
|
|
emit(evtName, data) {
|
|
let cStore = this.cStore;
|
|
if (cStore[evtName]) {
|
|
let len = cStore[evtName].length;
|
|
for (let i = 0; i < len; i++) {
|
|
let callback = cStore[evtName][i];
|
|
callback(data);
|
|
}
|
|
}
|
|
},
|
|
};
|
|
|
|
return Coordinator;
|
|
})();
|
|
|
|
let _connect = function (scope) {
|
|
let renderer = scope._renderer;
|
|
let coordinator = scope._coordinator;
|
|
let store = scope._store;
|
|
|
|
coordinator.on("renderpartial", renderer.renderPartial, renderer);
|
|
coordinator.on("renderall", renderer.renderAll, renderer);
|
|
coordinator.on("extremachange", (data) => {
|
|
scope._config.onExtremaChange &&
|
|
scope._config.onExtremaChange({
|
|
min: data.min,
|
|
max: data.max,
|
|
gradient: scope._config["gradient"] || scope._config["defaultGradient"],
|
|
});
|
|
});
|
|
store.setCoordinator(coordinator);
|
|
};
|
|
|
|
function Heatmap() {
|
|
let config = (this._config = Util.merge(HeatmapConfig, arguments[0] || {}));
|
|
this._coordinator = new Coordinator();
|
|
if (config["plugin"]) {
|
|
let pluginToLoad = config["plugin"];
|
|
if (!HeatmapConfig.plugins[pluginToLoad]) {
|
|
throw new Error(
|
|
`Plugin '${pluginToLoad}' not found. Maybe it was not registered.`,
|
|
);
|
|
} else {
|
|
let plugin = HeatmapConfig.plugins[pluginToLoad];
|
|
// set plugin renderer and store
|
|
this._renderer = new plugin.renderer(config);
|
|
this._store = new plugin.store(config);
|
|
}
|
|
} else {
|
|
this._renderer = new Renderer(config);
|
|
this._store = new Store(config);
|
|
}
|
|
_connect(this);
|
|
}
|
|
|
|
// @TODO:
|
|
// add API documentation
|
|
Heatmap.prototype = {
|
|
addData() {
|
|
this._store.addData.apply(this._store, arguments);
|
|
return this;
|
|
},
|
|
removeData() {
|
|
this._store.removeData && this._store.removeData.apply(this._store, arguments);
|
|
return this;
|
|
},
|
|
setData() {
|
|
this._store.setData.apply(this._store, arguments);
|
|
return this;
|
|
},
|
|
setDataMax() {
|
|
this._store.setDataMax.apply(this._store, arguments);
|
|
return this;
|
|
},
|
|
setDataMin() {
|
|
this._store.setDataMin.apply(this._store, arguments);
|
|
return this;
|
|
},
|
|
configure(config) {
|
|
this._config = Util.merge(this._config, config);
|
|
this._renderer.updateConfig(this._config);
|
|
this._coordinator.emit("renderall", this._store._getInternalData());
|
|
return this;
|
|
},
|
|
repaint() {
|
|
this._coordinator.emit("renderall", this._store._getInternalData());
|
|
return this;
|
|
},
|
|
getData() {
|
|
return this._store.getData();
|
|
},
|
|
getDataURL() {
|
|
return this._renderer.getDataURL();
|
|
},
|
|
getValueAt(point) {
|
|
if (this._store.getValueAt) {
|
|
return this._store.getValueAt(point);
|
|
} else if (this._renderer.getValueAt) {
|
|
return this._renderer.getValueAt(point);
|
|
}
|
|
return null;
|
|
},
|
|
};
|
|
|
|
return Heatmap;
|
|
})();
|
|
|
|
// core
|
|
let heatmapFactory = {
|
|
create(config) {
|
|
return new Heatmap(config);
|
|
},
|
|
register(pluginKey, plugin) {
|
|
HeatmapConfig.plugins[pluginKey] = plugin;
|
|
},
|
|
};
|
|
|
|
return heatmapFactory;
|
|
});
|