In this demo, backtracking algorithm is used for generating the sudoku. Difficulty and solvability is totally random as I randomly left a number of hints from a full-filled board.
A Pen by Siavash Habibi on CodePen.
| <!-- | |
| # Javascript Sudoku Puzzle Generator | |
| Generation process is handled in a producer wrapped in | |
| a worker that is embedded in html. | |
| API is designed to be pluggable, but needs more | |
| interface to support this. Currently it responds | |
| with an array of rows. | |
| JS code includes: | |
| - a utility object, | |
| - an adapter that acts as the communication layer | |
| between producer and consumer (app) | |
| - app singleton that includes the rendering logic | |
| --> | |
| <div id='sudoku-app'></div> | |
| <!-- https://github.com/scriptype/bem --> | |
| <script type='text/javascript'> | |
| "use strict";!function(a){if("function"==typeof bootstrap)bootstrap("bem",a);else if("object"==typeof exports&&"object"==typeof module)module.exports=a();else if("function"==typeof define&&define.amd)define(a);else if("undefined"!=typeof ses){if(!ses.ok())return;ses.makeBem=a}else{if("undefined"==typeof window&&"undefined"==typeof self)throw new Error("This environment was not anticipated by bem. Please file a bug.");var b="undefined"!=typeof window?window:self,c=b.bem;b.bem=a(),b.bem.noConflict=function(){return b.bem=c,this}}}(function(){function a(a){"undefined"!=typeof a.modifier&&(c.modifier=a.modifier),"undefined"!=typeof a.element&&(c.element=a.element)}function b(a){if(!d.validate(a))return null;var b=a.block,e=a.element,f=a.modifiers,g=b,h=[];return!!e&&(g+=""+c.element+e),!!f&&Object.keys(f).forEach(function(a){var d=f[a],i="function"==typeof d?d(b,e,f):d;!!i&&h.push(""+g+c.modifier+a+" ")}),(g+" "+h.join("")).slice(0,-1)}var c={element:"__",modifier:"--"},d={messages:{block:"You must specify the name of block.",element:"Element name must be a string.",modifier:"Modifiers must be supplied in the `{name : bool || fn}` style."},blockName:function(a){return"undefined"!=typeof a&&"string"==typeof a&&a.length?!0:(console.warn(this.messages.block),!1)},element:function(a){return"undefined"!=typeof a&&"string"!=typeof a?(console.warn(this.messages.element),!1):!0},modifiers:function(a){return"undefined"==typeof a||"object"==typeof a&&"[object Object]"===toString.call(a)?!0:(console.warn(this.messages.modifier),!1)},validate:function(a){return this.blockName(a.block)&&this.element(a.element)&&this.modifiers(a.modifiers)}};return{setDelimiters:a,makeClassName:b}}); | |
| </script> | |
| <!-- Include Babel to transform code in browser --> | |
| <script type='text/javascript' | |
| src='https://cdnjs.cloudflare.com/ajax/libs/babel-core/5.8.25/browser-polyfill.min.js'> | |
| </script> | |
| <script type='text/javascript' | |
| src='https://cdnjs.cloudflare.com/ajax/libs/babel-core/5.8.25/browser.min.js'> | |
| </script> | |
| <!-- worker, sudoku api --> | |
| <script type='text/babel' id='worker'> | |
| self.sudoku = null | |
| // Worker Setup | |
| self.addEventListener('message', (event) => { | |
| var options = { method: null } | |
| try { | |
| options = JSON.parse(event.data); | |
| } catch (e) { | |
| console.warn('event.data is misformed', event) | |
| } | |
| switch (options.method) { | |
| case 'generate': | |
| var { hints, limit } = options | |
| self.sudoku = new Sudoku(hints, limit).generate() | |
| self.postMessage({ | |
| success: self.sudoku.success, | |
| board: self.sudoku.getBoard(), | |
| solution: self.sudoku.getSolution() | |
| }); | |
| break; | |
| case 'validate': | |
| var { map, number, index } = options | |
| self.postMessage({ | |
| result: sudoku.validate(map, number, index) | |
| }); | |
| break; | |
| } | |
| }, false); | |
| // API | |
| class Sudoku { | |
| constructor(hints, limit) { | |
| this.hints = hints | |
| this.limit = limit || 10000 | |
| this._logs = { | |
| raw: [], | |
| incidents: { | |
| limitExceeded: 0, | |
| notValid: 0, | |
| noNumbers: 0 | |
| } | |
| } | |
| this.success = null | |
| this.numbers = () => | |
| new Array(9) | |
| .join(" ") | |
| .split(" ") | |
| .map((num , i) => i + 1) | |
| /* | |
| Will be used in initial map. Each row will be | |
| consisted of randomly ordered numbers | |
| */ | |
| this.randomRow = () => { | |
| var row = [] | |
| var numbers = this.numbers() | |
| while (row.length < 9) { | |
| var index = Math.floor(Math.random() * numbers.length) | |
| row.push(numbers[index]) | |
| numbers.splice(index, 1) | |
| } | |
| return row | |
| } | |
| /* | |
| This is the dummy placeholder for the | |
| final results. Will be overridden through the | |
| backtracking process, and at the and, this will | |
| be the real results. | |
| */ | |
| this.result = new Array(9 * 9) | |
| .join(" ") | |
| .split(" ") | |
| .map(entry => null) | |
| /* | |
| Will be used as the nodeTree in the | |
| process of backtracking. Each cell has 9 alternative | |
| paths (randomly ordered). | |
| */ | |
| this.map = new Array(9 * 9) | |
| .join(" ") | |
| .split(" ") | |
| .map(path => this.randomRow()) | |
| /* | |
| Will be used as history in the backtracking | |
| process for checking if a candidate number is valid. | |
| */ | |
| this.stack = [] | |
| return this | |
| } | |
| toRows(arr) { | |
| var row = 0 | |
| var asRows = new Array(9) | |
| .join(" ") | |
| .split(" ") | |
| .map(row => []) | |
| for (let [index, entry] of arr.entries()) { | |
| asRows[row].push(entry) | |
| if ( !((index + 1) % 9) ) { | |
| row += 1 | |
| } | |
| } | |
| return asRows | |
| } | |
| no(path, index, msg) { | |
| var number = path[path.length - 1] | |
| this._logs.raw.push(`no: @${index} [${number}] ${msg} ${path} `) | |
| } | |
| yes(path, index) { | |
| this._logs.raw.push(`yes: ${index} ${path}`) | |
| } | |
| finalLog() { | |
| console.groupCollapsed('Raw Logs') | |
| console.groupCollapsed(this._logs.raw) | |
| console.groupEnd() | |
| console.groupEnd() | |
| console.groupCollapsed('Incidents') | |
| console.groupCollapsed(this._logs.incidents) | |
| console.groupEnd() | |
| console.groupEnd() | |
| } | |
| getBoard() { | |
| return this.toRows(this.substractCells()) | |
| } | |
| getSolution() { | |
| return this.toRows(this.result) | |
| } | |
| substractCells() { | |
| var _getNonEmptyIndex = () => { | |
| var index = Math.floor(Math.random() * _result.length) | |
| return _result[index] ? index : _getNonEmptyIndex() | |
| } | |
| var _result = this.result.filter(() => true) | |
| while ( | |
| _result.length - this.hints > | |
| _result.filter(n => !n).length | |
| ) { | |
| _result[_getNonEmptyIndex()] = '' | |
| } | |
| return _result | |
| } | |
| validate(map, number, index) { | |
| var rowIndex = Math.floor(index / 9) | |
| var colIndex = index % 9 | |
| var row = map.slice( | |
| rowIndex * 9, 9 * (rowIndex + 1) | |
| ) | |
| var col = map.filter((e, i) => | |
| i % 9 === colIndex | |
| ) | |
| var boxRow = Math.floor(rowIndex / 3) | |
| var boxCol = Math.floor(colIndex / 3) | |
| var box = map.filter((e, i) => | |
| Math.floor(Math.floor(i / 9) / 3) === boxRow && | |
| Math.floor((i % 9) / 3) === boxCol | |
| ) | |
| return { | |
| row: { | |
| first: row.indexOf(number), | |
| last: row.lastIndexOf(number) | |
| }, | |
| col: { | |
| first: col.indexOf(number), | |
| last: col.lastIndexOf(number) | |
| }, | |
| box: { | |
| first: box.indexOf(number), | |
| last: box.lastIndexOf(number) | |
| } | |
| } | |
| } | |
| _validate(map, index) { | |
| if (!map[index].length) { | |
| return false | |
| } | |
| this.stack.splice(index, this.stack.length) | |
| var path = map[index] | |
| var number = path[path.length - 1] | |
| var didFoundNumber = this.validate(this.stack, number, index) | |
| return ( | |
| didFoundNumber.col.first === -1 && | |
| didFoundNumber.row.first === -1 && | |
| didFoundNumber.box.first === -1 | |
| ) | |
| } | |
| _generate(map, index) { | |
| if (index === 9 * 9) { | |
| return true | |
| } | |
| if (--this.limit < 0) { | |
| this._logs.incidents.limitExceeded++ | |
| this.no(map[index], index, 'limit exceeded') | |
| return false | |
| } | |
| var path = map[index] | |
| if (!path.length) { | |
| map[index] = this.numbers() | |
| map[index - 1].pop() | |
| this._logs.incidents.noNumbers++ | |
| this.no(path, index, 'no numbers in it') | |
| return false | |
| } | |
| var currentNumber = path[path.length - 1] | |
| var isValid = this._validate(map, index) | |
| if (!isValid) { | |
| map[index].pop() | |
| map[index + 1] = this.numbers() | |
| this._logs.incidents.notValid++ | |
| this.no(path, index, 'is not valid') | |
| return false | |
| } else { | |
| this.stack.push(currentNumber) | |
| } | |
| for (let number of path.entries()) { | |
| if (this._generate(map, index + 1)) { | |
| this.result[index] = currentNumber | |
| this.yes(path, index) | |
| return true | |
| } | |
| } | |
| return false | |
| } | |
| generate() { | |
| if (this._generate(this.map, 0)) { | |
| this.success = true | |
| } | |
| this.finalLog() | |
| return this | |
| } | |
| } | |
| </script> |
In this demo, backtracking algorithm is used for generating the sudoku. Difficulty and solvability is totally random as I randomly left a number of hints from a full-filled board.
A Pen by Siavash Habibi on CodePen.
| // Utility | |
| var utils = (() => { | |
| function dom (selector) { | |
| if (selector[0] === '#') { | |
| return document.getElementById(selector.slice(1)) | |
| } | |
| return document.querySelectorAll(selector) | |
| } | |
| function copyJSON (obj) { | |
| return JSON.parse(JSON.stringify(obj)) | |
| } | |
| function isTouchDevice () { | |
| return navigator.userAgent | |
| .match(/(iPhone|iPod|iPad|Android|BlackBerry)/) | |
| } | |
| function getWorkerURLFromElement(selector) { | |
| var element = dom(selector) | |
| var content = babel.transform(element.innerText).code | |
| var blob = new Blob([content], {type: 'text/javascript'}) | |
| return URL.createObjectURL(blob) | |
| } | |
| // Will be used for restoring caret positions on rerenders. | |
| // Taken from: | |
| // http://stackoverflow.com/questions/1125292/how-to-move-cursor-to-end-of-contenteditable-entity | |
| var cursorManager = (function () { | |
| var cursorManager = {} | |
| var voidNodeTags = [ | |
| 'AREA', 'BASE', 'BR', 'COL', 'EMBED', | |
| 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', | |
| 'MENUITEM', 'META', 'PARAM', 'SOURCE', | |
| 'TRACK', 'WBR', 'BASEFONT', 'BGSOUND', | |
| 'FRAME', 'ISINDEX' | |
| ]; | |
| Array.prototype.contains = function(obj) { | |
| var i = this.length; | |
| while (i--) { | |
| if (this[i] === obj) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| function canContainText(node) { | |
| if(node.nodeType == 1) { | |
| return !voidNodeTags.contains(node.nodeName); | |
| } else { | |
| return false; | |
| } | |
| }; | |
| function getLastChildElement(el){ | |
| var lc = el.lastChild; | |
| while(lc && lc.nodeType != 1) { | |
| if(lc.previousSibling) | |
| lc = lc.previousSibling; | |
| else | |
| break; | |
| } | |
| return lc; | |
| } | |
| cursorManager.setEndOfContenteditable = function(contentEditableElement) { | |
| while(getLastChildElement(contentEditableElement) && | |
| canContainText(getLastChildElement(contentEditableElement))) { | |
| contentEditableElement = getLastChildElement(contentEditableElement); | |
| } | |
| var range,selection; | |
| if(document.createRange) { | |
| range = document.createRange(); | |
| range.selectNodeContents(contentEditableElement); | |
| range.collapse(false); | |
| selection = window.getSelection(); | |
| selection.removeAllRanges(); | |
| selection.addRange(range); | |
| } | |
| else if(document.selection) | |
| { | |
| range = document.body.createTextRange(); | |
| range.moveToElementText(contentEditableElement); | |
| range.collapse(false); | |
| range.select(); | |
| } | |
| } | |
| return cursorManager | |
| })() | |
| return { | |
| copyJSON, cursorManager, dom, | |
| getWorkerURLFromElement, isTouchDevice | |
| } | |
| })(); | |
| // API Adapter | |
| class SudokuAdapter { | |
| constructor(url) { | |
| this.worker = new Worker(url) | |
| return this | |
| } | |
| _postMessage(options) { | |
| this.worker.postMessage(JSON.stringify(options)) | |
| return new Promise((resolve, reject) => { | |
| this.worker.onmessage = event => { | |
| resolve(event.data) | |
| } | |
| }) | |
| } | |
| generate(options) { | |
| options = Object.assign | |
| ({}, options, { method: 'generate' }) | |
| return this._postMessage(options) | |
| } | |
| validate(options) { | |
| options = Object.assign | |
| ({}, options, { method: 'validate' }) | |
| return this._postMessage(options) | |
| } | |
| } | |
| // Client Side Settings | |
| const SUDOKU_APP_CONFIG = { | |
| HINTS: 34, | |
| TRY_LIMIT: 100000, | |
| WORKER_URL: utils.getWorkerURLFromElement('#worker'), | |
| DOM_TARGET: utils.dom('#sudoku-app') | |
| } | |
| // Client Side | |
| var SudokuApp = (config => { | |
| const { | |
| HINTS, TRY_LIMIT, | |
| WORKER_URL, DOM_TARGET | |
| } = config | |
| var sudokuAdapter = new SudokuAdapter(WORKER_URL) | |
| var state = { | |
| success: null, | |
| board: null, | |
| solution: null, | |
| solved: null, | |
| errors: [] | |
| }; | |
| Object.observe(state, render) | |
| var history = [state] | |
| var historyStash = [] | |
| // Event listeners | |
| var onClickGenerate = initialize | |
| var onClickSolve = function () { | |
| setState({ | |
| board: state.solution, | |
| solved: true, | |
| errors: [] | |
| }) | |
| } | |
| var onKeyUpCell = function (event) { | |
| var key = event.keyCode | |
| if ( // a | |
| key === 36 || // r | |
| key === 37 || // r | |
| key === 38 || // o | |
| key === 39 || // w | |
| key === 9 || // tab | |
| // mod key flags are always false in keyup event | |
| // keyIdentifier doesn't seem to be implemented | |
| // in all browsers | |
| key === 17 || // Control | |
| key === 16 || // Shift | |
| key === 91 || // Meta | |
| key === 19 || // Alt | |
| event.keyIdentifier === 'Control' || | |
| event.keyIdentifier === 'Shift' || | |
| event.keyIdentifier === 'Meta' || | |
| event.keyIdentifier === 'Alt' | |
| ) return | |
| var cell = event.target | |
| var value = cell.innerText | |
| if (value.length > 4) { | |
| cell.innerText = value.slice(0, 4) | |
| return false | |
| } | |
| var cellIndex = cell.getAttribute('data-cell-index') | |
| cellIndex = parseInt(cellIndex, 10) | |
| var rowIndex = Math.floor(cellIndex / 9) | |
| var cellIndexInRow = cellIndex - (rowIndex * 9) | |
| var board = Object.assign([], state.board) | |
| board[rowIndex].splice(cellIndexInRow, 1, value) | |
| validate(board).then(errors => { | |
| historyStash = [] | |
| history.push({}) | |
| var solved = null | |
| if (errors.indexOf(true) === -1) { | |
| solved = true | |
| board.forEach(row => { | |
| row.forEach(value => { | |
| if (!value || !parseInt(value, 10) || value.length > 1) { | |
| solved = false | |
| } | |
| }) | |
| }) | |
| } | |
| if (solved) { | |
| board = Object.assign([], board).map(row => row.map(n => +n)) | |
| } | |
| setState({ board, errors, solved }, (newState) => { | |
| history[history.length - 1] = newState | |
| restoreCaretPosition(cellIndex) | |
| }) | |
| }) | |
| } | |
| function keyDown (event) { | |
| var keys = { | |
| ctrlOrCmd: event.ctrlKey || event.metaKey, | |
| shift: event.shiftKey, | |
| z: event.keyCode === 90 | |
| } | |
| if (keys.ctrlOrCmd && keys.z) { | |
| if (keys.shift && historyStash.length) { | |
| redo() | |
| } else if (!keys.shift && history.length > 1) { | |
| undo() | |
| } | |
| } | |
| } | |
| function undo () { | |
| historyStash.push(history.pop()) | |
| setState(utils.copyJSON(history[history.length - 1])) | |
| } | |
| function redo () { | |
| history.push(historyStash.pop()) | |
| setState(utils.copyJSON(history[history.length - 1])) | |
| } | |
| function initialize () { | |
| unbindEvents() | |
| render() | |
| getSudoku().then(sudoku => { | |
| setState({ | |
| success: sudoku.success, | |
| board: sudoku.board, | |
| solution: sudoku.solution, | |
| errors: [], | |
| solved: false | |
| }, newState => { | |
| history = [newState] | |
| historyStash = [] | |
| }) | |
| }) | |
| } | |
| function setState(newState, callback) { | |
| requestAnimationFrame(() => { | |
| Object.assign(state, newState) | |
| if (typeof callback === 'function') { | |
| var param = utils.copyJSON(state) | |
| requestAnimationFrame(callback.bind(null, param)) | |
| } | |
| }) | |
| } | |
| function bindEvents() { | |
| var generateButton = utils.dom('#generate-button') | |
| var solveButton = utils.dom('#solve-button') | |
| var undoButton = utils.dom('#undo-button') | |
| var redoButton = utils.dom('#redo-button') | |
| generateButton && | |
| generateButton | |
| .addEventListener('click', onClickGenerate) | |
| solveButton && | |
| solveButton | |
| .addEventListener('click', onClickSolve) | |
| undoButton && | |
| undoButton | |
| .addEventListener('click', undo) | |
| redoButton && | |
| redoButton | |
| .addEventListener('click', redo) | |
| var cells = utils.dom('.sudoku__table-cell') | |
| ;[].forEach.call(cells, (cell) => { | |
| cell.addEventListener('keyup', onKeyUpCell) | |
| }) | |
| window.addEventListener('keydown', keyDown) | |
| } | |
| function unbindEvents() { | |
| var generateButton = utils.dom('#generate-button') | |
| var solveButton = utils.dom('#solve-button') | |
| var undoButton = utils.dom('#undo-button') | |
| var redoButton = utils.dom('#redo-button') | |
| generateButton && | |
| generateButton | |
| .removeEventListener('click', onClickGenerate) | |
| solveButton && | |
| solveButton | |
| .removeEventListener('click', onClickSolve) | |
| undoButton && | |
| undoButton | |
| .removeEventListener('click', undo) | |
| redoButton && | |
| redoButton | |
| .removeEventListener('click', redo) | |
| var cells = utils.dom('.sudoku__table-cell') | |
| ;[].forEach.call(cells, (cell) => { | |
| cell.removeEventListener('keyup', onKeyUpCell) | |
| }) | |
| window.removeEventListener('keydown', keyDown) | |
| } | |
| function restoreCaretPosition(cellIndex) { | |
| utils.cursorManager.setEndOfContenteditable( | |
| utils.dom(`[data-cell-index="${ cellIndex }"]`)[0] | |
| ) | |
| } | |
| function getSudoku() { | |
| return sudokuAdapter.generate({ | |
| hints: HINTS, | |
| limit: TRY_LIMIT | |
| }) | |
| } | |
| function validate(board) { | |
| var map = board.reduce((memo, row) => { | |
| for (let num of row) { | |
| memo.push(num) | |
| } | |
| return memo | |
| }, []).map((num) => parseInt(num, 10)) | |
| var validations = [] | |
| // Will validate one by one | |
| for (let [index, number] of map.entries()) { | |
| if (!number) { | |
| validations.push( | |
| new Promise(res => { | |
| res({ result: { box: -1, col: -1, row: -1 } }) | |
| }) | |
| ) | |
| } else { | |
| let all = Promise.all(validations) | |
| validations.push(all.then(() => { | |
| return sudokuAdapter.validate({map, number, index}) | |
| })) | |
| } | |
| } | |
| return Promise.all(validations) | |
| .then(values => { | |
| var errors = [] | |
| for (let [index, validation] of values.entries()) { | |
| let { box, col, row } = validation.result | |
| let errorInBox = box.first !== box.last | |
| let errorInCol = col.first !== col.last | |
| let errorInRow = row.first !== row.last | |
| let indexOfRow = Math.floor(index / 9) | |
| let indexInRow = index - (indexOfRow * 9) | |
| errors[index] = errorInRow || errorInCol || errorInBox | |
| } | |
| return errors | |
| }) | |
| } | |
| function render() { | |
| unbindEvents() | |
| DOM_TARGET.innerHTML = ` | |
| <div class='sudoku'> | |
| ${ headerComponent() } | |
| ${ contentComponent() } | |
| </div> | |
| ` | |
| bindEvents() | |
| } | |
| function buttonComponent(props) { | |
| var { id, text, mods, classes } = props | |
| var blockName = 'button' | |
| var modifiers = {} | |
| var modType = toString.call(mods) | |
| if (modType === '[object String]') { | |
| modifiers[mods] = true | |
| } else if (modType === '[object Array]') { | |
| for (let modName of mods) { | |
| modifiers[modName] = true | |
| } | |
| } | |
| var blockClasses = bem.makeClassName({ | |
| block: blockName, | |
| modifiers: modifiers | |
| }); | |
| var buttonTextClass = `${blockName}-text` | |
| if (Object.keys(modifiers).length) { | |
| buttonTextClass += ( | |
| Object.keys(modifiers).reduce((memo, curr) => { | |
| return memo + ` ${blockName}--${curr}-text` | |
| }, '') | |
| ) | |
| } | |
| var lgText = typeof text === 'string' ? | |
| text : text[0] | |
| var mdText = typeof text === 'string' ? | |
| text : text[1] | |
| var smText = typeof text === 'string' ? | |
| text : text[2] | |
| return (` | |
| <button | |
| id='${ id }' | |
| class='${ blockClasses } ${ classes || "" }'> | |
| <span class='show-on-sm ${buttonTextClass}'> | |
| ${ smText } | |
| </span> | |
| <span class='show-on-md ${buttonTextClass}'> | |
| ${ mdText } | |
| </span> | |
| <span class='show-on-lg ${buttonTextClass}'> | |
| ${ lgText } | |
| </span> | |
| </button> | |
| `) | |
| } | |
| function messageComponent(options) { | |
| var { state, content } = options | |
| var messageClass = bem.makeClassName({ | |
| block: 'message', | |
| modifiers: state ? { | |
| [state]: true | |
| } : {} | |
| }) | |
| return (` | |
| <p class='${ messageClass }'> | |
| ${ content } | |
| </p> | |
| `) | |
| } | |
| function descriptionComponent(options) { | |
| var { className, infoLevel } = options | |
| var technical = ` | |
| In this demo, | |
| <a href='https://en.wikipedia.org/wiki/Backtracking'> | |
| backtracking algorithm | |
| </a> is used for <em>generating</em> | |
| the sudoku.` | |
| var description = ` | |
| Difficulty and solvability is | |
| totally random as I randomly left a certain number of hints | |
| from a full-filled board. | |
| ` | |
| if (infoLevel === 'full') { | |
| return (` | |
| <p class='${ className || '' }'> | |
| ${ technical } ${ description } | |
| </p> | |
| `) | |
| } else if (infoLevel === 'mini') { | |
| return (` | |
| <p class='${ className || '' }'> | |
| ${ description } | |
| </p> | |
| `) | |
| } | |
| } | |
| function restoreScrollPosComponent() { | |
| return `<div style='height: 540px'></div>` | |
| } | |
| function headerComponent() { | |
| return (` | |
| <div class='sudoku__header'> | |
| <h1 class='sudoku__title'> | |
| <span class='show-on-sm'> | |
| Sudoku | |
| </span> | |
| <span class='show-on-md'> | |
| Sudoku Puzzle | |
| </span> | |
| <span class='show-on-lg'> | |
| Javascript Sudoku Puzzle Generator | |
| </span> | |
| </h1> | |
| ${descriptionComponent({ | |
| infoLevel: 'mini', | |
| className: 'sudoku__description show-on-md' | |
| })} | |
| ${descriptionComponent({ | |
| infoLevel: 'full', | |
| className: 'sudoku__description show-on-lg' | |
| })} | |
| ${ | |
| state.success ? (` | |
| ${buttonComponent({ | |
| id: 'generate-button', | |
| text: ['New Board', 'New Board', 'New'], | |
| mods: 'primary' | |
| })} | |
| ${ state.solved ? | |
| buttonComponent({ | |
| id: 'solve-button', | |
| text: 'Solved', | |
| mods: ['tertiary', 'muted'] | |
| }) : | |
| buttonComponent({ | |
| id: 'solve-button', | |
| text: 'Solve', | |
| mods: 'secondary' | |
| }) | |
| } | |
| `) | |
| : (` | |
| ${buttonComponent({ | |
| id: 'generate-button', | |
| text: ['Generating', '', ''], | |
| mods: ['disabled', 'loading'] | |
| })} | |
| ${buttonComponent({ | |
| id: 'solve-button', | |
| text: 'Solve', | |
| mods: 'disabled' | |
| })} | |
| `) | |
| } | |
| ${ utils.isTouchDevice() ? (` | |
| ${buttonComponent({ | |
| id: 'redo-button', | |
| text: ['»', '»', '>', '>'], | |
| classes: 'fr', | |
| mods: [ | |
| 'neutral', | |
| 'compound', | |
| 'compound-last', | |
| `${ !historyStash.length ? | |
| 'disabled' : | |
| '' | |
| }` | |
| ] | |
| })} | |
| ${buttonComponent({ | |
| id: 'undo-button', | |
| text: ['«', '«', '<', '<'], | |
| classes: 'fr', | |
| mods: [ | |
| 'neutral', | |
| 'compound', | |
| 'compound-first', | |
| `${ history.length > 1 ? | |
| '' : | |
| 'disabled' | |
| }` | |
| ] | |
| })} | |
| `) : ''} | |
| </div> | |
| `) | |
| } | |
| function contentComponent() { | |
| var _isSeparator = (index) => | |
| !!index && !((index + 1) % 3) | |
| var resultReady = !!state.board | |
| var fail = resultReady && !state.success | |
| if (!resultReady) { | |
| return (` | |
| ${messageComponent({ | |
| state: 'busy', | |
| content: `Generating new board...` | |
| })} | |
| ${ restoreScrollPosComponent() } | |
| `) | |
| } | |
| if (fail) { | |
| return (` | |
| ${messageComponent({ | |
| state: 'fail', | |
| content: `Something went wrong with this board, try generating another one.` | |
| })} | |
| ${ restoreScrollPosComponent() } | |
| `) | |
| } | |
| var rows = state.board | |
| return (` | |
| <table class='sudoku__table'> | |
| ${rows.map((row, index) => { | |
| let className = bem.makeClassName({ | |
| block: 'sudoku', | |
| element: 'table-row', | |
| modifiers: { | |
| separator: _isSeparator(index) | |
| } | |
| }); | |
| return ( | |
| `<tr class='${ className }'> | |
| ${row.map((num, _index) => { | |
| let cellIndex = (index * 9) + _index | |
| let separator = _isSeparator(_index) | |
| let editable = typeof num !== 'number' | |
| let error = state.errors[cellIndex] | |
| let className = bem.makeClassName({ | |
| block: 'sudoku', | |
| element: 'table-cell', | |
| modifiers: { | |
| separator, | |
| editable, | |
| error, | |
| 'editable-error': editable && error | |
| } | |
| }); | |
| return ( | |
| `\n\t | |
| <td class='${ className }' | |
| data-cell-index='${ cellIndex }' | |
| ${ editable ? 'contenteditable' : ''}> | |
| ${ num } | |
| </td>` | |
| ) | |
| }).join('')} | |
| \n</tr>\n` | |
| ) | |
| }).join('')} | |
| </table> | |
| `) | |
| } | |
| return { initialize } | |
| })( SUDOKU_APP_CONFIG ).initialize() |
| <script src="https://cdn.rawgit.com/MaxArt2501/object-observe/master/dist/object-observe-lite.min.js"></script> |
| /**********************************\ | |
| --------- Sudoku App Styles -------- | |
| Contents: | |
| - Global definitions | |
| - Modules | |
| Each module may have its own | |
| variables, mixins, animations and | |
| media queries. | |
| This is the convention followed | |
| in structuring rules | |
| // ================== | |
| // Foo | |
| // ================== | |
| // Variables (Variables of Foo) | |
| [...] | |
| // Lorem (Lorem of foo) | |
| [...] | |
| // ================== | |
| // Bar | |
| // ================== | |
| [...] | |
| \**********************************/ | |
| // ================== | |
| // Global | |
| // ================== | |
| // Fonts | |
| @font-face { | |
| src: url("http://enes.in/GillSansTr-LightNr.otf"); | |
| font-family: Gill; | |
| font-weight: 100 | |
| } | |
| @font-face { | |
| src: url("http://enes.in/GillSansTr-Normal.otf"); | |
| font-family: Gill; | |
| font-weight: 300 | |
| } | |
| @font-face { | |
| src: url("http://enes.in/GillSansTr-Bold.otf"); | |
| font-family: Gill; | |
| font-weight: 600 | |
| } | |
| @font-face { | |
| src: url("http://enes.in/GillSansTr-ExtraBold.otf"); | |
| font-family: Gill; | |
| font-weight: 700 | |
| } | |
| @font-face { | |
| src: url("http://enes.in/GillSansTr-UltraBold.otf"); | |
| font-family: Gill; | |
| font-weight: 900 | |
| } | |
| html, body { | |
| width: 100%; | |
| height: 100% | |
| } | |
| body { | |
| margin: 0; | |
| background: #f0f0f0 | |
| } | |
| // Variables | |
| $g-transition-duration: .2s; | |
| $g-breakpoint-xs: 260px; | |
| $g-breakpoint-sm: 420px; | |
| $g-breakpoint-md: 615px; | |
| // Responsive | |
| @media (max-width: $g-breakpoint-xs) { | |
| .show-on-sm { display: none; } | |
| .show-on-md { display: none; } | |
| .show-on-lg { display: none; } | |
| .show-on-xs { display: block; } | |
| } | |
| @media (max-width: $g-breakpoint-sm) { | |
| .show-on-xs { display: none; } | |
| .show-on-md { display: none; } | |
| .show-on-lg { display: none; } | |
| .show-on-sm { display: block; } | |
| } | |
| @media (min-width: $g-breakpoint-sm + 1) | |
| and (max-width: $g-breakpoint-md) { | |
| .show-on-xs { display: none; } | |
| .show-on-sm { display: none; } | |
| .show-on-lg { display: none; } | |
| .show-on-md { display: block; } | |
| } | |
| @media (min-width: $g-breakpoint-md) { | |
| .show-on-xs { display: none; } | |
| .show-on-sm { display: none; } | |
| .show-on-md { display: none; } | |
| .show-on-lg { display: block; } | |
| } | |
| // Animations | |
| @keyframes progress { | |
| 0% { | |
| box-shadow: none; | |
| } | |
| 25% { | |
| box-shadow: 2px -2px 0 1px; | |
| } | |
| 50% { | |
| box-shadow: 2px -2px 0 1px, | |
| 7px -2px 0 1px; | |
| } | |
| 100% { | |
| box-shadow: 2px -2px 0 1px, | |
| 7px -2px 0 1px, | |
| 12px -2px 0 1px; | |
| } | |
| } | |
| .fr { float: right; } | |
| .fl { float: left; } | |
| // ================== | |
| // Modules | |
| // ================== | |
| // ================== | |
| // CTA Button | |
| // ================== | |
| // Variables | |
| $button-primary-color: lighten(desaturate(blue, 35%), 5%); | |
| $button-secondary-color: lighten(desaturate(red, 35%), 5%); | |
| $button-tertiary-color: #2ECC40; | |
| $button-neutral-color: #333; | |
| $button-disabled-color: #bbb; | |
| $button-border-radius: 3px; | |
| // Mixins | |
| @mixin button-base( | |
| $font-size, | |
| $margin, | |
| $padding, | |
| $loading-padding-right) { | |
| .button { | |
| padding: $padding; | |
| font-size: $font-size; | |
| &:not(:last-of-type) { | |
| margin-right: $margin; | |
| } | |
| &--loading { | |
| padding-right: $loading-padding-right | |
| } | |
| } | |
| } | |
| // Responsive | |
| @media (max-width: $g-breakpoint-xs) { | |
| @include button-base( | |
| .6em, | |
| .15em, | |
| .25em .5em, | |
| 1.5em); | |
| } | |
| @media (min-width: $g-breakpoint-xs + 1) | |
| and (max-width: $g-breakpoint-sm) { | |
| @include button-base( | |
| .75em, | |
| .25em, | |
| .25em .5em .15em, | |
| 1.5em); | |
| } | |
| @media (min-width: $g-breakpoint-sm + 1) | |
| and (max-width: $g-breakpoint-md) { | |
| @include button-base( | |
| .9em, | |
| .5em, | |
| .5em .75em .4em, | |
| 1.5em); | |
| } | |
| @media (min-width: $g-breakpoint-md) { | |
| @include button-base( | |
| 1em, | |
| .75em, | |
| .75em 1em .6em, | |
| 1.5em); | |
| } | |
| // Component | |
| .button { | |
| border: 1px solid; | |
| font-weight: normal; | |
| border-radius: $button-border-radius; | |
| background: none; | |
| box-shadow: none; | |
| transition: all $g-transition-duration; | |
| &--primary { | |
| color: $button-primary-color; | |
| font-weight: 600; | |
| &:hover, | |
| &:focus, | |
| &:active { | |
| border-color: $button-primary-color; | |
| background: $button-primary-color; | |
| } | |
| &:focus { | |
| box-shadow: 0 0 5px $button-primary-color; | |
| } | |
| } | |
| &--secondary { | |
| color: $button-secondary-color; | |
| &:hover, | |
| &:focus, | |
| &:active { | |
| border-color: $button-secondary-color; | |
| background: $button-secondary-color; | |
| } | |
| &:focus { | |
| box-shadow: 0 0 5px $button-secondary-color; | |
| } | |
| } | |
| &--tertiary { | |
| color: #fff; | |
| border-color: $button-tertiary-color; | |
| background: $button-tertiary-color; | |
| } | |
| &--neutral { | |
| color: $button-neutral-color; | |
| &:hover, | |
| &:focus, | |
| &:active { | |
| border-color: $button-neutral-color; | |
| background: $button-neutral-color; | |
| } | |
| &:focus { | |
| box-shadow: 0 0 5px $button-neutral-color; | |
| } | |
| } | |
| &--compound { | |
| border-radius: 0; | |
| border-right: none; | |
| &-first { | |
| border-bottom-left-radius: $button-border-radius; | |
| border-top-left-radius: $button-border-radius; | |
| } | |
| &-last { | |
| border-bottom-right-radius: $button-border-radius; | |
| border-top-right-radius: $button-border-radius; | |
| border-right: 1px solid; | |
| } | |
| } | |
| &--muted { | |
| pointer-events: none; | |
| } | |
| &--disabled { | |
| border-color: $button-disabled-color; | |
| color: $button-disabled-color; | |
| pointer-events: none; | |
| } | |
| &--loading { | |
| &-text::after { | |
| display: inline-block; | |
| width: 1px; | |
| height: 1px; | |
| content: ''; | |
| box-shadow: 2px -2px 1px 0; | |
| animation: progress 1s infinite; | |
| } | |
| } | |
| &:hover, | |
| &:focus, | |
| &:active { | |
| color: #fff; | |
| } | |
| &:focus { | |
| outline: none; | |
| } | |
| &:active { | |
| box-shadow: inset 0 -2px 10px rgba(#000, .4); | |
| } | |
| } | |
| // ================== | |
| // Feedback Messages | |
| // ================== | |
| // Component | |
| .message { | |
| font-size: .9em; | |
| padding: 2em; | |
| margin: 0; | |
| border-radius: 3px; | |
| color: rgba(black, .75); | |
| &--busy { | |
| background: rgba(blue, .1) | |
| } | |
| &--fail { | |
| background: rgba(red, .1) | |
| } | |
| } | |
| // ================== | |
| // Sudoku Table | |
| // ================== | |
| // Variables | |
| $sudoku-color: #444; | |
| // Mixins | |
| @mixin sudoku-base( | |
| $thin-border, | |
| $thick-border, | |
| $cell-size, | |
| $font-size, | |
| $title-size, | |
| $padding-around, | |
| $header-padding | |
| ) { | |
| .sudoku { | |
| margin: 0 auto; | |
| padding-top: $padding-around; | |
| padding-bottom: $padding-around; | |
| &__header { | |
| padding-bottom: $header-padding | |
| } | |
| &__title { | |
| font-size: $title-size | |
| } | |
| &__table { | |
| font-size: $font-size; | |
| border-top: $thick-border; | |
| border-left: $thick-border; | |
| border-collapse: collapse; | |
| &-row { | |
| border-bottom: $thin-border; | |
| border-right: $thick-border; | |
| &--separator { | |
| border-bottom: $thick-border; | |
| } | |
| } | |
| &-cell { | |
| width: $cell-size; | |
| height: $cell-size; | |
| border-right: $thin-border; | |
| &--separator { | |
| border-right: $thick-border; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // Responsive | |
| @media (max-width: $g-breakpoint-xs) { | |
| @include sudoku-base( | |
| 1px solid $sudoku-color, | |
| 2px solid $sudoku-color, | |
| 16px, .9em, 1em, .5em, .6em); | |
| .sudoku { | |
| max-width: calc(#{$g-breakpoint-xs} / 1.5); | |
| min-width: calc(#{$g-breakpoint-xs} / 2); | |
| } | |
| } | |
| @media (min-width: $g-breakpoint-xs + 1) | |
| and (max-width: $g-breakpoint-sm) { | |
| @include sudoku-base( | |
| 1px solid $sudoku-color, | |
| 3px solid $sudoku-color, | |
| 32px, 1.2em, 1.2em, 1em, .9em); | |
| .sudoku { | |
| width: $g-breakpoint-xs; | |
| } | |
| } | |
| @media (min-width: $g-breakpoint-sm + 1) | |
| and (max-width: $g-breakpoint-md) { | |
| @include sudoku-base( | |
| 1px solid $sudoku-color, | |
| 4px solid $sudoku-color, | |
| 48px, 1.5em, 1.5em, 2em, 1.3em); | |
| .sudoku { | |
| width: $g-breakpoint-sm | |
| } | |
| } | |
| @media (min-width: $g-breakpoint-md) { | |
| @include sudoku-base( | |
| 2px solid $sudoku-color, | |
| 6px solid $sudoku-color, | |
| 64px, 1.75em, 2em, 3em, 1.618em); | |
| .sudoku { | |
| width: $g-breakpoint-md | |
| } | |
| } | |
| // Component | |
| .sudoku { | |
| color: $sudoku-color; | |
| &__header { | |
| font-family: Gill, sans-serif; | |
| } | |
| &__title { | |
| font-weight: 600 | |
| } | |
| &__description { | |
| max-width: 640px; | |
| line-height: 1.4; | |
| font-weight: 100 | |
| } | |
| &__table { | |
| background: #fff; | |
| &-row {} | |
| &-cell { | |
| overflow: hidden; | |
| text-align: center; | |
| transition: all .25s; | |
| &--editable { | |
| color: desaturate(blue, 25); | |
| &:focus { | |
| background: rgba(blue, .1); | |
| outline: none; | |
| } | |
| } | |
| &--error { | |
| color: red; | |
| background: #fdd; | |
| } | |
| &--editable-error { | |
| text-shadow: 0 0 15px; | |
| &:focus { | |
| color: #eee; | |
| background: #f45; | |
| } | |
| } | |
| } | |
| } | |
| } |