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) => {
const returnValue = listener(data)
if (returnValue == undefined) return null
return returnValue
})
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() : "&nbsp;"
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
}