├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bower.json ├── composer.json ├── dist ├── css │ ├── epoch.css │ └── epoch.min.css └── js │ ├── epoch.js │ └── epoch.min.js ├── gulpfile.js ├── package.json ├── sass ├── _core.scss ├── epoch.scss └── themes │ ├── _dark.scss │ └── _default.scss ├── src ├── adapters.coffee ├── adapters │ ├── MooTools.coffee │ ├── jQuery.coffee │ └── zepto.coffee ├── basic.coffee ├── basic │ ├── area.coffee │ ├── bar.coffee │ ├── histogram.coffee │ ├── line.coffee │ ├── pie.coffee │ └── scatter.coffee ├── core │ ├── chart.coffee │ ├── context.coffee │ ├── css.coffee │ ├── d3.coffee │ ├── format.coffee │ └── util.coffee ├── data.coffee ├── epoch.coffee ├── model.coffee ├── time.coffee └── time │ ├── area.coffee │ ├── bar.coffee │ ├── gauge.coffee │ ├── heatmap.coffee │ └── line.coffee └── tests ├── render ├── basic │ ├── area.html │ ├── bar.html │ ├── histogram.html │ ├── line.html │ ├── model.html │ ├── options.html │ ├── pie.html │ └── scatter.html ├── css │ └── tests.css ├── index.html ├── js │ └── data.js ├── real-time │ ├── area.html │ ├── bar.html │ ├── gauge.html │ ├── heatmap.html │ ├── line.html │ ├── model.html │ └── options.html └── themes │ ├── dark.html │ └── default.html └── unit ├── core ├── charts.coffee ├── copy.coffee ├── css.coffee ├── d3.coffee ├── events.coffee ├── format.coffee ├── is.coffee └── util.coffee ├── data ├── array_format.coffee ├── chart.coffee ├── keyvalue_format.coffee └── tuple_format.coffee ├── init.coffee ├── time.coffee └── time └── line.coffee /.gitignore: -------------------------------------------------------------------------------- 1 | .stamp* 2 | /codo-doc/ 3 | /doc/ 4 | /css/ 5 | /js/ 6 | /build/ 7 | npm-debug.log 8 | node_modules/* 9 | .DS_Store 10 | sass/.sass-cache 11 | test.html 12 | /_site/ 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "4.1.1" 4 | sudo: false 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Epoch Changelog 2 | 3 | ## 0.8.4 - October 30th, 2015 4 | ### Bug Fixes 5 | * Fixed bower css path (@ftaiolivista) 6 | 7 | ## 0.8.3 - October 17th, 2015 8 | ### Enhancements / Features 9 | * Added `redraw` method for clearing styles on canvas based charts (#196, @woozyking) 10 | 11 | ## 0.8.2 - October 13th, 2015 12 | ### Enhancements / Features 13 | * Charts now auto draw on construction (#195) 14 | 15 | ## 0.8.1 - October 13th, 2015 16 | ### Enhancements / Features 17 | * Added packagist/composer package manager support (#202) 18 | 19 | ### Bug Fixes 20 | * Real-time charts no-longer error when pushing first data point after initialized 21 | with empty data layers. (#203) 22 | 23 | ## 0.8.0 - October 10th, 2015 24 | ### Enhancements / Features 25 | * Multi-axis support for basic and real-time line plots 26 | * Added new gulp build-system (for development) 27 | 28 | ## 0.7.1 - October 4th, 2015 29 | * Moved minified source to `dist/js` and `dist/css` respectively 30 | * Added non-minified source to aforementioned directories 31 | 32 | ## 0.7.0 - October 4th, 2015 33 | 34 | ### Enhancements / Features 35 | * New basic chart: Histogram 36 | * New Feature: Data formatters 37 | * Chart layers can now be hidden/shown 38 | 39 | ### Bug Fixes 40 | * Ticks now working for ordinal scaled bar charts 41 | * Fixed CSS builds by updating NPM sass-node package 42 | * Removed versions from minified release files (@RyanNielson) 43 | * Time based graphs can now have fixed ranges (@willwhitney) 44 | * NPM Package: epoch-charting (@sompylasar) 45 | * Right axes now using correct formatters (@Dav1dde) 46 | * Add 'main' attribute enabling webpack support. (@WRidder) 47 | * Fixed Bower D3 Dependencies (@loopj) 48 | * Fixed CSS errors by using `transparent` instead of `none` (@mwsmith2) 49 | * Fixed bower "version" property (@kkirsche) 50 | 51 | ## 0.6.0 - July 21st, 2014 52 | 53 | ### Enhancements / Features 54 | 55 | * Source code restructure for easier programming 56 | * Replaced Compass with node-sass 57 | * Removed put.js from the repository 58 | * Removed dependency on jQuery 59 | * Added CSS controlled themes 60 | * New "Dark" theme for dark backgrounds 61 | * Registered with bower 62 | * Added option accessor / mutator to all charts (making them adaptive) 63 | * Added bubble charts (special case of scatter plots) 64 | * Added MooTools and Zepto Adapters 65 | * Added Core Library Unit Testing 66 | * New `domain` and `range` options for basic charts 67 | 68 | ### Bug Fixes 69 | 70 | * Event `.off` method was completely busted, fixed 71 | * Swapped terminology for horizontal and vertical bar plots 72 | * Removed `isVisible` and related rendering hacks (caused all sorts of woe) 73 | 74 | 75 | ## 0.5.2 - June 24th, 2014 76 | 77 | ### Enhancements / Features 78 | 79 | * #36 - Fixed the readme to focus on development 80 | * #54 - Added vertical orientation option to the basic bar chart 81 | 82 | ## 0.5.1 - June 23rd, 2014 83 | 84 | ### Bug Fixes 85 | 86 | * #52 - Replaced instances of `$` with `jQuery` (ambiguous, otherwise) 87 | 88 | ## 0.5.0 - June 23rd, 2014 89 | 90 | ### Enhancements / Features 91 | 92 | * #32 - QueryCSS greatly enhanced - now builds a full DOM context when computing styles 93 | * #42 - Heat map now allows for painting of "zero" values via a new `paintZeroValues` option 94 | * #43 - Heat map color computation abstracted out of `_paintEntry` (makes it easier to extend) 95 | 96 | ### Bug Fixes 97 | 98 | * #22 - Fixed an issue with pie chart transitions 99 | * #30 - Layers without labels now correctly render on a various basic charts 100 | * #31 - Real-time Line Chart thickness fixed by taking pixel ratio into account 101 | * #41 - Fixed bucketing issues with the Heat Map 102 | * #46 - Removed default black stroke from the Real-Time Area chart 103 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Fastly, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Epoch 2 | By Ryan Sandor Richards 3 | 4 | [![Build Status](https://travis-ci.org/epochjs/epoch.svg?branch=master)](https://travis-ci.org/epochjs/epoch) 5 | [![Dependency Status](https://david-dm.org/epochjs/epoch.svg)](https://david-dm.org/epochjs/epoch) 6 | [![devDependency Status](https://david-dm.org/epochjs/epoch/dev-status.svg)](https://david-dm.org/epochjs/epoch#info=devDependencies) 7 | 8 | Epoch is a general purpose charting library for application developers and visualization designers. It focuses on two different aspects of visualization programming: **basic charts** for creating historical reports, and **real-time charts** for displaying frequently updating timeseries data. 9 | 10 | To get started using Epoch, please refer to the [Epoch Project Site](http://epochjs.github.io/epoch/). There you can find full documentation and guides to help you start using Epoch right away. 11 | 12 | ### Installation 13 | Epoch can be easily installed via the following package managers: 14 | 15 | * [npm](https://www.npmjs.com/package/epoch-charting) 16 | * [bower](http://bower.io/search/?q=epoch) 17 | * [packagist](https://packagist.org/packages/epochjs/epoch) 18 | 19 | If you don't see your favorite package manager in the list above feel free to 20 | [open up an issue](https://github.com/epochjs/epoch/issues/new) and let us know. 21 | Finally, you can download any release of the library from the 22 | [project releases page](https://github.com/epochjs/epoch/releases). 23 | 24 | **Important:** Epoch requires [d3](https://github.com/mbostock/d3). In order to 25 | work properly your page must load d3 before epoch. 26 | 27 | #### Public CDN URLs 28 | If you don't want to host the files yourself, you can use 29 | [jsDelivr](http://www.jsdelivr.com/) to serve the files: 30 | 31 | 1. Visit [epoch page on jsDelvr](http://www.jsdelivr.com/projects/epoch). 32 | 2. Copy the provided URL's and link to them in your project. 33 | 34 | ### Developing Epoch 35 | 36 | Developing Epoch is a reasonably straight forward process. In this section we'll 37 | cover the basic on how to develop Epoch by detailing common build task, exploring 38 | how the source is arranged, and finally show how to use rendering tests to aid 39 | development. 40 | 41 | #### Configuring Development Environment 42 | 43 | Epoch requires the following for development: 44 | 45 | 1. [Node.js](https://nodejs.org/en/) (v4.1.1+) 46 | 2. [NPM](https://www.npmjs.com/) (v2.1.0+) 47 | 48 | Once both are installed on your machine you will need to run `npm install` from 49 | the repository's root directory in order to install the npm packages required 50 | to develop epoch. 51 | 52 | Once you have installed the required npm packages you can use `gulp build` to 53 | fully rebuild the source (see more information about gulp tasks below). 54 | 55 | 56 | #### Basic Development Process 57 | 58 | The best way to start contributing to Epoch is to follow these steps: 59 | 60 | 1. Change to the source directory for the project 61 | 2. Run `gulp watch` to recompile the project after source files change 62 | 3. Make changes in a source file (either in `src/` or `sass/`) 63 | 4. In a web browser open the `test/index.html` and browse the rendering tests 64 | 5. Use the rendering tests to see if your changes had the desired result 65 | 6. Ensure unit tests with pass `npm test` 66 | 67 | #### Testing 68 | 69 | Epoch uses two types of testing to ensure that changes do not cause unintended 70 | side effects. The first, unit tests, ensure that the core functional components 71 | of the library work as expected. The second, rendering tests, allow you to 72 | ensure that charts and graphs are correctly rendered. 73 | 74 | It is important to keep both unit test and rendering tests up-to-date! When 75 | developing, use the following guidelines: 76 | 77 | * When adding new features make sure to add new tests 78 | * When changing existing functionality, ensure that the appropriate both types 79 | of tests still pass 80 | * If you want to make a new type of chart, add a whole new test suite for that 81 | chart! 82 | 83 | Keeping the tests current makes it easier for others to review your code and 84 | spot issues. Also, pull requests without appropriate testing will not be 85 | merged. 86 | 87 | 88 | #### Gulp Tasks 89 | 90 | Epoch uses [gulp](https://github.com/gulpjs/gulp) to perform various tasks. The 91 | `gulpfile.js` file defines the following tasks: 92 | 93 | * `gulp clean` - Cleans the `dist/` directory. 94 | * `gulp build` - Builds the CoffeeScript and Sass source into the `dist/` 95 | directory. 96 | * `gulp watch` - Starts a watch script to recompile CoffeeScript and Sass when 97 | any files change. 98 | 99 | #### Source Structure 100 | 101 | The directory structure for the Epoch project follows some basic guidelines, here's an overview of how it is structured: 102 | 103 | ``` 104 | dist/ - Compiled JavaScript and CSS source 105 | src/ - Main source directory 106 | core/ - Core Epoch Library Files 107 | util.coffee - Library Utility Routines 108 | d3.coffee - d3 Extensions 109 | format.coffee - Data formatters 110 | chart.coffee - Base Chart Classes 111 | css.coffee - CSS Querying Engine 112 | adapters/ - 3rd Party Library Adapters (currently only jQuery) 113 | basic/ - Basic Chart Classes 114 | time/ - Real-time Chart Classes 115 | adapters.coffee - Options / Global Classes for Adapter Implementations 116 | basic.coffee - Base Classes for Basic Charts 117 | data.coffee - Data Formatting 118 | epoch.coffee - Main source file, defines name spaces, etc. 119 | model.coffee - Data Model 120 | time.coffee - Base Classes for Real-Time Charts 121 | sass/ - Scss source for the default epoch stylesheet 122 | tests/ 123 | render/ - Rendering tests 124 | basic/ - Basic chart rendering tests 125 | real-time/ - Real-time rendering tests 126 | unit/ - Unit tests 127 | ``` 128 | 129 | ### Release Checklist 130 | 131 | - Run `npm test` and ensure all tests pass 132 | - Run `npm version [major|minor|patch]` 133 | - Run `npm publish` 134 | - Update CHANGELOG.md with the changes since last release 135 | - Update the `gh-pages` branch's library version in `_config.yml` 136 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "epoch", 3 | "description": "A general purpose, real-time visualization library.", 4 | "main": [ 5 | "dist/js/epoch.min.js", 6 | "dist/css/epoch.min.css" 7 | ], 8 | "license": "MIT", 9 | "ignore": [ 10 | "**/.*", 11 | "/src", 12 | "/sass", 13 | "/tests", 14 | "/Cakefile", 15 | "/CHANGELOG.md", 16 | "/package.json" 17 | ], 18 | "authors": [ 19 | "Ryan Sandor Richards " 20 | ], 21 | "keywords": [ 22 | "fastly", 23 | "realtime", 24 | "graph", 25 | "chart", 26 | "stats", 27 | "visualization" 28 | ], 29 | "homepage": "https://fastly.github.io/epoch/", 30 | "repository": { 31 | "type": "git", 32 | "url": "git://github.com/fastly/epoch.git" 33 | }, 34 | "dependencies": { 35 | "d3": "^3.4.13" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "epochjs/epoch", 3 | "description": "A general purpose, real-time visualization library.", 4 | "keywords": [ 5 | "epoch", 6 | "d3", 7 | "chart", 8 | "graph", 9 | "plot", 10 | "real-time" 11 | ], 12 | "homepage": "https://github.com/epochjs/epoch", 13 | "license": "MIT", 14 | "authors": [ 15 | { 16 | "name": "Ryan Sandor Richards" 17 | } 18 | ], 19 | "require": { 20 | "mbostock/d3": "@stable" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('coffee-script/register'); // For coffee-script mocha unit tests 4 | 5 | var gulp = require('gulp'); 6 | var coffee = require('gulp-coffee'); 7 | var concat = require('gulp-concat'); 8 | var mocha = require('gulp-mocha'); 9 | var order = require('gulp-order'); 10 | var rename = require('gulp-rename'); 11 | var sass = require('gulp-sass'); 12 | var uglify = require('gulp-uglify'); 13 | var gutil = require('gulp-util'); 14 | var del = require('del'); 15 | var exec = require('child_process').exec; 16 | 17 | /** 18 | * Common directories used by tasks below. 19 | * @type {object} 20 | */ 21 | var path = { 22 | source: { 23 | coffee: 'src/', 24 | sass: 'sass/' 25 | }, 26 | dist: { 27 | js: 'dist/js/', 28 | css: 'dist/css/' 29 | }, 30 | test: { 31 | unit: 'tests/unit/' 32 | }, 33 | doc: 'doc/' 34 | }; 35 | 36 | /** 37 | * The default task simply calls the master 'build' task. 38 | */ 39 | gulp.task('default', ['build']); 40 | 41 | /** 42 | * Builds the distribution files by packaging the compiled javascript source 43 | * into the `dist/js/` directory and building the css into the `dist/css` 44 | * directory 45 | */ 46 | gulp.task('build', ['sass', 'sass-minify'], function () { 47 | gulp.src(path.source.coffee + '**/*.coffee') 48 | .pipe(coffee({bare: true}).on('error', gutil.log)) 49 | .pipe(order([ 50 | 'epoch.js', 51 | 'core/context.js', 52 | 'core/util.js', 53 | 'core/d3.js', 54 | 'core/format.js', 55 | 'core/chart.js', 56 | 'core/css.js', 57 | 'data.js', 58 | 'model.js', 59 | 'basic.js', 60 | 'basic/*.js', 61 | 'time.js', 62 | 'time/*.js', 63 | 'adapters.js', 64 | 'adapters/*.js' 65 | ])) 66 | .pipe(concat('epoch.js')) 67 | .pipe(gulp.dest(path.dist.js)) 68 | .pipe(uglify().on('error', gutil.log)) 69 | .pipe(rename('epoch.min.js')) 70 | .pipe(gulp.dest(path.dist.js)); 71 | }); 72 | 73 | /** 74 | * Generates epoch CSS from Sass source. 75 | */ 76 | gulp.task('sass', function () { 77 | gulp.src(path.source.sass + 'epoch.scss') 78 | .pipe(sass({ outputStyle: 'compact' })) 79 | .pipe(rename('epoch.css')) 80 | .pipe(gulp.dest(path.dist.css)); 81 | }); 82 | 83 | /** 84 | * Generates the minified version of the epoch css from sass source. 85 | */ 86 | gulp.task('sass-minify', function () { 87 | gulp.src(path.source.sass + 'epoch.scss') 88 | .pipe(sass({ outputStyle: 'compressed' })) 89 | .pipe(rename('epoch.min.css')) 90 | .pipe(gulp.dest(path.dist.css)); 91 | }); 92 | 93 | /** 94 | * Watch script for recompiling JavaScript and CSS 95 | */ 96 | gulp.task('watch', function () { 97 | gulp.watch(path.source.coffee + '**/*.coffee', ['build']); 98 | gulp.watch(path.source.sass + '**/*.scss', ['sass', 'sass-minify']); 99 | }); 100 | 101 | /** 102 | * Cleans all build and distribution files. 103 | */ 104 | gulp.task('clean', function (cb) { 105 | del([ path.dist.js, path.dist.css]).then(function () { 106 | cb(); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "epoch-charting", 3 | "version": "0.8.4", 4 | "description": "A general purpose real-time charting library for building beautiful, smooth, and high performance visualizations.", 5 | "keywords": [ 6 | "chart", 7 | "charting", 8 | "visualization", 9 | "svg", 10 | "animation", 11 | "canvas", 12 | "d3" 13 | ], 14 | "homepage": "http://fastly.github.io/epoch/", 15 | "author": { 16 | "name": "rsandor", 17 | "url": "https://github.com/rsandor" 18 | }, 19 | "license": "MIT", 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/fastly/epoch.git" 23 | }, 24 | "main": "dist/js/epoch.js", 25 | "dependencies": { 26 | "d3": "^3.4.13" 27 | }, 28 | "devDependencies": { 29 | "chai": "^3.3.0", 30 | "codo": "^2.0.11", 31 | "coffee-script": "^1.10.0", 32 | "del": "^2.0.2", 33 | "gulp": "^3.9.0", 34 | "gulp-coffee": "^2.3.1", 35 | "gulp-concat": "^2.6.0", 36 | "gulp-mocha": "^2.1.3", 37 | "gulp-order": "^1.1.1", 38 | "gulp-rename": "^1.2.2", 39 | "gulp-sass": "^2.0.4", 40 | "gulp-uglify": "^1.4.1", 41 | "gulp-util": "^3.0.6", 42 | "jsdom": "^7.0.2", 43 | "mocha": "^2.3.3", 44 | "node-minify": "^1.2.1", 45 | "node-sass": "^3.3.3", 46 | "sinon": "^1.17.1", 47 | "xmlhttprequest": "^1.7.0" 48 | }, 49 | "scripts": { 50 | "build": "gulp build", 51 | "unit": "mocha --recursive --compilers coffee:coffee-script/register tests/unit/", 52 | "test": "npm run build && npm run unit", 53 | "codo": "./node_modules/.bin/codo --quiet --private --name Epoch --readme README.md --title 'Epoch Documentation' --output codo-doc src - LICENSE" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /sass/_core.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Core Epoch Styles 3 | */ 4 | 5 | /** 6 | * Generates the styles needed to define a fill color for a given class name. 7 | * @param $name Name of the class to use (sans the leading .) 8 | * @param $color Fill color to associate with the class name. 9 | */ 10 | @mixin epoch-color($name, $color) { 11 | div.ref.#{$name} { 12 | background-color: $color; 13 | } 14 | .#{$name} { 15 | .line { stroke: $color; } 16 | .area, .dot { fill: $color; } 17 | } 18 | .arc.#{$name} path { 19 | fill: $color; 20 | } 21 | .bar.#{$name} { 22 | fill: $color; 23 | } 24 | .#{$name} { 25 | .bucket { fill: $color; } 26 | } 27 | } 28 | 29 | /** 30 | * Produces categorical color classes for plots (excluding heatmaps). 31 | * @param $list List of colors to use for each category. 32 | * @param $entries Size of the input list. 33 | */ 34 | @mixin epoch-category-colors($list, $entries) { 35 | @for $i from 1 through $entries { 36 | div.ref.category#{$i} { 37 | background-color: nth($list, $i); 38 | } 39 | .category#{$i} { 40 | .line { stroke: nth($list, $i); } 41 | .area, .dot { fill: nth($list, $i); stroke: rgba(0,0,0,0); } 42 | } 43 | .arc.category#{$i} path { 44 | fill: nth($list, $i); 45 | } 46 | .bar.category#{$i} { 47 | fill: nth($list, $i); 48 | } 49 | } 50 | } 51 | 52 | /** 53 | * Produces categorical colors for heatmaps. 54 | * @param $list List of colors to use for categories. 55 | * @param $entries Size of the input list. 56 | */ 57 | @mixin epoch-heatmap-colors($list, $entries) { 58 | @for $i from 1 through $entries { 59 | .category#{$i} { 60 | .bucket { fill: nth($list, $i); } 61 | } 62 | } 63 | } 64 | 65 | // Axis and Tick Shape Rendering 66 | .epoch { 67 | .axis path, .axis line { 68 | shape-rendering: crispEdges; 69 | } 70 | 71 | .axis.canvas .tick line { 72 | shape-rendering: geometricPrecision; 73 | } 74 | } 75 | 76 | /* 77 | * Canvas Styles Reference Container 78 | * 79 | * The reference container is an SVG that is automatically created when Epoch is loaded. It is used 80 | * by the canvas based plots to obtain color information from the page styles by creating reference 81 | * elements and then reading their computed styles. 82 | * 83 | * Note: don't mess with this ;) 84 | */ 85 | div#_canvas_css_reference { 86 | width: 0; 87 | height: 0; 88 | position: absolute; 89 | top: -1000px; 90 | left: -1000px; 91 | svg { 92 | position: absolute; 93 | width: 0; 94 | height: 0; 95 | top: -1000px; 96 | left: -1000px; 97 | } 98 | } 99 | 100 | -------------------------------------------------------------------------------- /sass/epoch.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Epoch Master SCSS 3 | * 4 | * Includes the core styles and all the themes to produce the complete epoch css file. 5 | * 6 | * By Ryan Sandor Richards 7 | * Copyright 2013 Fastly, Inc. 8 | */ 9 | 10 | @import "core"; 11 | @import "themes/default"; 12 | @import "themes/dark"; 13 | -------------------------------------------------------------------------------- /sass/themes/_dark.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * theme/_dark.scss - Theme design for dark page backgrounds. 3 | * Designed by Ryan Sandor Richards 4 | */ 5 | 6 | $axisAndText: #d0d0d0; 7 | $background: #333; 8 | 9 | .epoch-theme-dark { 10 | // Axes and Ticks 11 | .epoch { 12 | .axis path, .axis line { 13 | stroke: $axisAndText; 14 | } 15 | .axis .tick text { 16 | fill: $axisAndText; 17 | } 18 | } 19 | 20 | // Pie Charts 21 | .arc.pie { 22 | stroke: $background; 23 | } 24 | 25 | .arc.pie text { 26 | fill: $background; 27 | } 28 | 29 | // Gauges 30 | .epoch .gauge-labels .value { 31 | fill: #BBB; 32 | } 33 | 34 | .epoch .gauge { 35 | .arc.outer { 36 | stroke: #999; 37 | } 38 | .arc.inner { 39 | stroke: #AAA; 40 | } 41 | .tick { 42 | stroke: #AAA; 43 | } 44 | 45 | .needle { 46 | fill: #F3DE88; 47 | } 48 | 49 | .needle-base { 50 | fill: #999; 51 | } 52 | } 53 | 54 | // Categorical Colors 55 | $dark_category10: 56 | #909CFF, #FFAC89, #E889E8, #78E8D3, #C2FF97, 57 | #B7BCD1, #FF857F, #F3DE88, #C9935E, #A488FF; 58 | 59 | .epoch, .epoch.category10 { 60 | @include epoch-category-colors($dark_category10, 10); 61 | } 62 | 63 | $dark_category20: 64 | #909CFF, #626AAD, #FFAC89, #BD7F66, 65 | #E889E8, #995A99, #78E8D3, #4F998C, 66 | #C2FF97, #789E5E, #B7BCD1, #7F8391, 67 | #CCB889, #A1906B, #F3DE88, #A89A5E, 68 | #FF857F, #BA615D, #A488FF, #7662B8; 69 | 70 | .epoch.category20 { 71 | @include epoch-category-colors($dark_category20, 20); 72 | } 73 | 74 | $dark_category20b: 75 | #909CFF, #7680D1, #656DB2, #525992, 76 | #FFAC89, #D18D71, #AB735C, #92624E, 77 | #E889E8, #BA6EBA, #9B5C9B, #7B487B, 78 | #78E8D3, #60BAAA, #509B8D, #3F7B70, 79 | #C2FF97, #9FD17C, #7DA361, #65854E; 80 | 81 | .epoch.category20b { 82 | @include epoch-category-colors($dark_category20b, 20); 83 | } 84 | 85 | $dark_category20c: 86 | #B7BCD1, #979DAD, #6E717D, #595C66, 87 | #FF857F, #DE746E, #B55F5A, #964E4B, 88 | #F3DE88, #DBC87B, #BAAA68, #918551, 89 | #C9935E, #B58455, #997048, #735436, 90 | #A488FF, #8670D1, #705CAD, #52447F; 91 | 92 | .epoch.category20c { 93 | @include epoch-category-colors($dark_category20c, 20); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /sass/themes/_default.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * theme/_default.scss - Default Color Theme 3 | * Categorical Colors Adapted from d3: 4 | * https://github.com/mbostock/d3/wiki/Ordinal-Scales#categorical-colors 5 | */ 6 | 7 | // Fonts 8 | .epoch { 9 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 10 | font-size: 12pt; 11 | } 12 | 13 | // Axes and Ticks 14 | .epoch { 15 | .axis path, .axis line { 16 | fill: transparent; 17 | stroke: #000; 18 | } 19 | 20 | .axis .tick text { 21 | font-size: 9pt; 22 | } 23 | } 24 | 25 | // Line Charts 26 | .epoch .line { 27 | fill: transparent; 28 | stroke-width: 2px; 29 | } 30 | 31 | .epoch.sparklines .line { 32 | stroke-width: 1px; 33 | } 34 | 35 | 36 | // Area Charts 37 | .epoch .area { 38 | stroke: transparent; 39 | } 40 | 41 | // Pie Charts 42 | .epoch { 43 | .arc.pie { 44 | stroke: #fff; 45 | stroke-width: 1.5px; 46 | } 47 | 48 | .arc.pie text { 49 | stroke: transparent; 50 | fill: white; 51 | font-size: 9pt; 52 | } 53 | } 54 | 55 | // Gauge Charts 56 | .epoch .gauge-labels { 57 | .value { 58 | text-anchor: middle; 59 | font-size: 140%; 60 | fill: #666; 61 | } 62 | } 63 | 64 | .epoch.gauge-tiny { 65 | width: 120px; height: 90px; 66 | 67 | .gauge-labels { 68 | .value { font-size: 80%; } 69 | } 70 | 71 | .gauge { 72 | .arc.outer { 73 | stroke-width: 2px; 74 | } 75 | } 76 | } 77 | 78 | .epoch.gauge-small { 79 | width: 180px; height: 135px; 80 | 81 | .gauge-labels { 82 | .value { font-size: 120%; } 83 | } 84 | 85 | .gauge { 86 | .arc.outer { 87 | stroke-width: 3px; 88 | } 89 | } 90 | } 91 | 92 | .epoch.gauge-medium { 93 | width: 240px; height: 180px; 94 | 95 | .gauge { 96 | .arc.outer { 97 | stroke-width: 3px; 98 | } 99 | } 100 | } 101 | 102 | .epoch.gauge-large { 103 | width: 320px; height: 240px; 104 | .gauge-labels { 105 | .value { font-size: 180%; } 106 | } 107 | } 108 | 109 | .epoch .gauge { 110 | .arc.outer { 111 | stroke-width: 4px; 112 | stroke: #666; 113 | } 114 | .arc.inner { 115 | stroke-width: 1px; 116 | stroke: #555; 117 | } 118 | .tick { 119 | stroke-width: 1px; 120 | stroke: #555; 121 | } 122 | 123 | .needle { 124 | fill: orange; 125 | } 126 | 127 | .needle-base { 128 | fill: #666; 129 | } 130 | } 131 | 132 | // Categorical Colors 133 | $category10: 134 | #1f77b4, #ff7f0e, #2ca02c, #d62728, #9467bd, 135 | #8c564b, #e377c2, #7f7f7f, #bcbd22, #17becf; 136 | 137 | .epoch, .epoch.category10 { 138 | @include epoch-category-colors($category10, 10); 139 | } 140 | 141 | $category20: 142 | #1f77b4, #aec7e8, #ff7f0e, #ffbb78, #2ca02c, 143 | #98df8a, #d62728, #ff9896, #9467bd, #c5b0d5, 144 | #8c564b, #c49c94, #e377c2, #f7b6d2, #7f7f7f, 145 | #c7c7c7, #bcbd22, #dbdb8d, #17becf, #9edae5; 146 | 147 | .epoch.category20 { 148 | @include epoch-category-colors($category20, 20); 149 | } 150 | 151 | $category20b: 152 | #393b79, #5254a3, #6b6ecf, #9c9ede, #637939, 153 | #8ca252, #b5cf6b, #cedb9c, #8c6d31, #bd9e39, 154 | #e7ba52, #e7cb94, #843c39, #ad494a, #d6616b, 155 | #e7969c, #7b4173, #a55194, #ce6dbd, #de9ed6; 156 | 157 | .epoch.category20b { 158 | @include epoch-category-colors($category20b, 20); 159 | } 160 | 161 | $category20c: 162 | #3182bd, #6baed6, #9ecae1, #c6dbef, 163 | #e6550d, #fd8d3c, #fdae6b, #fdd0a2, 164 | #31a354, #74c476, #a1d99b, #c7e9c0, 165 | #756bb1, #9e9ac8, #bcbddc, #dadaeb, 166 | #636363, #969696, #bdbdbd, #d9d9d9; 167 | 168 | .epoch.category20c { 169 | @include epoch-category-colors($category20c, 20); 170 | } 171 | 172 | 173 | /* 174 | * Heatmap Colors 175 | * 176 | * The heatmap real-time graph uses color blending to choose the color for which to render each bucket. 177 | * Basic d3 categorical colors do not work well in this instance because the colors in the sequence 178 | * vary wildly in precieved luminosity. 179 | * 180 | * Below we define small subsets that should work well together because they are of similar luminosities. 181 | * Note: darker colors work better since we use opacity to denote "height" or "intensity" for each bucket 182 | * after picking a mix color. 183 | */ 184 | $heatmap5: 185 | #1f77b4, #2ca02c, #d62728, #8c564b, #7f7f7f; 186 | 187 | .epoch, .epoch.heatmap5 { 188 | @include epoch-heatmap-colors($heatmap5, 5); 189 | } 190 | -------------------------------------------------------------------------------- /src/adapters.coffee: -------------------------------------------------------------------------------- 1 | # Maps short string names to classes for library adapters. 2 | Epoch._typeMap = 3 | 'area': Epoch.Chart.Area 4 | 'bar': Epoch.Chart.Bar 5 | 'line': Epoch.Chart.Line 6 | 'pie': Epoch.Chart.Pie 7 | 'scatter': Epoch.Chart.Scatter 8 | 'histogram': Epoch.Chart.Histogram 9 | 'time.area': Epoch.Time.Area 10 | 'time.bar': Epoch.Time.Bar 11 | 'time.line': Epoch.Time.Line 12 | 'time.gauge': Epoch.Time.Gauge 13 | 'time.heatmap': Epoch.Time.Heatmap 14 | -------------------------------------------------------------------------------- /src/adapters/MooTools.coffee: -------------------------------------------------------------------------------- 1 | MooToolsModule = -> 2 | # Data key to use for storing a reference to the chart instance on an element. 3 | DATA_NAME = 'epoch-chart' 4 | 5 | # Adds an Epoch chart of the given type to the referenced element. 6 | # @param [Object] options Options for the chart. 7 | # @option options [String] type The type of chart to append to the referenced element. 8 | # @return [Object] The chart instance that was associated with the containing element. 9 | Element.implement 'epoch', (options) -> 10 | self = $$(this) 11 | unless (chart = self.retrieve(DATA_NAME)[0])? 12 | options.el = this 13 | klass = Epoch._typeMap[options.type] 14 | unless klass? 15 | Epoch.exception "Unknown chart type '#{options.type}'" 16 | self.store DATA_NAME, (chart = new klass options) 17 | return chart 18 | 19 | MooToolsModule() if window.MooTools? 20 | -------------------------------------------------------------------------------- /src/adapters/jQuery.coffee: -------------------------------------------------------------------------------- 1 | jQueryModule = ($) -> 2 | # Data key to use for storing a reference to the chart instance on an element. 3 | DATA_NAME = 'epoch-chart' 4 | 5 | # Adds an Epoch chart of the given type to the referenced element. 6 | # @param [Object] options Options for the chart. 7 | # @option options [String] type The type of chart to append to the referenced element. 8 | # @return [Object] The chart instance that was associated with the containing element. 9 | $.fn.epoch = (options) -> 10 | options.el = @get(0) 11 | unless (chart = @data(DATA_NAME))? 12 | klass = Epoch._typeMap[options.type] 13 | unless klass? 14 | Epoch.exception "Unknown chart type '#{options.type}'" 15 | @data DATA_NAME, (chart = new klass options) 16 | return chart 17 | 18 | jQueryModule(jQuery) if window.jQuery? 19 | -------------------------------------------------------------------------------- /src/adapters/zepto.coffee: -------------------------------------------------------------------------------- 1 | zeptoModule = ($) -> 2 | # For mapping charts to selected elements 3 | DATA_NAME = 'epoch-chart' 4 | chartMap = {} 5 | chartId = 0 6 | next_cid = -> "#{DATA_NAME}-#{++chartId}" 7 | 8 | # Adds an Epoch chart of the given type to the referenced element. 9 | # @param [Object] options Options for the chart. 10 | # @option options [String] type The type of chart to append to the referenced element. 11 | # @return [Object] The chart instance that was associated with the containing element. 12 | $.extend $.fn, 13 | epoch: (options) -> 14 | return chartMap[cid] if (cid = @data(DATA_NAME))? 15 | options.el = @get(0) 16 | 17 | klass = Epoch._typeMap[options.type] 18 | unless klass? 19 | Epoch.exception "Unknown chart type '#{options.type}'" 20 | 21 | @data DATA_NAME, (cid = next_cid()) 22 | chart = new klass options 23 | chartMap[cid] = chart 24 | 25 | return chart 26 | 27 | zeptoModule(Zepto) if window.Zepto? 28 | -------------------------------------------------------------------------------- /src/basic.coffee: -------------------------------------------------------------------------------- 1 | # Base class for all two-dimensional basic d3 charts. This class handles axes and 2 | # margins so that subclasses can focus on the construction of particular chart 3 | # types. 4 | class Epoch.Chart.Plot extends Epoch.Chart.SVG 5 | defaults = 6 | domain: null, 7 | range: null, 8 | axes: ['left', 'bottom'] 9 | ticks: 10 | top: 14 11 | bottom: 14 12 | left: 5 13 | right: 5 14 | tickFormats: 15 | top: Epoch.Formats.regular 16 | bottom: Epoch.Formats.regular 17 | left: Epoch.Formats.si 18 | right: Epoch.Formats.si 19 | 20 | defaultAxisMargins = 21 | top: 25 22 | right: 50 23 | bottom: 25 24 | left: 50 25 | 26 | optionListeners = 27 | 'option:margins.top': 'marginsChanged' 28 | 'option:margins.right': 'marginsChanged' 29 | 'option:margins.bottom': 'marginsChanged' 30 | 'option:margins.left': 'marginsChanged' 31 | 'option:axes': 'axesChanged' 32 | 'option:ticks.top': 'ticksChanged' 33 | 'option:ticks.right': 'ticksChanged' 34 | 'option:ticks.bottom': 'ticksChanged' 35 | 'option:ticks.left': 'ticksChanged' 36 | 'option:tickFormats.top': 'tickFormatsChanged' 37 | 'option:tickFormats.right': 'tickFormatsChanged' 38 | 'option:tickFormats.bottom': 'tickFormatsChanged' 39 | 'option:tickFormats.left': 'tickFormatsChanged' 40 | 'option:domain': 'domainChanged' 41 | 'option:range': 'rangeChanged' 42 | 43 | # Creates a new plot chart. 44 | # @param [Object] options Options to use when constructing the plot. 45 | # @option options [Object] margins For setting explicit values for the top, 46 | # right, bottom, and left margins in the visualization. Normally these can 47 | # be omitted and the class will set appropriately sized margins given which 48 | # axes are specified. 49 | # @option options [Array] axes A list of axes to display (top, left, bottom, right). 50 | # @option options [Object] ticks Number of ticks to place on the top, left bottom 51 | # and right axes. 52 | # @option options [Object] tickFormats What tick formatting functions to use for 53 | # the top, bottom, left, and right axes. 54 | constructor: (@options={}) -> 55 | givenMargins = Epoch.Util.copy(@options.margins) or {} 56 | super(@options = Epoch.Util.defaults(@options, defaults)) 57 | 58 | # Margins are used in a special way and only for making room for axes. 59 | # However, a user may explicitly set margins in the options, so we need 60 | # to determine if they did so, and zero out the ones they didn't if no 61 | # axis is present. 62 | @margins = {} 63 | for pos in ['top', 'right', 'bottom', 'left'] 64 | @margins[pos] = if @options.margins? and @options.margins[pos]? 65 | @options.margins[pos] 66 | else if @hasAxis(pos) 67 | defaultAxisMargins[pos] 68 | else 69 | 6 70 | 71 | # Add a translation for the top and left margins 72 | @g = @svg.append("g") 73 | .attr("transform", "translate(#{@margins.left}, #{@margins.top})") 74 | 75 | # Register option change events 76 | @onAll optionListeners 77 | 78 | # Sets the tick formatting function to use on the given axis. 79 | # @param [String] axis Name of the axis. 80 | # @param [Function] fn Formatting function to use. 81 | setTickFormat: (axis, fn) -> 82 | @options.tickFormats[axis] = fn 83 | 84 | # @return [Boolean] true if the chart has an axis with a given name, false otherwise. 85 | # @param [String] axis Name of axis to check. 86 | hasAxis: (axis) -> 87 | @options.axes.indexOf(axis) > -1 88 | 89 | # @return [Number] Width of the visualization portion of the chart (width - margins). 90 | innerWidth: -> 91 | @width - (@margins.left + @margins.right) 92 | 93 | # @return [Number] Height of the visualization portion of the chart (height - margins). 94 | innerHeight: -> 95 | @height - (@margins.top + @margins.bottom) 96 | 97 | # @return [Function] The x scale for the visualization. 98 | x: -> 99 | domain = @options.domain ? @extent((d) -> d.x) 100 | d3.scale.linear() 101 | .domain(domain) 102 | .range([0, @innerWidth()]) 103 | 104 | # @return [Function] The y scale for the visualization. 105 | y: (givenDomain) -> 106 | d3.scale.linear() 107 | .domain(@_getScaleDomain(givenDomain)) 108 | .range([@innerHeight(), 0]) 109 | 110 | # @return [Function] d3 axis to use for the bottom of the visualization. 111 | bottomAxis: -> 112 | d3.svg.axis().scale(@x()).orient('bottom') 113 | .ticks(@options.ticks.bottom) 114 | .tickFormat(@options.tickFormats.bottom) 115 | 116 | # @return [Function] d3 axis to use for the top of the visualization. 117 | topAxis: -> 118 | d3.svg.axis().scale(@x()).orient('top') 119 | .ticks(@options.ticks.top) 120 | .tickFormat(@options.tickFormats.top) 121 | 122 | # @return [Function] d3 axis to use on the left of the visualization. 123 | leftAxis: -> 124 | range = if @options.range then @options.range.left else null 125 | d3.svg.axis().scale(@y(range)).orient('left') 126 | .ticks(@options.ticks.left) 127 | .tickFormat(@options.tickFormats.left) 128 | 129 | # @return [Function] d3 axis to use on the right of the visualization. 130 | rightAxis: -> 131 | range = if @options.range then @options.range.right else null 132 | d3.svg.axis().scale(@y(range)).orient('right') 133 | .ticks(@options.ticks.right) 134 | .tickFormat(@options.tickFormats.right) 135 | 136 | # Renders the axes for the visualization (subclasses must implement specific 137 | # drawing routines). 138 | draw: -> 139 | if @_axesDrawn 140 | @_redrawAxes() 141 | else 142 | @_drawAxes() 143 | super() 144 | 145 | # Redraws the axes for the visualization. 146 | _redrawAxes: -> 147 | if @hasAxis('bottom') 148 | @g.selectAll('.x.axis.bottom').transition() 149 | .duration(500) 150 | .ease('linear') 151 | .call(@bottomAxis()) 152 | if @hasAxis('top') 153 | @g.selectAll('.x.axis.top').transition() 154 | .duration(500) 155 | .ease('linear') 156 | .call(@topAxis()) 157 | if @hasAxis('left') 158 | @g.selectAll('.y.axis.left').transition() 159 | .duration(500) 160 | .ease('linear') 161 | .call(@leftAxis()) 162 | if @hasAxis('right') 163 | @g.selectAll('.y.axis.right').transition() 164 | .duration(500) 165 | .ease('linear') 166 | .call(@rightAxis()) 167 | 168 | # Draws the initial axes for the visualization. 169 | _drawAxes: -> 170 | if @hasAxis('bottom') 171 | @g.append("g") 172 | .attr("class", "x axis bottom") 173 | .attr("transform", "translate(0, #{@innerHeight()})") 174 | .call(@bottomAxis()) 175 | if @hasAxis('top') 176 | @g.append("g") 177 | .attr('class', 'x axis top') 178 | .call(@topAxis()) 179 | if @hasAxis('left') 180 | @g.append("g") 181 | .attr("class", "y axis left") 182 | .call(@leftAxis()) 183 | if @hasAxis('right') 184 | @g.append('g') 185 | .attr('class', 'y axis right') 186 | .attr('transform', "translate(#{@innerWidth()}, 0)") 187 | .call(@rightAxis()) 188 | @_axesDrawn = true 189 | 190 | dimensionsChanged: -> 191 | super() 192 | @g.selectAll('.axis').remove() 193 | @_axesDrawn = false 194 | @draw() 195 | 196 | # Updates margins in response to a option:margin.* event. 197 | marginsChanged: -> 198 | return unless @options.margins? 199 | for own pos, size of @options.margins 200 | unless size? 201 | @margins[pos] = 6 202 | else 203 | @margins[pos] = size 204 | 205 | @g.transition() 206 | .duration(750) 207 | .attr("transform", "translate(#{@margins.left}, #{@margins.top})") 208 | 209 | @draw() 210 | 211 | # Updates axes in response to a option:axes event. 212 | axesChanged: -> 213 | # Remove default axis margins 214 | for pos in ['top', 'right', 'bottom', 'left'] 215 | continue if @options.margins? and @options.margins[pos]? 216 | if @hasAxis(pos) 217 | @margins[pos] = defaultAxisMargins[pos] 218 | else 219 | @margins[pos] = 6 220 | 221 | # Update the margin offset 222 | @g.transition() 223 | .duration(750) 224 | .attr("transform", "translate(#{@margins.left}, #{@margins.top})") 225 | 226 | # Remove the axes and redraw 227 | @g.selectAll('.axis').remove() 228 | @_axesDrawn = false 229 | @draw() 230 | 231 | # Updates ticks in response to a option:ticks.* event. 232 | ticksChanged: -> @draw() 233 | 234 | # Updates tick formats in response to a option:tickFormats.* event. 235 | tickFormatsChanged: -> @draw() 236 | 237 | # Updates chart in response to a option:domain event. 238 | domainChanged: -> @draw() 239 | 240 | # Updates chart in response to a option:range event. 241 | rangeChanged: -> @draw() 242 | 243 | # "They will see us waving from such great heights, come down now..." - The Postal Service 244 | -------------------------------------------------------------------------------- /src/basic/area.coffee: -------------------------------------------------------------------------------- 1 | 2 | # Static stacked area chart implementation using d3. 3 | class Epoch.Chart.Area extends Epoch.Chart.Plot 4 | constructor: (@options={}) -> 5 | @options.type ?= 'area' 6 | super(@options) 7 | @draw() 8 | 9 | # Generates a scale needed to appropriately render the stacked visualization. 10 | # @return [Function] The y scale for the visualization. 11 | y: -> 12 | a = [] 13 | for layer in @getVisibleLayers() 14 | for own k, v of layer.values 15 | a[k] += v.y if a[k]? 16 | a[k] = v.y unless a[k]? 17 | d3.scale.linear() 18 | .domain(@options.range ? [0, d3.max(a)]) 19 | .range([@height - @margins.top - @margins.bottom, 0]) 20 | 21 | # Renders the SVG elements needed to display the stacked area chart. 22 | draw: -> 23 | [x, y, layers] = [@x(), @y(), @getVisibleLayers()] 24 | 25 | @g.selectAll('.layer').remove() 26 | return if layers.length == 0 27 | 28 | area = d3.svg.area() 29 | .x((d) -> x(d.x)) 30 | .y0((d) -> y(d.y0)) 31 | .y1((d) -> y(d.y0 + d.y)) 32 | 33 | stack = d3.layout.stack() 34 | .values((d) -> d.values) 35 | 36 | data = stack layers 37 | 38 | layer = @g.selectAll('.layer') 39 | .data(layers, (d) -> d.category) 40 | 41 | layer.select('.area') 42 | .attr('d', (d) -> area(d.values)) 43 | 44 | layer.enter().append('g') 45 | .attr('class', (d) -> d.className) 46 | 47 | layer.append('path') 48 | .attr('class', 'area') 49 | .attr('d', (d) -> area(d.values)) 50 | 51 | super() 52 | -------------------------------------------------------------------------------- /src/basic/bar.coffee: -------------------------------------------------------------------------------- 1 | # Static bar chart implementation (using d3). 2 | class Epoch.Chart.Bar extends Epoch.Chart.Plot 3 | defaults = 4 | type: 'bar' 5 | style: 'grouped' 6 | orientation: 'vertical' 7 | padding: 8 | bar: 0.08 9 | group: 0.1 10 | outerPadding: 11 | bar: 0.08 12 | group: 0.1 13 | 14 | horizontal_specific = 15 | tickFormats: 16 | top: Epoch.Formats.si 17 | bottom: Epoch.Formats.si 18 | left: Epoch.Formats.regular 19 | right: Epoch.Formats.regular 20 | 21 | horizontal_defaults = Epoch.Util.defaults(horizontal_specific, defaults) 22 | 23 | optionListeners = 24 | 'option:orientation': 'orientationChanged' 25 | 'option:padding': 'paddingChanged' 26 | 'option:outerPadding': 'paddingChanged' 27 | 'option:padding:bar': 'paddingChanged' 28 | 'option:padding:group': 'paddingChanged' 29 | 'option:outerPadding:bar': 'paddingChanged' 30 | 'option:outerPadding:group': 'paddingChanged' 31 | 32 | constructor: (@options={}) -> 33 | if @_isHorizontal() 34 | @options = Epoch.Util.defaults(@options, horizontal_defaults) 35 | else 36 | @options = Epoch.Util.defaults(@options, defaults) 37 | super(@options) 38 | @onAll optionListeners 39 | @draw() 40 | 41 | # @return [Boolean] True if the chart is vertical, false otherwise 42 | _isVertical: -> 43 | @options.orientation == 'vertical' 44 | 45 | # @return [Boolean] True if the chart is horizontal, false otherwise 46 | _isHorizontal: -> 47 | @options.orientation == 'horizontal' 48 | 49 | # @return [Function] The scale used to generate the chart's x scale. 50 | x: -> 51 | if @_isVertical() 52 | d3.scale.ordinal() 53 | .domain(Epoch.Util.domain(@getVisibleLayers())) 54 | .rangeRoundBands([0, @innerWidth()], @options.padding.group, @options.outerPadding.group) 55 | else 56 | extent = @extent((d) -> d.y) 57 | extent[0] = Math.min(0, extent[0]) 58 | d3.scale.linear() 59 | .domain(extent) 60 | .range([0, @width - @margins.left - @margins.right]) 61 | 62 | # @return [Function] The x scale used to render the horizontal bar chart. 63 | x1: (x0) -> 64 | d3.scale.ordinal() 65 | .domain((layer.category for layer in @getVisibleLayers())) 66 | .rangeRoundBands([0, x0.rangeBand()], @options.padding.bar, @options.outerPadding.bar) 67 | 68 | # @return [Function] The y scale used to render the bar chart. 69 | y: -> 70 | if @_isVertical() 71 | extent = @extent((d) -> d.y) 72 | extent[0] = Math.min(0, extent[0]) 73 | d3.scale.linear() 74 | .domain(extent) 75 | .range([@height - @margins.top - @margins.bottom, 0]) 76 | else 77 | d3.scale.ordinal() 78 | .domain(Epoch.Util.domain(@getVisibleLayers())) 79 | .rangeRoundBands([0, @innerHeight()], @options.padding.group, @options.outerPadding.group) 80 | 81 | # @return [Function] The x scale used to render the vertical bar chart. 82 | y1: (y0) -> 83 | d3.scale.ordinal() 84 | .domain((layer.category for layer in @getVisibleLayers())) 85 | .rangeRoundBands([0, y0.rangeBand()], @options.padding.bar, @options.outerPadding.bar) 86 | 87 | # Remaps the bar chart data into a form that is easier to display. 88 | # @return [Array] The reorganized data. 89 | _remapData: -> 90 | map = {} 91 | for layer in @getVisibleLayers() 92 | className = 'bar ' + layer.className.replace(/\s*layer\s*/, '') 93 | for entry in layer.values 94 | map[entry.x] ?= [] 95 | map[entry.x].push { label: layer.category, y: entry.y, className: className } 96 | ({group: k, values: v} for own k, v of map) 97 | 98 | # Draws the bar char. 99 | draw: -> 100 | if @_isVertical() 101 | @_drawVertical() 102 | else 103 | @_drawHorizontal() 104 | super() 105 | 106 | # Draws the bar chart with a vertical orientation 107 | _drawVertical: -> 108 | [x0, y] = [@x(), @y()] 109 | x1 = @x1(x0) 110 | height = @height - @margins.top - @margins.bottom 111 | data = @_remapData() 112 | 113 | # 1) Join 114 | layer = @g.selectAll(".layer") 115 | .data(data, (d) -> d.group) 116 | 117 | # 2) Update 118 | layer.transition().duration(750) 119 | .attr("transform", (d) -> "translate(#{x0(d.group)}, 0)") 120 | 121 | # 3) Enter / Create 122 | layer.enter().append("g") 123 | .attr('class', 'layer') 124 | .attr("transform", (d) -> "translate(#{x0(d.group)}, 0)") 125 | 126 | rects = layer.selectAll('rect') 127 | .data((group) -> group.values) 128 | 129 | rects.attr('class', (d) -> d.className) 130 | 131 | rects.transition().duration(600) 132 | .attr('x', (d) -> x1(d.label)) 133 | .attr('y', (d) -> y(d.y)) 134 | .attr('width', x1.rangeBand()) 135 | .attr('height', (d) -> height - y(d.y)) 136 | 137 | rects.enter().append('rect') 138 | .attr('class', (d) -> d.className) 139 | .attr('x', (d) -> x1(d.label)) 140 | .attr('y', (d) -> y(d.y)) 141 | .attr('width', x1.rangeBand()) 142 | .attr('height', (d) -> height - y(d.y)) 143 | 144 | rects.exit().transition() 145 | .duration(150) 146 | .style('opacity', '0') 147 | .remove() 148 | 149 | # 4) Update new and existing 150 | 151 | # 5) Exit / Remove 152 | layer.exit() 153 | .transition() 154 | .duration(750) 155 | .style('opacity', '0') 156 | .remove() 157 | 158 | # Draws the bar chart with a horizontal orientation 159 | _drawHorizontal: -> 160 | [x, y0] = [@x(), @y()] 161 | y1 = @y1(y0) 162 | width = @width - @margins.left - @margins.right 163 | data = @_remapData() 164 | 165 | # 1) Join 166 | layer = @g.selectAll(".layer") 167 | .data(data, (d) -> d.group) 168 | 169 | # 2) Update 170 | layer.transition().duration(750) 171 | .attr("transform", (d) -> "translate(0, #{y0(d.group)})") 172 | 173 | # 3) Enter / Create 174 | layer.enter().append("g") 175 | .attr('class', 'layer') 176 | .attr("transform", (d) -> "translate(0, #{y0(d.group)})") 177 | 178 | rects = layer.selectAll('rect') 179 | .data((group) -> group.values) 180 | 181 | rects.attr('class', (d) -> d.className) 182 | 183 | rects.transition().duration(600) 184 | .attr('x', (d) -> 0) 185 | .attr('y', (d) -> y1(d.label)) 186 | .attr('height', y1.rangeBand()) 187 | .attr('width', (d) -> x(d.y)) 188 | 189 | rects.enter().append('rect') 190 | .attr('class', (d) -> d.className) 191 | .attr('x', (d) -> 0) 192 | .attr('y', (d) -> y1(d.label)) 193 | .attr('height', y1.rangeBand()) 194 | .attr('width', (d) -> x(d.y)) 195 | 196 | rects.exit().transition() 197 | .duration(150) 198 | .style('opacity', '0') 199 | .remove() 200 | 201 | # 4) Update new and existing 202 | 203 | # 5) Exit / Remove 204 | layer.exit() 205 | .transition() 206 | .duration(750) 207 | .style('opacity', '0') 208 | .remove() 209 | 210 | # Generates specific tick marks to emulate d3's linear scale axis ticks 211 | # for ordinal scales. Note: this should only be called if the user has 212 | # defined a set number of ticks for a given axis. 213 | # @param [Number] numTicks Number of ticks to generate 214 | # @param [String] dataKey Property name of a datum to use for the tick value 215 | # @return [Array] The ticks for the given axis 216 | _getTickValues: (numTicks, dataKey='x') -> 217 | return [] unless @data[0]? 218 | total = @data[0].values.length 219 | step = Math.ceil(total / numTicks)|0 220 | tickValues = (@data[0].values[i].x for i in [0...total] by step) 221 | 222 | # @return [Function] d3 axis to use for the bottom of the visualization. 223 | bottomAxis: -> 224 | axis = d3.svg.axis().scale(@x()).orient('bottom') 225 | .ticks(@options.ticks.bottom) 226 | .tickFormat(@options.tickFormats.bottom) 227 | if @_isVertical() and @options.ticks.bottom? 228 | axis.tickValues @_getTickValues(@options.ticks.bottom) 229 | axis 230 | 231 | # @return [Function] d3 axis to use for the top of the visualization. 232 | topAxis: -> 233 | axis = d3.svg.axis().scale(@x()).orient('top') 234 | .ticks(@options.ticks.top) 235 | .tickFormat(@options.tickFormats.top) 236 | if @_isVertical() and @options.ticks.top? 237 | axis.tickValues @_getTickValues(@options.ticks.top) 238 | axis 239 | 240 | # @return [Function] d3 axis to use on the left of the visualization. 241 | leftAxis: -> 242 | axis = d3.svg.axis().scale(@y()).orient('left') 243 | .ticks(@options.ticks.left) 244 | .tickFormat(@options.tickFormats.left) 245 | if @_isHorizontal() and @options.ticks.left? 246 | axis.tickValues @_getTickValues(@options.ticks.left) 247 | axis 248 | 249 | # @return [Function] d3 axis to use on the right of the visualization. 250 | rightAxis: -> 251 | axis = d3.svg.axis().scale(@y()).orient('right') 252 | .ticks(@options.ticks.right) 253 | .tickFormat(@options.tickFormats.right) 254 | if @_isHorizontal() and @options.ticks.right? 255 | axis.tickValues @_getTickValues(@options.ticks.right) 256 | axis 257 | 258 | # Updates orientation in response option:orientation. 259 | orientationChanged: -> 260 | top = @options.tickFormats.top 261 | bottom = @options.tickFormats.bottom 262 | left = @options.tickFormats.left 263 | right = @options.tickFormats.right 264 | 265 | @options.tickFormats.left = top 266 | @options.tickFormats.right = bottom 267 | @options.tickFormats.top = left 268 | @options.tickFormats.bottom = right 269 | 270 | @draw() 271 | 272 | # Updates padding in response to option:padding:* and option:outerPadding:*. 273 | paddingChanged: -> @draw() 274 | -------------------------------------------------------------------------------- /src/basic/histogram.coffee: -------------------------------------------------------------------------------- 1 | class Epoch.Chart.Histogram extends Epoch.Chart.Bar 2 | defaults = 3 | type: 'histogram' 4 | domain: [0, 100] 5 | bucketRange: [0, 100] 6 | buckets: 10 7 | cutOutliers: false 8 | 9 | optionListeners = 10 | 'option:bucketRange': 'bucketRangeChanged' 11 | 'option:buckets': 'bucketsChanged' 12 | 'option:cutOutliers': 'cutOutliersChanged' 13 | 14 | constructor: (@options={}) -> 15 | super(@options = Epoch.Util.defaults(@options, defaults)) 16 | @onAll optionListeners 17 | @draw() 18 | 19 | # Prepares data by sorting it into histogram buckets as instructed by the chart options. 20 | # @param [Array] data Data to prepare for rendering. 21 | # @return [Array] The data prepared to be displayed as a histogram. 22 | _prepareData: (data) -> 23 | bucketSize = (@options.bucketRange[1] - @options.bucketRange[0]) / @options.buckets 24 | 25 | prepared = [] 26 | for layer in data 27 | buckets = (0 for i in [0...@options.buckets]) 28 | for point in layer.values 29 | index = parseInt((point.x - @options.bucketRange[0]) / bucketSize) 30 | 31 | if @options.cutOutliers and ((index < 0) or (index >= @options.buckets)) 32 | continue 33 | if index < 0 34 | index = 0 35 | else if index >= @options.buckets 36 | index = @options.buckets - 1 37 | 38 | buckets[index] += parseInt point.y 39 | 40 | preparedLayer = { values: (buckets.map (d, i) -> {x: parseInt(i) * bucketSize, y: d}) } 41 | for own k, v of layer 42 | preparedLayer[k] = v unless k == 'values' 43 | 44 | prepared.push preparedLayer 45 | 46 | return prepared 47 | 48 | # Called when options change, this prepares the raw data for the chart according to the new 49 | # options, sets it, and renders the chart. 50 | resetData: -> 51 | @setData @rawData 52 | @draw() 53 | 54 | # Updates the chart in response to an option:bucketRange event. 55 | bucketRangeChanged: -> @resetData() 56 | 57 | # Updates the chart in response to an option:buckets event. 58 | bucketsChanged: -> @resetData() 59 | 60 | # Updates the chart in response to an option:cutOutliers event. 61 | cutOutliersChanged: -> @resetData() 62 | -------------------------------------------------------------------------------- /src/basic/line.coffee: -------------------------------------------------------------------------------- 1 | # Static line chart implementation (using d3). 2 | class Epoch.Chart.Line extends Epoch.Chart.Plot 3 | constructor: (@options={}) -> 4 | @options.type ?= 'line' 5 | super(@options) 6 | @draw() 7 | 8 | # @return [Function] The line generator used to construct the plot. 9 | line: (layer) -> 10 | [x, y] = [@x(), @y(layer.range)] 11 | d3.svg.line() 12 | .x((d) -> x(d.x)) 13 | .y((d) -> y(d.y)) 14 | 15 | # Draws the line chart. 16 | draw: -> 17 | [x, y, layers] = [@x(), @y(), @getVisibleLayers()] 18 | 19 | # Zero visible layers, just drop all and get out 20 | if layers.length == 0 21 | return @g.selectAll('.layer').remove() 22 | 23 | # 1) Join 24 | layer = @g.selectAll('.layer') 25 | .data(layers, (d) -> d.category) 26 | 27 | # 2) Update (only existing) 28 | layer.select('.line').transition().duration(500) 29 | .attr('d', (l) => @line(l)(l.values)) 30 | 31 | # 3) Enter (Create) 32 | layer.enter().append('g') 33 | .attr('class', (l) -> l.className) 34 | .append('path') 35 | .attr('class', 'line') 36 | .attr('d', (l) => @line(l)(l.values)) 37 | 38 | # 4) Update (existing & new) 39 | # Nuuupp 40 | 41 | # 5) Exit (Remove) 42 | layer.exit().transition().duration(750) 43 | .style('opacity', '0') 44 | .remove() 45 | 46 | super() 47 | -------------------------------------------------------------------------------- /src/basic/pie.coffee: -------------------------------------------------------------------------------- 1 | 2 | # Static Pie Chart implementation (using d3). 3 | class Epoch.Chart.Pie extends Epoch.Chart.SVG 4 | defaults = 5 | type: 'pie' 6 | margin: 10 7 | inner: 0 8 | 9 | # Creates a new pie chart. 10 | # @param [Object] options Options for the pie chart. 11 | # @option options [Number] margin Margins to add around the pie chart (default: 10). 12 | # @option options [Number] inner The inner radius for the chart (default: 0). 13 | constructor: (@options={}) -> 14 | super(@options = Epoch.Util.defaults(@options, defaults)) 15 | @pie = d3.layout.pie().sort(null) 16 | .value (d) -> d.value 17 | @arc = d3.svg.arc() 18 | .outerRadius(=> (Math.max(@width, @height) / 2) - @options.margin) 19 | .innerRadius(=> @options.inner) 20 | @g = @svg.append('g') 21 | .attr("transform", "translate(#{@width/2}, #{@height/2})") 22 | @on 'option:margin', 'marginChanged' 23 | @on 'option:inner', 'innerChanged' 24 | @draw() 25 | 26 | # Draws the pie chart 27 | draw: -> 28 | @g.selectAll('.arc').remove() 29 | 30 | arcs = @g.selectAll(".arc") 31 | .data(@pie(@getVisibleLayers()), (d) -> d.data.category) 32 | 33 | arcs.enter().append('g') 34 | .attr('class', (d) -> "arc pie " + d.data.className) 35 | 36 | arcs.select('path') 37 | .attr('d', @arc) 38 | 39 | arcs.select('text') 40 | .attr("transform", (d) => "translate(#{@arc.centroid(d)})") 41 | .text((d) -> d.data.label or d.data.category) 42 | 43 | path = arcs.append("path") 44 | .attr("d", @arc) 45 | .each((d) -> @._current = d) 46 | 47 | text = arcs.append("text") 48 | .attr("transform", (d) => "translate(#{@arc.centroid(d)})") 49 | .attr("dy", ".35em") 50 | .style("text-anchor", "middle") 51 | .text((d) -> d.data.label or d.data.category) 52 | 53 | super() 54 | 55 | # Updates margins in response to an option:margin event. 56 | marginChanged: -> @draw() 57 | 58 | # Updates inner margin in response to an option:inner event. 59 | innerChanged: -> @draw() 60 | -------------------------------------------------------------------------------- /src/basic/scatter.coffee: -------------------------------------------------------------------------------- 1 | 2 | # Static scatter plot implementation (using d3). 3 | class Epoch.Chart.Scatter extends Epoch.Chart.Plot 4 | defaults = 5 | type: 'scatter' 6 | radius: 3.5 7 | axes: ['top', 'bottom', 'left', 'right'] 8 | 9 | # Creates a new scatter plot. 10 | # @param [Object] options Options for the plot. 11 | # @option options [Number] radius The default radius to use for the points in 12 | # the plot (default 3.5). This can be overrwitten by individual points. 13 | constructor: (@options={}) -> 14 | super(@options = Epoch.Util.defaults(@options, defaults)) 15 | @on 'option:radius', 'radiusChanged' 16 | @draw() 17 | 18 | # Draws the scatter plot. 19 | draw: -> 20 | [x, y, layers] = [@x(), @y(), @getVisibleLayers()] 21 | radius = @options.radius 22 | 23 | if layers.length == 0 24 | return @g.selectAll('.layer').remove() 25 | 26 | layer = @g.selectAll('.layer') 27 | .data(layers, (d) -> d.category) 28 | 29 | layer.enter().append('g') 30 | .attr('class', (d) -> d.className) 31 | 32 | dots = layer.selectAll('.dot') 33 | .data((l) -> l.values) 34 | 35 | dots.transition().duration(500) 36 | .attr("r", (d) -> d.r ? radius) 37 | .attr("cx", (d) -> x(d.x)) 38 | .attr("cy", (d) -> y(d.y)) 39 | 40 | dots.enter().append('circle') 41 | .attr('class', 'dot') 42 | .attr("r", (d) -> d.r ? radius) 43 | .attr("cx", (d) -> x(d.x)) 44 | .attr("cy", (d) -> y(d.y)) 45 | 46 | dots.exit().transition() 47 | .duration(750) 48 | .style('opacity', 0) 49 | .remove() 50 | 51 | layer.exit().transition() 52 | .duration(750) 53 | .style('opacity', 0) 54 | .remove() 55 | 56 | super() 57 | 58 | # Updates radius in response to an option:radius event. 59 | radiusChanged: -> @draw() 60 | -------------------------------------------------------------------------------- /src/core/context.coffee: -------------------------------------------------------------------------------- 1 | # Rendering context used for unit testing. 2 | class Epoch.TestContext 3 | VOID_METHODS = [ 4 | 'arc', 'arcTo', 'beginPath', 'bezierCurveTo', 'clearRect', 5 | 'clip', 'closePath', 'drawImage', 'fill', 'fillRect', 'fillText', 6 | 'moveTo', 'quadraticCurveTo', 'rect', 'restore', 'rotate', 'save', 7 | 'scale', 'scrollPathIntoView', 'setLineDash', 'setTransform', 8 | 'stroke', 'strokeRect', 'strokeText', 'transform', 'translate', 'lineTo' 9 | ] 10 | 11 | # Creates a new test rendering context. 12 | constructor: -> 13 | @_log = [] 14 | @_makeFauxMethod(method) for method in VOID_METHODS 15 | 16 | # Creates a fake method with the given name that logs the method called 17 | # and arguments passed when executed. 18 | # @param name Name of the fake method to create. 19 | _makeFauxMethod: (name) -> 20 | @[name] = -> @_log.push "#{name}(#{(arg.toString() for arg in arguments).join(',')})" 21 | 22 | # Faux method that emulates the "getImageData" method 23 | getImageData: -> 24 | @_log.push "getImageData(#{(arg.toString() for arg in arguments).join(',')})" 25 | return { width: 0, height: 0, resolution: 1.0, data: [] } 26 | -------------------------------------------------------------------------------- /src/core/css.coffee: -------------------------------------------------------------------------------- 1 | # Singelton class used to query CSS styles by way of reference elements. 2 | # This allows canvas based visualizations to use the same styles as their 3 | # SVG counterparts. 4 | class QueryCSS 5 | # Reference container id 6 | REFERENCE_CONTAINER_ID = '_canvas_css_reference' 7 | 8 | # Container Hash Attribute 9 | CONTAINER_HASH_ATTR = 'data-epoch-container-id' 10 | 11 | # Handles automatic container id generation 12 | containerCount = 0 13 | nextContainerId = -> "epoch-container-#{containerCount++}" 14 | 15 | # Expression used to derive tag name, id, and class names from 16 | # selectors given the the put method. 17 | PUT_EXPR = /^([^#. ]+)?(#[^. ]+)?(\.[^# ]+)?$/ 18 | 19 | # Whether or not to log full selector lists 20 | logging = false 21 | 22 | # Converts selectors into actual dom elements (replaces put.js) 23 | # Limited the functionality to what Epoch actually needs to 24 | # operate correctly. We detect class names, ids, and element 25 | # tag names. 26 | put = (selector) -> 27 | match = selector.match(PUT_EXPR) 28 | return Epoch.error('Query CSS cannot match given selector: ' + selector) unless match? 29 | [whole, tag, id, classNames] = match 30 | tag = (tag ? 'div').toUpperCase() 31 | 32 | element = document.createElement(tag) 33 | element.id = id.substr(1) if id? 34 | if classNames? 35 | element.className = classNames.substr(1).replace(/\./g, ' ') 36 | 37 | return element 38 | 39 | # Lets the user set whether or not to log selector lists and resulting DOM trees. 40 | # Useful for debugging QueryCSS itself. 41 | @log: (b) -> 42 | logging = b 43 | 44 | # Key-Value cache for computed styles that we found using this class. 45 | @cache = {} 46 | 47 | # List of styles to pull from the full list of computed styles 48 | @styleList = ['fill', 'stroke', 'stroke-width'] 49 | 50 | # The svg reference container 51 | @container = null 52 | 53 | # Purges the selector to style cache 54 | @purge: -> 55 | QueryCSS.cache = {} 56 | 57 | # Gets the reference element container. 58 | @getContainer: -> 59 | return QueryCSS.container if QueryCSS.container? 60 | container = document.createElement('DIV') 61 | container.id = REFERENCE_CONTAINER_ID 62 | document.body.appendChild(container) 63 | QueryCSS.container = d3.select(container) 64 | 65 | # @return [String] A unique identifier for the given container and selector. 66 | # @param [String] selector Selector from which to derive the styles 67 | # @param container The containing element for a chart. 68 | @hash: (selector, container) -> 69 | containerId = container.attr(CONTAINER_HASH_ATTR) 70 | unless containerId? 71 | containerId = nextContainerId() 72 | container.attr(CONTAINER_HASH_ATTR, containerId) 73 | return "#{containerId}__#{selector}" 74 | 75 | # @return The computed styles for the given selector in the given container element. 76 | # @param [String] selector Selector from which to derive the styles. 77 | # @param container HTML containing element in which to place the reference SVG. 78 | @getStyles: (selector, container) -> 79 | # 0) Check for cached styles 80 | cacheKey = QueryCSS.hash(selector, container) 81 | cache = QueryCSS.cache[cacheKey] 82 | return cache if cache? 83 | 84 | # 1) Build a full reference tree (parents, container, and selector elements) 85 | parents = [] 86 | parentNode = container.node().parentNode 87 | 88 | while parentNode? and parentNode.nodeName.toLowerCase() != 'body' 89 | parents.unshift parentNode 90 | parentNode = parentNode.parentNode 91 | parents.push container.node() 92 | 93 | selectorList = [] 94 | for element in parents 95 | sel = element.nodeName.toLowerCase() 96 | if element.id? and element.id.length > 0 97 | sel += '#' + element.id 98 | if element.className? and element.className.length > 0 99 | sel += '.' + Epoch.Util.trim(element.className).replace(/\s+/g, '.') 100 | selectorList.push sel 101 | 102 | selectorList.push('svg') 103 | 104 | for subSelector in Epoch.Util.trim(selector).split(/\s+/) 105 | selectorList.push(subSelector) 106 | 107 | console.log(selectorList) if logging 108 | 109 | parent = root = put(selectorList.shift()) 110 | while selectorList.length 111 | el = put(selectorList.shift()) 112 | parent.appendChild el 113 | parent = el 114 | 115 | console.log(root) if logging 116 | 117 | # 2) Place the reference tree and fetch styles given the selector 118 | QueryCSS.getContainer().node().appendChild(root) 119 | 120 | ref = d3.select('#' + REFERENCE_CONTAINER_ID + ' ' + selector) 121 | styles = {} 122 | for name in QueryCSS.styleList 123 | styles[name] = ref.style(name) 124 | QueryCSS.cache[cacheKey] = styles 125 | 126 | # 3) Cleanup and return the styles 127 | QueryCSS.getContainer().html('') 128 | return styles 129 | 130 | 131 | Epoch.QueryCSS = QueryCSS -------------------------------------------------------------------------------- /src/core/d3.coffee: -------------------------------------------------------------------------------- 1 | # Gets the width of the first node, or sets the width of all the nodes 2 | # in a d3 selection. 3 | # @param value [Number, String] (optional) Width to set for all the nodes in the selection. 4 | # @return The selection if setting the width of the nodes, or the width 5 | # in pixels of the first node in the selection. 6 | d3.selection::width = (value) -> 7 | if value? and Epoch.isString(value) 8 | @style('width', value) 9 | else if value? and Epoch.isNumber(value) 10 | @style('width', "#{value}px") 11 | else 12 | +Epoch.Util.getComputedStyle(@node(), null).width.replace('px', '') 13 | 14 | # Gets the height of the first node, or sets the height of all the nodes 15 | # in a d3 selection. 16 | # @param value (optional) Height to set for all the nodes in the selection. 17 | # @return The selection if setting the height of the nodes, or the height 18 | # in pixels of the first node in the selection. 19 | d3.selection::height = (value) -> 20 | if value? and Epoch.isString(value) 21 | @style('height', value) 22 | else if value? and Epoch.isNumber(value) 23 | @style('height', "#{value}px") 24 | else 25 | +Epoch.Util.getComputedStyle(@node(), null).height.replace('px', '') -------------------------------------------------------------------------------- /src/core/format.coffee: -------------------------------------------------------------------------------- 1 | # Tick formatter identity. 2 | Epoch.Formats.regular = (d) -> d 3 | 4 | # Tick formatter that formats the numbers using standard SI postfixes. 5 | Epoch.Formats.si = (d) -> Epoch.Util.formatSI(d) 6 | 7 | # Tick formatter for percentages. 8 | Epoch.Formats.percent = (d) -> (d*100).toFixed(1) + "%" 9 | 10 | # Tick formatter for seconds from timestamp data. 11 | Epoch.Formats.seconds = (t) -> d3Seconds(new Date(t*1000)) 12 | d3Seconds = d3.time.format('%I:%M:%S %p') 13 | 14 | # Tick formatter for bytes 15 | Epoch.Formats.bytes = (d) -> Epoch.Util.formatBytes(d) 16 | -------------------------------------------------------------------------------- /src/core/util.coffee: -------------------------------------------------------------------------------- 1 | typeFunction = (objectName) -> (v) -> 2 | Object::toString.call(v) == "[object #{objectName}]" 3 | 4 | # @return [Boolean] true if the given value is an array, false otherwise. 5 | # @param v Value to test. 6 | Epoch.isArray = Array.isArray ? typeFunction('Array') 7 | 8 | # @return [Boolean] true if the given value is an object, false otherwise. 9 | # @param v Value to test. 10 | Epoch.isObject = typeFunction('Object') 11 | 12 | # @return [Boolean] true if the given value is a string, false otherwise. 13 | # @param v Value to test. 14 | Epoch.isString = typeFunction('String') 15 | 16 | # @return [Boolean] true if the given value is a function, false otherwise. 17 | # @param v Value to test. 18 | Epoch.isFunction = typeFunction('Function') 19 | 20 | # @return [Boolean] true if the given value is a number, false otherwise. 21 | # @param v Value to test. 22 | Epoch.isNumber = typeFunction('Number') 23 | 24 | # Attempts to determine if a given value represents a DOM element. The result is always correct if the 25 | # browser implements DOM Level 2, but one can fool it on certain versions of IE. Adapted from: 26 | # Stack Overflow #384286. 27 | # @return [Boolean] true if the given value is a DOM element, false otherwise. 28 | # @param v Value to test. 29 | Epoch.isElement = (v) -> 30 | if HTMLElement? 31 | v instanceof HTMLElement 32 | else 33 | v? and Epoch.isObject(v) and v.nodeType == 1 and Epoch.isString(v.nodeName) 34 | 35 | # Determines if a given value is a non-empty array. 36 | # @param v Value to test. 37 | # @return [Boolean] true if the given value is an array with at least one element. 38 | Epoch.isNonEmptyArray = (v) -> 39 | Epoch.isArray(v) and v.length > 0 40 | 41 | # Generates shallow copy of an object. 42 | # @return A shallow copy of the given object. 43 | # @param [Object] original Object for which to make the shallow copy. 44 | Epoch.Util.copy = (original) -> 45 | return null unless original? 46 | copy = {} 47 | copy[k] = v for own k, v of original 48 | return copy 49 | 50 | # Creates a deep copy of the given options filling in missing defaults. 51 | # @param [Object] options Options to copy. 52 | # @param [Object] defaults Default values for the options. 53 | Epoch.Util.defaults = (options, defaults) -> 54 | result = Epoch.Util.copy(options) 55 | for own k, v of defaults 56 | opt = options[k] 57 | def = defaults[k] 58 | bothAreObjects = Epoch.isObject(opt) and Epoch.isObject(def) 59 | 60 | if opt? and def? 61 | if bothAreObjects and not Epoch.isArray(opt) 62 | result[k] = Epoch.Util.defaults(opt, def) 63 | else 64 | result[k] = opt 65 | else if opt? 66 | result[k] = opt 67 | else 68 | result[k] = def 69 | 70 | return result 71 | 72 | # Formats numbers with standard postfixes (e.g. K, M, G) 73 | # @param [Number] v Value to format. 74 | # @param [Integer] fixed Number of floating point digits to fix after conversion. 75 | # @param [Boolean] fixIntegers Whether or not to add floating point digits to non-floating point results. 76 | # @example Formatting a very large number 77 | # Epoch.Util.formatSI(1120000) == "1.1 M" 78 | Epoch.Util.formatSI = (v, fixed=1, fixIntegers=false) -> 79 | if v < 1000 80 | q = v 81 | q = q.toFixed(fixed) unless (q|0) == q and !fixIntegers 82 | return q 83 | 84 | for own i, label of ['K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'] 85 | base = Math.pow(10, ((i|0)+1)*3) 86 | if v >= base and v < Math.pow(10, ((i|0)+2)*3) 87 | q = v/base 88 | q = q.toFixed(fixed) unless (q % 1) == 0 and !fixIntegers 89 | return "#{q} #{label}" 90 | 91 | # Formats large bandwidth and disk space usage numbers with byte postfixes (e.g. KB, MB, GB, etc.) 92 | # @param [Number] v Value to format. 93 | # @param [Integer] fixed Number of floating point digits to fix after conversion. 94 | # @param [Boolean] fixIntegers Whether or not to add floating point digits to non-floating point results. 95 | # @example Formatting a large number of bytes 96 | # Epoch.Util.formatBytes(5.21 * Math.pow(2, 20)) == "5.2 MB" 97 | Epoch.Util.formatBytes = (v, fixed=1, fix_integers=false) -> 98 | if v < 1024 99 | q = v 100 | q = q.toFixed(fixed) unless (q % 1) == 0 and !fix_integers 101 | return "#{q} B" 102 | 103 | for own i, label of ['KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] 104 | base = Math.pow(1024, (i|0)+1) 105 | if v >= base and v < Math.pow(1024, (i|0)+2) 106 | q = v/base 107 | q = q.toFixed(fixed) unless (q % 1) == 0 and !fix_integers 108 | return "#{q} #{label}" 109 | 110 | # @return a "dasherized" css class names from a given string 111 | # @example Using dasherize 112 | # Epoch.Util.dasherize('My Awesome Name') == 'my-awesome-name' 113 | Epoch.Util.dasherize = (str) -> 114 | Epoch.Util.trim(str).replace("\n", '').replace(/\s+/g, '-').toLowerCase() 115 | 116 | # @return the full domain of a given variable from an array of layers 117 | # @param [Array] layers Layered plot data. 118 | # @param [String] key The key name of the value at on each entry in the layers. 119 | Epoch.Util.domain = (layers, key='x') -> 120 | set = {} 121 | domain = [] 122 | for layer in layers 123 | for entry in layer.values 124 | continue if set[entry[key]]? 125 | domain.push(entry[key]) 126 | set[entry[key]] = true 127 | return domain 128 | 129 | # Strips whitespace from the beginning and end of a string. 130 | # @param [String] string String to trim. 131 | # @return [String] The string without leading or trailing whitespace. 132 | # Returns null if the given parameter was not a string. 133 | Epoch.Util.trim = (string) -> 134 | return null unless Epoch.isString(string) 135 | string.replace(/^\s+/g, '').replace(/\s+$/g, '') 136 | 137 | # Returns the computed styles of an element in the document 138 | # @param [HTMLElement] Element for which to fetch the styles. 139 | # @param [String] pseudoElement Pseudo selectors on which to search for the element. 140 | # @return [Object] The styles for the given element. 141 | Epoch.Util.getComputedStyle = (element, pseudoElement) -> 142 | if Epoch.isFunction(window.getComputedStyle) 143 | window.getComputedStyle(element, pseudoElement) 144 | else if element.currentStyle? 145 | element.currentStyle 146 | 147 | # Converts a CSS color string into an RGBA string with the given opacity 148 | # @param [String] color Color string to convert into an rgba 149 | # @param [Number] opacity Opacity to use for the resulting color. 150 | # @return the resulting rgba color string. 151 | Epoch.Util.toRGBA = (color, opacity) -> 152 | if (parts = color.match /^rgba\(\s*([0-9]+)\s*,\s*([0-9]+)\s*,\s*([0-9]+)\s*,\s*[0-9\.]+\)/) 153 | [all, r, g, b] = parts 154 | result = "rgba(#{r},#{g},#{b},#{opacity})" 155 | else if (v = d3.rgb color) 156 | result = "rgba(#{v.r},#{v.g},#{v.b},#{opacity})" 157 | return result 158 | 159 | # Obtains a graphics context for the given canvas node. Nice to have 160 | # this abstracted out in case we want to support WebGL in the future. 161 | # Also allows us to setup a special context when unit testing, as 162 | # jsdom doesn't have canvas support, and node-canvas is a pain in the 163 | # butt to install properly across different platforms. 164 | Epoch.Util.getContext = (node, type='2d') -> 165 | node.getContext(type) 166 | 167 | # Basic eventing base class for all Epoch classes. 168 | class Epoch.Events 169 | constructor: -> 170 | @_events = {} 171 | 172 | # Registers a callback to a given event. 173 | # @param [String] name Name of the event. 174 | # @param [Function, String] callback Either a closure to call when the event fires 175 | # or a string that denotes a method name to call on this object. 176 | on: (name, callback) -> 177 | return unless callback? 178 | @_events[name] ?= [] 179 | @_events[name].push callback 180 | 181 | # Registers a map of event names to given callbacks. This method calls .on 182 | # directly for each of the events given. 183 | # @param [Object] map A map of event names to callbacks. 184 | onAll: (map) -> 185 | return unless Epoch.isObject(map) 186 | @on(name, callback) for own name, callback of map 187 | 188 | # Removes a specific callback listener or all listeners for a given event. 189 | # @param [String] name Name of the event. 190 | # @param [Function, String] callback (Optional) Callback to remove from the listener list. 191 | # If this parameter is not provided then all listeners will be removed for the event. 192 | off: (name, callback) -> 193 | return unless Epoch.isArray(@_events[name]) 194 | return delete(@_events[name]) unless callback? 195 | while (i = @_events[name].indexOf(callback)) >= 0 196 | @_events[name].splice(i, 1) 197 | 198 | # Removes a set of callback listeners for all events given in the map or array of strings. 199 | # This method calls .off directly for each event and callback to remove. 200 | # @param [Object, Array] mapOrList Either a map that associates event names to specific callbacks 201 | # or an array of event names for which to completely remove listeners. 202 | offAll: (mapOrList) -> 203 | if Epoch.isArray(mapOrList) 204 | @off(name) for name in mapOrList 205 | else if Epoch.isObject(mapOrList) 206 | @off(name, callback) for own name, callback of mapOrList 207 | 208 | # Triggers an event causing all active listeners to be executed. 209 | # @param [String] name Name of the event to fire. 210 | trigger: (name) -> 211 | return unless @_events[name]? 212 | args = (arguments[i] for i in [1...arguments.length]) 213 | for callback in @_events[name] 214 | fn = null 215 | if Epoch.isString(callback) 216 | fn = @[callback] 217 | else if Epoch.isFunction(callback) 218 | fn = callback 219 | unless fn? 220 | Epoch.exception "Callback for event '#{name}' is not a function or reference to a method." 221 | fn.apply @, args 222 | 223 | # Performs a single pass flatten on a multi-array 224 | # @param [Array] multiarray A deep multi-array to flatten 225 | # @returns [Array] A single pass flatten of the multi-array 226 | Epoch.Util.flatten = (multiarray) -> 227 | if !Array.isArray(multiarray) 228 | throw new Error('Epoch.Util.flatten only accepts arrays') 229 | result = [] 230 | for array in multiarray 231 | if Array.isArray(array) 232 | for item in array 233 | result.push item 234 | else 235 | result.push array 236 | result 237 | -------------------------------------------------------------------------------- /src/epoch.coffee: -------------------------------------------------------------------------------- 1 | window.Epoch ?= {} 2 | window.Epoch.Chart ?= {} 3 | window.Epoch.Time ?= {} 4 | window.Epoch.Util ?= {} 5 | window.Epoch.Formats ?= {} 6 | 7 | # Sends a warning to the developer console with the given message. 8 | # @param [String] msg Message for the warning. 9 | Epoch.warn = (msg) -> 10 | (console.warn or console.log)("Epoch Warning: #{msg}") 11 | 12 | # Raises an exception with the given message (with the 'Epoch Error:' preamble). 13 | # @param [String] msg Specific message for the exception. 14 | Epoch.exception = (msg) -> 15 | throw "Epoch Error: #{msg}" 16 | 17 | # "I think, baby, I was born just a little late!" -- Middle Class Rut 18 | -------------------------------------------------------------------------------- /src/model.coffee: -------------------------------------------------------------------------------- 1 | # Data model for epoch charts. By instantiating a model and passing it to each 2 | # of the charts on a page the application programmer can update data once and 3 | # have each of the charts respond accordingly. 4 | # 5 | # In addition to setting basic / historical data via the setData method, the 6 | # model also supports the push method, which when used will cause real-time 7 | # plots to automatically update and animate. 8 | class Epoch.Model extends Epoch.Events 9 | defaults = 10 | dataFormat: null 11 | 12 | # Creates a new Model. 13 | # @option options dataFormat The default data fromat for the model. 14 | # @option data Initial data for the model. 15 | constructor: (options={}) -> 16 | super() 17 | options = Epoch.Util.defaults options, defaults 18 | @dataFormat = options.dataFormat 19 | @data = options.data 20 | @loading = false 21 | 22 | # Sets the model's data. 23 | # @param data Data to set for the model. 24 | # @event data:updated Instructs listening charts that new data is available. 25 | setData: (data) -> 26 | @data = data 27 | @trigger 'data:updated' 28 | 29 | # Pushes a new entry into the model. 30 | # @param entry Entry to push. 31 | # @event data:push Instructs listening charts that a new data entry is available. 32 | push: (entry) -> 33 | @entry = entry 34 | @trigger 'data:push' 35 | 36 | # Determines if the model has data. 37 | # @return true if the model has data, false otherwise. 38 | hasData: -> 39 | @data? 40 | 41 | # Retrieves and formats adata for the specific chart type and data format. 42 | # @param [String] type Type of the chart for which to fetch the data. 43 | # @param [String, Object] dataFormat (optional) Used to override the model's default data format. 44 | # @return The model's data formatted based the parameters. 45 | getData: (type, dataFormat) -> 46 | dataFormat ?= @dataFormat 47 | Epoch.Data.formatData @data, type, dataFormat 48 | 49 | # Retrieves the latest data entry that was pushed into the model. 50 | # @param [String] type Type of the chart for which to fetch the data. 51 | # @param [String, Object] dataFormat (optional) Used to override the model's default data format. 52 | # @return The model's next data entry formatted based the parameters. 53 | getNext: (type, dataFormat) -> 54 | dataFormat ?= @dataFormat 55 | Epoch.Data.formatEntry @entry, type, dataFormat 56 | -------------------------------------------------------------------------------- /src/time/area.coffee: -------------------------------------------------------------------------------- 1 | 2 | # Real-time stacked area chart implementation. 3 | class Epoch.Time.Area extends Epoch.Time.Stack 4 | constructor: (@options={}) -> 5 | @options.type ?= 'time.area' 6 | super(@options) 7 | @draw() 8 | 9 | # Sets the appropriate styles to the graphics context given a particular layer. 10 | # @param [Object] layer Layer for which to set the styles. 11 | setStyles: (layer) -> 12 | if layer? && layer.className? 13 | styles = @getStyles "g.#{layer.className.replace(/\s/g,'.')} path.area" 14 | else 15 | styles = @getStyles "g path.area" 16 | @ctx.fillStyle = styles.fill 17 | if styles.stroke? 18 | @ctx.strokeStyle = styles.stroke 19 | if styles['stroke-width']? 20 | @ctx.lineWidth = styles['stroke-width'].replace('px', '') 21 | 22 | # Draws areas for the chart 23 | _drawAreas: (delta=0) -> 24 | [y, w, layers] = [@y(), @w(), @getVisibleLayers()] 25 | 26 | for i in [layers.length-1..0] 27 | continue unless (layer = layers[i]) 28 | 29 | @setStyles layer 30 | @ctx.beginPath() 31 | 32 | [j, k, trans] = [@options.windowSize, layer.values.length, @inTransition()] 33 | firstX = null 34 | while (--j >= -2) and (--k >= 0) 35 | entry = layer.values[k] 36 | args = [(j+1)*w+delta, y(entry.y + entry.y0)] 37 | args[0] += w if trans 38 | if i == @options.windowSize - 1 39 | @ctx.moveTo.apply @ctx, args 40 | else 41 | @ctx.lineTo.apply @ctx, args 42 | 43 | if trans 44 | borderX = (j+3)*w+delta 45 | else 46 | borderX = (j+2)*w+delta 47 | 48 | @ctx.lineTo(borderX, @innerHeight()) 49 | @ctx.lineTo(@width*@pixelRatio+w+delta, @innerHeight()) 50 | @ctx.closePath() 51 | @ctx.fill() 52 | 53 | # Draws strokes for the chart 54 | _drawStrokes: (delta=0) -> 55 | [y, w, layers] = [@y(), @w(), @getVisibleLayers()] 56 | 57 | for i in [layers.length-1..0] 58 | continue unless (layer = layers[i]) 59 | @setStyles layer 60 | @ctx.beginPath() 61 | 62 | [i, k, trans] = [@options.windowSize, layer.values.length, @inTransition()] 63 | firstX = null 64 | while (--i >= -2) and (--k >= 0) 65 | entry = layer.values[k] 66 | args = [(i+1)*w+delta, y(entry.y + entry.y0)] 67 | args[0] += w if trans 68 | if i == @options.windowSize - 1 69 | @ctx.moveTo.apply @ctx, args 70 | else 71 | @ctx.lineTo.apply @ctx, args 72 | 73 | @ctx.stroke() 74 | 75 | # Draws the area chart. 76 | draw: (delta=0) -> 77 | @clear() 78 | @_drawAreas(delta) 79 | @_drawStrokes(delta) 80 | super() 81 | -------------------------------------------------------------------------------- /src/time/bar.coffee: -------------------------------------------------------------------------------- 1 | 2 | # Real-time Bar Chart implementation. 3 | class Epoch.Time.Bar extends Epoch.Time.Stack 4 | constructor: (@options={}) -> 5 | @options.type ?= 'time.bar' 6 | super(@options) 7 | @draw() 8 | 9 | # @return [Number] An offset used to align the ticks to the center of the rendered bars. 10 | _offsetX: -> 11 | 0.5 * @w() / @pixelRatio 12 | 13 | # Sets the styles for the graphics context given a layer class name. 14 | # @param [String] className The class name to use when deriving the styles. 15 | setStyles: (className) -> 16 | styles = @getStyles "rect.bar.#{className.replace(/\s/g,'.')}" 17 | @ctx.fillStyle = styles.fill 18 | 19 | if !styles.stroke? or styles.stroke == 'none' 20 | @ctx.strokeStyle = 'transparent' 21 | else 22 | @ctx.strokeStyle = styles.stroke 23 | 24 | if styles['stroke-width']? 25 | @ctx.lineWidth = styles['stroke-width'].replace('px', '') 26 | 27 | # Draws the stacked bar chart. 28 | draw: (delta=0) -> 29 | @clear() 30 | [y, w] = [@y(), @w()] 31 | 32 | for layer in @getVisibleLayers() 33 | continue unless Epoch.isNonEmptyArray(layer.values) 34 | @setStyles(layer.className) 35 | 36 | [i, k, trans] = [@options.windowSize, layer.values.length, @inTransition()] 37 | iBoundry = if trans then -1 else 0 38 | 39 | while (--i >= iBoundry) and (--k >= 0) 40 | entry = layer.values[k] 41 | [ex, ey, ey0] = [i*w+delta, entry.y, entry.y0] 42 | ex += w if trans 43 | args = [ex+1, y(ey+ey0), w-2, @innerHeight()-y(ey)+0.5*@pixelRatio] 44 | 45 | @ctx.fillRect.apply(@ctx, args) 46 | @ctx.strokeRect.apply(@ctx, args) 47 | 48 | super() 49 | -------------------------------------------------------------------------------- /src/time/gauge.coffee: -------------------------------------------------------------------------------- 1 | 2 | # Real-time Gauge Visualization. Note: Looks best with a 4:3 aspect ratio (w:h) 3 | class Epoch.Time.Gauge extends Epoch.Chart.Canvas 4 | defaults = 5 | type: 'time.gauge' 6 | domain: [0, 1] 7 | ticks: 10 8 | tickSize: 5 9 | tickOffset: 5 10 | fps: 34 11 | format: Epoch.Formats.percent 12 | 13 | optionListeners = 14 | 'option:domain': 'domainChanged' 15 | 'option:ticks': 'ticksChanged' 16 | 'option:tickSize': 'tickSizeChanged' 17 | 'option:tickOffset': 'tickOffsetChanged' 18 | 'option:format': 'formatChanged' 19 | 20 | # Creates the new gauge chart. 21 | # @param [Object] options Options for the gauge chart. 22 | # @option options [Array] domain The domain to use when rendering values (default: [0, 1]). 23 | # @option options [Integer] ticks Number of ticks to render (default: 10). 24 | # @option options [Integer] tickSize The length (in pixels) for each tick (default: 5). 25 | # @option options [Integer] tickOffset The number of pixels by which to offset ticks from the outer arc (default: 5). 26 | # @option options [Integer] fps The number of animation frames to render per second (default: 34). 27 | # @option options [Function] format The formatting function to use when rendering the gauge label 28 | # (default: Epoch.Formats.percent). 29 | constructor: (@options={}) -> 30 | super(@options = Epoch.Util.defaults(@options, defaults)) 31 | @value = @options.value or 0 32 | 33 | if @options.model 34 | @options.model.on 'data:push', => @pushFromModel() 35 | 36 | # SVG Labels Overlay 37 | if @el.style('position') != 'absolute' and @el.style('position') != 'relative' 38 | @el.style('position', 'relative') 39 | 40 | @svg = @el.insert('svg', ':first-child') 41 | .attr('width', @width) 42 | .attr('height', @height) 43 | .attr('class', 'gauge-labels') 44 | 45 | @svg.style 46 | 'position': 'absolute' 47 | 'z-index': '1' 48 | 49 | @svg.append('g') 50 | .attr('transform', "translate(#{@textX()}, #{@textY()})") 51 | .append('text') 52 | .attr('class', 'value') 53 | .text(@options.format(@value)) 54 | 55 | # Animations 56 | @animation = 57 | interval: null 58 | active: false 59 | delta: 0 60 | target: 0 61 | 62 | @_animate = => 63 | if Math.abs(@animation.target - @value) < Math.abs(@animation.delta) 64 | @value = @animation.target 65 | clearInterval @animation.interval 66 | @animation.active = false 67 | else 68 | @value += @animation.delta 69 | 70 | @svg.select('text.value').text(@options.format(@value)) 71 | @draw() 72 | 73 | @onAll optionListeners 74 | @draw() 75 | 76 | # Sets the value for the gauge to display and begins animating the guage. 77 | # @param [Number] value Value to set for the gauge. 78 | update: (value) -> 79 | @animation.target = value 80 | @animation.delta = (value - @value) / @options.fps 81 | unless @animation.active 82 | @animation.interval = setInterval @_animate, (1000/@options.fps) 83 | @animation.active = true 84 | 85 | # Alias for the update() method. 86 | # @param [Number] value Value to set for the gauge. 87 | push: (value) -> 88 | @update value 89 | 90 | # Responds to a model's 'data:push' event. 91 | pushFromModel: -> 92 | next = @options.model.getNext(@options.type, @options.dataFormat) 93 | @update next 94 | 95 | # @return [Number] The radius for the gauge. 96 | radius: -> @getHeight() / 1.58 97 | 98 | # @return [Number] The center position x-coordinate for the gauge. 99 | centerX: -> @getWidth() / 2 100 | 101 | # @return [Number] The center position y-coordinate for the gauge. 102 | centerY: -> 0.68 * @getHeight() 103 | 104 | # @return [Number] The x-coordinate for the gauge text display. 105 | textX: -> @width / 2 106 | 107 | # @return [Number] The y-coordinate for the gauge text display. 108 | textY: -> 0.48 * @height 109 | 110 | # @return [Number] The angle to set for the needle given a value within the domain. 111 | # @param [Number] value Value to translate into a needle angle. 112 | getAngle: (value) -> 113 | [a, b] = @options.domain 114 | ((value - a) / (b - a)) * (Math.PI + 2*Math.PI/8) - Math.PI/2 - Math.PI/8 115 | 116 | # Sets context styles given a particular selector. 117 | # @param [String] selector The selector to use when setting the styles. 118 | setStyles: (selector) -> 119 | styles = @getStyles selector 120 | @ctx.fillStyle = styles.fill 121 | @ctx.strokeStyle = styles.stroke 122 | @ctx.lineWidth = styles['stroke-width'].replace('px', '') if styles['stroke-width']? 123 | 124 | # Draws the gauge. 125 | draw: -> 126 | [cx, cy, r] = [@centerX(), @centerY(), @radius()] 127 | [tickOffset, tickSize] = [@options.tickOffset, @options.tickSize] 128 | 129 | @clear() 130 | 131 | # Draw Ticks 132 | t = d3.scale.linear() 133 | .domain([0, @options.ticks]) 134 | .range([ -(9/8)*Math.PI, Math.PI/8 ]) 135 | 136 | @setStyles '.epoch .gauge .tick' 137 | @ctx.beginPath() 138 | for i in [0..@options.ticks] 139 | a = t(i) 140 | [c, s] = [Math.cos(a), Math.sin(a)] 141 | 142 | x1 = c * (r-tickOffset) + cx 143 | y1 = s * (r-tickOffset) + cy 144 | x2 = c * (r-tickOffset-tickSize) + cx 145 | y2 = s * (r-tickOffset-tickSize) + cy 146 | 147 | @ctx.moveTo x1, y1 148 | @ctx.lineTo x2, y2 149 | 150 | @ctx.stroke() 151 | 152 | # Outer arc 153 | @setStyles '.epoch .gauge .arc.outer' 154 | @ctx.beginPath() 155 | @ctx.arc cx, cy, r, -(9/8)*Math.PI, (1/8)*Math.PI, false 156 | @ctx.stroke() 157 | 158 | # Inner arc 159 | @setStyles '.epoch .gauge .arc.inner' 160 | @ctx.beginPath() 161 | @ctx.arc cx, cy, r-10, -(9/8)*Math.PI, (1/8)*Math.PI, false 162 | @ctx.stroke() 163 | 164 | @drawNeedle() 165 | 166 | super() 167 | 168 | # Draws the needle. 169 | drawNeedle: -> 170 | [cx, cy, r] = [@centerX(), @centerY(), @radius()] 171 | ratio = @value / @options.domain[1] 172 | 173 | @setStyles '.epoch .gauge .needle' 174 | @ctx.beginPath() 175 | @ctx.save() 176 | @ctx.translate cx, cy 177 | @ctx.rotate @getAngle(@value) 178 | 179 | @ctx.moveTo 4 * @pixelRatio, 0 180 | @ctx.lineTo -4 * @pixelRatio, 0 181 | @ctx.lineTo -1 * @pixelRatio, 19-r 182 | @ctx.lineTo 1, 19-r 183 | @ctx.fill() 184 | 185 | @setStyles '.epoch .gauge .needle-base' 186 | @ctx.beginPath() 187 | @ctx.arc 0, 0, (@getWidth() / 25), 0, 2*Math.PI 188 | @ctx.fill() 189 | 190 | @ctx.restore() 191 | 192 | # Correctly responds to an option: 193 | domainChanged: -> @draw() 194 | 195 | # Correctly responds to an option: 196 | ticksChanged: -> @draw() 197 | 198 | # Correctly responds to an option: 199 | tickSizeChanged: -> @draw() 200 | 201 | # Correctly responds to an option: 202 | tickOffsetChanged: -> @draw() 203 | 204 | # Correctly responds to an option: 205 | formatChanged: -> @svg.select('text.value').text(@options.format(@value)) 206 | 207 | 208 | 209 | # "The mother of a million sons... CIVILIZATION!" -- Justice 210 | -------------------------------------------------------------------------------- /src/time/heatmap.coffee: -------------------------------------------------------------------------------- 1 | 2 | # Real-time Heatmap Implementation. 3 | class Epoch.Time.Heatmap extends Epoch.Time.Plot 4 | defaults = 5 | type: 'time.heatmap' 6 | buckets: 10 7 | bucketRange: [0, 100] 8 | opacity: 'linear' 9 | bucketPadding: 2 10 | paintZeroValues: false 11 | cutOutliers: false 12 | 13 | # Easy to use "named" color functions 14 | colorFunctions = 15 | root: (value, max) -> Math.pow(value/max, 0.5) 16 | linear: (value, max) -> value / max 17 | quadratic: (value, max) -> Math.pow(value/max, 2) 18 | cubic: (value, max) -> Math.pow(value/max, 3) 19 | quartic: (value, max) -> Math.pow(value/max, 4) 20 | quintic: (value, max) -> Math.pow(value/max, 5) 21 | 22 | optionListeners = 23 | 'option:buckets': 'bucketsChanged' 24 | 'option:bucketRange': 'bucketRangeChanged' 25 | 'option:opacity': 'opacityChanged' 26 | 'option:bucketPadding': 'bucketPaddingChanged' 27 | 'option:paintZeroValues': 'paintZeroValuesChanged' 28 | 'option:cutOutliers': 'cutOutliersChanged' 29 | 30 | # Creates a new heatmap. 31 | # @param [Object] options Options for the heatmap. 32 | # @option options [Integer] buckets Number of vertical buckets to use when normalizing the 33 | # incoming histogram data for visualization in the heatmap (default: 10). 34 | # @option options [Array] bucketRange A range of acceptable values to be bucketed (default: [0, 100]). 35 | # @option options [String, Function] opacity The opacity coloring function to use when rendering buckets 36 | # in a column. The built-in functions (referenced by string) are: 'root', 'linear', 'quadratic', 'cubic', 37 | # 'quartic', and 'quintic'. A custom function can be supplied given it accepts two parameters (value, max) 38 | # and returns a numeric value from 0 to 1. Default: linear. 39 | # @option options [Number] bucketPadding Amount of padding to apply around buckets (default: 2). 40 | constructor: (@options={}) -> 41 | super(@options = Epoch.Util.defaults(@options, defaults)) 42 | @_setOpacityFunction() 43 | @_setupPaintCanvas() 44 | @onAll optionListeners 45 | @draw() 46 | 47 | _setOpacityFunction: -> 48 | if Epoch.isString(@options.opacity) 49 | @_opacityFn = colorFunctions[@options.opacity] 50 | Epoch.exception "Unknown coloring function provided '#{@options.opacity}'" unless @_opacityFn? 51 | else if Epoch.isFunction(@options.opacity) 52 | @_opacityFn = @options.opacity 53 | else 54 | Epoch.exception "Unknown type for provided coloring function." 55 | 56 | # Prepares initially set data for rendering. 57 | # @param [Array] data Layered histogram data for the visualization. 58 | setData: (data) -> 59 | super(data) 60 | for layer in @data 61 | layer.values = layer.values.map((entry) => @_prepareEntry(entry)) 62 | 63 | # Distributes the full histogram in the entry into the defined buckets 64 | # for the visualization. 65 | # @param [Object] entry Entry to prepare for visualization. 66 | _getBuckets: (entry) -> 67 | prepared = 68 | time: entry.time 69 | max: 0 70 | buckets: (0 for i in [0...@options.buckets]) 71 | 72 | # Bucket size = (Range[1] - Range[0]) / number of buckets 73 | bucketSize = (@options.bucketRange[1] - @options.bucketRange[0]) / @options.buckets 74 | 75 | for own value, count of entry.histogram 76 | index = parseInt((value - @options.bucketRange[0]) / bucketSize) 77 | 78 | # Remove outliers from the preprared buckets if instructed to do so 79 | if @options.cutOutliers and ((index < 0) or (index >= @options.buckets)) 80 | continue 81 | 82 | # Bound the histogram to the range (aka, handle out of bounds values) 83 | if index < 0 84 | index = 0 85 | else if index >= @options.buckets 86 | index = @options.buckets - 1 87 | 88 | prepared.buckets[index] += parseInt count 89 | 90 | for i in [0...prepared.buckets.length] 91 | prepared.max = Math.max(prepared.max, prepared.buckets[i]) 92 | 93 | return prepared 94 | 95 | # @return [Function] The y scale for the heatmap. 96 | y: -> 97 | d3.scale.linear() 98 | .domain(@options.bucketRange) 99 | .range([@innerHeight(), 0]) 100 | 101 | # @return [Function] The y scale for the svg portions of the heatmap. 102 | ySvg: -> 103 | d3.scale.linear() 104 | .domain(@options.bucketRange) 105 | .range([@innerHeight() / @pixelRatio, 0]) 106 | 107 | # @return [Number] The height to render each bucket in a column (disregards padding). 108 | h: -> 109 | @innerHeight() / @options.buckets 110 | 111 | # @return [Number] The offset needed to center ticks at the middle of each column. 112 | _offsetX: -> 113 | 0.5 * @w() / @pixelRatio 114 | 115 | # Creates the painting canvas which is used to perform all the actual drawing. The contents 116 | # of the canvas are then copied into the actual display canvas and through some image copy 117 | # trickery at the end of a transition the illusion of motion over time is preserved. 118 | # 119 | # Using two canvases in this way allows us to render an incredible number of buckets in the 120 | # visualization and animate them at high frame rates without smashing the cpu. 121 | _setupPaintCanvas: -> 122 | # Size the paint canvas to have a couple extra columns so we can perform smooth transitions 123 | @paintWidth = (@options.windowSize + 1) * @w() 124 | @paintHeight = @height * @pixelRatio 125 | 126 | # Create the "memory only" canvas and nab the drawing context 127 | @paint = document.createElement('CANVAS') 128 | @paint.width = @paintWidth 129 | @paint.height = @paintHeight 130 | @p = Epoch.Util.getContext @paint 131 | 132 | # Paint the initial data (rendering backwards from just before the fixed paint position) 133 | @redraw() 134 | 135 | # Hook into the events to paint the next row after it's been shifted into the data 136 | @on 'after:shift', '_paintEntry' 137 | 138 | # At the end of a transition we must reset the paint canvas by shifting the viewable 139 | # buckets to the left (this allows for a fixed cut point and single renders below in @draw) 140 | @on 'transition:end', '_shiftPaintCanvas' 141 | @on 'transition:end', => @draw(@animation.frame * @animation.delta()) 142 | 143 | # Redraws the entire heatmap for the current data. 144 | redraw: -> 145 | return unless Epoch.isNonEmptyArray(@data) and Epoch.isNonEmptyArray(@data[0].values) 146 | entryIndex = @data[0].values.length 147 | drawColumn = @options.windowSize 148 | 149 | # This addresses a strange off-by-one issue when the chart is transitioning 150 | drawColumn++ if @inTransition() 151 | 152 | while (--entryIndex >= 0) and (--drawColumn >= 0) 153 | @_paintEntry(entryIndex, drawColumn) 154 | @draw(@animation.frame * @animation.delta()) 155 | 156 | # Computes the correct color for a given bucket. 157 | # @param [Integer] value Normalized value at the bucket. 158 | # @param [Integer] max Normalized maximum for the column. 159 | # @param [String] color Computed base color for the bucket. 160 | _computeColor: (value, max, color) -> 161 | Epoch.Util.toRGBA(color, @_opacityFn(value, max)) 162 | 163 | # Paints a single entry column on the paint canvas at the given column. 164 | # @param [Integer] entryIndex Index of the entry to paint. 165 | # @param [Integer] drawColumn Column on the paint canvas to place the visualized entry. 166 | _paintEntry: (entryIndex=null, drawColumn=null) -> 167 | [w, h] = [@w(), @h()] 168 | 169 | entryIndex ?= @data[0].values.length - 1 170 | drawColumn ?= @options.windowSize 171 | 172 | entries = [] 173 | bucketTotals = (0 for i in [0...@options.buckets]) 174 | maxTotal = 0 175 | 176 | for layer in @getVisibleLayers() 177 | entry = @_getBuckets( layer.values[entryIndex] ) 178 | for own bucket, count of entry.buckets 179 | bucketTotals[bucket] += count 180 | maxTotal += entry.max 181 | styles = @getStyles ".#{layer.className.split(' ').join('.')} rect.bucket" 182 | entry.color = styles.fill 183 | entries.push entry 184 | 185 | xPos = drawColumn * w 186 | 187 | @p.clearRect xPos, 0, w, @paintHeight 188 | 189 | j = @options.buckets 190 | 191 | for own bucket, sum of bucketTotals 192 | color = @_avgLab(entries, bucket) 193 | max = 0 194 | for entry in entries 195 | max += (entry.buckets[bucket] / sum) * maxTotal 196 | if sum > 0 or @options.paintZeroValues 197 | @p.fillStyle = @_computeColor(sum, max, color) 198 | @p.fillRect xPos, (j-1) * h, w-@options.bucketPadding, h-@options.bucketPadding 199 | j-- 200 | 201 | # This shifts the image contents of the paint canvas to the left by 1 column width. 202 | # It is called after a transition has ended (yay, slight of hand). 203 | _shiftPaintCanvas: -> 204 | data = @p.getImageData @w(), 0, @paintWidth-@w(), @paintHeight 205 | @p.putImageData data, 0, 0 206 | 207 | # Performs an averaging of the colors for muli-layer heatmaps using the lab color space. 208 | # @param [Array] entries The layers for which the colors are to be averaged. 209 | # @param [Number] bucket The bucket in the entries that must be averaged. 210 | # @return [String] The css color code for the average of all the layer colors. 211 | _avgLab: (entries, bucket) -> 212 | [l, a, b, total] = [0, 0, 0, 0] 213 | for entry in entries 214 | continue unless entry.buckets[bucket]? 215 | total += entry.buckets[bucket] 216 | 217 | for own i, entry of entries 218 | if entry.buckets[bucket]? 219 | value = entry.buckets[bucket]|0 220 | else 221 | value = 0 222 | ratio = value / total 223 | color = d3.lab(entry.color) 224 | l += ratio * color.l 225 | a += ratio * color.a 226 | b += ratio * color.b 227 | 228 | d3.lab(l, a, b).toString() 229 | 230 | # Copies the paint canvas onto the display canvas, thus rendering the heatmap. 231 | draw: (delta=0) -> 232 | @clear() 233 | @ctx.drawImage @paint, delta, 0 234 | super() 235 | 236 | # Changes the number of buckets in response to an option:buckets event. 237 | bucketsChanged: -> @redraw() 238 | 239 | # Changes the range of the buckets in response to an option:bucketRange event. 240 | bucketRangeChanged: -> 241 | @_transitionRangeAxes() 242 | @redraw() 243 | 244 | # Changes the opacity function in response to an option:opacity event. 245 | opacityChanged: -> 246 | @_setOpacityFunction() 247 | @redraw() 248 | 249 | # Changes the bucket padding in response to an option:bucketPadding event. 250 | bucketPaddingChanged: -> @redraw() 251 | 252 | # Changes whether or not to paint zeros in response to an option:paintZeroValues event. 253 | paintZeroValuesChanged: -> @redraw() 254 | 255 | # Changes whether or not to cut outliers when bucketing in response to an 256 | # option:cutOutliers event. 257 | cutOutliersChanged: -> @redraw() 258 | 259 | layerChanged: -> @redraw() 260 | 261 | # "Audio... Audio... Audio... Video Disco..." - Justice 262 | -------------------------------------------------------------------------------- /src/time/line.coffee: -------------------------------------------------------------------------------- 1 | 2 | # Real-time line chart implementation 3 | class Epoch.Time.Line extends Epoch.Time.Plot 4 | constructor: (@options={}) -> 5 | @options.type ?= 'time.line' 6 | super(@options) 7 | @draw() 8 | 9 | # Sets the graphics context styles based ont he given layer class name. 10 | # @param [String] className The class name of the layer for which to set the styles. 11 | setStyles: (className) -> 12 | styles = @getStyles "g.#{className.replace(/\s/g,'.')} path.line" 13 | @ctx.fillStyle = styles.fill 14 | @ctx.strokeStyle = styles.stroke 15 | @ctx.lineWidth = @pixelRatio * styles['stroke-width'].replace('px', '') 16 | 17 | # Draws the line chart. 18 | draw: (delta=0) -> 19 | @clear() 20 | w = @w() 21 | for layer in @getVisibleLayers() 22 | continue unless Epoch.isNonEmptyArray(layer.values) 23 | @setStyles(layer.className) 24 | @ctx.beginPath() 25 | y = @y(layer.range) 26 | [i, k, trans] = [@options.windowSize, layer.values.length, @inTransition()] 27 | 28 | while (--i >= -2) and (--k >= 0) 29 | entry = layer.values[k] 30 | args = [(i+1)*w+delta, y(entry.y)] 31 | args[0] += w if trans 32 | if i == @options.windowSize - 1 33 | @ctx.moveTo.apply @ctx, args 34 | else 35 | @ctx.lineTo.apply @ctx, args 36 | 37 | @ctx.stroke() 38 | 39 | super() 40 | -------------------------------------------------------------------------------- /tests/render/basic/histogram.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 24 | 25 | 26 | 27 | 28 |

