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 { /** * undefined is an empty cell, null is a cell that was cleared * @typedef {(number|null|undefined)[]} SudokuState */ /** * * @type {SudokuState[]} */ #state = [] /** * * @type {SudokuState} */ #activeState = new Array(SUDOKU_GRID_SIZE) /** * * @returns {SudokuState} * @readonly */ 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 {SudokuState} */ 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 {SudokuState} * @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 {...SudokuState[]} */ 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 {SudokuState} * @return {Set} */ #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 {SudokuState} * @return {Set} */ #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 {SudokuState} * @return {Set} */ getValues(slot, state = this.#activeState) { return this.#boxGetValues(slot, state).union( this.#crossGetValues(slot, state), ) } /** * * @param value {number} * @param slot {number} * @param state {SudokuState} * @return boolean */ isValueValidForSlot(value, slot, state = this.#activeState) { return !this.getValues(slot, state).has(value) } /** * * @param newState {SudokuState} */ 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 } /** * * @returns {SudokuState} */ #generateRandomState() { const newState = Array.from({ length: SUDOKU_GRID_SIZE }) for (let i = 0; i < 17; i++) { this.fillRandomFieldInState(newState) } return newState } /** * * @returns {SudokuState} */ 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 } }), ) } /** * * @param newState {SudokuState} */ 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} */ #selectedCells = new Map() /** * * @type {Map 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 = " " 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)