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.

  1. 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:
  2. 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.
  3. 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.
  4. 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.
  5. Responsible use. Any risk arising from the use of information from this website is entirely the responsibility of the user.