UNPKG

smartdown-gallery

Version:

Example Smartdown documents and associated resources that demonstrate various Smartdown features and serve as raw material for other Smartdown demos.

1,576 lines (1,489 loc) 102 kB
'use strict'; /* MIT License Copyright (c) 2019 Viresh Ratnakar Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. The latest code and documentation for exolve can be found at: https://github.com/viresh-ratnakar/exolve */ const VERSION = 'Exolve v0.36 October 22 2019 (w/reentrancy via DoctorBud)' let puzzleInstanceId = 0; let puzzleInstances = {}; const exolvePrefix = 'exolve-'; class Puzzle { constructor() { const puzzleThis = this; puzzleInstanceId = puzzleInstanceId + 1; this.id = puzzleInstanceId; const puzzlePrefix = `${exolvePrefix}${puzzleInstanceId}-`; puzzleInstances[puzzlePrefix] = this; // ------ Begin globals. let puzzleId = 'exolve-grid' let gridWidth = 0 let gridHeight = 0 let boxWidth = 0 let boxHeight = 0 let gridFirstLine = -1 let gridLastLine = -1 let preludeFirstLine = -1 let preludeLastLine = -1 let acrossFirstLine = -1 let acrossLastLine = -1 let downFirstLine = -1 let downLastLine = -1 let nodirFirstLine = -1 let nodirLastLine = -1 let explanationsFirstLine = -1 let explanationsLastLine = -1 // Each nina will be an array containing location [i,j] pairs and/or span // class names. let ninas = [] // For span-class-specified ninas, ninaClassElements[] stores the elements // along with the colours to apply to them when showing the ninas. let ninaClassElements = [] let showingNinas = false let grid = [] let clues = {} let cellColours = [] let submitURL = null let submitKeys = [] let hasDiagramlessCells = false let hasUnsolvedCells = false let hasSomeAnnos = false let hasAcrossClues = false let hasDownClues = false let hasNodirClues = false // Clues labeled non-numerically (like [A] a clue...) use this to create a // unique clueIndex. let nextNonNumId = 1 let nonNumClueIndices = {} const SQUARE_DIM = 31 const SQUARE_DIM_BY2 = 16 const GRIDLINE = 1 const BAR_WIDTH = 3 const BAR_WIDTH_BY2 = 2 const SEP_WIDTH = 2 const SEP_WIDTH_BY2 = 1.5 const HYPHEN_WIDTH = 9 const HYPHEN_WIDTH_BY2 = 5 const CIRCLE_RADIUS = 0.0 + SQUARE_DIM / 2.0 const NUMBER_START_X = 2 const NUMBER_START_Y = 10 const LIGHT_START_X = 16.5 const LIGHT_START_Y = 21.925 let answersList = [] let revelationList = [] let currentRow = -1 let currentCol = -1 let currentDirectionIsAcross = true let currentClueIndex = null let activeCells = []; let activeClues = []; let numCellsToFill = 0 let allClueIndices = [] let orphanClueIndices = [] // For the orhpan-clues widget. let posInOrphanClueIndices = 0 const BLOCK_CHAR = '⬛'; const ACTIVE_COLOUR = 'mistyrose' const ORPHAN_CLUES_COLOUR = 'white' const TRANSPARENT_WHITE = 'rgba(255,255,255,0.0)' let nextPuzzleTextLine = 0 const STATE_SEP = 'eexxoollvvee' // Variables set by exolve-option let hideInferredNumbers = false let cluesPanelLines = -1 // Variables set in init(). let puzzleTextLines; let numPuzzleTextLines; let svg; let gridInputWrapper; let gridInput; let questions; let background; let acrossClues; let downClues; let nodirClues; let acrossPanel; let downPanel; let nodirPanel; let currentClue; let currentClueParent; let ninaGroup; let statusNumFilled; let statusNumTotal; let savingURL; let clearButton; let clearAllButton; let checkButton; let checkAllButton; let ninasButton; let revealButton; let revealAllButton; let submitButton; // ------ End globals. // ------ Begin functions. // Set up globals, version number and user agent in bug link. function init(puzzleText) { puzzleTextLines = [] let rawLines = puzzleText.trim().split('\n'); for (let rawLine of rawLines) { let cIndex = rawLine.indexOf('#'); // A # followed by a non-space/non-eol character is not a comment marker. while (cIndex >= 0 && cIndex + 1 < rawLine.length && rawLine.charAt(cIndex + 1) != ' ') { cIndex = rawLine.indexOf('#', cIndex + 1); } if (cIndex >= 0) { rawLine = rawLine.substr(0, cIndex).trim() } if (!rawLine) { continue; } puzzleTextLines.push(rawLine) } numPuzzleTextLines = puzzleTextLines.length svg = document.getElementById(puzzlePrefix + 'grid'); gridInputWrapper = document.getElementById(puzzlePrefix + 'grid-input-wrapper'); gridInput = document.getElementById(puzzlePrefix + 'grid-input'); questions = document.getElementById(puzzlePrefix + 'questions'); background = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); acrossPanel = document.getElementById(puzzlePrefix + 'across-clues-panel') downPanel = document.getElementById(puzzlePrefix + 'down-clues-panel') nodirPanel = document.getElementById(puzzlePrefix + 'nodir-clues-panel') acrossClues = document.getElementById(puzzlePrefix + 'across') downClues = document.getElementById(puzzlePrefix + 'down') nodirClues = document.getElementById(puzzlePrefix + 'nodir') currentClue = document.getElementById(puzzlePrefix + 'current-clue') currentClueParent = document.getElementById(puzzlePrefix + 'current-clue-parent') ninaGroup = document.getElementById(puzzlePrefix + 'nina-group') statusNumFilled = document.getElementById(puzzlePrefix + 'status-num-filled') statusNumTotal = document.getElementById(puzzlePrefix + 'status-num-total') savingURL = document.getElementById(puzzlePrefix + 'saving-url') clearButton = document.getElementById(puzzlePrefix + 'clear') clearAllButton = document.getElementById(puzzlePrefix + 'clear-all') checkButton = document.getElementById(puzzlePrefix + 'check') checkAllButton = document.getElementById(puzzlePrefix + 'check-all') ninasButton = document.getElementById(puzzlePrefix + 'ninas') revealButton = document.getElementById(puzzlePrefix + 'reveal') revealAllButton = document.getElementById(puzzlePrefix + 'reveal-all') submitButton = document.getElementById(puzzlePrefix + 'submit') let info = 'Version: ' + VERSION + ', User Agent: ' + navigator.userAgent document.getElementById(puzzlePrefix + 'report-bug').href = 'https://github.com/viresh-ratnakar/exolve/issues/new?body=' + encodeURIComponent(info); } // puzzleTextLines[] has been parsed till line # nextPuzzleTextLine. Fine the // next line beginning with 'exolve-<section>' and return <section> as well // as the 'value' of the section (the part after ':'). function parseToNextSection() { const MARKER = 'exolve-' while (nextPuzzleTextLine < numPuzzleTextLines && puzzleTextLines[nextPuzzleTextLine].trim().indexOf(MARKER) != 0) { nextPuzzleTextLine++; } if (nextPuzzleTextLine >= numPuzzleTextLines) { return null } // Skip past MARKER let line = puzzleTextLines[nextPuzzleTextLine].trim().substr(MARKER.length) let index = line.indexOf(':') if (index < 0) { index = line.length } nextPuzzleTextLine++ return {'section': line.substr(0, index).trim().toLowerCase(), 'value': line.substr(index + 1).trim()} } // Parse a nina line, which consists of cell locations of the nina specified // using "chess notation" (a1 = bottom-left, etc.). Convert the cell locations // to [row col] and push an array of these locations to the global ninas array. function parseNina(s) { let nina = [] let cellsOrClasses = s.split(' ') for (let cellOrClass of cellsOrClasses) { let cellLocation = parseCellLocation(cellOrClass) if (!cellLocation) { // Must be a class name, for a span-class-specified nina nina.push(cellOrClass) } else { nina.push(cellLocation) } } if (nina.length > 0) { ninas.push(nina) } } function parseColour(s) { let colourAndCells = s.split(' ') let colour = '' for (let c of colourAndCells) { if (!colour) { colour = c continue; } let cellLocation = parseCellLocation(c) if (!cellLocation) { addError('Could not parse cell location in: ' + c) return } else { cellColours.push(cellLocation.concat(colour)) } } } // Parse a question line and create the question element for it (which includes // an input box for the answer). The solution answer may be provided after the // last ')'. function parseQuestion(s) { let enumParse = parseEnum(s) let inputLen = enumParse.len + enumParse.hyphenAfter.length + enumParse.wordEndAfter.length let afterEnum = enumParse.afterEnum let rawQ = s.substr(0, afterEnum) let hideEnum = false if (inputLen > 0) { if (s.substr(afterEnum, 1) == '*') { beforeEnum = s.lastIndexOf('(', afterEnum - 1) if (beforeEnum < 0) { addError('Could not find open-paren strangely') return } rawQ = s.substr(0, beforeEnum) afterEnum++ hideEnum = true } } let correctAnswer = s.substr(afterEnum).trim() const question = document.createElement('div') question.setAttributeNS(null, 'class', 'question'); const questionText = document.createElement('span') questionText.innerHTML = rawQ question.appendChild(questionText) question.appendChild(document.createElement('br')) if (inputLen == 0) { hideEnum = true inputLen = '30' } const TEXTAREA_COLS = 68 let rows = Math.floor(inputLen / TEXTAREA_COLS) if (rows * TEXTAREA_COLS < inputLen) { rows++ } let cols = (rows > 1) ? TEXTAREA_COLS : inputLen let aType = 'input' if (rows > 1) { aType = 'textarea' } const answer = document.createElement(aType) if (rows > 1) { answer.setAttributeNS(null, 'rows', '' + rows); answer.setAttributeNS(null, 'cols', '' + cols); } else { answer.setAttributeNS(null, 'size', '' + cols); } answer.setAttributeNS(null, 'class', 'answer'); answersList.push({ 'ans': correctAnswer, 'input': answer, 'hasEnum': (inputLen > 0), }); if (!hideEnum) { let answerValue = '' let wordEndIndex = 0 let hyphenIndex = 0 for (let i = 0; i < enumParse.len; i++) { answerValue = answerValue + '?' if (wordEndIndex < enumParse.wordEndAfter.length && i == enumParse.wordEndAfter[wordEndIndex]) { answerValue = answerValue + ' ' wordEndIndex++ } if (hyphenIndex < enumParse.hyphenAfter.length && i == enumParse.hyphenAfter[hyphenIndex]) { answerValue = answerValue + '-' hyphenIndex++ } } answer.setAttributeNS(null, 'placeholder', '' + answerValue); } answer.setAttributeNS(null, 'class', 'answer'); if (rows == 1) { answer.setAttributeNS(null, 'type', 'text'); } answer.setAttributeNS(null, 'maxlength', '' + inputLen); answer.setAttributeNS(null, 'autocomplete', 'off'); answer.setAttributeNS(null, 'spellcheck', 'false'); question.appendChild(answer) questions.appendChild(question) answer.addEventListener('input', updateAndSaveState); } function parseSubmit(s) { let parts = s.split(' ') if (s.length < 2) { addError('Submit section must have a URL and a param name for the solution') return } submitURL = parts[0] submitKeys = [] for (let i = 1; i < parts.length; i++) { submitKeys.push(parts[i]) } } function parseOption(s) { let sparts = s.split(' ') for (let spart of sparts) { spart = spart.trim().toLowerCase() if (spart == "hide-inferred-numbers") { hideInferredNumbers = true continue } let kv = spart.split(':') if (kv.length != 2) { addError('Expected exolve-option: key:value, got: ' + spart) return } if (kv[0] == 'clues-panel-lines') { cluesPanelLines = parseInt(kv[1]) if (isNaN(cluesPanelLines)) { addError('Unexpected value in exolve-option: clue-panel-lines: ' + kv[1]) } continue } addError('Unexpected exolve-option: ' + spart) return } } // The overall parser for the puzzle text. Also takes care of parsing and // displaying all exolve-* sections except prelude, grid, across, down (for // these, it just captures where the start and end lines are). function parseOverallDisplayMost() { let sectionAndValue = parseToNextSection() while (sectionAndValue && sectionAndValue.section != 'end') { let firstLine = nextPuzzleTextLine let nextSectionAndValue = parseToNextSection() let lastLine = nextPuzzleTextLine - 2 if (sectionAndValue.section == 'begin') { } else if (sectionAndValue.section == 'id') { puzzleId = sectionAndValue.value } else if (sectionAndValue.section == 'title') { document.getElementById(puzzlePrefix + 'title').innerHTML = sectionAndValue.value } else if (sectionAndValue.section == 'setter') { if (sectionAndValue.value.trim() != '') { document.getElementById(puzzlePrefix + 'setter').innerHTML = 'By ' + sectionAndValue.value } } else if (sectionAndValue.section == 'copyright') { document.getElementById(puzzlePrefix + 'copyright').innerHTML = 'Ⓒ ' + sectionAndValue.value } else if (sectionAndValue.section == 'width') { gridWidth = parseInt(sectionAndValue.value) boxWidth = (SQUARE_DIM * gridWidth) + gridWidth + 1 } else if (sectionAndValue.section == 'height') { gridHeight = parseInt(sectionAndValue.value) boxHeight = (SQUARE_DIM * gridHeight) + gridHeight + 1 } else if (sectionAndValue.section == 'prelude') { preludeFirstLine = firstLine preludeLastLine = lastLine } else if (sectionAndValue.section == 'grid') { gridFirstLine = firstLine gridLastLine = lastLine } else if (sectionAndValue.section == 'nina') { parseNina(sectionAndValue.value) } else if (sectionAndValue.section == 'colour' || sectionAndValue.section == 'color') { parseColour(sectionAndValue.value) } else if (sectionAndValue.section == 'question') { parseQuestion(sectionAndValue.value) } else if (sectionAndValue.section == 'submit') { parseSubmit(sectionAndValue.value) } else if (sectionAndValue.section == 'across') { acrossFirstLine = firstLine acrossLastLine = lastLine } else if (sectionAndValue.section == 'down') { downFirstLine = firstLine downLastLine = lastLine } else if (sectionAndValue.section == 'nodir') { nodirFirstLine = firstLine nodirLastLine = lastLine } else if (sectionAndValue.section == 'option') { parseOption(sectionAndValue.value) } else if (sectionAndValue.section == 'explanations') { explanationsFirstLine = firstLine explanationsLastLine = lastLine } sectionAndValue = nextSectionAndValue } } // Extracts the prelude from its previously identified lines and sets up // its display. function parseAndDisplayPrelude() { if (preludeFirstLine >= 0 && preludeFirstLine <= preludeLastLine) { let preludeText = puzzleTextLines[preludeFirstLine] let l = preludeFirstLine + 1 while (l <= preludeLastLine) { preludeText = preludeText + '\n' + puzzleTextLines[l] l++; } document.getElementById(puzzlePrefix + 'prelude').innerHTML = preludeText } } // Extracts the explanations section from its previously identified lines, // populates its element, and adds it to revelationList. function parseAndDisplayExplanations() { if (explanationsFirstLine >= 0 && explanationsFirstLine <= explanationsLastLine) { let explanationsText = puzzleTextLines[explanationsFirstLine] let l = explanationsFirstLine + 1 while (l <= explanationsLastLine) { explanationsText = explanationsText + '\n' + puzzleTextLines[l] l++; } const explanations = document.getElementById(puzzlePrefix + 'explanations') explanations.innerHTML = explanationsText revelationList.push(explanations) } } // Append an error message to the errors div. Scuttle everything by seting // gridWidth to 0. function addError(error) { document.getElementById(puzzlePrefix + 'errors').innerHTML = document.getElementById(puzzlePrefix + 'errors').innerHTML + '<br/>' + error; gridWidth = 0 } // Run some checks for serious problems with grid id, dimensions, etc. If found, // abort with error. function checkIdAndConsistency() { if (puzzleId.match(/[^a-zA-Z\d-]/)) { addError('Puzzle id should only have alphanumeric characters or -: ' + puzzleId) return } if (gridWidth < 1 || gridWidth > 25 || gridHeight < 1 || gridHeight > 25) { addError('Bad/missing width/height'); return } else if (gridFirstLine < 0 || gridLastLine < gridFirstLine || gridHeight != gridLastLine - gridFirstLine + 1) { addError('Mismatched width/height'); return } for (let i = 0; i < gridHeight; i++) { let lineW = puzzleTextLines[i + gridFirstLine].toUpperCase(). replace(/[^A-Z.0]/g, '').length if (gridWidth != lineW) { addError('Width in row ' + i + ' is ' + lineW + ', not ' + gridWidth); return } } if (submitURL && submitKeys.length != answersList.length + 1) { addError('Have ' + submitKeys.length + ' submit paramater keys, need ' + (answersList.length + 1)); return } } // Parse grid lines into a gridWidth x gridHeight array of objects that have // the following properties: // isLight // hasBarAfter // hasBarUnder // hasCircle // isDiagramless // startsClueLabel // startsAcrossClue // startsDownClue // acrossClueLabel: # // downClueLabel: # // Also set the following globals: // hasDiagramlessCells // hasUnsolvedCells function parseGrid() { let hasSolvedCells = false for (let i = 0; i < gridHeight; i++) { grid[i] = new Array(gridWidth) let gridLine = puzzleTextLines[i + gridFirstLine]. replace(/\s/g, '').toUpperCase() let gridLineIndex = 0 for (let j = 0; j < gridWidth; j++) { grid[i][j] = {}; let letter = gridLine.charAt(gridLineIndex); if (letter != '.') { grid[i][j].isLight = true if (letter != '0') { letter = letter.toUpperCase() if (letter < 'A' || letter > 'Z') { addError('Bad grid entry: ' + letter); gridWidth = 0 return } grid[i][j].solution = letter } } else { grid[i][j].isLight = false } grid[i][j].hasBarAfter = false grid[i][j].hasBarUnder = false grid[i][j].hasCircle = false grid[i][j].isDiagramless = false grid[i][j].prefill = false gridLineIndex++ let thisChar = '' while (gridLineIndex < gridLine.length && (thisChar = gridLine.charAt(gridLineIndex)) && (thisChar == '|' || thisChar == '_' || thisChar == '+' || thisChar == '@' || thisChar == '*' || thisChar == '!' || thisChar == ' ')) { if (thisChar == '|') { grid[i][j].hasBarAfter = true } else if (thisChar == '_') { grid[i][j].hasBarUnder = true } else if (thisChar == '+') { grid[i][j].hasBarAfter = true grid[i][j].hasBarUnder = true } else if (thisChar == '@') { grid[i][j].hasCircle = true } else if (thisChar == '*') { grid[i][j].isDiagramless = true } else if (thisChar == '!') { grid[i][j].prefill = true } else if (thisChar == ' ') { } else { addError('Should not happen! thisChar = ' + thisChar); return } gridLineIndex++ } if (grid[i][j].isDiagramless && letter == '.') { grid[i][j].solution = '1' } if (grid[i][j].prefill && (!grid[i][j].isLight || letter < 'A' || letter > 'Z')) { addError('Bad pre-filled cell (' + i + ',' + j + ') with letter: ' + letter) return } if (grid[i][j].isDiagramless) { hasDiagramlessCells = true } if (letter == '0') { hasUnsolvedCells = true } if (letter >= 'A' && letter <= 'Z' && !grid[i][j].prefill) { hasSolvedCells = true } } } if (hasUnsolvedCells && hasSolvedCells) { addError('Either all or no solutions should be provided') } } function startsAcrossClue(i, j) { if (!grid[i][j].isLight) { return false; } if (j > 0 && grid[i][j - 1].isLight && !grid[i][j - 1].hasBarAfter) { return false; } if (grid[i][j].hasBarAfter) { return false; } if (j == gridWidth - 1) { return false; } if (!grid[i][j + 1].isLight) { return false; } return true; } function startsDownClue(i, j) { if (!grid[i][j].isLight) { return false; } if (i > 0 && grid[i - 1][j].isLight && !grid[i - 1][j].hasBarUnder) { return false; } if (grid[i][j].hasBarUnder) { return false; } if (i == gridHeight - 1) { return false; } if (!grid[i + 1][j].isLight) { return false; } return true; } // Sets starts{Across,Down}Clue (boolean) and startsClueLabel (#) in // grid[i][j]s where clues start. function markClueStartsUsingGrid() { if (hasDiagramlessCells && hasUnsolvedCells) { // Cannot rely on grid. Clue starts should be provided in clues using // prefixes like #a8, #d2, etc. return } let nextClueNumber = 1 for (let i = 0; i < gridHeight; i++) { for (let j = 0; j < gridWidth; j++) { if (startsAcrossClue(i, j)) { grid[i][j].startsAcrossClue = true grid[i][j].startsClueLabel = '' + nextClueNumber clues['A' + nextClueNumber] = {'cells': []} } if (startsDownClue(i, j)) { grid[i][j].startsDownClue = true grid[i][j].startsClueLabel = '' + nextClueNumber clues['D' + nextClueNumber] = {'cells': []} } if (grid[i][j].startsClueLabel) { nextClueNumber++ } } } } // If there are any html closing tags, move past them. function adjustAfterEnum(clueLine, afterEnum) { let lineAfter = clueLine.substr(afterEnum) while (lineAfter.trim().substr(0, 2) == '</') { let closer = clueLine.indexOf('>', afterEnum); if (closer < 0) { return afterEnum } afterEnum = closer + 1 lineAfter = clueLine.substr(afterEnum) } return afterEnum } // Parse a cell location in "chess notation" (a1 = bottom-left, etc.) and // return a two-element array [row, col]. function parseCellLocation(s) { s = s.trim() let col = s.charCodeAt(0) - 'a'.charCodeAt(0) let row = gridHeight - parseInt(s.substr(1)) if (isNaN(row) || isNaN(col) || row < 0 || row >= gridHeight || col < 0 || col >= gridWidth) { return null } return [row, col]; } // Parse an enum like (4) or (4,5), or (5-2,4). // Return an object with the following properties: // len // hyphenAfter[] (0-based indices) // wordEndAfter[] (0-based indices) // afterEnum index after enum function parseEnum(clueLine) { let parse = { 'len': 0, 'wordEndAfter': [], 'hyphenAfter': [], 'afterEnum': clueLine.length, }; let enumLocation = clueLine.search(/\([1-9]+[0-9\-,'’\s]*\)/) if (enumLocation < 0) { // Look for the the string 'word'/'letter'/? in parens. enumLocation = clueLine.search(/\([^)]*(word|letter|\?)[^)]*\)/i) if (enumLocation >= 0) { let enumEndLocation = enumLocation + clueLine.substr(enumLocation).indexOf(')') if (enumEndLocation <= enumLocation) { return parse } parse.afterEnum = adjustAfterEnum(clueLine, enumEndLocation + 1) } return parse } let enumEndLocation = enumLocation + clueLine.substr(enumLocation).indexOf(')') if (enumEndLocation <= enumLocation) { return parse } parse.afterEnum = adjustAfterEnum(clueLine, enumEndLocation + 1) let enumLeft = clueLine.substring(enumLocation + 1, enumEndLocation) let nextPart while (enumLeft && (nextPart = parseInt(enumLeft)) && !isNaN(nextPart) && nextPart > 0) { parse.len = parse.len + nextPart enumLeft = enumLeft.replace(/\s*\d+\s*/, '') let nextSymbol = enumLeft.substr(0, 1) if (nextSymbol == '-') { parse.hyphenAfter.push(parse.len - 1) enumLeft = enumLeft.substr(1) } else if (nextSymbol == ',') { parse.wordEndAfter.push(parse.len - 1) enumLeft = enumLeft.substr(1) } else if (nextSymbol == '\'') { enumLeft = enumLeft.substr(1) } else if (enumLeft.indexOf('’') == 0) { // Fancy apostrophe enumLeft = enumLeft.substr('’'.length) } else { break; } } return parse } // Parse a clue label from the start of clueLine. // Return an object with the following properties: // error // isFiller // clueLabel // isNonNum // dir // hasChildren // skip function parseClueLabel(clueLine) { let parse = {}; parse.dir = '' parse.hasChilden = false parse.skip = 0 const numberParts = clueLine.match(/^\s*[1-9]\d*/) if (numberParts && numberParts.length == 1) { let clueNum = parseInt(numberParts[0]) parse.clueLabel = '' + clueNum parse.isNonNum = false parse.skip = numberParts[0].length } else { let bracOpenParts = clueLine.match(/^\s*\[/) if (!bracOpenParts || bracOpenParts.length != 1) { parse.isFiller = true return parse } let pastBracOpen = bracOpenParts[0].length let bracEnd = clueLine.indexOf(']') if (bracEnd < 0) { parse.error = 'Missing matching ] in clue label in ' + clueLine return parse } parse.clueLabel = clueLine.substring(pastBracOpen, bracEnd).trim() let temp = parseInt(parse.clueLabel) if (!isNaN(temp)) { parse.error = 'Numeric label not allowed in []: ' + clueLabel return parse } if (parse.clueLabel.charAt(parse.clueLabel.length - 1) == '.') { // strip trailing period parse.clueLabel = parse.clueLabel.substr(0, parse.clueLabel.length - 1).trim() } parse.isNonNum = true parse.skip = bracEnd + 1 } clueLine = clueLine.substr(parse.skip) const dirParts = clueLine.match(/^[aAdD]/) // no leading space if (dirParts && dirParts.length == 1) { parse.dir = dirParts[0].trim().toUpperCase() parse.skip += dirParts[0].length clueLine = clueLine.substr(dirParts[0].length) } const commaParts = clueLine.match(/^\s*,/) if (commaParts && commaParts.length == 1) { parse.hasChildren = true parse.skip += commaParts[0].length clueLine = clueLine.substr(commaParts[0].length) } // Consume trailing period if it is there. const periodParts = clueLine.match(/^\s*\./) if (periodParts && periodParts.length == 1) { parse.hasChildren = false parse.skip += periodParts[0].length clueLine = clueLine.substr(periodParts[0].length) } return parse } // Parse a single clue. // Return an object with the following properties: // clueIndex // clueLabel // isNonNum // children[] (raw parseClueLabel() resutls, not yet clueIndices) // clue // len // hyphenAfter[] (0-based indices) // wordEndAfter[] (0-based indices) // startCell[] optional, used in diagramless+unsolved and nonth -numeric labels // anno (the part after the enum, if present) // isFiller // error function parseClue(dir, clueLine) { let parse = {}; clueLine = clueLine.trim() if (clueLine.indexOf('#') == 0) { let startCell = parseCellLocation(clueLine.substr(1)); if (startCell) { parse.startCell = startCell } clueLine = clueLine.replace(/^#[a-z][0-9]*\s*/, '') } let clueLabelParse = parseClueLabel(clueLine) if (clueLabelParse.error) { parse.error = clueLabelParse.error return parse } if (clueLabelParse.isFiller) { parse.isFiller = true return parse } if (clueLabelParse.dir && clueLabelParse.dir != dir) { parse.error = 'Explicit dir ' + clueLabelParse.dir + ' does not match ' + dir + ' in clue: ' + clueLine return parse } parse.clueLabel = clueLabelParse.clueLabel parse.isNonNum = clueLabelParse.isNonNum let clueIndex = dir + parse.clueLabel if (parse.isNonNum) { let nonNumIndex = dir + '#' + (nextNonNumId++) if (!nonNumClueIndices[parse.clueLabel]) { nonNumClueIndices[parse.clueLabel] = [] } nonNumClueIndices[parse.clueLabel].push(nonNumIndex) clueIndex = nonNumIndex } parse.clueIndex = clueIndex clueLine = clueLine.substr(clueLabelParse.skip) parse.children = [] while (clueLabelParse.hasChildren) { clueLabelParse = parseClueLabel(clueLine) if (clueLabelParse.error) { parse.error = 'Error in linked clue number/label: ' + clueLabelParse.error return parse } parse.children.push(clueLabelParse) clueLine = clueLine.substr(clueLabelParse.skip) } let enumParse = parseEnum(clueLine) parse.len = enumParse.len parse.hyphenAfter = enumParse.hyphenAfter parse.wordEndAfter = enumParse.wordEndAfter parse.clue = clueLine.substr(0, enumParse.afterEnum).trim() parse.anno = clueLine.substr(enumParse.afterEnum).trim() return parse } // Parse across and down clues from their exolve sections previously // identified by parseOverallDisplayMost(). function parseClueLists() { // Parse across, down, nodir clues for (let clueDirection of ['A', 'D', 'X']) { let first, last if (clueDirection == 'A') { first = acrossFirstLine last = acrossLastLine } else if (clueDirection == 'D') { first = downFirstLine last = downLastLine } else { first = nodirFirstLine last = nodirLastLine } if (first < 0 || last < first) { continue } let prev = null let filler = '' for (let l = first; l <= last; l++) { let clueLine = puzzleTextLines[l].trim(); if (clueLine == '') { continue; } let clueParse = parseClue(clueDirection, clueLine) if (clueParse.error) { addError('Clue parsing error in: ' + clueLine + ': ' + clueParse.error); return } if (clueParse.isFiller) { filler = filler + clueLine + '\n' continue } if (!clueParse.clueIndex) { addError('Could not parse clue: ' + clueLine); return } if (clues[clueParse.clueIndex] && clues[clueParse.clueIndex].clue) { addError('Clue entry already exists for clue: ' + clueLine); return } if (!clues[clueParse.clueIndex]) { clues[clueParse.clueIndex] = {'cells': []} } clues[clueParse.clueIndex].clue = clueParse.clue clues[clueParse.clueIndex].clueLabel = clueParse.clueLabel clues[clueParse.clueIndex].isNonNum = clueParse.isNonNum clues[clueParse.clueIndex].displayLabel = clueParse.clueLabel clues[clueParse.clueIndex].clueDirection = clueDirection clues[clueParse.clueIndex].fullDisplayLabel = clueParse.clueLabel if (clueDirection != 'X' && clueParse.clueLabel) { clues[clueParse.clueIndex].fullDisplayLabel = clues[clueParse.clueIndex].fullDisplayLabel + clueDirection.toLowerCase() } clues[clueParse.clueIndex].children = clueParse.children clues[clueParse.clueIndex].childrenClueIndices = [] clues[clueParse.clueIndex].len = clueParse.len clues[clueParse.clueIndex].hyphenAfter = clueParse.hyphenAfter clues[clueParse.clueIndex].wordEndAfter = clueParse.wordEndAfter clues[clueParse.clueIndex].anno = clueParse.anno if (clueParse.anno) { hasSomeAnnos = true } if (clueParse.startCell) { let row = clueParse.startCell[0] let col = clueParse.startCell[1] grid[row][col].startsClueLabel = clueParse.clueLabel grid[row][col].forcedClueLabel = true if (clueDirection == 'A') { grid[row][col].startsAcrossClue = true } else if (clueDirection == 'D') { grid[row][col].startsDownClue = true } } clues[clueParse.clueIndex].prev = prev clues[clueParse.clueIndex].next = null if (prev) { clues[prev].next = clueParse.clueIndex } prev = clueParse.clueIndex if (filler) { clues[clueParse.clueIndex].filler = filler filler = '' } if (clueParse.clue) { allClueIndices.push(clueParse.clueIndex) } } if (filler) { addError('Filler line should not be at the end: ' + filler) return } } } // For each cell grid[i][j], set {across,down}ClueLabels using previously // marked clue starts. Adds clues to orphanClueIndices[] if warranted. function setClueMemberships() { // Set across clue memberships for (let i = 0; i < gridHeight; i++) { let clueLabel = '' for (let j = 0; j < gridWidth; j++) { if (grid[i][j].startsAcrossClue) { clueLabel = grid[i][j].startsClueLabel } if (!clueLabel) { continue } if (!grid[i][j].isLight || grid[i][j].isDiagramless) { clueLabel = ''; continue } if (!grid[i][j].startsAcrossClue && j > 0 && grid[i][j - 1].hasBarAfter) { clueLabel = ''; continue } grid[i][j].acrossClueLabel = clueLabel let clueIndex = 'A' + clueLabel if (!clues[clueIndex]) { clueIndex = 'X' + clueLabel } if (!clues[clueIndex]) { if (!nonNumClueIndices[clueLabel]) { clueLabel = '' continue } clueIndex = '' for (ci of nonNumClueIndices[clueLabel]) { if (ci.charAt(0) == 'A' || ci.charAt(0) == 'X') { clueIndex = ci break } } if (!clueIndex) { clueLabel = '' continue } } clues[clueIndex].cells.push([i, j]) } } // Set down clue memberships for (let j = 0; j < gridWidth; j++) { let clueLabel = '' for (let i = 0; i < gridHeight; i++) { if (grid[i][j].startsDownClue) { clueLabel = grid[i][j].startsClueLabel } if (!clueLabel) { continue } if (!grid[i][j].isLight || grid[i][j].isDiagramless) { clueLabel = ''; continue } if (!grid[i][j].startsDownClue && i > 0 && grid[i - 1][j].hasBarUnder) { clueLabel = ''; continue } grid[i][j].downClueLabel = clueLabel let clueIndex = 'D' + clueLabel if (!clues[clueIndex]) { clueIndex = 'X' + clueLabel } if (!clues[clueIndex]) { if (!nonNumClueIndices[clueLabel]) { clueLabel = '' continue } clueIndex = '' for (ci of nonNumClueIndices[clueLabel]) { if (ci.charAt(0) == 'D' || ci.charAt(0) == 'X') { clueIndex = ci break } } if (!clueIndex) { clueLabel = '' continue } } clues[clueIndex].cells.push([i, j]) } } for (let clueIndex of allClueIndices) { if (!clues[clueIndex].cells || !clues[clueIndex].len || !clues[clueIndex].cells.length) { orphanClueIndices.push(clueIndex) } } } // For clues that have "child" clues (indicated like, '2, 13, 14' for parent 2, // child 13, child 14), save the parent-child relationships, and successor grid // cells for last cells in component clues, and spilled-over hyphenAfter and // wordEndAfter locations. function processClueChildren() { for (let clueIndex of allClueIndices) { let clue = clues[clueIndex] if (!clue.children) { continue } // Process children // We also need to note the successor of he last cell from the parent // to the first child, and then from the first child to the next, etc. let lastRowCol = null if (clue.cells.length > 0) { lastRowCol = clue.cells[clue.cells.length - 1] // If we do not know the enum of this clue (likely a diagramless puzzle), // do not set successors. if (!clue.len || clue.len <= 0) { lastRowCol = null } } let lastRowColDir = clue.clueDirection const dupes = {} const allDirections = ['A', 'D', 'X'] for (let child of clue.children) { if (child.error) { addError('Bad child ' + child + ' in ' + clue.cluelabel + clue.clueDirection); return } // Direction could be the same as the direction of the parent. Or, // if there is no such clue, then direction could be the other direction. // The direction could also be explicitly specified with a 'd' or 'a' // suffix. let childIndex = clue.clueDirection + child.clueLabel if (!child.isNonNum) { if (!clues[childIndex]) { for (let otherDir of allDirections) { if (otherDir == clue.clueDirection) { continue; } childIndex = otherDir + child.clueLabel if (clues[childIndex]) { break } } } if (child.dir) { childIndex = child.dir + child.clueLabel } } else { if (!nonNumClueIndices[child.clueLabel] || nonNumClueIndices[child.clueLabel].length < 1) { addError('non-num child label ' + child.clueLabel + ' was not seen') return } childIndex = nonNumClueIndices[child.clueLabel][0] } if (!clues[childIndex] || childIndex == clueIndex) { addError('Invalid child ' + childIndex + ' in ' + clue.cluelabel + clue.clueDirection); return } if (dupes[childIndex]) { addError('Duplicate child ' + childIndex + ' in ' + clue.cluelabel + clue.clueDirection); return } dupes[childIndex] = true if (child.clueLabel) { clue.displayLabel = clue.displayLabel + ', ' + child.clueLabel if (child.dir && child.dir != clue.clueDirection) { clue.displayLabel = clue.displayLabel + child.dir.toLowerCase() } clue.fullDisplayLabel = clue.fullDisplayLabel + ', ' + child.clueLabel if (childIndex.charAt(0) != 'X') { clue.fullDisplayLabel = clue.fullDisplayLabel + childIndex.charAt(0).toLowerCase() } } clue.childrenClueIndices.push(childIndex) let childClue = clues[childIndex] childClue.parentClueIndex = clueIndex if (lastRowCol && childClue.cells.length > 0) { let cell = childClue.cells[0] if (lastRowCol[0] == cell[0] && lastRowCol[1] == cell[1]) { addError('loop in successor for ' + lastRowCol) return } else { grid[lastRowCol[0]][lastRowCol[1]]['successor' + lastRowColDir] = { 'cell': cell, 'direction': childClue.clueDirection }; } } lastRowCol = null if (childClue.cells.length > 0) { lastRowCol = childClue.cells[childClue.cells.length - 1] if (!childClue.len || childClue.len <= 0) { lastRowCol = null } } lastRowColDir = childClue.clueDirection } if (hasDiagramlessCells) { continue } // If clue.wordEndAfter[] or clue.hyphenAfter() spill into children, // then copy the appropriate parts there. let prevLen = clue.cells.length let wordEndIndex = 0 while (wordEndIndex < clue.wordEndAfter.length && clue.wordEndAfter[wordEndIndex] < prevLen) { wordEndIndex++; } let hyphenIndex = 0 while (hyphenIndex < clue.hyphenAfter.length && clue.hyphenAfter[hyphenIndex] < prevLen) { hyphenIndex++; } for (let childIndex of clue.childrenClueIndices) { let childLen = clues[childIndex].cells.length while (wordEndIndex < clue.wordEndAfter.length && clue.wordEndAfter[wordEndIndex] < prevLen + childLen) { let pos = clue.wordEndAfter[wordEndIndex] - prevLen clues[childIndex].wordEndAfter.push(pos) wordEndIndex++ } while (hyphenIndex < clue.hyphenAfter.length && clue.hyphenAfter[hyphenIndex] < prevLen + childLen) { let pos = clue.hyphenAfter[hyphenIndex] - prevLen clues[childIndex].hyphenAfter.push(pos) hyphenIndex++ } prevLen = prevLen + childLen } } } // Place a trailing period and space at the end of clue full display labels that // are not empty. function fixFullDisplayLabels() { for (let clueIndex of allClueIndices) { if (clues[clueIndex].fullDisplayLabel) { clues[clueIndex].fullDisplayLabel = clues[clueIndex].fullDisplayLabel + '. ' } } } // Using hyphenAfter[] and wordEndAfter[] in clues, set // {hyphen,wordEnd}{ToRight,Below} in grid[i][j]s. function setGridWordEndsAndHyphens() { if (hasDiagramlessCells) { // Give up on this return } // Going across for (let i = 0; i < gridHeight; i++) { let clueLabel = '' let clueIndex = '' let positionInClue = -1 for (let j = 0; j < gridWidth; j++) { if (!grid[i][j].acrossClueLabel) { clueLabel = '' clueIndex = '' positionInClue = -1 continue } if (clueLabel == grid[i][j].acrossClueLabel) { positionInClue++ } else { clueLabel = grid[i][j].acrossClueLabel positionInClue = 0 clueIndex = 'A' + clueLabel if (!clues[clueIndex]) { if (!nonNumClueIndices[clueLabel]) { clueLabel = '' clueIndex = '' positionInClue = -1 continue } for (ci of nonNumClueIndices[clueLabel]) { if (ci.charAt(0) == 'A' || ci.charAt(0) == 'X') { clueIndex = ci break } } } if (!clues[clueIndex] || !clues[clueIndex].clue) { clueLabel = '' clueIndex = '' positionInClue = -1 continue } } for (let wordEndPos of clues[clueIndex].wordEndAfter) { if (positionInClue == wordEndPos && j < gridWidth - 1) { grid[i][j].wordEndToRight = true break } } for (let hyphenPos of clues[clueIndex].hyphenAfter) { if (positionInClue == hyphenPos && j < gridWidth - 1) { grid[i][j].hyphenToRight = true break } } } } // Going down for (let j = 0; j < gridWidth; j++) { let clueLabel = '' let clueIndex = '' let positionInClue = -1 for (let i = 0; i < gridHeight; i++) { if (!grid[i][j].downClueLabel) { clueLabel = '' clueIndex = '' positionInClue = -1 continue } if (clueLabel == grid[i][j].downClueLabel) { positionInClue++ } else { clueLabel = grid[i][j].downClueLabel positionInClue = 0 clueIndex = 'D' + clueLabel if (!clues[clueIndex]) { if (!nonNumClueIndices[clueLabel]) { clueLabel = '' clueIndex = '' positionInClue = -1 continue } for (ci of nonNumClueIndices[clueLabel]) { if (ci.charAt(0) == 'D' || ci.charAt(0) == 'X') { clueIndex = ci break } } } if (!clues[clueIndex] || !clues[clueIndex].clue) { clueLabel = '' clueIndex = '' positionInClue = -1 continue } } for (let wordEndPos of clues[clueIndex].wordEndAfter) { if (positionInClue == wordEndPos && i < gridHeight - 1) { grid[i][j].wordEndBelow = true break } } for (let hyphenPos of clues[clueIndex].hyphenAfter) { if (positionInClue == hyphenPos && i < gridHeight - 1) { grid[i][j].hyphenBelow = true break } } } } } function stripLineBreaks(s) { s = s.replace(/<br\s*\/?>/gi, " / ") return s.replace(/<\/br\s*>/gi, "") } function displayClues() { // Populate clues tables. Check that we have all clues for (let clueIndex of allClueIndices) { if (!clues[clueIndex].clue && !clues[clueIndex].parentClueIndex) { addError('Found no clue text nor a parent clue for ' + clueIndex) return } let table = null if (clues[clueIndex].clueDirection == 'A') { table = acrossClues hasAcrossClues = true } else if (clues[clueIndex].clueDirection == 'D') { table = downClues hasDownClues = true } else if (clues[clueIndex].clueDirection == 'X') { table = nodirClues hasNodirClues = true } else { addError('Unexpected clue direction ' + clues[clueIndex].clueDirection + ' in ' + clueIndex) return } if (clues[clueIndex].filler) { let tr = document.createElement('tr') let col = document.createElement('td') col.setAttributeNS(null, 'colspan', '2'); col.setAttributeNS(null, 'class', 'filler'); col.innerHTML = clues[clueIndex].filler tr.appendChild(col) table.appendChild(tr) } let tr = document.createElement('tr') let col1 = document.createElement('td') col1.innerHTML = `<div class="clue-label">${clues[clueIndex].displayLabel}</div>`; let col2 = document.createElement('td') col2.innerHTML = clues[clueIndex].clue // If clue contains <br> tags, replace them with "/" for future renderings // in the "current clue" strip. if (clues[clueIndex].clue.indexOf('<') >= 0) { clues[clueIndex].clue = stripLineBreaks(clues[clueIndex].clue) } if (clues[clueIndex].anno) { let anno = document.createElement('span') anno.setAttributeNS(null, 'class', 'anno-text'); anno.innerHTML = ' ' + clues[clueIndex].anno anno.style.display = 'none' revelationList.push(anno) col2.appendChild(anno) clues[clueIndex].annoSpan = anno } tr.appendChild(col1) tr.appendChild(col2) if (clues[clueIndex].cells.length > 0) { let i = clues[clueIndex].cells[0][0] let j = clues[clueIndex].cells[0][1] tr.addEventListener('click', getRowColDirActivator( i, j, clues[clueIndex].clueDirection)); } else { // Fully diagramless. Just select clue. tr.addEventListener('click', getClueSelector(clueIndex)); } clues[clueIndex].clueTR = tr table.appendChild(tr) } if (cluesPanelLines > 0) { const ems = 1.40 * cluesPanelLines const emsStyle = '' + ems + 'em' acrossPanel.style.height = emsStyle downPanel.style.height = emsStyle if (nodirPanel) { nodirPanel.style.height = emsStyle } } if (hasAcrossClues) { acrossPanel.style.display = '' } if (hasDownClues) { downPanel.style.display = '' } if (hasNodirClues) { nodirPanel.style.display = '' } } function displayGridBackground() { svg.setAttributeNS(null, 'viewBox', '0 0 ' + boxWidth + ' ' + boxHeight) svg.setAttributeNS(null, 'width', boxWidth); svg.setAttributeNS(null, 'height', boxHeight); background.setAttributeNS(null, 'x', 0); background.setAttributeNS(null, 'y', 0); background.setAttributeNS(null, 'width', boxWidth); background.setAttributeNS(null, 'height', boxHeight); background.setAttributeNS(null, 'class', 'background'); svg.appendChild(background); } // Return a string encoding the current entries in the whole grid and // also the number of squares that have been filled. function getGridStateAndNumFilled() { let state = ''; let numFilled = 0 for (let i = 0; i < gridHeight; i++) { for (let j = 0; j < gridWidth; j++) { if (grid[i][j].isLight || grid[i][j].isDiagramless) { let letter = grid[i][j].currentLetter.trim() if (letter == '') { state = state + '0' } else { state = state + letter numFilled++ } } else { state = state + '.' } } } return [state, numFilled]; } // Update status, ensure answer fields are upper-case (when they have // an enum), disable buttons as needed, and return the state. function updateDisplayAndGetState() { let stateAndFilled = getGridStateAndNumFilled(); let state = stateAndFilled[0] let numFilled = stateAndFilled[1] statusNumFilled.innerHTML = numFilled for (let a of answersList) { if (a.hasEnum) { a.input.value = a.input.value.toUpperCase() } } clearButton.disabled = (activeCells.length == 0) checkButton.disabled = (activeCells.length == 0) revealButton.disabled = (activeCells.length == 0) submitButton.disabled = (numFilled != numCellsToFill) return state } // Call updateDisplayAndGetState() and save state in cookie and location.hash. function updateAndSaveState() { let state = updateDisplayAndGetState() for (let a of answersList) { state = state + STATE_SEP + a.input.value } // Keep cookie for these many days const KEEP_FOR_DAYS = 90 let d = new Date(); d.setTime(d.getTime() + (KEEP_FOR_DAYS * 24 * 60 * 60 * 1000)); let expires = 'expires=' + d.toUTCString(); document.cookie = puzzleId + '=' + state + ';' + expires + ';path=/'; let oldState = decodeURIComponent(location.hash.substr(1)) let parts = oldState.split('?'); let newParts = []; let newPartsForSaving = []; let found = false; for (const part in parts) { const partString = parts[part]; if (partString.indexOf(exolvePrefix) === 0) { const eqIndex = partString.indexOf('='); const k