function randInt(min, max) { min = Math.ceil(min) max = Math.floor(max) return Math.floor(Math.random() * (max - min + 1)) + min } class EventHandler { constructor() { this.events = {} this.eventCount = 0 this.suppresEvents = false this.debugEvents = false } on(event, listener) { this.events[event] ||= { name: name, listeners: [], callCount: 0 } this.events[event].listeners.push(listener) } off(event, listenerToRemove) { if (!this.events[event]) return this.events[event].listeners = this.events[event].listeners.filter( (listener) => listener !== listenerToRemove, ) } emit(event, data) { if (!this.events[event]) return [] if (this.suppresEvents) return [] this.eventCount++ const returnValue = this.events[event].listeners.map((listener) => { return listener(data) ?? null }) this.events[event].callCount++ if (this.debugEvents) { console.debug("debugEvent", { event: event, arg: data, callCount: this.events[event].callCount, number: this.eventCount, returnValues: returnValue, }) } return returnValue } suppres(fn) { const originallySuppressed = this.suppresEvents this.suppresEvents = true fn(this) this.suppresEvents = originallySuppressed return originallySuppressed } } class Col extends EventHandler { id = 0 values = [] initial = false _marked = false row = null index = 0 valid = true _selected = false _value = 0 _isValidXy() { if (!this._value) return true return ( this.row.puzzle.fields .filter((field) => { return ( (field.index == this.index && field.value == this._value) || (field.row.index == this.row.index && field.value == this._value) ) }) .filter((field) => field != this).length == 0 ) } mark() { this.marked = true } unmark() { this.marked = false } select() { this.selected = true } unselect() { this.selected = false } _isValidBox() { if (!this._value) return true const startRow = this.row.index - (this.row.index % (this.row.puzzle.size / 3)) const startCol = this.index - (this.index % (this.row.puzzle.size / 3)) for (let i = 0; i < this.row.puzzle.size / 3; i++) { for (let j = 0; j < this.row.puzzle.size / 3; j++) { const field = this.row.puzzle.get(i + startRow, j + startCol) if (field != this && field.value == this._value) { return false } } } return true } validate() { if (!this.row.puzzle.initalized) { return this.valid } if (this.initial) { this.valid = true return this.valid } if (!this.value && !this.valid) { this.valid = true this.emit("update", this) return this.valid } const oldValue = this.valid this.valid = this._isValidXy() && this._isValidBox() if (oldValue != this.valid) { this.emit("update", this) } return this.valid } set value(val) { if (this.initial) return const digit = Number(val) const validDigit = digit >= 0 && digit <= 9 const update = validDigit && digit != this.value if (update) { this._value = Number(digit) this.validate() this.emit("update", this) } } get value() { return this._value } get selected() { return this._selected } set selected(val) { if (val != this._selected) { this._selected = val if (this.row.puzzle.initalized) this.emit("update", this) } } get marked() { return this._marked } set marked(val) { if (val != this._marked) { this._marked = val if (this.row.puzzle.initalized) { this.emit("update", this) } } } constructor(row) { super() this.row = row this.index = this.row.cols.length this.id = this.row.puzzle.rows.length * this.row.puzzle.size + this.index this.initial = false this.selected = false this._value = 0 this.marked = false this.valid = true } update() { this.emit("update", this) } toggleSelected() { this.selected = !this.selected } toggleMarked() { this.marked = !this.marked } get data() { return { values: this.values, value: this.value, index: this.index, id: this.id, row: this.row.index, col: this.index, valid: this.valid, initial: this.initial, selected: this.selected, marked: this.marked, } } toString() { return String(this.value) } toText() { return this.toString().replace("0", " ") } } class Row extends EventHandler { cols = [] puzzle = null index = 0 initialized = false constructor(puzzle) { super() this.puzzle = puzzle this.cols = [] this.index = this.puzzle.rows.length this.initialized = false for (let i = 0; i < puzzle.size; i++) { const col = new Col(this) this.cols.push(col) col.on("update", (field) => { this.emit("update", field) }) } this.initialized = true } get data() { return { cols: this.cols.map((col) => col.data), index: this.index, } } toText() { let result = "" for (const col of this.cols) { result += col.toText() } return result } toString() { return this.toText().replaceAll(" ", "0") } } class Puzzle extends EventHandler { rows = [] size = 0 hash = 0 states = [] parsing = false _initialized = false initalized = false _fields = null constructor(arg) { super() this.debugEvents = true this.initalized = false this.rows = [] if (isNaN(arg)) { // load session } else { this.size = Number(arg) } for (let i = 0; i < this.size; i++) { const row = new Row(this) this.rows.push(row) row.on("update", (field) => { this.onFieldUpdate(field) }) } this._initialized = true this.initalized = true this.commitState() } validate() { return this.valid } _onEventHandler() { this.eventCount++ } makeInvalid() { if (!app.valid) { const invalid = this.invalid return invalid[invalid.length - 1] } for (const row of this.rows) { for (const col of row.cols) { if (col.value) { let modify = null if (col.index == this.size) { modify = this.get(row.index, col.index - 2) } else { modify = this.get(row.index, col.index + 1) } modify.value = col.value // last one is invalid return modify.index > col.index ? modify : col } col.valid = false } } this.get(0, 0).value = 1 this.get(0, 1).value = 1 return this.get(0, 1) } reset() { this._initialized = false this.initalized = false this.parsing = true for (const field of this.fields) { field.initial = false field.selected = false field.marked = false field.value = 0 } this.hash = 0 this.states = [] this.parsing = false this.initalized = true this._initialized = true this.commitState() } get valid() { return this.invalid.length == 0 } get invalid() { this.emit("validating", this) const result = this.fields.filter((field) => !field.validate()) this.emit("validated", this) return result } get selected() { return this.fields.filter((field) => field.selected) } get marked() { return this.fields.filter((field) => field.marked) } loadString(content) { this.emit("parsing", this) this.reset() this.parsing = true this.initalized = false this._initialized = false const regex = /\d/g const matches = [...content.matchAll(regex)] const max = this.size * this.size for (const [index, match] of matches.entries()) { const digit = Number(match[0]) const field = this.fields[index] field.value = digit field.initial = digit != 0 } this._initialized = true this.parsing = false this.deselect() this.initalized = true this.suppres(() => { for (const field of this.fields) { field.validate() } }) this.commitState() this.emit("parsed", this) this.emit("update", this) } get state() { return this.getData(true) } get previousState() { if (this.states.length == 0) return null return this.states.at(this.states.length - 1) } get stateChanged() { if (!this._initialized) return false return !this.previousState || this.state != this.previousState } commitState() { if (!this.initalized) return false this.hash = this._generateHash() if (this.stateChanged) { this.states.push(this.state) this.emit("commitState", this) return true } return false } onFieldUpdate(field) { if (!this.initalized) return false if (!this._initialized) return this.validate() this.commitState() this.emit("update", this) } get data() { return this.getData(true) } popState() { let prevState = this.previousState if (!prevState) { this.deselect() return null } while (prevState && prevState.hash == this.state.hash) prevState = this.states.pop() if (!prevState) { this.deselect() return null } this.applyState(prevState) this.emit("popState", this) return prevState } applyState(newState) { this._initialized = false for (const stateField of newState.fields) { const field = this.get(stateField.row, stateField.col) field.selected = stateField.selected field.values = stateField.values field.value = stateField.value field.initial = stateField.initial field.validate() } this._initialized = true this.emit("stateApplied", this) this.emit("update", this) } getData(withHash = false) { const result = { fields: this.fields.map((field) => field.data), size: this.size, valid: this.valid, } if (withHash) { result.hash = this._generateHash() } return result } get(row, col) { if (!this.initalized) return null if (!this.rows.length) return null if (!this.rows[row]) return null return this.rows[row].cols[col] } get fields() { if (this._fields == null) { this._fields = [] for (const row of this.rows) { for (const col of row.cols) { this._fields.push(col) } } } return this._fields } _generateHash() { return JSON.stringify(this.getData(false)) .split("") .map((char) => { return char.charCodeAt(0) - "0".charCodeAt(0) }) .reduce((result, num) => { return result + num + 26 }, 0) } get text() { let result = "" for (const row of this.rows) { result += `${row.toText()}\n` } result = result.slice(0, result.length - 1) return result } get initialFields() { return this.fields.filter((field) => field.initial) } get json() { return JSON.stringify(this.data) } get zeroedText() { return this.text.replaceAll(" ", "0") } get string() { return this.toString() } toString() { return this.text.replaceAll("\n", "").replaceAll(" ", "0") } get humanFormat() { return ` ${this.text.replaceAll(" ", "0").split("").join(" ")}` } getRandomField() { const emptyFields = this.empty return emptyFields[randInt(0, emptyFields.length - 1)] } update(callback) { this.commitState() this.intalized = false callback(this) this.intalized = true this.validate() this.commitState() this.intalized = false if (this.valid) this.deselect() this.intalized = true this.emit("update", this) } get empty() { return this.fields.filter((field) => field.value == 0) } getRandomEmptyField() { const field = this.getRandomField() if (!field) return null return field } deselect() { for (const field of this.fields) { field.selected = false } } generate() { this.reset() this.initalized = false for (let i = 0; i < 17; i++) { this.fillRandomField() } this.deselect() this.initalized = true this.commitState() this.emit("update", this) } fillRandomField() { const field = this.getRandomEmptyField() if (!field) return this.deselect() field.selected = true let number = 0 number++ while (number <= 9) { field.value = randInt(1, 9) field.update() if (this.validate()) { field.initial = true return field } number++ } return false } autoSolve() { window.requestAnimationFrame(() => { if (this.fillRandomField()) { if (this.empty.length) return this.autoSolve() } }) } } class PuzzleManager { constructor(size) { this.size = size this.puzzles = [] this._activePuzzle = null } addPuzzle(puzzle) { this.puzzles.push(puzzle) } get active() { return this.activePuzzle } set activePuzzle(puzzle) { this._activePuzzle = puzzle } get activePuzzle() { return this._activePuzzle } } const puzzleManager = new PuzzleManager(9) class Sudoku extends HTMLElement { styleSheet = ` .sudoku { font-size: 13px; color:#222; display: grid; grid-template-columns: repeat(9, 1fr); grid-template-rows: auto; gap: 0px; user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; background-color: #e5e5e5; border-radius: 5px; aspect-ratio: 1/1; } .sudoku-field-initial { color: #777; } .sudoku-field-selected { background-color: lightgreen; } .soduku-field-marked { background-color: blue; } .sudoku-field-invalid { color: red; } .sudoku-field { border: 1px solid #ccc; text-align: center; padding: 2px; aspect-ratio: 1/1; width: 1em; line-height: 1; }` set fieldSize(val) { this._fieldSize = val ? Number(val) : null for (const field of this.fieldElements) { field.style.fontSize = this._fieldSize ? `${this._fieldSize.toString()}px` : "" } } get fieldSize() { return this._fieldSize } get eventCount() { return this.puzzle.eventCount } get puzzleContent() { return this.puzzle.humanFormat } set puzzleContent(val) { if (val == "generate") { this.puzzle.generate() } else if (val) { this.puzzle.loadString(val) } else { this.puzzle.reset() } } connectedCallback() { this.puzzleContent = this.getAttribute("puzzle") ? this.getAttribute("puzzle") : null this._fieldSize = null this.fieldSize = this.getAttribute("size") ? this.getAttribute("size") : null this.readOnly = !!this.getAttribute("read-only") this.attachShadow({ mode: "open" }) this.shadowRoot.appendChild(this.styleElement) this.shadowRoot.appendChild(this.puzzleDiv) } toString() { return this.puzzleContent } set active(val) { this._active = val if (this._active) this.manager.activePuzzle = this } get active() { return this._active } set readOnly(val) { this._readOnly = !!val } get readOnly() { return this._readOnly } constructor() { super() this._readOnly = false this._active = false this.fieldElements = [] this.puzzle = new Puzzle(9) this.fields = [] this.styleElement = document.createElement("style") this.styleElement.textContent = this.styleSheet this.puzzleDiv = document.createElement("div") this.puzzleDiv.classList.add("sudoku") this._bind() this.manager.addPuzzle(this) } get manager() { return puzzleManager } _bind() { this._bindFields() this._bindEvents() this._sync() } _bindFields() { for (const row of this.puzzle.rows) { for (const field of row.cols) { const fieldElement = document.createElement("div") const subFieldElement = document.createElement("div") fieldElement.classList.add("sudoku-field") fieldElement.field = field fieldElement.appendChild(subFieldElement) field.on("update", (field) => { this._sync() }) fieldElement.addEventListener("click", (e) => { if (!this.readOnly) field.toggleSelected() }) fieldElement.addEventListener("contextmenu", (e) => { e.preventDefault() field.row.puzzle.update(() => { field.selected = false field.value = 0 }) }) this.fields.push(field) this.fieldElements.push(fieldElement) this.puzzleDiv.appendChild(fieldElement) } } } _bindEvents() { this.puzzle.on("update", () => { this._sync() }) this.puzzleDiv.addEventListener("mouseenter", (e) => { this.active = true }) this.puzzleDiv.addEventListener("mouseexit", (e) => { this.active = false }) document.addEventListener("keydown", (e) => { if (this.readOnly) return if (!puzzleManager.active) return const puzzle = puzzleManager.active.puzzle if (!isNaN(e.key)) { puzzle.update((target) => { for (const field of puzzle.selected) { field.value = Number(e.key) } }) } else { const keyLookup = { u() { puzzle.popState() }, d() { puzzle.update((target) => { for (const field of puzzle.selected) { field.value = 0 } }) }, a() { puzzle.autoSolve() }, r() { puzzle.fillRandomField() }, m() { const fields = [] puzzle.update((target) => { for (const field of target.selected) { field.selected = false fields.push(field) } }) puzzle.update((target) => { for (const field of fields) { field.toggleMarked() } }) puzzle.emit("update", puzzle) }, } keyLookup[e.key]?.() } }) } get(row, col) { return this.puzzle.get(row, col) } _syncField(fieldElement) { const field = fieldElement.field fieldElement.classList.remove( "sudoku-field-selected", "sudoku-field-empty", "sudoku-field-invalid", "sudoku-field-initial", "sudoku-field-marked", ) console.info("Removed marked class") fieldElement.innerHTML = field.value ? field.value.toString() : " " if (field.selected) { fieldElement.classList.add("sudoku-field-selected") window.selected = field.field } if (!field.valid) { fieldElement.classList.add("sudoku-field-invalid") } if (!field.value) { fieldElement.classList.add("sudoku-field-empty") } if (field.initial) { fieldElement.classList.add("sudoku-field-initial") } if (field.marked) { fieldElement.classList.add("sudoku-field-marked") console.info("added marked lcass") } } _sync() { for (const fieldElement of this.fieldElements) { this._syncField(fieldElement) } } } customElements.define("my-sudoku", Sudoku) function generateIdByPosition(element) { const parent = element.parentNode const index = Array.prototype.indexOf.call(parent.children, element) const generatedId = `${element.tagName.toLowerCase()}-${index}` element.id = generatedId.replace("div-", "session-key-") return element.id }