843 lines
23 KiB
JavaScript
Raw Normal View History

2025-01-10 21:53:10 +00:00
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 {
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)
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;
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() {
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
const me = this
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
_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) {
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;
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)]
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;
}
.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;
}`
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') ? true : false
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 ? true : false
}
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(){
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() {
const me = this
window.requestAnimationFrame(() => {
if (me.fillRandomField()) {
if (me.empty.length)
return me.autoSolve()
}
})
}
get(row, col){
return this.puzzle.get(row,col)
}
_syncField(fieldElement) {
const field = fieldElement.field
fieldElement.classList.remove('sudoku-field-selected')
fieldElement.classList.remove('sudoku-field-empty')
fieldElement.classList.remove('sudoku-field-invalid')
fieldElement.classList.remove('sudoku-field-initial')
fieldElement.classList.remove('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;
}