In this solution for the first ng-quiz you learn about Angularjs in general using controller and services. Also you learn about using the HTML File API. You can download the source at my GitHub repository.
The HTML for this solution is pretty straightforward: (we omit the CSS here)
<!DOCTYPE html> <html> <head> <script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/angularjs/1.2.11/angular.min.js"></script> <script type="text/javascript" src="lettercrush.js"></script> <link rel="stylesheet" href="http://netdna.bootstrapcdn.com/bootstrap/3.1.0/css/bootstrap.min.css"> <link rel="stylesheet" href="http://netdna.bootstrapcdn.com/bootstrap/3.1.0/css/bootstrap-theme.min.css"> <link rel="stylesheet" href="lettercrush.css"> </head> <body> <div ng-app="LetterCrush" class="container"> <div ng-controller="LetterCrush" class="col-md-9"> <div class="jumbotron"> <h1>Letter Crush</h1> <p>Fun with words</p> </div> <div class="col-md-7"> <div class="form-group"> <label for="dictionary" class="control-label">Please choose a dictionary to play with (one word per line)</label> <input type="file" id="dictionary" ng-model="file"> </div> </div> <div class="col-md-5"> <form ng-submit="testWord()"> <div class="form-group"> <label for="score" class="control-label">Your score</label> <span class="form-control-static"><big>{{score}}</big></span> </div> <div class="form-group"> <label for="word" class="control-label">Your word</label> <input ng-model="word" type="text" id="word"> </div> </form> </div> <div class="col-md-12"> <table class="table"> <tr ng-repeat="row in board.content track by $index"><td ng-repeat="cell in row track by $index">{{cell}}</td></tr> </table> </div> </div> </body> </html>
The bits you should take a deeper look at are the attributes starting with ng (called Angularjs directives) and the variables enclosed in double curly braces. Directives are the glue between JavaScript and the DOM. The first important ng attribute is ng-app
in line 12. The value of the ng-app attribute is the name of the module used inside the JavaScript.
var module = angular.module("LetterCrush", []);
Everything inside this div is treated specially by Angularjs. So you can use references like {{score}}
. These references enclose expressions which evaluate properties defined on the Angularjs $scope
object. For bundling functionality you can use controllers. Controllers are used by setting ng-controller
. In line 13 we declare a controller named LetterCrush. This controller is defined inside the JavaScript like
module.controller('LetterCrush', ['$scope', 'board', 'dictionary', 'util', function ($scope, board, dictionary, util) { }]);
The strings in the array are the dependencies which should be injected. Every declared dependency needs to be a parameter in the function which implements the functionality of the controller. Angularjs’ own objects are prefixed with $
as you see with $scope
. All other ones are defined by us using a concept called services. These services are defined similar to controllers but with a factory.
// with no special dependencies, just jQueryLite $q and log module.factory('fileReader', function($q, $log) { }); // with our own dependencies module.factory('board', ['letterGenerator', 'wordFinder', function(letterGenerator, wordFinder) { }]);
The factory returns an object. Services are singletons and are commonly used for backend access, service like functionality or model like objects. Services can be injected into other services, directives or controllers.
All expressions enclosed in double curly braces or as value of a ng-model
directive are used for two way data binding. These expressions are evaluated on the $scope
object and all changes either from the DOM or via JavaScript are reflected on the other side. This means when the user enters text into the input in line 32 $scope.word
is updated with the value. Also if the code updates $scope.word
the value of the input is updated accordingly.
In this solution we use two other directives: ng-submit
and ng-repeat
. ng-submit
just calls a function when the form is submitted and ng-repeat
repeats the enclosed DOM subtree for every item in the passed array. Note here the track by
in line 38. Normally Angularjs tracks the item by its value but since we can have the same letter more than once we need a different tracking mechanisms, so we use the index of the array here.
Accessing local files can only be done when they are inside a file input. We adapted a solution from ode to code for handling the details of the file API.
// FileReader service // adapted from http://odetocode.com/blogs/scott/archive/2013/07/03/building-a-filereader-service-for-angularjs-the-service.aspx module.factory('fileReader', function($q, $log) { var onLoad = function(reader, deferred, scope) { return function () { scope.$apply(function () { deferred.resolve(reader.result); }); }; }; var onError = function (reader, deferred, scope) { return function () { scope.$apply(function () { deferred.reject(reader.result); }); }; }; var onProgress = function(reader, scope) { return function (event) { scope.$broadcast("fileProgress", { total: event.total, loaded: event.loaded }); }; }; var getReader = function(deferred, scope) { var reader = new FileReader(); reader.onload = onLoad(reader, deferred, scope); reader.onerror = onError(reader, deferred, scope); reader.onprogress = onProgress(reader, scope); return reader; }; var readAsText = function (file, scope) { var deferred = $q.defer(); var reader = getReader(deferred, scope); reader.readAsText(file); return deferred.promise; }; return {readAsText: readAsText}; });
We can then use this service to read out the file. Therefore we need to listen to changes of the input and then read out the content:
module.factory('dictionary', ['fileReader', 'util', function(fileReader, util) { var dictionary = []; var init = function(scope) { var getFile = function (evt) { fileReader.readAsText(evt.target.files[0], scope).then(function(result) { dictionary = result.split("\n"); }); }; document.getElementById('dictionary').addEventListener('change', getFile, false); }; var containsWord = function(w) { return util.containsIgnoreCase(dictionary, w); }; var isEmpty = function() { return dictionary.length === 0; }; return {init: init, containsWord: containsWord, isEmpty: isEmpty}; }]);
The fibonacci sequence for calculating the score and the random letter generation are pretty straightforward.
module.factory('util', function($q, $log) { var containsIgnoreCase = function(array, e) { for (var i = 0; i < array.length; i++) { if (e.toUpperCase() === array[i].toUpperCase()) { return true; } } return false; }; var fib = function(n) { if (n === 0) { return 0; } if (n === 1) { return 1; } return fib(n - 1) + fib(n - 2); }; return {containsIgnoreCase: containsIgnoreCase, fib: fib}; }); module.factory('letterGenerator', function($q, $log) { var frequencies = [8.167,1.492,2.782,4.253,12.702,2.228,2.015,6.094,6.966,0.153,0.772,4.025,2.406,6.749,7.507,1.929,0.095,5.987,6.327,9.056,2.758,0.978,2.360,0.150,1.974,0.074]; var codeA = 65; var newLetter = function() { var frequency = Math.random() * 100; for (var i = 0; i < frequencies.length; i++) { frequency -= frequencies[i]; if (frequency <= 0) { return String.fromCharCode(codeA + i); } } return 'Z'; }; return {newLetter: newLetter}; });
One slightly more difficult piece is the algorithm for finding the word on the board. Here we used a depth first search and represent the neighbours as an array instead of two loops which improves the readability of the algorithm.
module.factory('wordFinder', function($q, $log) { var insideBoard = function(board, row, column) { return row >= 0 && column >= 0 && row < board.length && column < board[row].length; }; var neighboursOf = function(cell) { return [ [cell[0] - 1, cell[1] - 1], [cell[0] - 1, cell[1]], [cell[0] - 1, cell[1] + 1], [cell[0], cell[1] - 1], [cell[0], cell[1] + 1], [cell[0] + 1, cell[1] - 1], [cell[0] + 1, cell[1]], [cell[0] + 1, cell[1] + 1] ]; }; var contains = function(array, e) { for (var i = 0; i < array.length; i++) { if (e[0] === array[i][0] && e[1] === array[i][1]) { return true; } } return false; }; var findNextLetter = function(board, word, path) { if (word.length === 0) { return path; } var position = path[path.length - 1]; var neighbours = neighboursOf(position); for (var i = 0; i < neighbours.length; i++) { var neighbour = neighbours[i]; if (!insideBoard(board, neighbour[0], neighbour[1])) { continue; } if (contains(path, neighbour)) { continue; } if (word.charAt(0).toUpperCase() === board[neighbour[0]][neighbour[1]]) { var foundPath = findNextLetter(board, word.slice(1), path.concat([neighbour])); if (foundPath) { return foundPath; } } } return null; }; var find = function(board, word) { var foundPath; angular.forEach(board, function(row, i) { angular.forEach(row, function(column, j) { if (word.charAt(0).toUpperCase() === column) { var path = findNextLetter(board, word.slice(1), [[i, j]]); if (path) { foundPath = path; } } }); }); return foundPath; }; return {find: find}; });
For the board we use an Angularjs service and encapsulate the letters as a two dimensional array, the word finding algorithm, the letter generation and the logic for clearing and falling of letters.
module.factory('board', ['letterGenerator', 'wordFinder', function(letterGenerator, wordFinder) { var content = []; var init = function(boardSize) { for (var lineNo = 0; lineNo < boardSize; lineNo++) { var line = []; for (var count = 0; count < boardSize; count++) { line.push(letterGenerator.newLetter()); } content.push(line); } }; var fall = function() { for (var i = content.length - 1; i > 0; i--) { for (var j = 0; j < content[i].length; j++) { if (content[i][j] === '') { for (var k = i - 1; k >= 0; k--) { if (content[k][j] !== '') { content[i][j] = content[k][j]; content[k][j] = ''; break; } } } } } }; var fillEmpty = function() { angular.forEach(content, function(row, i) { angular.forEach(row, function(column, j) { if (column === '') { content[i][j] = letterGenerator.newLetter(); } }); }); }; var clear = function(path) { angular.forEach(path, function(pos, i) { content[pos[0]][pos[1]] = ''; }); fall(); fillEmpty(); }; var find = function(word) { return wordFinder.find(content, word); }; return {content: content, init: init, clear: clear, find: find}; }]);
Finally we glue everything together inside the controller:
module.controller('LetterCrush', ['$scope', 'board', 'dictionary', 'util', function ($scope, board, dictionary, util) { var penalty = 1; $scope.score = 0; $scope.board = board; board.init(5); dictionary.init($scope); $scope.testWord = function() { if (dictionary.isEmpty()) { alert('Please specify a dictionary file.'); return; } if (!dictionary.containsWord($scope.word)) { $scope.score -= penalty; alert($scope.word + ' is no word.'); $scope.word = ''; return; } var found = $scope.board.find($scope.word); if (!found) { $scope.score -= penalty; alert($scope.word + ' is not on the board.'); $scope.word = ''; return; } $scope.score += $scope.calculateScore(found.length); $scope.word = ''; $scope.board.clear(found); }; $scope.calculateScore = function(len) { return util.fib(len); }; }]);
This concludes our first ng-quiz. We hope you had fun and learned something along the way. If you have questions or improvements please don’t hesitate to comment. In the next ng-quiz we tackle a smaller piece of functionality since this first one seemed a bit too big.