Basic Bar Chart Test

29 | 30 | 31 |
    32 |
  1. Beta(2, 5)
  2. 33 |
  3. Beta(2, 5) Horizontal
  4. 34 |
  5. Option: buckets
  6. 35 |
  7. Options: bucketRange & cutOutliers
  8. 36 |
37 | 38 | 39 |
40 |

1. Beta(2, 5)

41 |

Plot a random selection of points from the Beta(2, 5) distribution as a histogram.

42 |
43 |

44 |
45 | 58 | 59 | 60 |
61 |

2. Beta(2, 5) Horizontal

62 |

Plot a random selection of points from Beta(2, 5) and display in a horizontal histogram.

63 |
64 |

65 |
66 | 80 | 81 | 82 |
83 |

3. Option: buckets

84 |

Plot Beta(2, 2) and change number of buckets on the fly.

85 |
86 |

87 | 88 | 89 | 90 | 91 | | 92 | 93 |

94 |
95 | 113 | 114 | 115 |
116 |

4. Options: bucketRange & cutOutliers

117 |

Plot beta(2, 5) and change the bucket range on the fly.

118 |
119 |

120 | 121 | 122 | | 123 | 124 | | 125 | 126 |

127 |
128 | 154 | 155 | 156 | -------------------------------------------------------------------------------- /tests/render/basic/model.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 21 | 22 | 23 |

