Module:Peg solitaire diagram
-- Module:Peg solitaire diagram
--
-- A reusable peg-solitaire renderer for Wikipedia/MediaWiki.
--
-- Features:
-- * Renders a single board or a sequence of boards.
-- * Supports arbitrary board shapes using raw board strings.
-- * Supports standard presets: english, european, triangle15.
-- * Supports highlight classes for move diagrams: focus, from, over, to.
-- * Supports label-only cells for notation diagrams (e.g. a b c / ...).
-- * Supports per-board labels and an overall caption.
--
-- Board-string syntax:
-- . or · or 1 = peg
-- o or 0 = hole
-- - or # or _ = off-board / no cell
-- * = peg + focus highlight
-- ! = hole + "to" highlight
-- ? = hole + "over" highlight
-- \x = literal label cell containing x
-- any other visible character = label cell
--
-- Rows are separated by "/" or by newlines.
--
-- Coordinate syntax for highlight/edit parameters:
-- 0:0 zero-based row:col
-- r1c1 one-based row/col
-- A1 spreadsheet-like col/row (one-based)
--
-- List syntax for multi-cell params:
-- semicolon-separated, e.g. "3:3;3:4;4:4"
--
-- Label map syntax:
-- labels = "0:2=a; 0:3=b; 0:4=c; 1:2=d"
--
-- Single-board usage:
-- {{Peg solitaire diagram
-- |preset=english
-- |caption=English peg solitaire board
-- }}
--
-- Sequence usage:
-- {{Peg solitaire diagram
-- |board1=--...--/--.*.--/......./...o.../......./--...--/--...--
-- |board2=--...--/--.o.--/...o.../...*.../......./--...--/--...--
-- |label1=Before
-- |label2=After
-- |separator=→
-- }}
local p = {}
local function trim(s)
if s == nil then
return nil
end
s = mw.text.trim(tostring(s))
if s == '' then
return nil
end
return s
end
local function inferLayout(presetName, explicitLayout)
explicitLayout = trim(explicitLayout)
if explicitLayout then
return explicitLayout
end
if presetName and tostring(presetName):match('^triangle') then
return 'triangle'
end
return 'orthogonal'
end
local function yesno(v, default)
v = trim(v)
if v == nil then
return default
end
v = mw.ustring.lower(v)
if v == '1' or v == 'yes' or v == 'y' or v == 'true' then
return true
elseif v == '0' or v == 'no' or v == 'n' or v == 'false' then
return false
end
return default
end
local function mergedArgs(frame)
local args = {}
if frame:getParent() then
for k, v in pairs(frame:getParent().args) do
if trim(v) ~= nil then
args[k] = v
end
end
end
for k, v in pairs(frame.args) do
if trim(v) ~= nil then
args[k] = v
end
end
return args
end
local function pick(args, key, idx, fallBackToGlobal)
local v
if idx then
v = trim(args[key .. tostring(idx)])
if v ~= nil then
return v
end
end
if fallBackToGlobal then
return trim(args[key])
end
return nil
end
local presets = {
english = table.concat({
'--...--',
'--...--',
'.......',
'...o...',
'.......',
'--...--',
'--...--',
}, '/'),
english_full = table.concat({
'--...--',
'--...--',
'.......',
'.......',
'.......',
'--...--',
'--...--',
}, '/'),
european = table.concat({
'--...--',
'-.....-',
'.......',
'...o...',
'.......',
'-.....-',
'--...--',
}, '/'),
european_full = table.concat({
'--...--',
'-.....-',
'.......',
'.......',
'.......',
'-.....-',
'--...--',
}, '/'),
triangle15 = table.concat({
'----o----',
'---..---',
'--...--',
'-....-',
'.....',
}, '/'),
triangle15_full = table.concat({
'----.----',
'---..---',
'--...--',
'-....-',
'.....',
}, '/'),
}
local pegChars = {
['.'] = true, ['·'] = true, ['1'] = true,
['p'] = true, ['P'] = true,
['●'] = true, ['•'] = true,
}
local holeChars = {
['o'] = true, ['0'] = true,
['○'] = true, ['◦'] = true,
}
local voidChars = {
['-'] = true, ['#'] = true, ['_'] = true,
}
local skipChars = {
[' '] = true, ['\t'] = true, ['|'] = true, [','] = true,
}
local function splitRows(raw)
raw = raw:gsub('\r\n', '\n'):gsub('\r', '\n')
local rows = {}
if raw:find('/') then
for row in mw.text.gsplit(raw, '/', true) do
table.insert(rows, row)
end
else
for row in mw.text.gsplit(raw, '\n', false) do
if trim(row) ~= nil then
table.insert(rows, row)
end
end
end
return rows
end
local function newCell(state, label)
return {
state = state, -- peg, hole, void, label
label = label,
marks = {},
}
end
local function tokenizeRow(row)
local tokens = {}
local i = 1
local len = mw.ustring.len(row)
while i <= len do
local ch = mw.ustring.sub(row, i, i)
if ch == '\\' then
-- Escape: treat the next character literally as a label cell.
if i < len then
local nextCh = mw.ustring.sub(row, i + 1, i + 1)
table.insert(tokens, { ch = nextCh, literal = true })
i = i + 2
else
-- Trailing backslash: render it literally.
table.insert(tokens, { ch = '\\', literal = true })
i = i + 1
end
else
if not skipChars[ch] then
table.insert(tokens, { ch = ch, literal = false })
end
i = i + 1
end
end
return tokens
end
local function parseBoard(raw)
raw = trim(raw)
if not raw then
error('No board or preset supplied.')
end
local rows = splitRows(raw)
if #rows == 0 then
error('Board string is empty.')
end
local cells = {}
local maxCols = 0
for r, row in ipairs(rows) do
cells[r] = {}
for _, token in ipairs(tokenizeRow(row)) do
local ch = token.ch
local cell
if token.literal then
cell = newCell('label', ch)
elseif pegChars[ch] then
cell = newCell('peg')
elseif holeChars[ch] then
cell = newCell('hole')
elseif voidChars[ch] then
cell = newCell('void')
elseif ch == '*' then
cell = newCell('peg')
cell.marks.focus = true
elseif ch == '!' then
cell = newCell('hole')
cell.marks.to = true
elseif ch == '?' then
cell = newCell('hole')
cell.marks.over = true
else
cell = newCell('label', ch)
end
table.insert(cells[r], cell)
end
if #cells[r] > maxCols then
maxCols = #cells[r]
end
end
for r = 1, #cells do
while #cells[r] < maxCols do
table.insert(cells[r], newCell('void'))
end
end
return {
rows = #cells,
cols = maxCols,
cells = cells,
}
end
local function colLettersToNumber(s)
s = mw.ustring.upper(s)
local n = 0
for ch in mw.ustring.gmatch(s, '.') do
local byte = mw.ustring.byte(ch)
if byte < 65 or byte > 90 then
return nil
end
n = n * 26 + (byte - 64)
end
return n
end
local function parseCoord(token)
token = trim(token)
if not token then
return nil, nil
end
-- zero-based row:col
local r0, c0 = token:match('^(%-?%d+)%s*:%s*(%-?%d+)$')
if r0 and c0 then
return tonumber(r0) + 1, tonumber(c0) + 1
end
-- one-based rNcM
local r1, c1 = token:match('^[Rr](%d+)[Cc](%d+)$')
if r1 and c1 then
return tonumber(r1), tonumber(c1)
end
-- spreadsheet-like A1
local col, row = token:match('^([A-Za-z]+)%s*(%d+)$')
if col and row then
return tonumber(row), colLettersToNumber(col)
end
-- one-based bare row,col
local r2, c2 = token:match('^(%d+)%s*,%s*(%d+)$')
if r2 and c2 then
return tonumber(r2), tonumber(c2)
end
return nil, nil
end
local function boardHasCell(board, r, c)
return board
and board.cells[r]
and board.cells[r][c]
and board.cells[r][c].state ~= 'void'
end
local function splitSemicolonList(s)
local out = {}
s = trim(s)
if not s then
return out
end
s = s:gsub('\n', ';')
for item in mw.text.gsplit(s, ';', true) do
item = trim(item)
if item then
table.insert(out, item)
end
end
return out
end
local function applyStateList(board, s, newState)
for _, token in ipairs(splitSemicolonList(s)) do
local r, c = parseCoord(token)
if boardHasCell(board, r, c) then
board.cells[r][c].state = newState
if newState ~= 'label' then
board.cells[r][c].label = nil
end
end
end
end
local function applyMarkList(board, s, markName)
for _, token in ipairs(splitSemicolonList(s)) do
local r, c = parseCoord(token)
if boardHasCell(board, r, c) then
board.cells[r][c].marks[markName] = true
end
end
end
local function applyLabels(board, s)
for _, item in ipairs(splitSemicolonList(s)) do
local coord, label = item:match('^(.-)%s*=%s*(.+)$')
if coord and label then
local r, c = parseCoord(coord)
if boardHasCell(board, r, c) then
board.cells[r][c].label = label
if board.cells[r][c].state == 'void' then
board.cells[r][c].state = 'label'
end
end
end
end
end
local function defaultAltForBoard(board)
local pegCount, holeCount, labelCount = 0, 0, 0
for r = 1, board.rows do
for c = 1, board.cols do
local cell = board.cells[r][c]
if cell.state == 'peg' then
pegCount = pegCount + 1
elseif cell.state == 'hole' then
holeCount = holeCount + 1
elseif cell.state == 'label' then
labelCount = labelCount + 1
end
end
end
if labelCount > 0 and pegCount == 0 and holeCount == 0 then
return 'Peg solitaire notation diagram'
end
return string.format('Peg solitaire board with %d pegs and %d holes', pegCount, holeCount)
end
local function buildBoard(args, idx)
local rawBoard = pick(args, 'board', idx, idx == nil)
local presetName = pick(args, 'preset', idx, idx == nil)
local preset = presetName and presets[presetName] or nil
local board = parseBoard(rawBoard or preset)
-- State edits
applyStateList(board, pick(args, 'fill', idx, idx == nil), 'peg')
applyStateList(board, pick(args, 'empty', idx, idx == nil), 'hole')
applyStateList(board, pick(args, 'void', idx, idx == nil), 'void')
-- Labels
applyLabels(board, pick(args, 'labels', idx, idx == nil))
-- Highlight classes
applyMarkList(board, pick(args, 'focus', idx, idx == nil), 'focus')
applyMarkList(board, pick(args, 'from', idx, idx == nil), 'from')
applyMarkList(board, pick(args, 'over', idx, idx == nil), 'over')
applyMarkList(board, pick(args, 'to', idx, idx == nil), 'to')
return board
end
local function addCellContent(node, cell)
if cell.label ~= nil then
node:wikitext(mw.text.nowiki(tostring(cell.label)))
end
end
local function addRenderedCell(parent, cell, opts)
opts = opts or {}
local size = opts.size or '1.8em'
local gap = opts.gap or '0.18em'
local notFirst = opts.notFirst
local cellNode = parent:tag('span')
:addClass('pegsol-cell')
:css('width', size)
:css('height', size)
:css('line-height', size)
:css('font-size', 'calc(' .. size .. ' * 0.52)')
if opts.absolute then
cellNode
:css('position', 'absolute')
:css('left', opts.left)
:css('top', opts.top)
else
if notFirst then
cellNode:css('margin-left', gap)
end
end
if cell.state == 'void' then
cellNode:addClass('pegsol-void')
:attr('aria-hidden', 'true')
else
cellNode:addClass('pegsol-' .. cell.state)
for markName, enabled in pairs(cell.marks) do
if enabled then
cellNode:addClass('pegsol-' .. markName)
end
end
addCellContent(cellNode, cell)
end
end
local function renderBoardHtml(board, opts)
opts = opts or {}
local layout = trim(opts.layout) or 'orthogonal'
local size = trim(opts.cell)
or (layout == 'triangle'
and 'clamp(0.95em, 2.8vw, 1.55em)'
or 'clamp(1.10em, 2.4vw, 1.8em)')
local gap = trim(opts.gap)
or (layout == 'triangle'
and '0.14em'
or '0.12em')
local alt = trim(opts.alt) or defaultAltForBoard(board)
local boardLabel = trim(opts.label)
local wrap = mw.html.create('div')
:addClass('pegsol-board-wrap')
local boardNode = wrap:tag('div')
:addClass('pegsol-board')
:attr('role', 'img')
:attr('aria-label', alt)
if layout == 'triangle' then
boardNode
:addClass('pegsol-board-triangle')
:css('position', 'relative')
local playableRows = {}
local maxPlayable = 0
for r = 1, board.rows do
local playable = {}
for c = 1, board.cols do
local cell = board.cells[r][c]
if cell.state ~= 'void' then
table.insert(playable, cell)
end
end
if #playable > 0 then
table.insert(playableRows, playable)
if #playable > maxPlayable then
maxPlayable = #playable
end
end
end
local rowCount = #playableRows
boardNode
:css('width',
'calc(' .. size .. ' + ' .. tostring(maxPlayable - 1) .. ' * (' .. size .. ' + ' .. gap .. '))')
:css('height',
'calc(' .. size .. ' + ' .. tostring(rowCount - 1) .. ' * ((' .. size .. ' + ' .. gap .. ') * 0.8660254))')
for r, playable in ipairs(playableRows) do
local rowLen = #playable
local rowOffset = (maxPlayable - rowLen) / 2
for i, cell in ipairs(playable) do
addRenderedCell(boardNode, cell, {
size = size,
gap = gap,
absolute = true,
left = 'calc((' .. string.format('%.6f', rowOffset + (i - 1)) .. ') * (' .. size .. ' + ' .. gap .. '))',
top = 'calc(' .. string.format('%.6f', (r - 1) * 0.8660254) .. ' * (' .. size .. ' + ' .. gap .. '))',
})
end
end
else
for r = 1, board.rows do
local row = boardNode:tag('div')
:addClass('pegsol-row')
for c = 1, board.cols do
addRenderedCell(row, board.cells[r][c], {
size = size,
gap = gap,
notFirst = c > 1,
})
end
end
end
if boardLabel then
wrap:tag('div')
:addClass('pegsol-board-label')
:wikitext(boardLabel)
end
return wrap
end
local function renderSequence(args, boards)
local root = mw.html.create('div')
:addClass('pegsol-root')
local figure = root:tag('div')
:addClass('pegsol-figure')
local sequence = figure:tag('div')
:addClass('pegsol-sequence')
if yesno(args.stack, false) then
sequence:addClass('pegsol-sequence-vertical')
end
local separator = trim(args.separator)
if separator == 'arrow' then
separator = '→'
end
for i, spec in ipairs(boards) do
sequence:node(renderBoardHtml(spec.board, {
cell = trim(args.cell),
gap = trim(args.gap),
alt = spec.alt,
label = spec.label,
layout = spec.layout,
}))
if separator and i < #boards then
sequence:tag('span')
:addClass('pegsol-separator')
:wikitext(mw.text.nowiki(separator))
end
end
local caption = trim(args.caption)
if caption then
figure:tag('div')
:addClass('pegsol-caption')
:wikitext(caption)
end
return tostring(root)
end
local function collectBoards(args)
local boards = {}
local i = 1
while true do
local hasIndexedBoard = pick(args, 'board', i, false) ~= nil
or pick(args, 'preset', i, false) ~= nil
if not hasIndexedBoard then
break
end
local presetName = pick(args, 'preset', i, false)
table.insert(boards, {
board = buildBoard(args, i),
label = pick(args, 'label', i, false),
alt = pick(args, 'alt', i, false),
layout = inferLayout(presetName, pick(args, 'layout', i, false)),
})
i = i + 1
end
if #boards == 0 then
local presetName = trim(args.preset)
table.insert(boards, {
board = buildBoard(args, nil),
label = trim(args.label),
alt = trim(args.alt),
layout = inferLayout(presetName, trim(args.layout)),
})
end
return boards
end
local function renderError(msg)
return tostring(
mw.html.create('strong')
:addClass('error')
:wikitext('Peg solitaire diagram error: ' .. mw.text.nowiki(tostring(msg)))
)
end
function p.main(frame)
local ok, result = pcall(function()
local args = mergedArgs(frame)
local boards = collectBoards(args)
return renderSequence(args, boards)
end)
if ok then
return result
end
return renderError(result)
end
return p
Content Disclaimer
Informasi ini disarikan dari Wikipedia dan disajikan kembali untuk tujuan edukasi. Konten tersedia di bawah lisensi CC BY-SA 3.0. Kami tidak bertanggung jawab atas ketidakakuratan data yang bersumber dari kontribusi publik tersebut.
- The information displayed on this website is sourced in part or in whole from Wikipedia and has been adapted for the purpose of restating it. We strive to provide accurate and relevant information, however:
- There is no guarantee of absolute accuracy. Wikipedia is an open, collaborative project that can be edited by anyone, so information is subject to change.
- It is not intended to constitute professional advice. The content displayed is for informational and educational purposes only. For important decisions (e.g., medical, legal, or financial), please consult a professional.
- Content copyright. Wikipedia is licensed under the Creative Commons Attribution-ShareAlike License (CC BY-SA). This means that content may be reused with appropriate attribution and shared under a similar license.
- Responsible use. Any risk arising from the use of information from this website is entirely the responsibility of the user.