Sudoku/sudoku.js

447 lines
16 KiB
JavaScript

const VALUES = "123456789"
suggestionTimer = null
highlightedValue = null
function createGrid() {
rows = Array.from(Array(9), x => [])
columns = Array.from(Array(9), x => [])
regions = Array.from(Array(9), x => [])
for (let regionRow = 0; regionRow < 3; regionRow++) {
const gridRow = document.createElement("tr")
for(let regionColumn = 0; regionColumn < 3; regionColumn++) {
const gridCell = document.createElement("td")
const regionTable = document.createElement("table")
regionTable.className = "region"
for (let row = 3*regionRow; row < 3*(regionRow+1); row++) {
const regionTr = document.createElement("tr")
for (let column = 3*regionColumn; column < 3*(regionColumn+1); column++) {
const regionCell = document.createElement("td")
const box = document.createElement("input")
box.type = "text"
box.oninput = oninput
box.oninvalid = oninvalid
box.onfocus = box.select
box.onkeydown = keyboardBrowse
box.setAttribute("inputmode", "numeric")
box.required = false
box.region = 3*regionRow + regionColumn
box.column = column
box.row = row
box.minLength = 0
box.maxLength = 1
box.tabIndex = 9
rows[row].push(box)
columns[column].push(box)
regions[box.region].push(box)
regionCell.appendChild(box)
regionTr.appendChild(regionCell)
}
regionTable.appendChild(regionTr)
}
gridCell.appendChild(regionTable)
gridRow.appendChild(gridCell)
}
gridTable.appendChild(gridRow)
}
grid = rows.reduce((grid, row) => grid.concat(row), [])
// box.neighbourhood: boxes in the same row, column and region as box
grid.forEach(box => {
box.neighbourhood = new Set(rows[box.row].concat(columns[box.column]).concat(regions[box.region]))
box.neighbourhood.delete(box)
box.neighbourhood = Array.from(box.neighbourhood)
})
}
function clearGrid() {
clearTimeout(suggestionTimer)
suggestionTimer = null
grid.forEach(box => {
box.value = ""
box.allowedValues = new Set(VALUES)
box.testValueWasAllowed = []
box.pattern = "[1-9]?"
box.placeholder = ""
box.disabled = false
box.required = false
})
enableHighlightOptions()
}
function loadGrid() {
window.onhashchange = function() {
if (location.hash) {
if (location.hash.match(/^#[1-9?]{81}$/)) {
gridAsString = location.hash.substring(1)
if (gridAsString != grid.map(box => box.disabled? box.value : "?").join("")) {
// Load grid from hash if it differs
clearGrid()
if (savedGame = localStorage.getItem(gridAsString + "SavedGame")) {
// We trust grid is valid if it was previously saved
grid.forEach((box, i) => {
box.value = savedGame[i] == "?"? "" : savedGame[i]
box.disabled = Boolean(gridAsString[i] != "?")
})
startTime = Date.now() - localStorage.getItem(gridAsString + "Time")
finishGridLoad()
} else {
// Else we need to check if grid is valid
grid.forEach((box, i) => box.value = gridAsString[i] == "?"? "" : gridAsString[i])
grid.forEach(box => searchAllowedValuesOf(box))
checkGrid()
}
}
} else {
alert(location.hash.substring(1) + " n'est pas une grille valide.")
}
}
}
if (location.hash)
window.onhashchange()
else if ((lastPlayedGrid = localStorage.getItem("lastPlayedGrid")) && localStorage.getItem(lastPlayedGrid + "SavedGame").match("[?]"))
location.hash = lastPlayedGrid
else
generateGrid()
}
function checkGrid() {
if (sudokuForm.checkValidity()) {
switch(new Set(findSolutions(false, 2, 4)).size) {
case 0:
this.reportValidity()
alert("Cette grille n'a pas de solution. Veuillez la corriger.")
break
case 1:
freezeGrid()
break
default:
alert("Cette grille a plusieurs solutions. Rajoutez des indices.")
}
} else {
sudokuForm.reportValidity()
}
}
function freezeGrid() {
grid.forEach(box => box.disabled = Boolean(box.value.length))
startTime = Date.now()
gridAsString = grid.map(box => box.disabled? box.value : "?").join("")
location.hash = gridAsString
finishGridLoad()
}
function finishGridLoad() {
grid.forEach(box => {
searchAllowedValuesOf(box)
showAllowedValuesOn(box)
box.pattern = `[${Array.from(box.allowedValues).join("")}]?`
})
enableHighlightOptions()
highlightAndTabOrder()
shareA.href = location
shareA.innerHTML = location.hostname + location.pathname + "#<br/>" + Array(9).fill().map((_,i) => gridAsString.slice(9*i, 9*i+9)).join("<br/>")
solutions = findSolutions(false, 1, 4)
localStorage.setItem("lastPlayedGrid", gridAsString)
saveGame()
suggestionTimer = setTimeout(showSuggestion, 30000)
}
function saveGame() {
localStorage.setItem(gridAsString + "SavedGame", grid.map(box => box.value || "?").join(""))
localStorage.setItem(gridAsString + "Time", Date.now() - startTime)
}
fromEasyToDifficult = (box1, box2) => box1.allowedValues.size - box2.allowedValues.size
function shuffle(iterable) {
array = Array.from(iterable)
if (array.length > 1) {
let i, j, tmp
for (i = array.length - 1; i > 0; i--) {
j = Math.floor(Math.random() * (i+1))
tmp = array[i]
array[i] = array[j]
array[j] = tmp
}
}
return array
}
function* findSolutions(randomized=false, maxSolutions=1, maxTries=4) {
let emptyBoxes = grid.filter(box => box.value == "")
if (emptyBoxes.length) {
if (randomized) // Generate random grids
emptyBoxes = shuffle(emptyBoxes)
const testBox = emptyBoxes.sort(fromEasyToDifficult)[0] // Pick an easy box to solve
// Try every allowed value
let nbSolutionsFound = 0
let nbTries = 0
for (testBox.value of randomized? shuffle(testBox.allowedValues) : testBox.allowedValues) {
testBox.neighbourhood.forEach(neighbour => {
// Remember if testBox.value was in neighbour.allowedValues in case we rewrite it
neighbour.testValueWasAllowed.push(neighbour.allowedValues.has(testBox.value))
neighbour.allowedValues.delete(testBox.value)
})
// If grid still correct, yield all solutions with this hypothesis
if (testBox.neighbourhood.filter(box => box.value == "").every(emptyBox => emptyBox.allowedValues.size)) {
for (const solution of findSolutions(randomized, maxSolutions-nbSolutionsFound, maxTries)) {
yield solution
nbSolutionsFound++
}
}
testBox.neighbourhood.filter(
neighbour => neighbour.testValueWasAllowed.pop()
).forEach(neighbour => neighbour.allowedValues.add(testBox.value))
if (maxSolutions && nbSolutionsFound >= maxSolutions) break
if (++nbTries >= maxTries) break
}
testBox.value = ""
} else {
yield grid.map(box => box.value).join("")
}
return "No more solutions"
}
function generateGrid() {
clearGrid()
shuffle(Array.from(VALUES)).forEach((value, c) => {
rows[0][c].value = value
rows[0][c].neighbourhood.forEach(neighbour => neighbour.allowedValues.delete(rows[0][c].value))
})
// Generate a random valid grid where all boxes are clues
while (findSolutions(true, 1, 4).next().done) {}
// Remove clues while there is still a unique solution
let untestedBoxes = shuffle(grid)
let nbClues = 81
while(untestedBoxes.length) {
const testBoxes = [untestedBoxes.pop()]
if (nbClues >= 30)
testBoxes.push(rows[8-testBoxes[0].row][8-testBoxes[0].column])
if (nbClues >= 61) {
testBoxes.push(rows[8-testBoxes[0].row][testBoxes[0].column])
testBoxes.push(rows[testBoxes[0].row][8-testBoxes[0].column])
}
const erasedValues = testBoxes.map(box => box.value)
testBoxes.forEach(box => {
box.value = ""
box.neighbourhood.forEach(neighbour => searchAllowedValuesOf(neighbour))
})
if (Array.from(findSolutions(false, 2, 4)).length == 1) {
nbClues -= testBoxes.length
testBoxes.slice(1).forEach(box => untestedBoxes.splice(untestedBoxes.indexOf(box), 1))
} else {
testBoxes.forEach((box, i) => {
box.value = erasedValues[i]
box.neighbourhood.forEach(neighbour => neighbour.allowedValues.delete(box.value))
})
}
}
freezeGrid()
}
function customGrid() {
clearGrid()
grid.forEach(box => showAllowedValuesOn(box))
enableHighlightOptions()
highlightAndTabOrder()
solutions = findSolutions(false, 1, 4)
customGridButton.innerText = "Figer la grille"
customGridButton.onclick = checkGrid
location.hash = ""
}
function searchAllowedValuesOf(box) {
box.allowedValues = new Set(VALUES)
box.neighbourhood.forEach(neighbour => box.allowedValues.delete(neighbour.value))
}
function keyboardBrowse(event) {
switch(event.key) {
case "ArrowLeft":
event.preventDefault()
moveOn(rows[this.row], this.column, 8)
break
case "ArrowRight":
event.preventDefault()
moveOn(rows[this.row], this.column, 1)
break
case "ArrowUp":
event.preventDefault()
moveOn(columns[this.column], this.row, 8)
break
case "ArrowDown":
event.preventDefault()
moveOn(columns[this.column], this.row, 1)
break
}
}
function moveOn(area, position, direction) {
if (area.filter(box => box.disabled).length < 9) {
do {
position = (position + direction) % 9
} while (area[position].disabled)
area[position].focus()
}
}
timeFormat = new Intl.DateTimeFormat("fr-FR", {
hour: "numeric",
minute: "2-digit",
second: "2-digit",
timeZone: "UTC"
}).format
function oninput() {
saveGame()
this.neighbourhood.concat([this]).forEach(box => {
box.setCustomValidity("")
searchAllowedValuesOf(box)
box.pattern = `[${Array.from(box.allowedValues).join("")}]?`
})
highlightAndTabOrder()
enableHighlightOptions()
this.neighbourhood.concat([this]).forEach(neighbour => showAllowedValuesOn(neighbour))
if (this.form.checkValidity()) { // Correct grid
if (grid.filter(box => box.value == "").length == 0) {
alert(`Bravo ! Vous avez résolu la grille en ${timeFormat(Date.now() - startTime)}.`)
} else {
solutions = findSolutions(false, 1, 4) // Reset solutions generator
if (suggestionTimer) clearTimeout(suggestionTimer)
suggestionTimer = setTimeout(showSuggestion, 30000)
}
} else { // Errors on grid
this.select()
this.reportValidity()
}
}
// Help functions
function showAllowedValuesOn(box) {
box.required = box.allowedValues.size == 0
if (box.value.length) {
box.title = ""
} else if (box.allowedValues.size) {
const allowedValuesArray = Array.from(box.allowedValues).sort()
box.title = allowedValuesArray.length ==1 ? allowedValuesArray[0] : allowedValuesArray.slice(0, allowedValuesArray.length-1).join(", ") + " ou " + allowedValuesArray[allowedValuesArray.length-1]
} else {
box.title = "Aucune valeur possible !"
}
}
function oninvalid() {
if (this.value.length && !this.value.match(/[1-9]/))
this.setCustomValidity("Entrez un chiffre entre 1 et 9.")
else if (sameValueIn(regions[this.region]))
this.setCustomValidity(`Il y a un autre ${this.value} dans cette région.`)
else if (sameValueIn(rows[this.row]))
this.setCustomValidity(`Il y a un autre ${this.value} dans cette ligne.`)
else if (sameValueIn(columns[this.column]))
this.setCustomValidity(`Il y a un autre ${this.value} dans cette colonne.`)
else if (this.allowedValues.size == 0)
this.setCustomValidity("La grille est incorrecte.")
}
function sameValueIn(area) {
for (const box1 of area) {
for (const box2 of area) {
if (box1 != box2 && box1.value.length && box1.value == box2.value) {
return true
}
}
}
return false
}
function highlight(value) {
highlightedValue = value
highlightAndTabOrder()
}
function highlightAndTabOrder() {
if (highlightedValue) {
for (const box of grid) {
box.tabIndex = -1
box.className = "unhighlighted"
if (box.value == "") {
if (box.allowedValues.has(highlightedValue)) {
box.tabIndex = 0
box.className = "highlighted"
}
}
}
} else {
for (const box of grid) {
box.className = "unhighlighted"
if (box.value == "" && box.allowedValues.size == 1) {
box.tabIndex = 0
} else {
box.tabIndex = -1
}
}
}
}
function enableHighlightOptions() {
for (value of VALUES) {
let highlightRadio = document.getElementById("highlightRadio" + value)
let highlightLabel = document.getElementById("highlightLabel" + value)
if (grid.filter(box => box.value == "").some(box => box.allowedValues.has(value))) {
highlightRadio.disabled = false
highlightLabel.className = ""
} else {
highlightRadio.disabled = true
highlightRadio.checked = false
highlightLabel.className = "disabled"
}
}
}
function showSuggestion() {
const emptyBoxes = grid.filter(box => box.value == "")
if (emptyBoxes.length) {
shuffle(emptyBoxes).sort(fromEasyToDifficult)[0].placeholder = "!"
} else {
clearTimeout(suggestionTimer)
suggestionTimer = null
}
}
function erase() {
grid.filter(box => !box.disabled).forEach(box => {
box.value = ""
box.placeholder = ""
})
grid.filter(box => !box.disabled).forEach(box => {
searchAllowedValuesOf(box)
showAllowedValuesOn(box)
})
sudokuForm.checkValidity()
sudokuForm.reportValidity()
enableHighlightOptions()
solutions = findSolutions(false, 1, 4)
solveButton.innerText = "Résoudre"
solveButton.type = "button"
solveButton.onclick = solve
solveButton.disabled = false
startTime = Date.now()
}
function solve() {
if (sudokuForm.checkValidity()) {
if (solutions.next().done) { // End of solutions generator
solveButton.innerText = "Montrer la solution"
solutions = findSolutions(false, 1, 4)
} else {
solveButton.innerText = "Effacer la solution"
}
grid.forEach(box => showAllowedValuesOn(box))
} else {
sudokuForm.reportValidity()
}
}