Basic Chart Model / Data Test

24 | 25 | 26 |

27 | 28 | 29 | 30 |

31 | 32 |
33 |
34 |
35 | 36 | 93 | 94 | -------------------------------------------------------------------------------- /tests/render/basic/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 16 |

Basic Chart Options and Events

17 | 18 |
    19 |
  1. Axes
  2. 20 |
  3. Margins
  4. 21 |
  5. Ticks and Tick Formats
  6. 22 |
  7. Resize
  8. 23 |
  9. Domain
  10. 24 |
  11. Range
  12. 25 |
26 | 27 | 28 |
29 |

1. Axes

30 |

31 | Correctly add and remove axes when options are set. 32 |

33 |
34 |
35 | 36 | 37 | 38 | 39 | 40 | 41 |
42 |
43 | 64 | 65 | 66 | 67 |
68 |

2. Margins

69 |

70 | Correctly resize margins when options are set. 71 |

72 |
73 |
74 | 75 | 76 | 77 |
78 |
79 | 102 | 103 | 104 |
105 |

3. Ticks and Tick Formats

106 |

107 | Correctly resize margins when options are set. 108 |

109 |
110 |
111 | 112 | 113 |
114 |
115 | 174 | 175 | 176 |
177 |

4. Resize

178 |

179 | Correctly resize the chart when the width and height options are set. 180 |

