Files
2025-09-29 00:52:08 +02:00

500 lines
17 KiB
JavaScript
Executable File

/*
Copyright (c) 2007, the Eden Ridgway
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
/*
This file contains a wrapper around the dojo graphing library which makes
it easier to add new statistics graphs.
*/
var Browser = {
isIE: !!(window.attachEvent && !window.opera),
isOpera: !!window.opera,
isWebKit: navigator.userAgent.indexOf('AppleWebKit/')> -1,
isGecko: navigator.userAgent.indexOf('Gecko')> -1 &&
navigator.userAgent.indexOf('KHTML') == -1
};
var AxisType = {
x: 1,
y: 2
};
//Include the required dojo libraries/namespaces
dojo.require("dojo.collections.Store");
dojo.require("dojo.collections.Queue");
dojo.require("dojo.charting.Chart");
dojo.require('dojo.json');
function Graph()
/// <summary>
/// Graph object that wraps the rendering of the graph and handles elements like the legend and ticks.
/// </summary>
{
var graphContainerArea = null;
var graphContainer = null;
var legendTable = null;
var legendContainer = null;
var heading = null;
var store = new dojo.collections.Store();
var dataSource = null;
//Array that contains all the series arrays
var series = [];
var xTicks = [];
var yAxisAttributeNames = [];
this.numXTicks = 5;
this.numYTicks = 5;
this.yRange = null;
this.xRange = null;
this.dataType = 'integer';
var determineRange = function(attributeName)
/// <summary>
/// Determines the upper and lower bounds for the summary data attribute
/// </summary>
{
var range = {
lower: min(dataSource, attributeName),
upper: max(dataSource, attributeName)
};
return range;
}
this.determineMultiSourceRange = function(attributeNames)
/// <summary>
/// Using all the series values calculates the upper and lower
/// bounds for the graph's y-axis
/// </summary>
{
var ranges = dojo.lang.map(attributeNames, determineRange);
return { lower: min(ranges, "lower"), upper: max(ranges, "upper") };
}
this.setDataSource = function(graphDataSource)
/// <summary>
/// Sets the graph's data source
/// </summary>
{
store.setData(graphDataSource);
dataSource = graphDataSource;
}
this.setContainer = function(containerElement)
/// <summary>
/// Sets the graph container and creates the heading and legend boxes
/// </summary>
{
//If the container element is being changed then clear out the old legend and heading elements
if (graphContainerArea != null && graphContainerArea != containerElement)
{
document.removeElement(heading);
document.removeElement(legendContainer);
document.removeElement(graphContainer);
}
graphContainerArea = containerElement;
//Create the heading element
function createContainerElements()
{
heading = document.createElement("h2");
graphContainerArea.appendChild(heading);
//Create a container for the "canvas"
graphContainer = document.createElement("div");
graphContainer.className = "GraphContainer";
graphContainer.innerHTML = "Loading...";
graphContainerArea.appendChild(graphContainer);
//Create the legend box
legendContainer = document.createElement("div");
legendContainer.className = "Legend";
graphContainerArea.appendChild(legendContainer);
legendTable = document.createElement("table");
legendContainer.appendChild(legendTable);
}
createContainerElements();
}
this.generatexAxisTickMarks = function(labelAttribute, numberTicks)
/// <summary>
/// Creates the xAxis tick marks based on the target label attribute
/// </summary>
/// <remarks>
/// Get the ticks from the actual data source as opposed to deriving it from the range
/// </remarks>
{
//Ensure that if there are too few data points then we only use the number available
numberTicks = Math.min(numberTicks, dataSource.length);
var labels = [];
var tickIndexDelta = parseInt(dataSource.length / numberTicks);
var tickIndex = 0;
for (var i = 0; i <= dataSource.length; i += tickIndexDelta)
{
var tickIndex = Math.min(dataSource.length - 1, Math.round(i));
var tickLabel = dataSource[tickIndex][labelAttribute].toString();
labels.push({ label: tickLabel, value: tickIndex });
}
return labels;
}
this.generateyAxisTickMarks = function(range, numberTicks)
/// <summary>
/// Creates the yAxis tick marks based on the graph value range
/// </summary>
{
var labels = [];
var rangeDelta = range.upper - range.lower;
var tickDelta = rangeDelta / numberTicks;
var tickLabel;
var tickDataType = this.dataType;
//Make certain that the tick marks don't remain the same number for multiple ticks because they have
//been truncated.
if (tickDelta < 1)
{
tickDataType = 'decimal';
}
for (var tickValue = range.lower; tickValue <= range.upper; tickValue += tickDelta)
{
if (tickDataType == 'integer')
{
tickLabel = parseInt(tickValue);
}
//Round to 2 decimal places
else
{
tickLabel = Math.round(tickValue * 100) / 100;
}
labels.push({ label: tickLabel.toString(), value: tickValue });
}
return labels;
}
this.createAxis = function(axisValueAttributeNames, tickAttributeName, numberTicks, axisType)
/// <summary>
/// Creates an axis for the summary data using the attribute values in the summary data
/// </summary>
/// <remarks>
/// Note that the number of ticks excludes the tick at the origin, so there will end
/// up being N+1 ticks
/// </remarks>
{
var axis = new dojo.charting.Axis();
axis.showTicks = true;
axis.showLines = true;
axis.label = "";
//Set the upper and lower data range values based on the number of days being viewed
if (axisType == AxisType.x)
{
axis.origin = "max";
axis.range = this.xRange || this.determineMultiSourceRange(axisValueAttributeNames);
}
else if (axisType == AxisType.y)
{
axis.origin = "min";
axis.range = this.yRange || this.determineMultiSourceRange(axisValueAttributeNames);
}
else
{
alert('Invalid axis type specified');
return axis;
}
//If there are no values then don't bother
if (dataSource.length == 0)
{
return axis;
}
var rangeDelta = axis.range.upper - axis.range.lower;
//If there isn't more than one tick mark then simply add that to the graph
if (rangeDelta == 0)
{
axis.range.lower = 0;
axis.labels.push({ label: "0", value: 0 });
axis.labels.push({ label: axis.range.upper.toString(), value: axis.range.upper });
return axis;
}
//Use specialised tick generation logic instead of a generic approach
if (axisType == AxisType.x)
{
axis.labels = this.generatexAxisTickMarks(tickAttributeName, numberTicks);
}
else if (axisType == AxisType.y)
{
axis.labels = this.generateyAxisTickMarks(axis.range, numberTicks);
}
//The ticks in IE are normal HTML elements and therefore we can break the tick line appropriately
if (Browser.isIE)
{
for (var i = 0; i < axis.labels.length; i++)
{
axis.labels[i].label = axis.labels[i].label.replace("\n", "<br/>");
}
}
return axis;
}
this.setTitle = function(title)
/// <summary>
/// Sets the graph's title
/// </summary>
{
heading.innerHTML = title;
}
this.addSeries = function(seriesName, seriesAttributeName, color)
/// <summary>
/// Adds the series values that will be used to draw the graph
/// </summary>
{
//Store the attribute being used to create the series values to be used later to determine the axis range
yAxisAttributeNames.push(seriesAttributeName);
var seriesItem = new dojo.charting.Series({
dataSource: store,
bindings: { x: "index", y: seriesAttributeName },
label: seriesName,
color: color
});
series.push(seriesItem);
//Add the legend information for the series
var legendRow = legendTable.insertRow(legendTable.rows.length);
var legendBoxCell = legendRow.insertCell(0);
var legendTextCell = legendRow.insertCell(1);
legendBoxCell.style.width = "14px";
var colorBoxDiv = document.createElement("div");
colorBoxDiv.className = "ColorBox";
colorBoxDiv.style.backgroundColor = color;
legendBoxCell.appendChild(colorBoxDiv);
legendTextCell.innerHTML = seriesName;
}
this.draw = function()
/// <summary>
/// Draws the graph using the supplied series
/// </summary>
{
var layoutOptions = {};
//TODO: Since the x-axis does not change between graphs, this could be a global value that is reused
var xAxis = this.createAxis([ "index" ], "label", this.numXTicks, AxisType.x);
var yAxis = this.createAxis(yAxisAttributeNames, "", this.numYTicks, AxisType.y);
//Create the actual graph with the x and y axes defined above
var chartPlot = new dojo.charting.Plot(xAxis, yAxis);
dojo.lang.forEach(series, function(seriesItem)
{
chartPlot.addSeries({
data: seriesItem,
plotter: dojo.charting.Plotters.CurvedArea
});
});
//Define the plot area
var chartPlotArea = new dojo.charting.PlotArea();
chartPlotArea.size = { width: 600, height: 250 };
chartPlotArea.padding = { top: 20, right: 20, bottom: 50, left: 50 };
//Add the plot to the area
chartPlotArea.plots.push(chartPlot);
//Create the actual chart "canvas"
var chart = new dojo.charting.Chart(null, "Statistics Chart", "");
//Add the plot area at an offset of 10 pixels from the top left
chart.addPlotArea({ x: 10, y: 10, plotArea: chartPlotArea });
chart.node = graphContainer;
chart.render();
}
}
function getArrayOfAttributeValues(sourceArray, attributeName)
/// <summary>
/// Determines whether or not any of the values in the supplied series have any values
/// </summary>
{
var attributeValueArray = [];
for (var i = 0; i < sourceArray.length; i++)
{
attributeValueArray.push(sourceArray[attributeName]);
}
return attributeValueArray;
}
function hasSeriesValues(dataSource, series)
/// <summary>
/// Determines whether or not any of the values in the supplied series have any values
/// </summary>
{
//Check for just one non-zero value in the series
for (var index = 0; index < dataSource.length; index++)
{
var statistic = dataSource[index];
for (var i = 0; i < series.length; i++)
{
var value = zeroIfInvalid(statistic[series[i].attributeName]);
if (value != 0)
{
return true;
}
}
}
return false;
}
function createGraph(options)
/// <summary>
/// Creates a graph using the option object supplied as the template.
/// </summary>
/// <remarks>
/// A graph will not be created if all the y-axis values for the series are zero.
/// It now also places a "loading" placeholder for the graph and queues it to be rendered.
/// </remarks>
{
//Don't render blank graphs or graphs with just one value
if (options.dataSource.length < 2 || !hasSeriesValues(options.dataSource, options.series))
{
return;
}
var dataSource = options.dataSource;
var graphContainer = options.containerElement;
var graph = new Graph();
graph.setDataSource(dataSource);
graph.setContainer(graphContainer);
graph.setTitle(options.graphName);
graph.numXTicks = options.numXTicks || 5;
graph.numYTicks = options.numYTicks || 5;
graph.dataType = options.dataType || 'integer';
graph.xRange = options.xRange;
graph.yRange = options.yRange;
var series = {};
if (typeof(options.chartType) != 'undefined')
{
graph.chartType = options.chartType;
}
//Create a placeholder array for each series that will be generated
for (var i = 0; i < options.series.length; i++)
{
var seriesItem = options.series[i];
graph.addSeries(seriesItem.name, seriesItem.attributeName, seriesItem.color);
}
//Add the graph to the rendering queue
_graphProcessingQueue.enqueue(graph, graph.draw);
return graph;
}
function ProcessingQueue()
/// <summary>
/// Processes requests one after each other allowing the UI to render any changes before starting
/// the next item.
/// </summary>
{
this.queue = new dojo.collections.Queue();
this.isTimerActive = false;
this.enqueue = function(targetObject, targetFunction)
/// <summary>
/// Queues the target object for processing and calls the target function on the object
/// when it is processed.
/// </summary>
{
this.queue.enqueue({
targetObject: targetObject,
targetFunction: targetFunction
});
if (!this.isTimerActive)
{
var processingQueue = this;
window.setTimeout(function() { processingQueue.process() }, 150);
this.isTimerActive = true;
}
}
this.process = function()
/// <summary>
/// The logic that processes the queue in a FIFO manner and sets up the next queue call.
/// </summary>
{
if (this.queue.count > 0)
{
var queueItem = this.queue.dequeue();
queueItem.targetFunction.apply(queueItem.targetObject);
}
//Determine whether or not any more queued calls are necessary
if (this.queue.count == 0)
{
this.isTimerActive = false;
}
else
{
var processingQueue = this;
window.setTimeout(function() { processingQueue.process() }, 150);
}
}
}
var _graphProcessingQueue = new ProcessingQueue();