Sudoku review #1

Open
BordedDev wants to merge 19 commits from BordedDev/sudoku:main into main
7 changed files with 2545 additions and 0 deletions
Showing only changes of commit b5c1058b3b - Show all commits

View File

@ -0,0 +1,27 @@
It doesnt render correctly on firefox but does in edge, its a rectangle, not a square.
Since the scaling is based on the font size, using `em` + `line-height: 1` allows it to scale correctly by adding it to
`.sudoku-field`.
Fixing it by wrapping the numbers and adding `height: 100%` fixes the individual cells, but the whole table is still a
rectangle but causes the cells to overlap.
There is a bunch of unused code
Autosolver is broken, I had to move the function around

Ah, sad. I didn't know status of the code. But if it's working again, i will put the sudoku on my site.

Ah, sad. I didn't know status of the code. But if it's working again, i will put the sudoku on my site.
Switched .forEach to for loop for performance (recommended practice)
Convert the keyboard key lookup to a map, mostly stylistic but I imagine its also easier to edit.
Not sure why there is a col and row class that manages cells if the layout is flat
Getters, setter, properties, functions and constructors are all mixed together, it's hard to read
A custom event manager is used, EventTarget is built into the browser not sure why its not used
Usage of `_` instead of `#` for private variables
It's not recommended to setup the elements in the constructor https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements#implementing_a_custom_element
You can use `||=` for assigning to an undefined variable (if it's undefined)
Borders are on all grid elements, causing a double border on the edges

925
reviews/BordedDev/sudoku.js Normal file
View File

@ -0,0 +1,925 @@
function randInt(min, max) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function cssRuleExists(match) {
for (let i = 0; i < document.styleSheets.length; i++) {
let styleSheet = document.styleSheets[i]
let rules = styleSheet.cssRules
for (let j = 0; j < rules.length; j++) {
let rule = rules[j]
if (rule.selectorText && rule.selectorText == match) {
return true;
}
}
}
return false
}
class EventHandler {

A pretty standard, pretty robust way of handling events. I'd lean to the built-in EventTarget until the need for something better is needed.

A pretty standard, pretty robust way of handling events. I'd lean to the built-in [EventTarget](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget) until the need for something better is needed.
constructor() {
this.events = {};
this.eventCount = 0;
this.suppresEvents = false;
this.debugEvents = false;
}
on(event, listener) {
if (!this.events[event]) {
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 => {
var returnValue = listener(data)

var :(

var :(

Oh no! A VAR!

Oh no! A VAR!
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

A double filter, it's inefficient (since both will loop through the array) but for the size it doesn't really matter

A double filter, it's inefficient (since both will loop through the array) but for the size it doesn't really matter
}
mark() {
this.marked = true
}
unmark() {
this.marked = false
}
select() {
this.selected = true
}
unselect() {
this.selected = false
}
_isValidBox() {
if (!this._value)
return true;
let startRow = this.row.index - this.row.index % (this.row.puzzle.size / 3);
let 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
}
let 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;
let 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() {

Seeing someone implement a custom toString is always nice

Seeing someone implement a custom `toString` is always nice
return String(this.value)
}
toText() {
return this.toString().replace("0", " ");
}
}
class Row extends EventHandler {

The Row/Col classes are confusing in the current situation, since Row isn't used for more than a bag holder/event forwarding

The Row/Col classes are confusing in the current situation, since Row isn't used for more than a bag holder/event forwarding
cols = []
puzzle = null
index = 0
initialized = false
constructor(puzzle) {
super()
this.puzzle = puzzle
this.cols = []
this.index = this.puzzle.rows.length
const me = this

A lot of these me proxies aren't necessary with the arrow functions you're using, they're removed in the modified version

A lot of these `me` proxies aren't necessary with the arrow functions you're using, they're removed in the modified version
this.initialized = false
for (let i = 0; i < puzzle.size; i++) {
const col = new Col(this);
this.cols.push(col);
col.on('update', (field) => {
me.emit('update', field)
})
}
this.initialized = true
}
get data() {
return {
cols: this.cols.map(col => col.data),
index: this.index
}
}
toText() {
let result = ''
for (let 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

Why are there 2 initialized properties?

Why are there 2 initialized properties?

Even worse, one is wrongly written :P

Even worse, one is wrongly written :P
_fields = null
constructor(arg) {
super()
this.debugEvents = true;
this.initalized = false
this.rows = []
if (isNaN(arg)) {

Nice usage of the coercion, even if my IDE complains because there is coercion

Nice usage of the coercion, even if my IDE complains because there is coercion
// 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) {
let invalid = this.invalid;
return invalid[invalid.length - 1];
}
this.rows.forEach(row => {
row.cols.forEach(col => {
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;

Accidental comparison 😅

Accidental comparison 😅

Oh fuck, that can maybe be the reason something is not working or why even the second variable is implemented. Because I really don't know what the reason was.

Oh fuck, that can maybe be the reason something is not working or why even the second variable is implemented. Because I really don't know what the reason was.
this.parsing = true
this.fields.forEach(field => {
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)]

Regex strikes again 🏃💨. Technically inefficient, practically I like it.

BTW it's intentionally not an array it returns so that you can efficiently stream the results through the iterator (that's how I understand the below)

With matchAll() available, you can avoid the while loop and exec with g. Instead, you get an iterator to use with the more convenient for...of, array spreading, or Array.from() constructs:
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/matchAll

Regex strikes again 🏃💨. Technically inefficient, practically I like it. BTW it's intentionally not an array it returns so that you can efficiently stream the results through the iterator (that's how I understand the below) >With matchAll() available, you can avoid the while loop and exec with g. Instead, you get an iterator to use with the more convenient for...of, array spreading, or Array.from() constructs: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/matchAll
let index = 0;
const max = this.size * this.size;
matches.forEach(match => {
const digit = Number(match[0]);
let field = this.fields[index]
field.value = digit;
field.initial = digit != 0
index++;
});
this._initialized = true;
this.parsing = false
this.deselect();
this.initalized = true;
this.suppres(() => {
this.fields.forEach((field) => {
field.update()
})
})
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
newState.fields.forEach(stateField => {
let 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) {
let 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 (let row of this.rows) {
for (let col of row.cols) {
this._fields.push(col)
}
}
}
return this._fields
}
_generateHash() {
var result = 0;
JSON.stringify(this.getData(false)).split('').map(char => {
return char.charCodeAt(0) - '0'.charCodeAt(0)
}).forEach(num => {
result += 26
result = result + num
})
return result
}
get text() {
let result = ''
for (let 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() {
let field = this.getRandomField()
if (!field)
return null
return field
}
deselect() {
this.fields.forEach(field => 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() {
let 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;
}
}
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;

Unfortunately, this doesn't work with dynamic inside out sizing/nested aspect-ration on firefox. Luckily, you can use em since the size is based on font size

Unfortunately, this doesn't work with dynamic inside out sizing/nested aspect-ration on firefox. Luckily, you can use `em` since the size is based on font size

So they're not nice squares on firefox? Upgrade to chrome!

So they're not nice squares on firefox? Upgrade to chrome!

Never! Google can claw my faux-privacy from my cold dead hands (also ublock). I've been considering using chromium/vavaldi so I can get functional HDR (right now I use edge when I need chrome)

Never! Google can claw my faux-privacy from my cold dead hands (also ublock). I've been considering using chromium/vavaldi so I can get functional HDR (right now I use edge when I need chrome)
}
.sudoku-field-initial {

You can combine styles as well, I find it to be a bit easier to work with on average since you can toggle and combine them when it's needed much easier.
Also CSS now supports nested styling rules:

.sudoku-field {
		&.initial {
			color: #777;
		}
		&.selected {
			background-color: lightgreen;
		}
		&.marked {
			background-color: blue;
		}
		&.invalid {
			color: red;
		}
}
You can combine styles as well, I find it to be a bit easier to work with on average since you can toggle and combine them when it's needed much easier. Also CSS now supports nested styling rules: ```css .sudoku-field { &.initial { color: #777; } &.selected { background-color: lightgreen; } &.marked { background-color: blue; } &.invalid { color: red; } } ```

Ah facks. Thanks. Yh, my css skills are very outdated.

Ah facks. Thanks. Yh, my css skills are very outdated.

Still better than my junior. I was very surprised, learning about the CSS upgrade ~this~ last year

Still better than my junior. I was very surprised, learning about the CSS upgrade ~this~ last year
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;

Not sure if the double border was intentional, I assume it wasn't.

The way I solved it was making the border on the bottom and right, you can use the nth selector to disable it on the edges if need be

Not sure if the double border was intentional, I assume it wasn't. The way I solved it was making the border on the bottom and right, you can use the nth selector to disable it on the edges if need be
text-align: center;
padding: 2px;
aspect-ratio: 1/1;
}`
set fieldSize(val) {
this._fieldSize = val ? Number(val) : null
this.fieldElements.forEach(field => {
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');

From what I've read, it's not recommended to create elements in the constructor and instead should be done in the connectedCallback

From what I've read, it's not recommended to create elements in the constructor and instead should be done in the `connectedCallback`

I'm not sure about that. Will look it up, but you kinda want to have the component ready right when it get 'connected'? I also use that event somewhere I guess.

I'm not sure about that. Will look it up, but you kinda want to have the component ready right when it get 'connected'? I also use that event somewhere I guess.

It's here: https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements

In the class constructor, you can set up initial state and default values, register event listeners and perhaps create a shadow root. At this point, you should not inspect the element's attributes or children, or add new attributes or children. See Requirements for custom element constructors and reactions for the complete set of requirements.

That's what the connected event is for. They break that "rule" in the example on the same page, and yeah, it gets called through the _bind function. I think the comment was originally meant to be on the _bind call in the constructor

It's here: https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements > In the class constructor, you can set up initial state and default values, register event listeners and perhaps create a shadow root. At this point, you should not inspect the element's attributes or children, or add new attributes or children. See Requirements for custom element constructors and reactions for the complete set of requirements. That's what the connected event is for. They break that "rule" in the example on the same page, and yeah, it gets called through the `_bind` function. I think the comment was originally meant to be on the `_bind` call in the constructor
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() {
const me = this
this.puzzle.rows.forEach((row) => {
row.cols.forEach((field) => {
const fieldElement = document.createElement('div');
fieldElement.classList.add('sudoku-field');
fieldElement.field = field
field.on('update', (field) => {
me._sync()
})
fieldElement.addEventListener('click', (e) => {
if (!me.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() {
const me = this
this.puzzle.on('update', () => {
me._sync()
});
this.puzzleDiv.addEventListener('mouseenter', (e) => {
me.active = true
})
this.puzzleDiv.addEventListener('mouseexit', (e) => {
me.active = false
})
document.addEventListener('keydown', (e) => {
if (me.readOnly)
return
if (!puzzleManager.active)
return
const puzzle = puzzleManager.active.puzzle
if (e.key == 'u') {
puzzle.popState();
} else if (e.key == 'd') {
puzzle.update((target) => {
puzzle.selected.forEach(field => {
field.value = 0
});
})
} else if (e.key == 'a') {
puzzle.autoSolve()
} else if (e.key == 'r') {
puzzle.fillRandomField();
} else if (!isNaN(e.key)) {
puzzle.update((target) => {
puzzle.selected.forEach(field => {
field.value = Number(e.key)
})
});
} else if (e.key == 'm') {
let fields = [];
puzzle.update((target) => {
target.selected.forEach(field => {
field.selected = false;
fields.push(field)
});
});
puzzle.update((target) => {
fields.forEach((field) => {
field.toggleMarked();
})
});
puzzle.emit('update', puzzle);
}
})
}
autoSolve() {
window.requestAnimationFrame(() => {
if (this.fillRandomField()) {
if (this.empty.length)
return this.autoSolve()
}
})
}
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() {
this.fieldElements.forEach(fieldElement => {
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;
}

View File

@ -0,0 +1,858 @@
function randInt(min, max) {
min = Math.ceil(min)
max = Math.floor(max)
return Math.floor(Math.random() * (max - min + 1)) + min
}

I ran Biome.js over this, and I'm a ; hater (my default config), that's why they're not here

I ran Biome.js over this, and I'm a `;` hater (my default config), that's why they're not here
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 }

Simplified the empty event handler assignment. It was meant to be ??= not ||= both will work in this case because of you how JS works though

Simplified the empty event handler assignment. It was meant to be `??=` not `||=` both will work in this case because of you how JS works though
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]
}

Switched .forEach to for of loop for performance (recommended practice, but honestly, it’s not a big deal - I'd even call it a nitpick)

Switched `.forEach` to `for of` loop for performance (recommended practice, but honestly, it’s not a big deal - I'd even call it a nitpick)
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

Collapsed the index to come from the matches array, since I noted the 1-to-1 relation

Collapsed the index to come from the `matches` array, since I noted the 1-to-1 relation
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)
}
})

This is mostly personal preference, since an "industry standard" way would be key => action enum => (action transform =>) action function. I find this a bit easier to read. If I'd bring in a library for the matching I'd have used cond from lodash https://lodash.com/docs/4.17.15#cond

This is mostly personal preference, since an "industry standard" way would be key => action enum => (action transform =>) action function. I find this a bit easier to read. If I'd bring in a library for the matching I'd have used `cond` from lodash https://lodash.com/docs/4.17.15#cond
} 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
}

View File

@ -0,0 +1,460 @@
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}
*/

Type hints are here since I can see them in my IDE and make things slightly easier to work with, hence why I've put them everywhere but with no actual docs

Type hints are here since I can see them in my IDE and make things slightly easier to work with, hence why I've put them everywhere but with no actual docs
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)[]}
*/

This contains a stack of states, essentially the initial state + all steps

This contains a stack of states, essentially the initial state + all steps
#activeState = new Array(9 * 9)

Ooooh, magic numbers :P Did i have that too?

Ooooh, magic numbers :P Did i have that too?

Dang it I missed it, it's meant to be SUDOKU_GRID_SIZE. You did with the new Puzzle(9)

Dang it I missed it, it's meant to be `SUDOKU_GRID_SIZE`. You did with the `new Puzzle(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()

This mainly to enforce that the underlying grid isn't modifiable, technically we return a new array anyway, so there is little point. #activeState is also always replaced so we can freeze it when it's created, but this felt more flexible

This mainly to enforce that the underlying grid isn't modifiable, technically we return a new array anyway, so there is little point. `#activeState` is also always replaced so we can freeze it when it's created, but this felt more flexible
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
}
}

Since 0 is an invalid value for the SudokuState this allows for them to be filtered out while still increasing the number placement

Since 0 is an invalid value for the `SudokuState` this allows for them to be filtered out while still increasing the number placement
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)[][]}
*/

Recent learned that the variables are optional in destruction, had them as _ before.

Recent learned that the variables are optional in destruction, had them as `_` before.
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])
}
}
}

I'm being a little cheeky here, making the multiplication an addition instead. I can see an argument that this should be a 1 to 3 loop with multiplication, I liked the simplicity of positionIndex this way

I'm being a little cheeky here, making the multiplication an addition instead. I can see an argument that this should be a 1 to 3 loop with multiplication, I liked the simplicity of `positionIndex` this way
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}

These return a Set to simplify/combine the logic for automatically resolving and checking if a number is valid. In theory, the check would be faster since it can check early

These return a `Set` to simplify/combine the logic for automatically resolving and checking if a number is valid. In theory, the check would be faster since it can check early
* @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}

These were necessary for me to have attributeChangedCallback work

These were necessary for me to have `attributeChangedCallback` work
*/
#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 was more convenient for me than the mouseenter/mouseexit events

This was more convenient for me than the mouseenter/mouseexit events
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)

This was more convenient for me than replacing the outer HTML with a <style>...</style> string to get syntax highlighting

This was more convenient for me than replacing the outer HTML with a `<style>...</style>` string to get syntax highlighting
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)

View File

@ -0,0 +1,83 @@
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="text/javascript" src="sudoku.js"></script>
<style>
body {
background-color: #222;
}
.sudoku {
float: left;
color: hwb(0 20% 80%);
opacity: 0.90
}
.sudoku:hover {
opacity: 1;
}
.sudoku-dark {
background-color: #222 !important;
color: #ffffff;
opacity: 0.8
}
.sudoku-dark > .sudoku-field {
color: #333333;
}
.sudoku-dark>.sudoku-field-selected {
color: black;
}
.sudoku-dark>.grid-item {
border: none;
border: 1px solid #333;
}
</style>
</head>
<body>
<pre style="opacity: 0.8; color: #fff;margin:0;padding:10;text-align: left">
Refresh the page for different theme. The widgets are in two colors and multiple sizes available.
These keyboard shortcuts are available for active puzzle widget:
- `d` for deleting last filled field.
- `a` for auto resolving.
- `u` for unlimited undo's.
- `r` for a tip.
Developer notes:
- Widget size is defined by font size on the .soduku css class or as html
attribute to the component.
- You can use an existing puzzle by setting the puzzle attribute on a component.
It must contain at least 81 digits to be valid.
</pre>
<my-sudoku size="17" class="sudoku sudoku-dark" puzzle="
A nice generated puzzle.
It will parse, even with this
nonsense above it. Cool huh?
0 0 8 0 9 0 0 0 0
0 9 0 0 0 0 0 0 7
0 0 0 0 2 0 0 0 0
0 0 3 0 0 0 0 0 0
5 0 0 0 8 0 0 0 0
0 0 6 0 0 0 0 0 0
0 0 0 0 0 3 9 0 0
9 1 0 0 0 0 0 0 0
0 0 0 0 1 0 0 0 0
">
</my-sudoku>
<my-sudoku size="8" class="sudoku" puzzle="generate">
Small generated puzzle
</my-sudoku>
<my-sudoku class="sudoku" puzzle="generate">
Default generated puzzle
</my-sudoku>
<my-sudoku class="sudoku">
Default empty puzzle
</my-sudoku>
</body>
</html>

View File

@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sudoku</title>
<script type="text/javascript" src="sudoku.modified.js" async defer></script>
<style>
body {
background-color: #222;
}
.sudoku {
float: left;
color: hwb(0 20% 80%);
opacity: 0.90
}
.sudoku:hover {
opacity: 1;
}
.sudoku-dark {
background-color: #222 !important;
color: #ffffff;
opacity: 0.8
}
.sudoku-dark > .sudoku-field {
color: #333333;
}
.sudoku-dark > .sudoku-field-selected {
color: black;
}
.sudoku-dark > .grid-item {
border: none;
border: 1px solid #333;
}
.instructions {
opacity: 0.8;
color: #fff;
margin: 0;
padding: 10px;
text-align: left
}
</style>
</head>
<body>
<pre class="instructions">
Refresh the page for different theme. The widgets are in two colors and multiple sizes available.
These keyboard shortcuts are available for active puzzle widget:
- `d` for deleting last filled field.
- `a` for auto resolving.
- `u` for unlimited undo's.
- `r` for a tip.
Developer notes:
- Widget size is defined by font size on the .soduku css class or as html
attribute to the component.
- You can use an existing puzzle by setting the puzzle attribute on a component.
It must contain at least 81 digits to be valid.
</pre>
<my-sudoku size="17" class="sudoku sudoku-dark" puzzle="
A nice generated puzzle.
It will parse, even with this
nonsense above it. Cool huh?
0 0 8 0 9 0 0 0 0
0 9 0 0 0 0 0 0 7
0 0 0 0 2 0 0 0 0
0 0 3 0 0 0 0 0 0
5 0 0 0 8 0 0 0 0
0 0 6 0 0 0 0 0 0
0 0 0 0 0 3 9 0 0
9 1 0 0 0 0 0 0 0
0 0 0 0 1 0 0 0 0
">
</my-sudoku>
<my-sudoku size="8" class="sudoku" puzzle="generate">
Small generated puzzle
</my-sudoku>
<my-sudoku class="sudoku" puzzle="generate">
Default generated puzzle
</my-sudoku>
<my-sudoku class="sudoku">
Default empty puzzle
</my-sudoku>
</body>
</html>

View File

@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sudoku</title>
<script type="text/javascript" src="sudoku.rewrite.js" async defer></script>
<style>
body {
background-color: #222;
}
.sudoku {
float: left;
color: hwb(0 20% 80%);
opacity: 0.90
}
.sudoku:hover {
opacity: 1;
}
.sudoku-dark {
background-color: #222 !important;
color: #ffffff;
opacity: 0.8
}
.sudoku-dark > .sudoku-field {
color: #333333;
}
.sudoku-dark > .sudoku-field-selected {
color: black;
}
.sudoku-dark > .grid-item {
border: none;
border: 1px solid #333;
}
.instructions {
opacity: 0.8;
color: #fff;
margin: 0;
padding: 10px;
text-align: left
}
</style>
</head>
<body>
<pre class="instructions">
Refresh the page for different theme. The widgets are in two colors and multiple sizes available.
These keyboard shortcuts are available for active puzzle widget:
- `d` for deleting last filled field.
- `a` for auto resolving.
- `u` for unlimited undo's.
- `r` for a tip.
Developer notes:
- Widget size is defined by font size on the .soduku css class or as html
attribute to the component.
- You can use an existing puzzle by setting the puzzle attribute on a component.
It must contain at least 81 digits to be valid.
</pre>
<my-sudoku size="17" class="sudoku sudoku-dark" puzzle="
A nice generated puzzle.
It will parse, even with this
nonsense above it. Cool huh?
0 0 8 0 9 0 0 0 0
0 9 0 0 0 0 0 0 7
0 0 0 0 2 0 0 0 0
0 0 3 0 0 0 0 0 0
5 0 0 0 8 0 0 0 0
0 0 6 0 0 0 0 0 0
0 0 0 0 0 3 9 0 0
9 1 0 0 0 0 0 0 0
0 0 0 0 1 0 0 0 0
">
</my-sudoku>
<my-sudoku size="8" class="sudoku" puzzle="generate">
Small generated puzzle
</my-sudoku>
<my-sudoku class="sudoku" puzzle="generate">
Default generated puzzle
</my-sudoku>
<my-sudoku class="sudoku">
Default empty puzzle
</my-sudoku>
</body>
</html>