181 |
182 |
183 | 184 | 185 |
186 |
187 | 206 | 207 | 208 |
209 |

6. Option: domain

210 |
211 |

212 | 213 | 214 |

215 |
216 | 234 | 235 | 236 | 237 |
238 |

7. Option: range

239 |
240 |

241 | 242 | 243 |

244 |
245 | 265 | 266 | 267 | -------------------------------------------------------------------------------- /tests/render/basic/pie.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 15 | 16 | 17 |

Basic Pie Chart Test

18 | 19 |
    20 |
  1. Basic Pie Test
  2. 21 |
  3. Basic Donut Test
  4. 22 |
  5. Pie Tranisition I
  6. 23 |
  7. Pie Tranisition II
  8. 24 |
  9. Color Override
  10. 25 |
  11. Categorical Colors
  12. 26 |
  13. Pie Chart Layers without Labels
  14. 27 |
  15. Margin Changes
  16. 28 |
  17. Inner Changes
  18. 29 |
  19. Show/Hide Layers
  20. 30 |
31 | 32 | 33 |
34 |

Basic Pie Test

35 |

36 | Correctly render a pie chart with three categories: 37 |

42 |

43 |
44 |
45 | 46 | 58 | 59 | 60 |
61 |

2. Basic Donut Test

62 |

