const PARSE_RANGE_START = "0"
const PARSE_RANGE_END = "9"
const VALID_RANGE_START = "1"
const VALID_RANGE_END = "9"
const SUDOKU_GRID_SIZE = 9 * 9
const SUDOKU_VALUES = new Set([1, 2, 3, 4, 5, 6, 7, 8, 9])
/**
*
* @param min {number}
* @param max {number}
* @returns {number}
*/
function randInt(min, max) {
const minCeil = Math.ceil(min)
const maxFloor = Math.floor(max)
return Math.trunc(Math.random() * (maxFloor - minCeil + 1)) + minCeil
}
class SudokuPuzzle extends EventTarget {
/**
*
* @type {(number|null)[][]}
*/
#state = []
/**
*
* @type {(number|null|undefined)[]}
*/
#activeState = new Array(9 * 9)
get grid() {
const gridValue = [...this.#activeState]
Object.freeze(gridValue)
return gridValue
}
/**
*
* @param start {string} Either "generate" for a random sudoku or numbers to prefill into the grid (0 is empty)
*/
constructor(start) {
super()
if (start === "generate") {
this.applyState(this.#generateRandomState())
} else if (start) {
this.applyState(SudokuPuzzle.#stateFromString(start))
} else {
this.applyState(Array.from({ length: SUDOKU_GRID_SIZE }))
}
}
/**
*
* @param serializedState {string}
* @returns {(?string)[]}
*/
static #stateFromString(serializedState) {
let cellIndex = 0
const newState = Array.from({ length: SUDOKU_GRID_SIZE })
for (const char of serializedState) {
if (PARSE_RANGE_START <= char && char <= PARSE_RANGE_END) {
if (VALID_RANGE_START <= char && char <= PARSE_RANGE_END) {
newState[cellIndex] = Number.parseInt(char, 10)
}
cellIndex++
}
if (cellIndex >= newState.length) {
break
}
}
return newState
}
/**
*
* @param stateToSample {(number|null|undefined)[]}
* @returns {undefined|number}
*/
#sampleRandomEmptyField(stateToSample = this.#activeState) {
const iters = [...stateToSample.entries().filter(([_, v]) => v == null)]
if (iters.length > 0) {
return iters[randInt(0, iters.length - 1)][0]
}
}
/**
*
* @param states {...(number|null|undefined)[][]}
*/
static collapseStates(...states) {
return Array.from({ length: SUDOKU_GRID_SIZE }, (_, i) => {
for (let stateIndex = states.length - 1; stateIndex >= 0; stateIndex--) {
if (states[stateIndex][i] !== undefined) {
return states[stateIndex][i]
}
}
})
}
/**
*
* @param slot {number}
* @param state {(number|null|undefined)[]}
* @return {Set<number>}
*/
#boxGetValues(slot, state = this.#activeState) {
const containedValues = new Set()
const left = Math.trunc(Math.trunc(slot % 9) / 3) * 3
const top = Math.trunc(Math.trunc(slot / 9) / 3) * (9 * 3)
for (let y = 0; y <= 27; y += 9) {
for (let x = 0; x < 3; x++) {
const positionIndex = top + y + left + x
if (positionIndex !== slot) {
containedValues.add(state[positionIndex])
}
}
}
return containedValues
}
/**
*
* @param slot {number}
* @param state {(number|null|undefined)[]}
* @return {Set<number>}
*/
#crossGetValues(slot, state = this.#activeState) {
const containedValues = new Set()
const left = slot % 9
for (let leftIndex = slot - left; leftIndex < slot; leftIndex++) {
if (state[leftIndex] != null) {
containedValues.add(state[leftIndex])
}
}
for (let rightIndex = slot - left + 9; rightIndex > slot; rightIndex--) {
if (state[rightIndex] != null) {
containedValues.add(state[rightIndex])
}
}
for (let topIndex = left; topIndex < slot; topIndex += 9) {
if (state[topIndex] != null) {
containedValues.add(state[topIndex])
}
}
for (
let bottomIndex = SUDOKU_GRID_SIZE - (9 - left);
bottomIndex > slot;
bottomIndex -= 9
) {
if (state[bottomIndex] != null) {
containedValues.add(state[bottomIndex])
}
}
return containedValues
}
/**
*
* @param slot {number}
* @param state {(number|null|undefined)[]}
* @return {Set<number>}
*/
getValues(slot, state = this.#activeState) {
return this.#boxGetValues(slot, state).union(
this.#crossGetValues(slot, state),
)
}
/**
*
* @param value {number}
* @param slot {number}
* @param state {(number|null|undefined)[]}
* @return boolean
*/
isValueValidForSlot(value, slot, state = this.#activeState) {
return !this.#boxGetValues(slot, state)
.union(this.#crossGetValues(slot, state))
.has(value)
}
/**
*
* @param newState {(number|null|undefined)[]}
*/
fillRandomFieldInState(newState = Array.from({ length: SUDOKU_GRID_SIZE })) {
const inProgressState = SudokuPuzzle.collapseStates(
this.#activeState,
newState,
)
const cell = this.#sampleRandomEmptyField(inProgressState)
if (cell === undefined) return newState
const availableValues = [
...SUDOKU_VALUES.difference(this.getValues(cell, inProgressState)),
]
newState[cell] = availableValues[randInt(0, availableValues.length - 1)]
return newState
}
#generateRandomState() {
const newState = Array.from({ length: SUDOKU_GRID_SIZE })
for (let i = 0; i < 17; i++) {
this.fillRandomFieldInState(newState)
}
return newState
}
get baseState() {
return this.#state[0]
}
popState() {
if (this.#state.length <= 1) return
this.#state.pop()
this.#activeState = SudokuPuzzle.collapseStates(...this.#state)
this.dispatchEvent(
new CustomEvent("state-changed", { detail: { value: this } }),
)
}
applyState(newState) {
this.#state.push(newState)
this.#activeState = SudokuPuzzle.collapseStates(...this.#state)
this.dispatchEvent(
new CustomEvent("state-changed", { detail: { value: this } }),
)
}
}
class SudokuHost extends HTMLElement {
static observedAttributes = ["size", "puzzle", "readonly"]
/**
* @type {?ShadowRoot}
*/
#root
/**
* @type {?HTMLLinkElement}
*/
#styling
/**
* @type {?HTMLElement}
*/
#container
/**
* @type {HTMLElement[]}
*/
#cellGrid
/**
* @type {SudokuPuzzle}
*/
#activePuzzle
/**
*
* @type {Map<number, HTMLElement>}
*/
#selectedCells = new Map()
/**
*
* @type {Map<string, (event: KeyboardEvent) => void>}
*/
#keyProcessors = new Map()
get isActive() {
return this.matches(":hover")
}
constructor() {
super()
const cellValueProcessor = (event) => {
if (this.#selectedCells.size === 0) return
const cellValue = Number.parseInt(event.key, 10)
const newState = Array.from({ length: SUDOKU_GRID_SIZE })
const baseState = this.#activePuzzle.baseState
for (const [index] of this.#selectedCells) {
if (baseState[index] == null) {
newState[index] = cellValue
}
}
this.#activePuzzle.applyState(newState)
}
for (let i = 1; i <= 9; i++) {
this.#keyProcessors.set(i.toString(), cellValueProcessor)
}
this.#keyProcessors.set("d", () => {
const newState = Array.from({ length: SUDOKU_GRID_SIZE })
for (const [index] of this.#selectedCells) {
newState[index] = null
}
this.#activePuzzle.applyState(newState)
})
this.#keyProcessors.set("a", () => {
const generator = () => {
const newState = this.#activePuzzle.fillRandomFieldInState()
if (newState.some((v) => v != null)) {
this.#activePuzzle.applyState(newState)
if (
this.#activePuzzle.grid.includes(null) ||
this.#activePuzzle.grid.includes(undefined)
) {
requestAnimationFrame(generator)
}
}
}
requestAnimationFrame(generator)
})
this.#keyProcessors.set("u", () => {
this.#activePuzzle.popState()
})
this.#keyProcessors.set("r", () => {
this.#activePuzzle.applyState(this.#activePuzzle.fillRandomFieldInState())
})
this.#keyProcessors.set("m", () => {
for (const [_, cell] of this.#selectedCells) {
cell.classList.toggle("marked")
cell.classList.remove("selected")
}
this.#selectedCells.clear()
})
}
syncCellsToSudoku() {
const puzzleGrid = this.#activePuzzle.grid
const baseGrid = this.#activePuzzle.baseState
for (let i = 0; i < SUDOKU_GRID_SIZE; i++) {
this.#cellGrid[i].innerHTML = puzzleGrid[i] ?? ""
if (baseGrid[i] != null) {
this.#cellGrid[i].classList.add("initial")
} else if (puzzleGrid[i] != null) {
if (!this.#activePuzzle.isValueValidForSlot(puzzleGrid[i], i)) {
this.#cellGrid[i].classList.add("invalid")
}
}
}
}
#selectCell(i, cell) {
if (this.hasAttribute("readonly") || !this.isActive) return
this.#selectedCells.set(i, cell)
cell.classList.add("selected")
}
#deselectCell(i, cell) {
if (this.hasAttribute("readonly") || !this.isActive) return
this.#selectedCells.delete(i)
cell.classList.remove("selected")
}
#keyHandler(event) {
if (this.hasAttribute("readonly") || !this.isActive) return
const key = event.key
this.#keyProcessors.get(key)?.(event)
}
connectedCallback() {
this.#root = this.attachShadow({ mode: "open" })
this.#styling = document.createElement("link")
this.#styling.rel = "stylesheet"
this.#styling.href = "sudoku.rewrite.css"
this.#root.appendChild(this.#styling)
this.#container = document.createElement("div")
this.#container.classList.add("sudoku")
this.#root.appendChild(this.#container)
if (this.hasAttribute("size")) {
this.#container.style.fontSize = `${this.getAttribute("size")}px`
}
this.#cellGrid = Array.from({ length: SUDOKU_GRID_SIZE }, (_, i) => {
const cell = document.createElement("div")
cell.classList.add("sudoku-field")
cell.innerHTML = "&nbsp;"
cell.addEventListener("click", () => {
this.#selectCell(i, cell)
})
cell.addEventListener("contextmenu", (event) => {
event.preventDefault()
this.#deselectCell(i, cell)
})
this.#container.appendChild(cell)
return cell
})
document.addEventListener("keydown", this.#keyHandler.bind(this))
this.#activePuzzle = new SudokuPuzzle(this.getAttribute("puzzle"))
this.syncCellsToSudoku()
this.#activePuzzle.addEventListener(
"state-changed",
this.syncCellsToSudoku.bind(this),
)
console.log(this.#activePuzzle)
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === "size" && this.#container) {
this.#container.style.fontSize = `${newValue}px`
}
if (name === "readonly") {
if (this.#container) {
this.#container.classList.toggle("readonly", newValue !== null)
}
if (newValue) {
this.#selectedCells.clear()
}
}
}
}
customElements.define("my-sudoku", SudokuHost)