├── .gitignore ├── .npmignore ├── .travis.yml ├── bower.json ├── package.json ├── timer.spec.js ├── karma.conf.js ├── timer.html ├── README.md └── timer.js /.gitignore: -------------------------------------------------------------------------------- 1 | bower_components/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | bower_components 3 | .git 4 | .gitignore 5 | .npmignore 6 | .travis.yml 7 | npm-debug.log 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 0.10 4 | before_script: 5 | - npm install -g bower 6 | - bower install 7 | - export DISPLAY=:99.0 8 | - sh -e /etc/init.d/xvfb start 9 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-svg-timer", 3 | "version": "0.1.1", 4 | "main": "timer.js", 5 | "dependencies": { 6 | "angular": "^1.3.0", 7 | "angular-moment": "~0.9.0" 8 | }, 9 | "devDependencies": { 10 | "angular-mocks": "^1.3.0" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-svg-timer", 3 | "version": "0.1.1", 4 | "description": "An SVG-based timer button", 5 | "main": "timer.js", 6 | "scripts": { 7 | "test": "./node_modules/karma/bin/karma start --browsers Firefox --single-run" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git://github.com/markau/angular-svg-timer.git" 12 | }, 13 | "keywords": [ 14 | "angular", 15 | "angularjs", 16 | "timer", 17 | "svg", 18 | "directive" 19 | ], 20 | "author": "Mark Andrews", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/markau/angular-svg-timer/issues" 24 | }, 25 | "homepage": "https://github.com/markau/angular-svg-timer", 26 | "devDependencies": { 27 | "karma": "^0.12.9", 28 | "karma-jasmine": "^0.1.5", 29 | "karma-firefox-launcher": "^0.1.3", 30 | "karma-chrome-launcher": "^0.1.3", 31 | "karma-ng-html2js-preprocessor": "*" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /timer.spec.js: -------------------------------------------------------------------------------- 1 | describe('markauTimer', function() { 2 | 3 | var elm, scope; 4 | 5 | // Load the directive JavaScript 6 | beforeEach(module('markau.timer')); 7 | 8 | // load the template 9 | beforeEach(module('bower_components/angular-svg-timer/timer.html')); 10 | 11 | // render the directive 12 | beforeEach(inject(function($rootScope, $compile) { 13 | elm = angular.element(''); 14 | scope = $rootScope; 15 | $compile(elm)(scope); 16 | scope.$digest(); 17 | })); 18 | 19 | it('should work as an element', function () { 20 | expect(elm.html()).toContain(''); 21 | // expect(elm.html()).toContain('00:20'); 22 | }); 23 | 24 | }); 25 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | module.exports = function (config) { 3 | config.set({ 4 | basePath: '', 5 | files: [ 6 | 'bower_components/angular/angular.js', 7 | 'bower_components/angular-mocks/angular-mocks.js', 8 | 'bower_components/moment/moment.js', 9 | 'bower_components/angular-moment/angular-moment.js', 10 | 'timer.js', 11 | '*.spec.js', 12 | 'timer.html' 13 | ], 14 | 15 | // generate js files from html templates 16 | preprocessors: { 17 | 'timer.html': 'ng-html2js' 18 | }, 19 | 20 | ngHtml2JsPreprocessor: { 21 | // Add the path to the template that the directive is expecting 22 | prependPrefix: 'bower_components/angular-svg-timer/' 23 | }, 24 | 25 | reporters: ['progress'], 26 | 27 | port: 9876, 28 | colors: true, 29 | 30 | logLevel: config.LOG_INFO, 31 | 32 | browsers: ['Chrome'], 33 | frameworks: ['jasmine'], 34 | 35 | captureTimeout: 60000, 36 | 37 | autoWatch: true, 38 | singleRun: false 39 | }); 40 | }; 41 | -------------------------------------------------------------------------------- /timer.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 12 | 13 |
14 | 15 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | {{timerController.formattedTime}} 78 | 79 | 80 | 81 |
82 | 83 |
84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # angular-svg-timer 2 | 3 | [![Build Status](https://travis-ci.org/markau/angular-svg-timer.png)](https://travis-ci.org/markau/angular-svg-timer) 4 | 5 | An Angular directive to provide a self-contained, SVG-based timer button with visual feedback of elapsed time: 6 | 7 | ![Timer screenshots](/../screenshots/timerexample.png?raw=true "Timer screenshots") 8 | 9 | The SVG is based on [this fiddle](https://jsfiddle.net/prafuitu/xRmGV/). Extending this into an Angular directive allows additional features, including the start/stop button (works with touch) and communication between the directive and the view so that timer events can be handled. 10 | 11 | ### Demo 12 | 13 | See the [demo page](http://timerdemo.azurewebsites.net) for a working example. 14 | 15 | ## Usage 16 | 17 | 1. Install with bower: 18 | 19 | `bower install angular-svg-timer` 20 | 21 | 2. Include the scripts in your main index.html file: 22 | 23 | ```` 24 | 25 | 26 | 27 | ```` 28 | 29 | 3. Register the module dependency in your main app.js file, e.g.: 30 | 31 | `var App = angular.module('App', ['markau.timer']);` 32 | 33 | ### Quick start 34 | The minimal declaration is: 35 | 36 | ```````` 37 | 38 | ### Options 39 | 40 | The directive uses an isolate scope with 2-way binding on provided attributes, so the view can remain aware of changes in timer state. 41 | 42 | The directive exposes the following attributes: 43 | 44 | * Time: countdown time (in milliseconds). Required. 45 | * Status: 46 | * notstarted 47 | * running 48 | * complete 49 | * \ (based on events below) 50 | * Events: an array of objects in the form `{ 'time': '' }`. This is intended to allow the timer to check for \ milestones and update the status accordingly. The one event supported so far is `{ 'time': 'half' }`; when the countdown is half way through, the status attribute changes from 'running' to 'halftime'. Other useful events may include 1/4 and 3/4 time, '10 seconds remaining' etc. 51 | 52 | ### Example 53 | 54 | Instantiate scope variables: 55 | 56 | ```` 57 | $scope.time = 20; 58 | $scope.status = 'notstarted'; 59 | $scope.events: [{ 'time': 'half' }]; 60 | ```` 61 | 62 | Bind the scope variables to attributes on the element: 63 | 64 | ```` 65 | 66 | ```` 67 | 68 | Add a placeholder to show the current value of the scope variable: 69 | 70 | ```` 71 |

Timer status: {{status}}

72 | ```` 73 | 74 | More advanced use cases involve a `$watch` on the `$scope.status` variable, or use of `ng-class` to show different content depending on the status. The [demo page](http://timerdemo.azurewebsites.net) shows this in action. 75 | 76 | ### Style 77 | 78 | The directive uses an html template which exposes the `svg-container` and `svg-timer-text` classes. You can change the style on the countdown text by overriding the class: 79 | 80 | .svg-timer-text { 81 | fill: #262626; /* 'fill' is the svg version of 'color' */ 82 | font-size: 42px; 83 | } 84 | 85 | ### Size 86 | 87 | Being an SVG, the timer scales to fill the containing DOM element (effectively, width: 100%). Place it inside a width-constrained block element to control the size of the timer. 88 | 89 | ## A note on precision 90 | 91 | Counting setTimeout() intervals is an [unreliable](http://stackoverflow.com/a/985692/3003102) method of measuring time in JavaScript; a 1000ms interval is not necessarily 1000ms, up to 200-300ms, depending on the load on the client device (intervals can be blocked). 92 | 93 | This directive follows [an approach](http://stackoverflow.com/a/29972322/3003102) of comparing the elapsed time of each setTimeout() interval against Date.now(), in order to calculate and adjust for any drift. This use of system time ensures reliable results across devices. 94 | 95 | ## Compatability 96 | 97 | This has been tested on Android 4.2 and iOS 6 / 7 in a Phonegap project, in addition to a variety of modern desktop browsers. 98 | 99 | ## License 100 | 101 | MIT 102 | 103 | 104 | 105 | -------------------------------------------------------------------------------- /timer.js: -------------------------------------------------------------------------------- 1 | /* 2 | * angular-svg-timer v0.1.0 3 | * (c) 2015 Mark Andrews 4 | * License: MIT 5 | */ 6 | 7 | 'use strict'; 8 | 9 | angular.module('markau.timer', []). 10 | 11 | directive('markauTimer', function($timeout) { 12 | return { 13 | restrict: 'E', 14 | replace: true, 15 | templateUrl: 'bower_components/angular-svg-timer/timer.html', 16 | scope: { 17 | time: '=time', 18 | status: '=status', 19 | events: '=events', 20 | }, 21 | controllerAs: 'timerController', 22 | //bindToController: true, 23 | controller: function ($scope, $element, $attrs, $timeout) { 24 | 25 | // Private properties 26 | var go; 27 | var missedTicks; 28 | var interval; 29 | var startTime; 30 | var pauseTime; 31 | var finalTime; 32 | var durationMsec; 33 | var time; 34 | var elapsed; 35 | var totalTime; 36 | var goalTimeMillis; 37 | var myTimeout = null; 38 | var degrees; 39 | var hasHalftimeEvent = false; 40 | var _this = this; 41 | 42 | // Format milliseconds as a string. 43 | var formatMillisToTime = function (millis) { 44 | // Handles when the timer reaches 0 and goes negative (displays + not -) 45 | var response = ''; 46 | if (millis < 0) { 47 | response = "+"; 48 | } 49 | // Calculations (ensuring +ve numbers) 50 | var duration = moment.duration(millis); 51 | var minutes = ('00' + Math.abs(duration.minutes())).slice(-2); 52 | var seconds = ('00' + Math.abs(duration.seconds())).slice(-2); 53 | 54 | response += (minutes + ':' + seconds); 55 | return response; 56 | }; 57 | 58 | var init = function () { 59 | go = false; 60 | missedTicks = null; 61 | interval = 10; 62 | startTime = 0; 63 | pauseTime = 0; 64 | finalTime = 0; 65 | durationMsec = 0; 66 | time = 0; 67 | elapsed = 0; 68 | totalTime = 0; 69 | 70 | goalTimeMillis = parseInt($scope.time) * 1000; 71 | _this.formattedTime = formatMillisToTime(goalTimeMillis); 72 | degrees = 360 / goalTimeMillis; 73 | 74 | _this.playVisibility = 1; 75 | _this.pauseVisibility = 0; 76 | 77 | // Determine which events to subscribe to 78 | if ($scope.events) { 79 | $scope.events.forEach(function(event) { 80 | if (event.time === 'half') { 81 | hasHalftimeEvent = true; 82 | } 83 | }); 84 | } 85 | }; 86 | 87 | init(); 88 | 89 | // Play / Pause button visibility 90 | this.startPauseTimer = function() { 91 | if (_this.timerRunning) { 92 | //$scope.status = 'paused'; 93 | pause(); 94 | _this.playVisibility = 1; 95 | _this.pauseVisibility = 0; 96 | } else { 97 | if (($scope.status !== 'halftime') && ($scope.status !== 'complete')) { 98 | $scope.status = 'running'; 99 | } 100 | _this.playVisibility = 0; 101 | _this.pauseVisibility = 1; 102 | if (startTime === 0) { 103 | start(goalTimeMillis); 104 | } else { 105 | start(pauseTime); 106 | } 107 | } 108 | }; 109 | 110 | function start(time) { 111 | _this.timerRunning = true; 112 | startTime = time; 113 | startCountdown(time); 114 | } 115 | 116 | function stop() { 117 | _this.timerRunning = false; 118 | go = false; 119 | $timeout.cancel(myTimeout); 120 | finalTime = durationMsec - time; 121 | } 122 | 123 | function startCountdown(duration) { 124 | durationMsec = duration; 125 | startTime = Date.now(); 126 | // end_time = startTime + durationMsec; 127 | time = 0; 128 | elapsed = '0.0'; 129 | go = true; 130 | _tick(); 131 | } 132 | 133 | function pause() { 134 | pauseTime = lap(); 135 | stop(); 136 | } 137 | 138 | function lap() { 139 | if (go) { 140 | var now; 141 | now = durationMsec - (Date.now() - startTime); 142 | return now; 143 | } 144 | return pauseTime || finalTime; 145 | } 146 | 147 | 148 | /** 149 | * Called every tick for countdown clocks. 150 | * i.e. once every this.interval ms 151 | */ 152 | function _tick() { 153 | time += interval; 154 | totalTime += interval; 155 | 156 | var t = this; 157 | var diff = (Date.now() - startTime) - time; 158 | var nextIntervalIn = interval - diff; 159 | 160 | var isComplete = false; 161 | 162 | // Check for complete 163 | if (totalTime === goalTimeMillis) { 164 | $scope.status = 'complete'; 165 | // console.log('complete: ' + lap()); 166 | update(359, totalTime / 1000); 167 | } 168 | 169 | // Check for Half-time event 170 | if (hasHalftimeEvent) { 171 | if (totalTime === (goalTimeMillis / 2)) { 172 | $scope.status = 'halftime'; 173 | // console.log('halftime: ' + lap()); 174 | } 175 | } 176 | 177 | // Other events here 178 | 179 | 180 | // Show the new time 181 | _this.formattedTime = formatMillisToTime(lap()); 182 | 183 | // Draw more of the timer circle if not complete 184 | if (totalTime < goalTimeMillis) { 185 | update(totalTime * degrees, totalTime / 1000); 186 | } 187 | 188 | // Calculate drift and call a new interval 189 | if (nextIntervalIn <= 0) { 190 | missedTicks = Math.floor(Math.abs(nextIntervalIn) / interval); 191 | time += missedTicks * interval; 192 | totalTime += missedTicks * interval; 193 | if (go) { 194 | _tick(); 195 | } 196 | } else { 197 | if (go) { 198 | myTimeout = $timeout(_tick, nextIntervalIn); 199 | } 200 | } 201 | 202 | } 203 | 204 | // Check for changes to time based on prep work 205 | $scope.$watch('time', function(newValue, oldValue){ 206 | $scope.time = newValue; 207 | init(); 208 | }); 209 | 210 | 211 | // SVG drawing 212 | function update(deg, sec) { 213 | var RGB = []; 214 | _this.draw = drawCoord(radius, deg); 215 | 216 | col_H = 120 - Math.round(deg / 3); 217 | RGB = hsl2rgb(col_H, col_S, col_L); 218 | } 219 | 220 | 221 | var radius = 60; 222 | var offset = 10; 223 | 224 | var col_H = 120; 225 | var col_S = 95; 226 | var col_L = 48; 227 | 228 | function hue2rgb(t1, t2, t3) { 229 | if (t3 < 0) { t3 += 1; } 230 | if (t3 > 1) { t3 -= 1; } 231 | 232 | if (t3 * 6 < 1) { return t2 + (t1 - t2) * 6 * t3; } 233 | if (t3 * 2 < 1) { return t1; } 234 | if (t3 * 3 < 2) { return t2 + (t1 - t2) * (2 / 3 - t3) * 6; } 235 | 236 | return t2; 237 | } 238 | 239 | function hsl2rgb(H, S, L){ 240 | var R, G, B; 241 | var t1, t2; 242 | 243 | H = H / 360; 244 | S = S / 100; 245 | L = L / 100; 246 | 247 | if ( S === 0 ) { 248 | R = G = B = L; 249 | } else { 250 | 251 | t1 = (L < 0.5) ? L * (1 + S) : L + S - L * S; 252 | t2 = 2 * L - t1; 253 | 254 | R = hue2rgb(t1, t2, H + 1/3); 255 | G = hue2rgb(t1, t2, H); 256 | B = hue2rgb(t1, t2, H - 1/3); 257 | } 258 | 259 | return [ 260 | Math.round(R * 255), 261 | Math.round(G * 255), 262 | Math.round(B * 255) 263 | ]; 264 | } 265 | 266 | function drawCoord(radius, degrees) { 267 | var radians = degrees * Math.PI / 180; 268 | 269 | var rX = radius + offset + Math.sin(radians) * radius; 270 | var rY = radius + offset - Math.cos(radians) * radius; 271 | 272 | var dir = (degrees > 180) ? 1 : 0; 273 | 274 | var coord = 'M' + (radius + offset) + ',' + (radius + offset) + ' ' + 275 | 'L' + (radius + offset) + ',' + offset + ' ' + 276 | 'A' + radius + ',' + radius + ' 0 ' + dir + ',1 ' + 277 | rX + ',' + rY; 278 | 279 | return coord; 280 | } 281 | 282 | // Ensure $timeouts are cleared 283 | $scope.$on('$destroy', function(){ 284 | $timeout.cancel(myTimeout); 285 | }); 286 | } 287 | }; 288 | 289 | }); 290 | --------------------------------------------------------------------------------