63 | Correctly render a donut chart with three categories: 64 |

69 |

70 |
71 |
72 | 73 | 86 | 87 | 88 |
89 |

3. Pie Tranisition I

90 |

91 | Correctly transition between set A: 92 |

96 | and set B: 97 | 102 | Use the buttons below the chart to initiate the transitions. 103 |

104 |
105 |

106 | 107 | 108 |

109 |
110 | 111 | 131 | 132 | 133 |
134 |

4. Pie Tranisition II

135 |

136 | Correctly transition between set A: 137 |

141 | and set B: 142 | 146 | Use the buttons below the chart to initiate the transitions. 147 |

148 |
149 |

150 | 151 | 152 |

153 |
154 | 155 | 174 | 175 | 176 |
177 |

5. Color Override

178 |

179 | Override the colors as such: 180 |

185 |

186 |
187 |
188 | 189 | 194 | 195 | 207 | 208 | 209 |
210 |

6. Categorical Colors

211 |

212 | Correctly transition between different categorical colors sets. 213 |

214 |
215 |

216 | 217 | 218 | 219 | 220 |

221 |
222 | 223 | 224 | 246 | 247 | 248 | 249 |
250 |

7. Pie Chart Layers without Labels

251 |

252 | Correctly render a pie chart with three categories: 253 |

