flot是一个比较强大的js 折线图插件,依赖于jquery,属于在github上托管的一个开源项目,在众多大牛的帮助下拥有了很多插件,实现了很多很强大的功能。
具体的内容见官网www.flotcharts.org
这篇文章不是一个教程,而是旨在解决flot不能提供的功能,话题主要包含以下方面。
- 构建显示值为空的数据点的折线图
- 图表上方出现了异常的css样式
- 在复用自己重定义的flot与原生的flot 可能会出现异常显示的问题。
##构建显示值为空的数据点的折线图
首先对flot的折线图功能做一个简单的介绍,原生的折线图提供基于数据点主要是[[num,num],[num,num]]格式的数据点进行显示的方式,在相邻点之间通过连线来形成折线,通过添加插件可以实现x轴为时间坐标,添加x轴或者y轴名称等不错的功能,但是不能摆脱一个限制折线图的显示依赖于数值,没有数值我们就无法确定点需要显示的位置和顺序,
当然官网上说支持显示空数据点,空数据点会表示为线段这段,但是还是存在问题,问题表现为如果我们存在连续的多个null值,flot还是会帮我们当做一个空值处理。
效果如下图
这是我之前博客中做的一个组件,通过右下角的显示 我们可以知道其实最后一个数据节点之后 其实还存在着数据,并且数据的值不存在,flot帮我们当做一个数据点处理,最后通过放大页面可以看到确实存在一个空点。然而在我们的需求中这样的显示效果肯定不是我们想要的效果,我们的通常需求是如果数据值为空 折线图上就在对应位置显示一个空的数据点就行了,我们需要这个数据点不显示但是占住那块的位置,效果如下图所示
这样用户能够明显的感觉到那块数据未存在。
我们在传入数据的时候plot的函数已经在做处理了,所以想在画线段的时候做处理已经太晚了。所以我构想了一个另外的解决方案,在程序中我们将null数据值转化成一个超过显示数据范围的值,一般都是小于 y轴最小值。
这个解决方案能够保证数据点不存在于折线图的可见区域,但是这个带来了一个另外的问题,flot会给相邻的数据点之间连线,那么就null数据点附近的点会与x轴的某个点存在连线,这个与我们最后要的效果也是相悖的。
这个问题那么就转化到了flot本身,我们需要flot在画折线图的时候不要在null点与非null数据之间连线,于是我们可以查看flot的源码,在内部我们可以找到一个
function plotLine(datapoints, xoffset, yoffset, axisx, axisy)
这个方法就是在画折线的
// clip with ymin
if (y1 <= y2 && y1 < axisy.min) {
if (y2 < axisy.min)
continue; // line segment is outside
// compute new intersection point
//x1 = (axisy.min - y1) / (y2 - y1) * (x2 - x1) + x1;
//y1 = axisy.min;
continue;
}
else if (y2 <= y1 && y2 < axisy.min) {
if (y1 < axisy.min)
continue;
//x2 = (axisy.min - y1) / (y2 - y1) * (x2 - x1) + x1;
//y2 = axisy.min;
continue;
}
在函数内部这段(已经是我修改之后的代码)是处理数据点的值小于 y轴最小值的问题的,那么问题就简单了,我们只需要在当前数据节点或者前一个数据节点小于 y轴最小值的时候不画连线就行了,具体内容就照着上面改。
这就解决了基本的需求,但是如果折线图需要显示覆盖区域那么就麻烦了。虽然我们修改上面一个方法可以不再数据为null的时候显示连线,但是null数据点还是会影响到覆盖区域的显示。
那么下一步就是找画覆盖区域的方法,最后确定是这个
function plotLineArea(datapoints, axisx, axisy)
查看逻辑可以看出来,他是对数据节点进行了两遍遍历最后画出了一个封闭区域然后调用了canvas的fill()方式实现填充,那么我们需要解决的就是让plot正确的画出这个封闭区域
最后做出的修改如下
// clip with ymin
if (y1 <= y2 && y1 < axisy.min && y2 >= axisy.min) {
//x1 = (axisy.min - y1) / (y2 - y1) * (x2 - x1) + x1;
y1 = axisy.min;
y2 = axisy.min;
//continue;
}
else if (y2 <= y1 && y2 < axisy.min && y1 >= axisy.min) {
//x2 = (axisy.min - y1) / (y2 - y1) * (x2 - x1) + x1;
y1 = axisy.min;
y2 = axisy.min;
//continue;
}
在数据点为null的时候让数据值为空的时候画一条与x轴重合的线段,正向遍历与方向遍历画出来的线段就重合了,这样这一段canvas是不会填充颜色的
这样我们就基本解决了显示值为null的问题,当然存在应用场景,也就是我们的折线图必须存在一个y轴最小值。
##图表上方出现了异常的css样式
这个问题在我将组件添加进项目的时候出现的,当时发现我的组件的折线图上方出现了一条线,这个与css中的bottom-line的效果相同,那么我就可以断定是嵌套css的影响,
这里分享一个虽然比较low但是有效率的debug的方案吧,我们可以在chrome中确定异常区域的位置,然后将内部组件一个删除掉,直到确定直接产生这个效果的块,然后递归到最小影响区域上,通过css的继承树 我们可以找到到底是哪个控件影响的。
这个问题最后定位到了折现图的legend,也就是图例上,查看源码我发现legend是被包含在一个div中的,图例采用偏移的方式画出,所以图例不在div的区域中,而这个div被预定义了一个legend的css类,如果项目中存在了这个css类的定义 那么这个div的样式就会收到影响。
最后的解决方式是在源码中删掉css声明。
##在复用自己重定义的flot与原生的flot 可能会出现异常显示的问题
这个也是将添加了新特性的组件放入原有工程中遇到的一个问题,因为需要最小化修改,所以自定义的组件采用增量式的方式添加到了项目中,在需要使用的地方引用了这个自定义的模块,但是我们需要知道flot本是是需要与$ 也就是jquery的全局命令空间交互的, 他会占用 $.plot 字段,并且flot的插件也通过给类似的全局字段添加值的方式将自己添加到flot的引用中去,而每次引用自定义模块或者是引用原生flot模块都会将对应属性初始化,这样就造成了显示异常的问题,解决方案是给自定义的flot清理出独立的命名空间,这里我们采用的是在属性最后加m 来与原有方法区分
$.plotm.version = "0.8.3";
// Also add the plot function as a chainable property
$.fn.plotm = function(data, options) {
return this.each(function() {
$.plot(this, data, options);
});
};
到这里 基本问题就解决了
最后附带上源码
注意在调用自定义flot的时候使用 $.plotm(....)
(function($){$.color={};$.color.make=function(r,g,b,a){var o={};o.r=r||0;o.g=g||0;o.b=b||0;o.a=a!=null?a:1;o.add=function(c,d){for(var i=0;i<c.length;++i)o[c.charAt(i)]+=d;return o.normalize()};o.scale=function(c,f){for(var i=0;i<c.length;++i)o[c.charAt(i)]*=f;return o.normalize()};o.toString=function(){if(o.a>=1){return"rgb("+[o.r,o.g,o.b].join(",")+")"}else{return"rgba("+[o.r,o.g,o.b,o.a].join(",")+")"}};o.normalize=function(){function clamp(min,value,max){return value<min?min:value>max?max:value}o.r=clamp(0,parseInt(o.r),255);o.g=clamp(0,parseInt(o.g),255);o.b=clamp(0,parseInt(o.b),255);o.a=clamp(0,o.a,1);return o};o.clone=function(){return $.color.make(o.r,o.b,o.g,o.a)};return o.normalize()};$.color.extract=function(elem,css){var c;do{c=elem.css(css).toLowerCase();if(c!=""&&c!="transparent")break;elem=elem.parent()}while(elem.length&&!$.nodeName(elem.get(0),"body"));if(c=="rgba(0, 0, 0, 0)")c="transparent";return $.color.parse(c)};$.color.parse=function(str){var res,m=$.color.make;if(res=/rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(str))return m(parseInt(res[1],10),parseInt(res[2],10),parseInt(res[3],10));if(res=/rgba\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(str))return m(parseInt(res[1],10),parseInt(res[2],10),parseInt(res[3],10),parseFloat(res[4]));if(res=/rgb\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*\)/.exec(str))return m(parseFloat(res[1])*2.55,parseFloat(res[2])*2.55,parseFloat(res[3])*2.55);if(res=/rgba\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(str))return m(parseFloat(res[1])*2.55,parseFloat(res[2])*2.55,parseFloat(res[3])*2.55,parseFloat(res[4]));if(res=/#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/.exec(str))return m(parseInt(res[1],16),parseInt(res[2],16),parseInt(res[3],16));if(res=/#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/.exec(str))return m(parseInt(res[1]+res[1],16),parseInt(res[2]+res[2],16),parseInt(res[3]+res[3],16));var name=$.trim(str).toLowerCase();if(name=="transparent")return m(255,255,255,0);else{res=lookupColors[name]||[0,0,0];return m(res[0],res[1],res[2])}};var lookupColors={aqua:[0,255,255],azure:[240,255,255],beige:[245,245,220],black:[0,0,0],blue:[0,0,255],brown:[165,42,42],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgrey:[169,169,169],darkgreen:[0,100,0],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkviolet:[148,0,211],fuchsia:[255,0,255],gold:[255,215,0],green:[0,128,0],indigo:[75,0,130],khaki:[240,230,140],lightblue:[173,216,230],lightcyan:[224,255,255],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightyellow:[255,255,224],lime:[0,255,0],magenta:[255,0,255],maroon:[128,0,0],navy:[0,0,128],olive:[128,128,0],orange:[255,165,0],pink:[255,192,203],purple:[128,0,128],violet:[128,0,128],red:[255,0,0],silver:[192,192,192],white:[255,255,255],yellow:[255,255,0]}})(jQuery);
// the actual Flot code
(function($) {
// Cache the prototype hasOwnProperty for faster access
var hasOwnProperty = Object.prototype.hasOwnProperty;
// A shim to provide 'detach' to jQuery versions prior to 1.4. Using a DOM
// operation produces the same effect as detach, i.e. removing the element
// without touching its jQuery data.
// Do not merge this into Flot 0.9, since it requires jQuery 1.4.4+.
if (!$.fn.detach) {
$.fn.detach = function() {
return this.each(function() {
if (this.parentNode) {
this.parentNode.removeChild( this );
}
});
};
}
///
// The Canvas object is a wrapper around an HTML5 <canvas> tag.
//
// @constructor
// @param {string} cls List of classes to apply to the canvas.
// @param {element} container Element onto which to append the canvas.
//
// Requiring a container is a little iffy, but unfortunately canvas
// operations don't work unless the canvas is attached to the DOM.
function Canvas(cls, container) {
var element = container.children("." + cls)[0];
if (element == null) {
element = document.createElement("canvas");
element.className = cls;
$(element).css({ direction: "ltr", position: "absolute", left: 0, top: 0 })
.appendTo(container);
// If HTML5 Canvas isn't available, fall back to [Ex|Flash]canvas
if (!element.getContext) {
if (window.G_vmlCanvasManager) {
element = window.G_vmlCanvasManager.initElement(element);
} else {
throw new Error("Canvas is not available. If you're using IE with a fall-back such as Excanvas, then there's either a mistake in your conditional include, or the page has no DOCTYPE and is rendering in Quirks Mode.");
}
}
}
this.element = element;
var context = this.context = element.getContext("2d");
// Determine the screen's ratio of physical to device-independent
// pixels. This is the ratio between the canvas width that the browser
// advertises and the number of pixels actually present in that space.
// The iPhone 4, for example, has a device-independent width of 320px,
// but its screen is actually 640px wide. It therefore has a pixel
// ratio of 2, while most normal devices have a ratio of 1.
var devicePixelRatio = window.devicePixelRatio || 1,
backingStoreRatio =
context.webkitBackingStorePixelRatio ||
context.mozBackingStorePixelRatio ||
context.msBackingStorePixelRatio ||
context.oBackingStorePixelRatio ||
context.backingStorePixelRatio || 1;
this.pixelRatio = devicePixelRatio / backingStoreRatio;
// Size the canvas to match the internal dimensions of its container
this.resize(container.width(), container.height());
// Collection of HTML div layers for text overlaid onto the canvas
this.textContainer = null;
this.text = {};
// Cache of text fragments and metrics, so we can avoid expensively
// re-calculating them when the plot is re-rendered in a loop.
this._textCache = {};
}
// Resizes the canvas to the given dimensions.
//
// @param {number} width New width of the canvas, in pixels.
// @param {number} width New height of the canvas, in pixels.
Canvas.prototype.resize = function(width, height) {
if (width <= 0 || height <= 0) {
throw new Error("Invalid dimensions for plot, width = " + width + ", height = " + height);
}
var element = this.element,
context = this.context,
pixelRatio = this.pixelRatio;
// Resize the canvas, increasing its density based on the display's
// pixel ratio; basically giving it more pixels without increasing the
// size of its element, to take advantage of the fact that retina
// displays have that many more pixels in the same advertised space.
// Resizing should reset the state (excanvas seems to be buggy though)
if (this.width != width) {
element.width = width * pixelRatio;
element.style.width = width + "px";
this.width = width;
}
if (this.height != height) {
element.height = height * pixelRatio;
element.style.height = height + "px";
this.height = height;
}
// Save the context, so we can reset in case we get replotted. The
// restore ensure that we're really back at the initial state, and
// should be safe even if we haven't saved the initial state yet.
context.restore();
context.save();
// Scale the coordinate space to match the display density; so even though we
// may have twice as many pixels, we still want lines and other drawing to
// appear at the same size; the extra pixels will just make them crisper.
context.scale(pixelRatio, pixelRatio);
};
// Clears the entire canvas area, not including any overlaid HTML text
Canvas.prototype.clear = function() {
this.context.clearRect(0, 0, this.width, this.height);
};
// Finishes rendering the canvas, including managing the text overlay.
Canvas.prototype.render = function() {
var cache = this._textCache;
// For each text layer, add elements marked as active that haven't
// already been rendered, and remove those that are no longer active.
for (var layerKey in cache) {
if (hasOwnProperty.call(cache, layerKey)) {
var layer = this.getTextLayer(layerKey),
layerCache = cache[layerKey];
layer.hide();
for (var styleKey in layerCache) {
if (hasOwnProperty.call(layerCache, styleKey)) {
var styleCache = layerCache[styleKey];
for (var key in styleCache) {
if (hasOwnProperty.call(styleCache, key)) {
var positions = styleCache[key].positions;
for (var i = 0, position; position = positions[i]; i++) {
if (position.active) {
if (!position.rendered) {
layer.append(position.element);
position.rendered = true;
}
} else {
positions.splice(i--, 1);
if (position.rendered) {
position.element.detach();
}
}
}
if (positions.length == 0) {
delete styleCache[key];
}
}
}
}
}
layer.show();
}
}
};
// Creates (if necessary) and returns the text overlay container.
//
// @param {string} classes String of space-separated CSS classes used to
// uniquely identify the text layer.
// @return {object} The jQuery-wrapped text-layer div.
Canvas.prototype.getTextLayer = function(classes) {
var layer = this.text[classes];
// Create the text layer if it doesn't exist
if (layer == null) {
// Create the text layer container, if it doesn't exist
if (this.textContainer == null) {
this.textContainer = $("<div class='flot-text'></div>")
.css({
position: "absolute",
top: 0,
left: 0,
bottom: 0,
right: 0,
'font-size': "smaller",
color: "#545454"
})
.insertAfter(this.element);
}
layer = this.text[classes] = $("<div></div>")
.addClass(classes)
.css({
position: "absolute",
top: 0,
left: 0,
bottom: 0,
right: 0
})
.appendTo(this.textContainer);
}
return layer;
};
// Creates (if necessary) and returns a text info object.
//
// The object looks like this:
//
// {
// width: Width of the text's wrapper div.
// height: Height of the text's wrapper div.
// element: The jQuery-wrapped HTML div containing the text.
// positions: Array of positions at which this text is drawn.
// }
//
// The positions array contains objects that look like this:
//
// {
// active: Flag indicating whether the text should be visible.
// rendered: Flag indicating whether the text is currently visible.
// element: The jQuery-wrapped HTML div containing the text.
// x: X coordinate at which to draw the text.
// y: Y coordinate at which to draw the text.
// }
//
// Each position after the first receives a clone of the original element.
//
// The idea is that that the width, height, and general 'identity' of the
// text is constant no matter where it is placed; the placements are a
// secondary property.
//
// Canvas maintains a cache of recently-used text info objects; getTextInfo
// either returns the cached element or creates a new entry.
//
// @param {string} layer A string of space-separated CSS classes uniquely
// identifying the layer containing this text.
// @param {string} text Text string to retrieve info for.
// @param {(string|object)=} font Either a string of space-separated CSS
// classes or a font-spec object, defining the text's font and style.
// @param {number=} angle Angle at which to rotate the text, in degrees.
// Angle is currently unused, it will be implemented in the future.
// @param {number=} width Maximum width of the text before it wraps.
// @return {object} a text info object.
Canvas.prototype.getTextInfo = function(layer, text, font, angle, width) {
var textStyle, layerCache, styleCache, info;
// Cast the value to a string, in case we were given a number or such
text = "" + text;
// If the font is a font-spec object, generate a CSS font definition
if (typeof font === "object") {
textStyle = font.style + " " + font.variant + " " + font.weight + " " + font.size + "px/" + font.lineHeight + "px " + font.family;
} else {
textStyle = font;
}
// Retrieve (or create) the cache for the text's layer and styles
layerCache = this._textCache[layer];
if (layerCache == null) {
layerCache = this._textCache[layer] = {};
}
styleCache = layerCache[textStyle];
if (styleCache == null) {
styleCache = layerCache[textStyle] = {};
}
info = styleCache[text];
// If we can't find a matching element in our cache, create a new one
if (info == null) {
var element = $("<div></div>").html(text)
.css({
position: "absolute",
'max-width': width,
top: -9999
})
.appendTo(this.getTextLayer(layer));
if (typeof font === "object") {
element.css({
font: textStyle,
color: font.color
});
} else if (typeof font === "string") {
element.addClass(font);
}
info = styleCache[text] = {
width: element.outerWidth(true),
height: element.outerHeight(true),
element: element,
positions: []
};
element.detach();
}
return info;
};
// Adds a text string to the canvas text overlay.
//
// The text isn't drawn immediately; it is marked as rendering, which will
// result in its addition to the canvas on the next render pass.
//
// @param {string} layer A string of space-separated CSS classes uniquely
// identifying the layer containing this text.
// @param {number} x X coordinate at which to draw the text.
// @param {number} y Y coordinate at which to draw the text.
// @param {string} text Text string to draw.
// @param {(string|object)=} font Either a string of space-separated CSS
// classes or a font-spec object, defining the text's font and style.
// @param {number=} angle Angle at which to rotate the text, in degrees.
// Angle is currently unused, it will be implemented in the future.
// @param {number=} width Maximum width of the text before it wraps.
// @param {string=} halign Horizontal alignment of the text; either "left",
// "center" or "right".
// @param {string=} valign Vertical alignment of the text; either "top",
// "middle" or "bottom".
Canvas.prototype.addText = function(layer, x, y, text, font, angle, width, halign, valign) {
var info = this.getTextInfo(layer, text, font, angle, width),
positions = info.positions;
// Tweak the div's position to match the text's alignment
if (halign == "center") {
x -= info.width / 2;
} else if (halign == "right") {
x -= info.width;
}
if (valign == "middle") {
y -= info.height / 2;
} else if (valign == "bottom") {
y -= info.height;
}
// Determine whether this text already exists at this position.
// If so, mark it for inclusion in the next render pass.
for (var i = 0, position; position = positions[i]; i++) {
if (position.x == x && position.y == y) {
position.active = true;
return;
}
}
// If the text doesn't exist at this position, create a new entry
// For the very first position we'll re-use the original element,
// while for subsequent ones we'll clone it.
position = {
active: true,
rendered: false,
element: positions.length ? info.element.clone() : info.element,
x: x,
y: y
};
positions.push(position);
// Move the element to its final position within the container
position.element.css({
top: Math.round(y),
left: Math.round(x),
'text-align': halign // In case the text wraps
});
};
// Removes one or more text strings from the canvas text overlay.
//
// If no parameters are given, all text within the layer is removed.
//
// Note that the text is not immediately removed; it is simply marked as
// inactive, which will result in its removal on the next render pass.
// This avoids the performance penalty for 'clear and redraw' behavior,
// where we potentially get rid of all text on a layer, but will likely
// add back most or all of it later, as when redrawing axes, for example.
//
// @param {string} layer A string of space-separated CSS classes uniquely
// identifying the layer containing this text.
// @param {number=} x X coordinate of the text.
// @param {number=} y Y coordinate of the text.
// @param {string=} text Text string to remove.
// @param {(string|object)=} font Either a string of space-separated CSS
// classes or a font-spec object, defining the text's font and style.
// @param {number=} angle Angle at which the text is rotated, in degrees.
// Angle is currently unused, it will be implemented in the future.
Canvas.prototype.removeText = function(layer, x, y, text, font, angle) {
if (text == null) {
var layerCache = this._textCache[layer];
if (layerCache != null) {
for (var styleKey in layerCache) {
if (hasOwnProperty.call(layerCache, styleKey)) {
var styleCache = layerCache[styleKey];
for (var key in styleCache) {
if (hasOwnProperty.call(styleCache, key)) {
var positions = styleCache[key].positions;
for (var i = 0, position; position = positions[i]; i++) {
position.active