258 | when the layers are not provided labels. 259 |

260 |
261 |
262 | 263 | 271 | 272 | 273 |
274 |

8. Margin Changes

275 |
276 |

277 | 278 | 279 |

280 |
281 | 294 | 295 | 296 |
297 |

9. Inner Changes

298 |
299 |

300 | 301 | 302 | 303 |

304 |
305 | 318 | 319 | 320 |
321 |

10. Show/Hide Layers

322 |
323 |

324 | 325 | 326 | 327 |

328 |
329 | 345 | 346 | -------------------------------------------------------------------------------- /tests/render/css/tests.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: helvetica; 3 | } 4 | 5 | /* Default test chart height of 220px. */ 6 | .test .epoch { 7 | height: 220px; 8 | } 9 | 10 | .broken { 11 | color: red; 12 | } 13 | -------------------------------------------------------------------------------- /tests/render/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Epoch - Chart Rendering Tests 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |

Epoch Chart Rendering Tests

13 | 14 |

Basic Charts

15 |

16 |

27 |

28 | 29 |

Real-time Charts

30 |

31 |

39 |

40 | 41 |

Themes

42 |

43 |

47 |

48 | 49 |

Data / Models

50 |

51 |

55 |

56 | 57 | 58 | -------------------------------------------------------------------------------- /tests/render/js/data.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | // Quick data generator 4 | Data = function() { 5 | this.layers = [] 6 | }; 7 | 8 | Data.prototype.add = function(fn) { 9 | fn = fn ? fn : function(x) { return x; }; 10 | this.layers.push(fn); 11 | return this; 12 | }; 13 | 14 | Data.prototype.get = function(domain, step) { 15 | domain = domain ? domain : [0, 10]; 16 | step = step ? step : 1; 17 | 18 | var data = [] 19 | for (var i = 0; i < this.layers.length; i++) { 20 | layer = { label: String.fromCharCode(i + 65), values: [] }; 21 | for (var x = domain[0]; x < domain[1]; x += step) { 22 | layer.values.push({ x: x, y: this.layers[i](x) }); 23 | } 24 | data.push(layer); 25 | } 26 | return data; 27 | }; 28 | 29 | Data.prototype.random = function(entries, domain, range) { 30 | entries = entries ? entries : 50; 31 | domain = domain ? domain : [0, 100]; 32 | range = range ? range : [0, 100]; 33 | 34 | var values = []; 35 | for (var i = 0; i < entries; i++) { 36 | var x = (domain[1] - domain[0]) * Math.random() + domain[0], 37 | y = (range[1] - range[0]) * Math.random() + range[1]; 38 | values.push({ x: x, y: y }); 39 | } 40 | 41 | return [{ label: 'A', values: values }]; 42 | }; 43 | 44 | Data.prototype.multiRandom = function(numSeries, entries, domain, range) { 45 | numSeries = numSeries ? numSeries : 3; 46 | entries = entries ? entries : 50; 47 | domain = domain ? domain : [0, 100]; 48 | range = range ? range : [0, 100]; 49 | 50 | var data = []; 51 | 52 | for (var j = 0; j < numSeries; j++) { 53 | var layer = { label: String.fromCharCode(65 + j), values: [] }; 54 | for (var i = 0; i < entries; i++) { 55 | var x = (domain[1] - domain[0]) * Math.random() + domain[0], 56 | y = (range[1] - range[0]) * Math.random() + range[1]; 57 | layer.values.push({ x: x, y: y }); 58 | } 59 | data.push(layer); 60 | } 61 | 62 | return data; 63 | }; 64 | 65 | window.data = function() { return new Data(); }; 66 | 67 | 68 | // Quick real-time data generator 69 | Time = function() { 70 | Data.call(this); 71 | }; 72 | 73 | Time.prototype = new Data() 74 | 75 | Time.prototype.get = function(domain, step) { 76 | var data = Data.prototype.get.apply(this, arguments), 77 | time = parseInt(new Date().getTime() / 1000); 78 | 79 | for (var i = 0; i < data[0].values.length; i++) { 80 | for (var j = 0; j < this.layers.length; j++) { 81 | delete data[j].values[i].x; 82 | data[j].values[i].time = time + i; 83 | } 84 | } 85 | 86 | this.currentTime = time; 87 | this.lastX = domain[1]; 88 | 89 | return data; 90 | }; 91 | 92 | Time.prototype.next = function(step) { 93 | this.currentTime++; 94 | this.lastX += (step ? step : 1); 95 | 96 | var data = []; 97 | for (var j = 0; j < this.layers.length; j++) { 98 | data.push({ time: this.currentTime, y: this.layers[j](this.lastX) }) 99 | } 100 | 101 | return data; 102 | } 103 | 104 | window.time = function() { return new Time(); }; 105 | 106 | 107 | 108 | 109 | window.nextTime = (function() { 110 | var currentTime = parseInt(new Date().getTime() / 1000); 111 | return function() { return currentTime++; } 112 | })(); 113 | 114 | 115 | /* 116 | * Normal distribution random histogram data generator. 117 | */ 118 | var NormalData = function(layers) { 119 | this.layers = layers; 120 | this.timestamp = ((new Date()).getTime() / 1000)|0; 121 | }; 122 | 123 | var normal = function() { 124 | var U = Math.random(), 125 | V = Math.random(); 126 | return Math.sqrt(-2*Math.log(U)) * Math.cos(2*Math.PI*V); 127 | }; 128 | 129 | NormalData.prototype.sample = function() { 130 | return parseInt(normal() * 12.5 + 50); 131 | } 132 | 133 | NormalData.prototype.rand = function() { 134 | var histogram = {}; 135 | 136 | for (var i = 0; i < 1000; i ++) { 137 | var r = this.sample(); 138 | if (!histogram[r]) { 139 | histogram[r] = 1; 140 | } 141 | else { 142 | histogram[r]++; 143 | } 144 | } 145 | 146 | return histogram; 147 | }; 148 | 149 | NormalData.prototype.history = function(entries) { 150 | if (typeof(entries) != 'number' || !entries) { 151 | entries = 60; 152 | } 153 | 154 | var history = []; 155 | for (var k = 0; k < this.layers; k++) { 156 | history.push({ label: String.fromCharCode(65+k), values: [] }); 157 | } 158 | 159 | for (var i = 0; i < entries; i++) { 160 | for (var j = 0; j < this.layers; j++) { 161 | history[j].values.push({time: this.timestamp, histogram: this.rand()}); 162 | } 163 | this.timestamp++; 164 | } 165 | 166 | return history; 167 | }; 168 | 169 | NormalData.prototype.next = function() { 170 | var entry = []; 171 | for (var i = 0; i < this.layers; i++) { 172 | entry.push({ time: this.timestamp, histogram: this.rand() }); 173 | } 174 | this.timestamp++; 175 | return entry; 176 | } 177 | 178 | window.NormalData = NormalData; 179 | 180 | 181 | /* 182 | * Beta distribution histogram data generator. 183 | */ 184 | var BetaData = function(alpha, beta, layers) { 185 | this.alpha = alpha; 186 | this.beta = beta; 187 | this.layers = layers; 188 | this.timestamp = ((new Date()).getTime() / 1000)|0; 189 | }; 190 | 191 | BetaData.prototype = new NormalData(); 192 | 193 | BetaData.prototype.sample = function() { 194 | var X = 0, 195 | Y = 0; 196 | 197 | for (var j = 1; j <= this.alpha; j++) 198 | X += -Math.log(1 - Math.random()); 199 | 200 | for (var j = 1; j <= this.beta; j++) 201 | Y += -Math.log(1 - Math.random()); 202 | 203 | return parseInt(100 * X / (X + Y)); 204 | } 205 | 206 | window.BetaData = BetaData; 207 | 208 | })(); 209 | 210 | 211 | -------------------------------------------------------------------------------- /tests/render/real-time/gauge.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 16 | 17 | 18 |

Real-time Gauge Test

19 | 20 |
    21 |
  1. Single Value Display
  2. 22 |
  3. Single Transition
  4. 23 |
  5. Stream Transition
  6. 24 |
  7. Gauge Sizes
  8. 25 |
  9. Color Override
  10. 26 |
  11. Option: domain
  12. 27 |
  13. Option: ticks
  14. 28 |
  15. Option: tickSize
  16. 29 |
  17. Option: tickOffset
  18. 30 |
  19. Option: format
  20. 31 |
32 | 33 | 34 |
35 |

1. Single Value Display

36 |

Display a single value of 25%

37 |
38 |
39 | 44 | 45 | 46 | 47 |
48 |

2. Single Transition

49 |

Display value of 0% and transition to a random value when the button is pressed.

50 |
51 |

52 |
53 | 65 | 66 | 67 | 68 |
69 |

3. Stream Transition

70 |

Display value of 0% and transition to a random value every second when the button is pressed.

71 |
72 |

73 |
74 | 99 | 100 | 101 |
102 |

4. Gauge Sizes

103 |

Display the four built-in gauge sizes in this order: tiny, small, medium, large.

104 |
105 |
106 |
107 |
108 |
109 | 117 | 118 | 119 |
120 |

5. Color Override

121 |

122 | Override the basic gauge styles with the following 123 |

130 |

131 |
132 |
133 | 134 | 158 | 159 | 164 | 165 | 168 | 216 | 217 | 218 | 219 |
220 |

6. Option: domain

221 |
222 |

223 | | 224 | 225 | 226 |

227 |
228 | 229 | 230 | 231 |
232 |

7. Option: ticks

233 |
234 |

235 | | 236 | 237 | 238 | 239 | 240 |

241 |
242 | 243 | 244 | 245 |
246 |

8. Option: tickSize

247 |
248 |

249 | | 250 | 251 | 252 | 253 | 254 |

255 |
256 | 257 | 258 | 259 |
260 |

9. Option: tickOffset

261 |
262 |

263 | | 264 | 265 | 266 | 267 |

268 |
269 | 270 | 271 | 272 |
273 |

10. Option: format

274 |
275 |

276 | | 277 | 278 | 279 |

280 |
281 | 282 | 283 | 284 | -------------------------------------------------------------------------------- /tests/render/real-time/model.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 21 | 22 | 23 |

Real-time Chart Model / Data Test

24 | 25 | 26 |

27 | 28 |
29 |
30 |
31 |
32 | 33 | 84 | 85 | -------------------------------------------------------------------------------- /tests/render/real-time/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 16 |

Real-time Chart Options and Events

17 | 18 |
    19 |
  1. Resize
  2. 20 |
  3. Axes
  4. 21 |
  5. Ticks
  6. 22 |
  7. Tick Formats
  8. 23 |
  9. Margins
  10. 24 |
25 | 26 | 27 |
28 |

1. Resize

29 |

Correctly Resize a Real-time Chart.

30 |
31 |

32 | | 33 | 34 | 35 | 36 |

37 |
38 | 79 | 80 | 81 |
82 |

2. Axes

83 |
84 |
85 | | 86 | 87 | 88 | 89 | 90 | 91 | 92 |
93 |
94 | 136 | 137 | 138 |
139 |

3. Ticks

140 |
141 |

142 | | 143 | 144 | 145 |

146 |
147 | 186 | 187 | 188 |
189 |

4. Tick Formats

190 |
191 |

192 | | 193 | 194 | 195 |

196 |
197 | 246 | 247 | 248 |
249 |

5. Margins

250 |
251 |

252 | | 253 | 254 | 255 | 256 |

257 |
258 | 298 | 299 | -------------------------------------------------------------------------------- /tests/render/themes/dark.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 31 | 32 | 61 | 62 | 63 |

Dark Theme

64 | 65 |

Category 10

66 |
67 |
68 | 69 |
70 | 71 |
72 | 77 | 78 | 79 | 80 |

Category 20

81 |
82 |
83 |
84 |
85 |
86 | 87 |
88 | 89 |
90 | 95 | 96 | 97 |

Category 20b

98 |
99 |
100 |
101 |
102 |
103 | 104 |
105 | 106 |
107 | 112 | 113 | 114 |

Category 20c

115 |
116 |
117 |
118 |
119 |
120 | 121 |
122 | 123 |
124 | 129 | 130 |

Chart Examples

131 | 132 |
133 | 138 | 139 |
140 | 156 | 157 | 158 |
159 | 181 | 182 |
183 | 198 | 199 | 200 | 210 | 211 | -------------------------------------------------------------------------------- /tests/render/themes/default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 27 | 28 | 38 | 39 | 40 | 41 |

Default Theme

42 | 43 |

Category 10

44 |
45 |
46 | 47 |

Category 20

48 |
49 |
50 |
51 |
52 |
53 | 54 |

Category 20b

55 |
56 |
57 |
58 |
59 |
60 | 61 |

Category 20c

62 |
63 |
64 |
65 |
66 |
67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /tests/unit/core/copy.coffee: -------------------------------------------------------------------------------- 1 | describe 'Epoch.Util', -> 2 | describe 'copy', -> 3 | it 'should correctly create a shallow copy', -> 4 | object = 5 | a: 20 6 | b: 'hello' 7 | 8 | copy = Epoch.Util.copy(object) 9 | 10 | assert.equal copy.a, object.a 11 | assert.equal copy.b, object.b 12 | 13 | it 'should not recursively copy objects', -> 14 | object = 15 | a: 16 | foo: 'bar' 17 | 18 | copy = Epoch.Util.copy(object) 19 | object.a.foo = 'baz' 20 | assert.equal object.a.foo, copy.a.foo 21 | 22 | describe 'defaults', -> 23 | it 'should set default values when keys are missing', -> 24 | options = {a: 'foo', b: 'bar'} 25 | defaults = {c: 'baz'} 26 | result = Epoch.Util.defaults(options, defaults) 27 | assert.equal result.c, defaults.c 28 | 29 | it 'should not set default values when keys are present', -> 30 | options = { a: 'foo', b: 'bar' } 31 | defaults = { a: 'wow', b: 'neat' } 32 | result = Epoch.Util.defaults(options, defaults) 33 | assert.equal result.a, options.a 34 | assert.equal result.b, options.b 35 | 36 | it 'should recursively set defaults from sub objects', -> 37 | options = 38 | a: 39 | b: 'foo' 40 | defaults = 41 | a: 42 | b: '' 43 | c: 'bar' 44 | result = Epoch.Util.defaults(options, defaults) 45 | 46 | assert.equal result.a.b, options.a.b 47 | assert.equal result.a.c, defaults.a.c 48 | -------------------------------------------------------------------------------- /tests/unit/core/css.coffee: -------------------------------------------------------------------------------- 1 | describe 'Epoch.QueryCSS', -> 2 | styleMap = 3 | '#container rect': 4 | 'fill': 'blue' 5 | 'stroke': 'red' 6 | 'stroke-width': '5px' 7 | '#container rect.a': 8 | 'fill': 'green' 9 | 'stroke': 'yellow' 10 | 'stroke-width': '1px' 11 | 'rect#byid': 12 | 'fill': 'purple' 13 | 'stroke': '#94105A' 14 | 'stroke-width': '15px' 15 | 'body.alt-color rect#byid': 16 | 'fill': '#abcdef1' 17 | 'stroke': '#48419A' 18 | 'stroke-width': '2em' 19 | 20 | [container, svg, styleTag] = [null, null, null] 21 | 22 | makeStyleSheet = -> 23 | cssStatements = [] 24 | for selector, rules of styleMap 25 | cssStatements.push (selector + "{" + ("#{k}: #{v}" for k, v of rules).join(';') + "}") 26 | css = cssStatements.join('\n') 27 | styleTag = addStyleSheet(css) 28 | 29 | makeContainer = -> 30 | container = d3.select(doc.body).append('div') 31 | .attr('id', 'container') 32 | svg = container.append('svg') 33 | .attr('width', 10) 34 | .attr('height', 10) 35 | 36 | assertStyles = (object, selector) -> 37 | unless object? 38 | assert(false, "Object contains no styles") 39 | 40 | unless (mapping = styleMap[selector])? 41 | assert(false, "Could not find styles with selector: #{selector}") 42 | 43 | for key, value of mapping 44 | assert.equal object[key], value, "Style mismatch on rule '#{key}'" 45 | 46 | before (done) -> 47 | makeStyleSheet() 48 | makeContainer() 49 | done() 50 | 51 | after (done) -> 52 | doc.head.removeChild(styleTag) 53 | doc.body.removeChild(container.node()) 54 | done() 55 | 56 | describe 'getStyles', -> 57 | it 'should find styles for an svg element', -> 58 | styles = Epoch.QueryCSS.getStyles('rect', container) 59 | assertStyles styles, '#container rect' 60 | 61 | it 'should find styles using a specific class name', -> 62 | styles = Epoch.QueryCSS.getStyles('rect.a', container) 63 | assertStyles styles, '#container rect.a' 64 | 65 | it 'should find styles using an id', -> 66 | styles = Epoch.QueryCSS.getStyles('rect#byid', container) 67 | assertStyles styles, 'rect#byid' 68 | 69 | describe 'purge', -> 70 | before (done) -> 71 | d3.select(doc.body).attr('class', 'alt-color') 72 | done() 73 | 74 | after (done) -> 75 | d3.select(doc.body).attr('class', null) 76 | done() 77 | 78 | it 'should find cached styles before a purge', -> 79 | styles = Epoch.QueryCSS.getStyles('rect#byid', container) 80 | assertStyles styles, 'rect#byid' 81 | 82 | it 'should find new styles after purging the cache', -> 83 | Epoch.QueryCSS.purge() 84 | styles = Epoch.QueryCSS.getStyles('rect#byid', container) 85 | assertStyles styles, 'body.alt-color rect#byid' 86 | -------------------------------------------------------------------------------- /tests/unit/core/d3.coffee: -------------------------------------------------------------------------------- 1 | describe 'd3.selection', -> 2 | [width, height] = [345, 543, null] 3 | [element, id] = [null, 'd3-element'] 4 | 5 | before (done) -> 6 | element = doc.createElement('DIV') 7 | element.id = id 8 | doc.body.appendChild(element) 9 | d3.select('#' + id).style 10 | 'width': width + "px" 11 | 'height': height + "px" 12 | done() 13 | 14 | describe 'width', -> 15 | it 'should return the width of an element', -> 16 | assert.equal d3.select('#' + id).width(), width 17 | 18 | it 'should set the width of an element given a number', -> 19 | widthNumber = 50 20 | d3.select('#'+id).width(widthNumber) 21 | assert.equal d3.select('#'+id).width(), widthNumber 22 | 23 | it 'should set the width of an element given a css pixel length', -> 24 | widthString = '500px' 25 | d3.select('#'+id).width(widthString) 26 | assert.equal d3.select('#'+id).width(), +widthString.replace('px', '') 27 | 28 | describe 'height', -> 29 | it 'should return the height of an element', -> 30 | assert.equal d3.select('#' + id).height(), height 31 | 32 | it 'should set the height of an element given a number', -> 33 | heightNumber = 75 34 | d3.select('#'+id).height(heightNumber) 35 | assert.equal d3.select('#'+id).height(), heightNumber 36 | 37 | it 'should set the height of an element given a css pixel length', -> 38 | heightString = '343px' 39 | d3.select('#'+id).height(heightString) 40 | assert.equal d3.select('#'+id).height(), +heightString.replace('px', '') 41 | -------------------------------------------------------------------------------- /tests/unit/core/events.coffee: -------------------------------------------------------------------------------- 1 | describe 'Epoch.Events', -> 2 | eventsObject = null 3 | 4 | before (done) -> 5 | eventsObject = new Epoch.Events() 6 | done() 7 | 8 | it 'should execute callbacks when events are triggered', (done) -> 9 | errorCallback = -> 10 | assert false, 'Event callback never executed' 11 | done() 12 | timeout = setTimeout errorCallback, 1000 13 | eventsObject.on 'event', -> 14 | clearTimeout(timeout) 15 | done() 16 | eventsObject.trigger 'event' 17 | 18 | it 'should not execute callbacks that have been removed', (done) -> 19 | errorCallback = -> 20 | assert false, 'Event callback still executed' 21 | done() 22 | eventsObject.on 'example', errorCallback 23 | eventsObject.off 'example', errorCallback 24 | eventsObject.trigger 'example' 25 | done() 26 | 27 | it 'should execute all callbacks associated with an event name', (done) -> 28 | total = 4 29 | 30 | errorCallback = -> 31 | assert false, 'Not all callbacks were executed' 32 | done() 33 | timeout = setTimeout errorCallback, 1000 34 | 35 | makeCallback = -> -> 36 | total-- 37 | if total == 0 38 | clearTimeout(timeout) 39 | done() 40 | eventsObject.on('multi', makeCallback()) for i in [0...total] 41 | eventsObject.trigger 'multi' 42 | 43 | it 'should remove all callbacks when using .off([String])', (done) -> 44 | makeCallback = -> -> 45 | assert false, "A callback was still executed" 46 | done() 47 | eventsObject.on('multi2', makeCallback()) for i in [0...4] 48 | eventsObject.off('multi2') 49 | eventsObject.trigger('multi2') 50 | setTimeout (-> done()), 200 51 | 52 | it 'should execute methods on the object when using .on([String], [String])', (done) -> 53 | errorCallback = -> 54 | assert false, 'Trigger did not call the appropriate method.' 55 | done() 56 | timeout = setTimeout(errorCallback, 1000) 57 | 58 | eventsObject.method = -> 59 | clearTimeout(timeout) 60 | done() 61 | 62 | eventsObject.on 'method-event', 'method' 63 | eventsObject.trigger 'method-event' 64 | 65 | it 'should register all events when executing .onAll([Object])', (done) -> 66 | errorCallback = -> 67 | assert false, 'Not all events were triggered.' 68 | done() 69 | timeout = setTimeout(errorCallback, 1000) 70 | 71 | eventNames = ['multi:a', 'multi:b', 'multi:c', 'multi:d'] 72 | total = 0 73 | 74 | eventCallback = -> 75 | total += 1 76 | if total == eventNames.length 77 | clearTimeout(timeout) 78 | done() 79 | 80 | eventMap = {} 81 | eventMap[name] = eventCallback for name in eventNames 82 | 83 | eventsObject.onAll(eventMap) 84 | eventsObject.trigger(name) for name in eventNames 85 | 86 | 87 | it 'should remove all events when executing .offAll([Array])', -> 88 | eventCallback = -> 89 | assert false, 'A removed callback was still triggered.' 90 | 91 | eventNames = ['multi-off:a', 'multi-off:b', 'multi-off:c', 'multi-off:d'] 92 | eventMap = {} 93 | eventMap[name] = eventCallback for name in eventNames 94 | 95 | eventsObject.onAll(eventMap) 96 | eventsObject.offAll(eventNames) 97 | eventsObject.trigger(name) for name in eventNames 98 | 99 | 100 | it 'should remove specific event callbacks when executing .offAll([Object])', (done) -> 101 | makeEventCallback = -> -> 102 | assert false, 'A removed callback was still triggered.' 103 | 104 | eventNames = ['multi-off:a', 'multi-off:b', 'multi-off:c', 'multi-off:d'] 105 | eventMap = {} 106 | eventMap[name] = makeEventCallback() for name in eventNames 107 | 108 | eventsObject.onAll(eventMap) 109 | eventsObject.offAll(eventMap) 110 | eventsObject.trigger(name) for name in eventNames 111 | setTimeout (-> done()), 200 112 | -------------------------------------------------------------------------------- /tests/unit/core/format.coffee: -------------------------------------------------------------------------------- 1 | describe 'Epoch.Util', -> 2 | describe 'formatSI', -> 3 | it 'should produce the same number for integers < 1000', -> 4 | number = 678 5 | assert.equal Epoch.Util.formatSI(number), number 6 | 7 | it 'should only set a fixed decimal for integers when instructed', -> 8 | number = 20 9 | assert.equal Epoch.Util.formatSI(number), number 10 | assert.equal Epoch.Util.formatSI(number, 1, true), "#{number}.0" 11 | 12 | it 'should set the appropriate number of fixed digits', -> 13 | number = 3.1415 14 | for i in [1..5] 15 | match = Epoch.Util.formatSI(number, i).split('.')[1] 16 | assert.isNotNull match 17 | assert.isString match 18 | assert.equal match.length, i 19 | 20 | it 'should set the appropriate postfix based on the number\'s order of magnitude', -> 21 | for i, postfix of ['K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'] 22 | number = Math.pow(10, ((i|0)+1)*3) 23 | assert.equal Epoch.Util.formatSI(number), "1 #{postfix}" 24 | 25 | 26 | describe 'formatBytes', -> 27 | it 'should postfix numbers < 1024 with "B"', -> 28 | number = 512 29 | assert.equal Epoch.Util.formatBytes(number), "#{number} B" 30 | 31 | it 'should only set a fixed decimal for integers when instructed', -> 32 | assert.equal Epoch.Util.formatBytes(128), '128 B' 33 | assert.equal Epoch.Util.formatBytes(128, 1, true), '128.0 B' 34 | assert.equal Epoch.Util.formatBytes(1024), '1 KB' 35 | assert.equal Epoch.Util.formatBytes(1024, 1, true), '1.0 KB' 36 | 37 | it 'should set the appropriate number of fixed digits', -> 38 | number = 3.1415 39 | for i in [1..5] 40 | fixed = Epoch.Util.formatBytes(number, i).replace(/\sB$/, '') 41 | assert.isString fixed 42 | match = fixed.split('.')[1] 43 | assert.isNotNull match 44 | assert.equal match.length, i 45 | 46 | it 'should set the appropriate postfix based on the number\'s order of magnitude', -> 47 | for i, postfix of ['KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] 48 | number = Math.pow(1024, (i|0)+1) 49 | regexp = new RegExp(" #{postfix}$") 50 | assert.isNotNull Epoch.Util.formatBytes(number).match(regexp) 51 | 52 | 53 | describe 'Epoch.Formats', -> 54 | describe 'regular', -> 55 | it 'should return what it was given', -> 56 | assert.equal Epoch.Formats.regular(10), 10 57 | assert.equal Epoch.Formats.regular("hello"), "hello" 58 | 59 | describe 'percent', -> 60 | it 'should return a percent given a number', -> 61 | assert.equal Epoch.Formats.percent(0.1), '10.0%' 62 | assert.equal Epoch.Formats.percent(0.5), '50.0%' 63 | assert.equal Epoch.Formats.percent(1), '100.0%' 64 | assert.equal Epoch.Formats.percent(23.245), '2324.5%' 65 | 66 | describe 'seconds', -> 67 | it 'should return a well formatted date given a timestamp', -> 68 | assert.match Epoch.Formats.seconds(1404385979), /\d{2}:\d{2}:\d{2} AM|PM/ 69 | -------------------------------------------------------------------------------- /tests/unit/core/is.coffee: -------------------------------------------------------------------------------- 1 | describe 'Epoch.Util', -> 2 | describe 'isArray', -> 3 | it 'should return true if given an array', -> 4 | assert.ok Epoch.isArray([]) 5 | assert.ok Epoch.isArray([1, 2, 3]) 6 | 7 | it 'should return false if not given an array', -> 8 | assert.notOk Epoch.isArray(2) 9 | assert.notOk Epoch.isArray("hello") 10 | assert.notOk Epoch.isArray({}) 11 | 12 | describe 'isObject', -> 13 | it 'should return true if given an flat object', -> 14 | assert.ok Epoch.isObject({}) 15 | 16 | it 'should return false if given a number object', -> 17 | assert.notOk Epoch.isObject(new Number()) 18 | 19 | it 'should return false if given a non-object', -> 20 | assert.notOk Epoch.isObject([]) 21 | assert.notOk Epoch.isObject(2) 22 | assert.notOk Epoch.isObject("string") 23 | 24 | describe 'isString', -> 25 | it 'should return true if given a string', -> 26 | assert.ok Epoch.isString("example") 27 | assert.ok Epoch.isString(new String()) 28 | 29 | it 'should return false if given a non-string', -> 30 | assert.notOk Epoch.isString(2) 31 | assert.notOk Epoch.isString([]) 32 | assert.notOk Epoch.isString({}) 33 | 34 | describe 'isFunction', -> 35 | it 'should return true if given a function', -> 36 | assert.ok Epoch.isFunction(->) 37 | 38 | it 'should return false if given a non-function', -> 39 | assert.notOk Epoch.isFunction([]) 40 | assert.notOk Epoch.isFunction({}) 41 | assert.notOk Epoch.isFunction(42) 42 | assert.notOk Epoch.isFunction("cool") 43 | 44 | describe 'isNumber', -> 45 | it 'should return true if given a number', -> 46 | assert.ok Epoch.isNumber(new Number()) 47 | 48 | it 'should return true if given an integer literal', -> 49 | assert.ok Epoch.isNumber(1983) 50 | 51 | it 'should return true if given a floating point literal', -> 52 | assert.ok Epoch.isNumber(3.1415) 53 | 54 | it 'should return false if given a non-number', -> 55 | assert.notOk Epoch.isNumber(->) 56 | assert.notOk Epoch.isNumber([]) 57 | assert.notOk Epoch.isNumber({}) 58 | assert.notOk Epoch.isNumber("nan") 59 | 60 | describe 'isElement', -> 61 | it 'should return true given an html element', -> 62 | p = doc.createElement('P') 63 | assert.ok Epoch.isElement(p) 64 | 65 | it 'should return false given a non-element', -> 66 | assert.notOk Epoch.isElement(1) 67 | assert.notOk Epoch.isElement("1") 68 | assert.notOk Epoch.isElement({}) 69 | assert.notOk Epoch.isElement([]) 70 | assert.notOk Epoch.isElement(->) 71 | 72 | describe 'isNonEmptyArray', -> 73 | it 'should return true given a non-empty array', -> 74 | assert.ok Epoch.isNonEmptyArray([1]) 75 | assert.ok Epoch.isNonEmptyArray([1, 3]) 76 | assert.ok Epoch.isNonEmptyArray(["foo", 4, "bar"]) 77 | 78 | it 'should return false given a non-array', -> 79 | assert.notOk Epoch.isNonEmptyArray(2) 80 | assert.notOk Epoch.isNonEmptyArray("five") 81 | assert.notOk Epoch.isNonEmptyArray({}) 82 | assert.notOk Epoch.isNonEmptyArray(->) 83 | 84 | it 'should return false given a null value', -> 85 | assert.notOk Epoch.isNonEmptyArray(null) 86 | 87 | it 'should return false given an empty array', -> 88 | assert.notOk Epoch.isNonEmptyArray([]) 89 | -------------------------------------------------------------------------------- /tests/unit/core/util.coffee: -------------------------------------------------------------------------------- 1 | describe 'Epoch.Util', -> 2 | describe 'trim', -> 3 | it 'should return null unless given a string', -> 4 | assert.isNotNull Epoch.Util.trim('test string') 5 | assert.isNull Epoch.Util.trim(34) 6 | 7 | it 'should trim leading and trailing whitespace', -> 8 | assert.equal Epoch.Util.trim("\t\n\r indeed \n\t\t\r"), 'indeed' 9 | 10 | it 'should leave inner whitespace', -> 11 | assert.equal Epoch.Util.trim('Hello world'), 'Hello world' 12 | 13 | describe 'dasherize', -> 14 | it 'should dasherize regular strings', -> 15 | assert.equal Epoch.Util.dasherize('Hello World'), 'hello-world' 16 | 17 | it 'should trim leading and trailing whitespace before dasherizing', -> 18 | assert.equal Epoch.Util.dasherize(' Airieee is KewL '), 'airieee-is-kewl' 19 | 20 | describe 'domain', -> 21 | testLayers = [ 22 | { values: [{x: 'A', y: 10}, {x: 'B', y: 20}, {x: 'C', y: 40}] } 23 | ] 24 | 25 | testLayers2 = [ 26 | { values: [{x: 'A', y: 10}, {x: 'B', y: 20}, {x: 'C', y: 40}] }, 27 | { values: [{x: 'D', y: 15}, {x: 'E', y: 30}, {x: 'F', y: 90}] } 28 | ] 29 | 30 | it 'should find the correct domain of a set of keys and values', -> 31 | xDomain = Epoch.Util.domain(testLayers, 'x') 32 | assert.sameMembers xDomain, ['A', 'B', 'C'] 33 | yDomain = Epoch.Util.domain(testLayers, 'y') 34 | assert.sameMembers yDomain, [10, 20, 40] 35 | 36 | it 'should find all the values across multiple layers', -> 37 | xDomain = Epoch.Util.domain(testLayers2, 'x') 38 | assert.sameMembers xDomain, ['A', 'B', 'C', 'D', 'E', 'F'] 39 | yDomain = Epoch.Util.domain(testLayers2, 'y') 40 | assert.sameMembers yDomain, [10, 20, 40, 15, 30, 90] 41 | 42 | describe 'toRGBA', -> 43 | it 'should produce the correct rgba style when given an rgba color style', -> 44 | assert.equal Epoch.Util.toRGBA('rgba(1, 2, 3, 0.4)', 0.1), 'rgba(1,2,3,0.1)' 45 | 46 | it 'should produce the correct rgba style when given any rgb color style', -> 47 | assert.equal Epoch.Util.toRGBA('black', 0.25), 'rgba(0,0,0,0.25)' 48 | assert.equal Epoch.Util.toRGBA('#FF0000', 0.9), 'rgba(255,0,0,0.9)' 49 | assert.equal Epoch.Util.toRGBA('rgb(10, 20, 40)', 0.99), 'rgba(10,20,40,0.99)' 50 | 51 | describe 'getComputedStyle', -> 52 | overrideStyles = 53 | 'width': '320px' 54 | 'height': '240px' 55 | 'background-color': 'blue' 56 | 57 | [style, div] = [null, null] 58 | 59 | before (done) -> 60 | style = addStyleSheet('#get-style-div { padding-left: 30px; background: green }') 61 | div = doc.createElement('div') 62 | div.id = 'get-style-div' 63 | doc.body.appendChild(div) 64 | d3.select('#get-style-div').style(overrideStyles) 65 | done() 66 | 67 | after (done) -> 68 | doc.body.removeChild(div) 69 | doc.head.removeChild(style) 70 | done() 71 | 72 | it 'should find