Модуль:ТранспортнаяСхема: различия между версиями
Внешний вид
Tringoli (обсуждение | вклад) мНет описания правки |
Tringoli (обсуждение | вклад) мНет описания правки Метка: ручная отмена |
||
| (не показано 14 промежуточных версий этого же участника) | |||
| Строка 1: | Строка 1: | ||
local p = {} | local p = {} | ||
-- Извлечение аргументов | |||
local function getArgs(frame) | local function getArgs(frame) | ||
local args = {} | local args = {} | ||
| Строка 15: | Строка 16: | ||
end | end | ||
-- Безопасная очистка текста JSON | |||
local function cleanJsonText(rawText) | |||
rawText = mw.ustring.gsub(rawText, '<noinclude>.-</noinclude>', '') | |||
rawText = string.gsub(rawText, '<!%-%-.-%-%->', '') | |||
rawText = string.gsub(rawText, '\194\160', ' ') | |||
rawText = string.gsub(rawText, '\194\173', '') | |||
return mw.text.trim(rawText) | |||
end | |||
-- Загрузка данных из data.json | |||
local function loadData(args) | local function loadData(args) | ||
local dataParam = args['данные'] or args[1] | local dataParam = args['данные'] or args[1] or 'Муроморского' | ||
dataParam = mw.text.trim(dataParam) | |||
local | local cleanName = mw.ustring.gsub(dataParam, '^[Шш][Аа][Бб][Лл][Оо][Нн]:', '') | ||
cleanName = mw.ustring.gsub(cleanName, '^[Tt][Ee][Mm][Pp][Ll][Aa][Tt][Ee]:', '') | |||
cleanName = mw.ustring.gsub(cleanName, '/data%.json$', '') | |||
cleanName = mw.ustring.gsub(cleanName, '%.json$', '') | |||
cleanName = mw.ustring.gsub(cleanName, '^[Ии]нтерактивная схема%s*', '') | |||
cleanName = mw.text.trim(cleanName) | |||
local | local candidates = {} | ||
local function addCandidate(title) | |||
if title and title ~= '' then | |||
if | for _, existing in ipairs(candidates) do | ||
if existing == title then return end | |||
end | end | ||
table.insert(candidates, title) | |||
end | end | ||
end | end | ||
addCandidate(dataParam) | |||
addCandidate('Шаблон:Интерактивная схема ' .. cleanName .. '/data.json') | |||
addCandidate('Шаблон:Интерактивная схема ' .. cleanName .. ' метрополитена/data.json') | |||
addCandidate('Шаблон:' .. cleanName .. '/data.json') | |||
addCandidate('Шаблон:' .. cleanName) | |||
addCandidate('Интерактивная схема ' .. cleanName .. '/data.json') | |||
addCandidate('Интерактивная схема ' .. cleanName .. ' метрополитена/data.json') | |||
addCandidate(cleanName .. '/data.json') | |||
addCandidate(cleanName) | |||
local jsonErrors = {} | |||
local | |||
for _, | for _, pageTitle in ipairs(candidates) do | ||
if | local ok, data = pcall(mw.loadJsonData, pageTitle) | ||
if ok and type(data) == 'table' then | |||
return data | |||
end | end | ||
local titleObj = mw.title.new(pageTitle) | |||
if | if titleObj then | ||
local | local rawText = titleObj:getContent() | ||
if rawText and rawText ~= '' then | |||
rawText = cleanJsonText(rawText) | |||
local decOk, decoded = pcall(mw.text.jsonDecode, rawText) | |||
if decOk and type(decoded) == 'table' then | |||
return decoded | |||
else | |||
table.insert(jsonErrors, '[[' .. pageTitle .. ']] (' .. tostring(decoded) .. ')') | |||
end | |||
end | end | ||
end | end | ||
end | end | ||
if #jsonErrors > 0 then | |||
error('Ошибка в синтаксисе JSON:\n* ' .. table.concat(jsonErrors, '\n* ')) | |||
end | |||
error('Страница данных не найдена ни по одному из путей:\n* [[' .. table.concat(candidates, ']]\n* [[') .. ']]') | |||
end | end | ||
local function | -- Парсер координат "X Y" | ||
local | local function parseCoords(str) | ||
if not str then return nil, nil end | |||
local x, y = mw.ustring.match(str, '(%d+%.?%d*)%s+(%d+%.?%d*)') | |||
return tonumber(x), tonumber(y) | |||
return | |||
end | end | ||
function p. | -- Генерация интерактивной Canvas-схемы с панорамированием и управлением | ||
function p.canvas(frame) | |||
local args = getArgs(frame) | local args = getArgs(frame) | ||
local | local data = loadData(args) | ||
local origW = tonumber(args['ширина_оригинала'] or 850) or 850 | |||
local origH = tonumber(args['высота_оригинала'] or 620) or 620 | |||
-- Кадрирование | |||
local cropX, cropY, cropW, cropH = nil, nil, nil, nil | |||
if args['кадрирование'] or args['crop'] then | |||
local c1, c2, c3, c4 = mw.ustring.match(args['кадрирование'] or args['crop'], '(%d+%.?%d*)%s+(%d+%.?%d*)%s+(%d+%.?%d*)%s+(%d+%.?%d*)') | |||
cropX, cropY, cropW, cropH = tonumber(c1), tonumber(c2), tonumber(c3), tonumber(c4) | |||
elseif args['фокус'] or args['focus'] then | |||
local fx, fy = parseCoords(args['фокус'] or args['focus']) | |||
local fw = tonumber(args['ширина_фокуса'] or 320) or 320 | |||
local fh = tonumber(args['высота_фокуса'] or 240) or 240 | |||
if fx and fy then | |||
cropX = math.max(0, fx - fw / 2) | |||
cropY = math.max(0, fy - fh / 2) | |||
cropW = fw | |||
cropH = fh | |||
end | |||
end | |||
local targetW = cropW or origW | |||
local targetH = cropH or origH | |||
local boxW = tonumber(args['ширина_блока'] or args['ширина_карточки'] or args['ширина']) | |||
local scale = tonumber(args['масштаб'] or args['scale']) | |||
local isScrollMode = (args['прокрутка'] == 'да' or args['скролл'] == 'да' or args['scroll'] == 'yes') | |||
if not isScrollMode and boxW and not scale then | |||
scale = boxW / targetW | |||
elseif not scale then | |||
scale = 1.0 | |||
end | |||
local | local finalBoxW = boxW or (targetW * scale) | ||
local | local finalBoxH = tonumber(args['высота_блока'] or args['высота_карточки'] or args['высота']) or (targetH * scale) | ||
local | -- Внешний контейнер-вьюпорт с поддержкой перемещения клавиатурой и скролла | ||
:addClass('ts-transport- | local wrapper = mw.html.create('div') | ||
: | :addClass('ts-transport-map-viewport') | ||
:attr('tabindex', '0') | |||
:attr('title', 'Кликните для управления стрелками клавиатуры (← ↑ → ↓) или скролла мышью') | |||
:css({ | :css({ | ||
[' | ['position'] = 'relative', | ||
['width'] = | ['width'] = string.format('%.1fpx', finalBoxW), | ||
['height'] = string.format('%.1fpx', finalBoxH), | |||
['max-width'] = '100%', | ['max-width'] = '100%', | ||
[' | ['overflow'] = isScrollMode and 'auto' or 'hidden', | ||
['border'] = | ['scroll-behavior'] = 'smooth', | ||
[' | ['overscroll-behavior'] = 'contain', | ||
[' | ['-webkit-overflow-scrolling'] = 'touch', | ||
['box-shadow'] = | ['border-radius'] = '10px', | ||
['border'] = args['без_рамки'] and 'none' or '1px solid #d0d7de', | |||
['margin'] = (args['прижатие'] == 'лево' and '0 auto 0 0') or (args['прижатие'] == 'право' and '0 0 0 auto') or '0 auto', | |||
['background-color'] = '#ffffff', | |||
['background-image'] = 'radial-gradient(#e1e4e8 1px, transparent 1px)', | |||
['background-size'] = '20px 20px', | |||
['box-shadow'] = args['без_тени'] and 'none' or '0 2px 8px rgba(0,0,0,0.06)', | |||
['outline'] = 'none', | |||
['user-select'] = 'none' | |||
}) | }) | ||
local | local shiftX = cropX or 0 | ||
local shiftY = cropY or 0 | |||
-- Внутренний холст схемы | |||
local canvas = wrapper:tag('div') | |||
:addClass('ts-html-transport-canvas') | |||
:css({ | :css({ | ||
[' | ['position'] = isScrollMode and 'relative' or 'absolute', | ||
[' | ['left'] = not isScrollMode and string.format('%.1fpx', -shiftX * scale) or '0', | ||
[' | ['top'] = not isScrollMode and string.format('%.1fpx', -shiftY * scale) or '0', | ||
[' | ['width'] = origW .. 'px', | ||
['height'] = origH .. 'px', | |||
['transform'] = (scale ~= 1.0 and not isScrollMode) and string.format('scale(%.4f)', scale) or 'none', | |||
['transform-origin'] = '0 0', | |||
['font-family'] = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" | |||
}) | }) | ||
local | -- Шапка/логотип | ||
if not (args['шапка'] == 'нет' or cropX ~= nil) then | |||
local header = canvas:tag('div') | |||
:css({ | |||
['position'] = 'absolute', | |||
['top'] = '16px', | |||
['left'] = '18px', | |||
['display'] = 'flex', | |||
['align-items'] = 'center', | |||
['gap'] = '10px', | |||
['z-index'] = '5', | |||
['background'] = 'rgba(255,255,255,0.92)', | |||
['padding'] = '6px 12px', | |||
['border-radius'] = '8px', | |||
['border'] = '1px solid #e1e4e8', | |||
['box-shadow'] = '0 2px 6px rgba(0,0,0,0.04)' | |||
}) | |||
header:tag('div') | |||
:css({ | |||
['width'] = '24px', | |||
['height'] = '24px', | |||
['background'] = '#ED1B24', | |||
['color'] = '#fff', | |||
['font-weight'] = 'bold', | |||
['font-size'] = '13px', | |||
['display'] = 'flex', | |||
['align-items'] = 'center', | |||
['justify-content'] = 'center', | |||
['border-radius'] = '50%' | |||
}):wikitext('M') | |||
header:tag('div') | |||
:css({['text-align'] = 'left'}) | |||
:tag('div'):css({['font-weight'] = 'bold', ['font-size'] = '13px', ['color'] = '#1f2328'}):wikitext(data.title or 'Скоростной транспорт'):done() | |||
:tag('div'):css({['font-size'] = '10px', ['color'] = '#57606a', ['text-transform'] = 'uppercase', ['letter-spacing'] = '0.5px'}):wikitext('Интерактивная схема линий'):done() | |||
end | |||
if | -- Индикатор подсказки управления | ||
if isScrollMode then | |||
wrapper:tag('div') | |||
:css({ | :css({ | ||
['font-size'] = ' | ['position'] = 'sticky', | ||
['top'] = '10px', | |||
['left'] = 'calc(100% - 130px)', | |||
['width'] = '115px', | |||
['z-index'] = '10', | |||
['background'] = 'rgba(255,255,255,0.88)', | |||
['border'] = '1px solid #d0d7de', | |||
['border-radius'] = '16px', | |||
['padding'] = '3px 8px', | |||
['font-size'] = '10px', | |||
['color'] = '#57606a', | ['color'] = '#57606a', | ||
[' | ['display'] = 'flex', | ||
[' | ['align-items'] = 'center', | ||
[' | ['gap'] = '4px', | ||
['pointer-events'] = 'none', | |||
['box-shadow'] = '0 1px 4px rgba(0,0,0,0.05)', | |||
['backdrop-filter'] = 'blur(2px)' | |||
}) | }) | ||
:wikitext(' | :wikitext('⌨ ⇄ ⇅ Скролл') | ||
end | end | ||
if data.lines then | |||
if | -- 1. Слой путей | ||
for _, line in ipairs(data.lines) do | |||
local | local color = line.color or '#888888' | ||
local stations = line.stations or {} | |||
for i = 1, #stations - 1 do | |||
local st1, st2 = stations[i], stations[i+1] | |||
local x1, y1 = parseCoords(st1.coords) | |||
local x2, y2 = parseCoords(st2.coords) | |||
if x1 and y1 and x2 and y2 then | |||
local dx = x2 - x1 | |||
local dy = y2 - y1 | |||
local length = math.sqrt(dx * dx + dy * dy) | |||
local angle = math.deg(math.atan2(dy, dx)) | |||
local isFuture = (st1.future and st2.future) | |||
canvas:tag('div') | |||
:css({ | |||
['position'] = 'absolute', | |||
['left'] = x1 .. 'px', | |||
['top'] = (y1 - 3) .. 'px', | |||
['width'] = length .. 'px', | |||
['height'] = isFuture and '0px' or '6px', | |||
['background-color'] = isFuture and 'transparent' or color, | |||
['border-top'] = isFuture and ('5px dashed ' .. color) or 'none', | |||
['transform-origin'] = '0 50%', | |||
['transform'] = 'rotate(' .. angle .. 'deg)', | |||
['border-radius'] = '3px', | |||
['z-index'] = '1', | |||
['pointer-events'] = 'none' | |||
}) | |||
end | |||
end | end | ||
end | end | ||
-- 2. Слой станций и подписей | |||
for _, line in ipairs(data.lines) do | |||
local color = line.color or '#888888' | |||
for _, st in ipairs(line.stations or {}) do | |||
local x, y = parseCoords(st.coords) | |||
if x and y then | |||
local name = st.name or st.article or '' | |||
local article = st.article or name | |||
local isFuture = (st.future == true) | |||
local node = canvas:tag('div') | |||
:css({ | |||
['position'] = 'absolute', | |||
['left'] = (x - 7) .. 'px', | |||
['top'] = (y - 7) .. 'px', | |||
['width'] = '14px', | |||
['height'] = '14px', | |||
['border-radius'] = '50%', | |||
['background-color'] = '#ffffff', | |||
['border'] = isFuture and ('2.5px dashed ' .. color) or ('3px solid ' .. color), | |||
['box-sizing'] = 'border-box', | |||
['box-shadow'] = '0 1px 3px rgba(0,0,0,0.25)', | |||
['z-index'] = '3', | |||
['cursor'] = 'pointer' | |||
}) | |||
node:wikitext('[[' .. article .. '|<span style="display:block;width:100%;height:100%;" title="' .. name .. '"></span>]]') | |||
local isRightSide = (x >= (origW / 2)) | |||
local label = canvas:tag('div') | |||
:css({ | |||
['position'] = 'absolute', | |||
['left'] = isRightSide and ((x + 11) .. 'px') or ((x - 11) .. 'px'), | |||
['top'] = y .. 'px', | |||
['transform'] = isRightSide and 'translateY(-50%)' or 'translate(-100%, -50%)', | |||
['text-align'] = isRightSide and 'left' or 'right', | |||
['white-space'] = 'nowrap', | |||
['font-size'] = '11.5px', | |||
['font-weight'] = '500', | |||
['z-index'] = '4', | |||
['text-shadow'] = '0 0 3px #ffffff, 0 0 3px #ffffff, 0 0 3px #ffffff, 0 0 4px #ffffff' | |||
}) | |||
local labelStyle = isFuture and 'color: #6e7781; font-style: italic; text-decoration: none;' or 'color: #1f2328; text-decoration: none;' | |||
label:wikitext('[[' .. article .. '|<span style="' .. labelStyle .. '">' .. name .. '</span>]]') | |||
local | |||
end | end | ||
end | end | ||
end | end | ||
end | end | ||
return tostring( | return tostring(wrapper) | ||
end | end | ||
return p | return p | ||
Текущая версия от 17:01, 22 августа 2026
Для документации этого модуля может быть создана страница Модуль:ТранспортнаяСхема/doc
local p = {}
-- Извлечение аргументов
local function getArgs(frame)
local args = {}
if frame.getParent then
local parent = frame:getParent()
if parent and parent.args then
for k, v in pairs(parent.args) do args[k] = v end
end
end
if frame.args then
for k, v in pairs(frame.args) do args[k] = v end
end
return args
end
-- Безопасная очистка текста JSON
local function cleanJsonText(rawText)
rawText = mw.ustring.gsub(rawText, '<noinclude>.-</noinclude>', '')
rawText = string.gsub(rawText, '<!%-%-.-%-%->', '')
rawText = string.gsub(rawText, '\194\160', ' ')
rawText = string.gsub(rawText, '\194\173', '')
return mw.text.trim(rawText)
end
-- Загрузка данных из data.json
local function loadData(args)
local dataParam = args['данные'] or args[1] or 'Муроморского'
dataParam = mw.text.trim(dataParam)
local cleanName = mw.ustring.gsub(dataParam, '^[Шш][Аа][Бб][Лл][Оо][Нн]:', '')
cleanName = mw.ustring.gsub(cleanName, '^[Tt][Ee][Mm][Pp][Ll][Aa][Tt][Ee]:', '')
cleanName = mw.ustring.gsub(cleanName, '/data%.json$', '')
cleanName = mw.ustring.gsub(cleanName, '%.json$', '')
cleanName = mw.ustring.gsub(cleanName, '^[Ии]нтерактивная схема%s*', '')
cleanName = mw.text.trim(cleanName)
local candidates = {}
local function addCandidate(title)
if title and title ~= '' then
for _, existing in ipairs(candidates) do
if existing == title then return end
end
table.insert(candidates, title)
end
end
addCandidate(dataParam)
addCandidate('Шаблон:Интерактивная схема ' .. cleanName .. '/data.json')
addCandidate('Шаблон:Интерактивная схема ' .. cleanName .. ' метрополитена/data.json')
addCandidate('Шаблон:' .. cleanName .. '/data.json')
addCandidate('Шаблон:' .. cleanName)
addCandidate('Интерактивная схема ' .. cleanName .. '/data.json')
addCandidate('Интерактивная схема ' .. cleanName .. ' метрополитена/data.json')
addCandidate(cleanName .. '/data.json')
addCandidate(cleanName)
local jsonErrors = {}
for _, pageTitle in ipairs(candidates) do
local ok, data = pcall(mw.loadJsonData, pageTitle)
if ok and type(data) == 'table' then
return data
end
local titleObj = mw.title.new(pageTitle)
if titleObj then
local rawText = titleObj:getContent()
if rawText and rawText ~= '' then
rawText = cleanJsonText(rawText)
local decOk, decoded = pcall(mw.text.jsonDecode, rawText)
if decOk and type(decoded) == 'table' then
return decoded
else
table.insert(jsonErrors, '[[' .. pageTitle .. ']] (' .. tostring(decoded) .. ')')
end
end
end
end
if #jsonErrors > 0 then
error('Ошибка в синтаксисе JSON:\n* ' .. table.concat(jsonErrors, '\n* '))
end
error('Страница данных не найдена ни по одному из путей:\n* [[' .. table.concat(candidates, ']]\n* [[') .. ']]')
end
-- Парсер координат "X Y"
local function parseCoords(str)
if not str then return nil, nil end
local x, y = mw.ustring.match(str, '(%d+%.?%d*)%s+(%d+%.?%d*)')
return tonumber(x), tonumber(y)
end
-- Генерация интерактивной Canvas-схемы с панорамированием и управлением
function p.canvas(frame)
local args = getArgs(frame)
local data = loadData(args)
local origW = tonumber(args['ширина_оригинала'] or 850) or 850
local origH = tonumber(args['высота_оригинала'] or 620) or 620
-- Кадрирование
local cropX, cropY, cropW, cropH = nil, nil, nil, nil
if args['кадрирование'] or args['crop'] then
local c1, c2, c3, c4 = mw.ustring.match(args['кадрирование'] or args['crop'], '(%d+%.?%d*)%s+(%d+%.?%d*)%s+(%d+%.?%d*)%s+(%d+%.?%d*)')
cropX, cropY, cropW, cropH = tonumber(c1), tonumber(c2), tonumber(c3), tonumber(c4)
elseif args['фокус'] or args['focus'] then
local fx, fy = parseCoords(args['фокус'] or args['focus'])
local fw = tonumber(args['ширина_фокуса'] or 320) or 320
local fh = tonumber(args['высота_фокуса'] or 240) or 240
if fx and fy then
cropX = math.max(0, fx - fw / 2)
cropY = math.max(0, fy - fh / 2)
cropW = fw
cropH = fh
end
end
local targetW = cropW or origW
local targetH = cropH or origH
local boxW = tonumber(args['ширина_блока'] or args['ширина_карточки'] or args['ширина'])
local scale = tonumber(args['масштаб'] or args['scale'])
local isScrollMode = (args['прокрутка'] == 'да' or args['скролл'] == 'да' or args['scroll'] == 'yes')
if not isScrollMode and boxW and not scale then
scale = boxW / targetW
elseif not scale then
scale = 1.0
end
local finalBoxW = boxW or (targetW * scale)
local finalBoxH = tonumber(args['высота_блока'] or args['высота_карточки'] or args['высота']) or (targetH * scale)
-- Внешний контейнер-вьюпорт с поддержкой перемещения клавиатурой и скролла
local wrapper = mw.html.create('div')
:addClass('ts-transport-map-viewport')
:attr('tabindex', '0')
:attr('title', 'Кликните для управления стрелками клавиатуры (← ↑ → ↓) или скролла мышью')
:css({
['position'] = 'relative',
['width'] = string.format('%.1fpx', finalBoxW),
['height'] = string.format('%.1fpx', finalBoxH),
['max-width'] = '100%',
['overflow'] = isScrollMode and 'auto' or 'hidden',
['scroll-behavior'] = 'smooth',
['overscroll-behavior'] = 'contain',
['-webkit-overflow-scrolling'] = 'touch',
['border-radius'] = '10px',
['border'] = args['без_рамки'] and 'none' or '1px solid #d0d7de',
['margin'] = (args['прижатие'] == 'лево' and '0 auto 0 0') or (args['прижатие'] == 'право' and '0 0 0 auto') or '0 auto',
['background-color'] = '#ffffff',
['background-image'] = 'radial-gradient(#e1e4e8 1px, transparent 1px)',
['background-size'] = '20px 20px',
['box-shadow'] = args['без_тени'] and 'none' or '0 2px 8px rgba(0,0,0,0.06)',
['outline'] = 'none',
['user-select'] = 'none'
})
local shiftX = cropX or 0
local shiftY = cropY or 0
-- Внутренний холст схемы
local canvas = wrapper:tag('div')
:addClass('ts-html-transport-canvas')
:css({
['position'] = isScrollMode and 'relative' or 'absolute',
['left'] = not isScrollMode and string.format('%.1fpx', -shiftX * scale) or '0',
['top'] = not isScrollMode and string.format('%.1fpx', -shiftY * scale) or '0',
['width'] = origW .. 'px',
['height'] = origH .. 'px',
['transform'] = (scale ~= 1.0 and not isScrollMode) and string.format('scale(%.4f)', scale) or 'none',
['transform-origin'] = '0 0',
['font-family'] = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"
})
-- Шапка/логотип
if not (args['шапка'] == 'нет' or cropX ~= nil) then
local header = canvas:tag('div')
:css({
['position'] = 'absolute',
['top'] = '16px',
['left'] = '18px',
['display'] = 'flex',
['align-items'] = 'center',
['gap'] = '10px',
['z-index'] = '5',
['background'] = 'rgba(255,255,255,0.92)',
['padding'] = '6px 12px',
['border-radius'] = '8px',
['border'] = '1px solid #e1e4e8',
['box-shadow'] = '0 2px 6px rgba(0,0,0,0.04)'
})
header:tag('div')
:css({
['width'] = '24px',
['height'] = '24px',
['background'] = '#ED1B24',
['color'] = '#fff',
['font-weight'] = 'bold',
['font-size'] = '13px',
['display'] = 'flex',
['align-items'] = 'center',
['justify-content'] = 'center',
['border-radius'] = '50%'
}):wikitext('M')
header:tag('div')
:css({['text-align'] = 'left'})
:tag('div'):css({['font-weight'] = 'bold', ['font-size'] = '13px', ['color'] = '#1f2328'}):wikitext(data.title or 'Скоростной транспорт'):done()
:tag('div'):css({['font-size'] = '10px', ['color'] = '#57606a', ['text-transform'] = 'uppercase', ['letter-spacing'] = '0.5px'}):wikitext('Интерактивная схема линий'):done()
end
-- Индикатор подсказки управления
if isScrollMode then
wrapper:tag('div')
:css({
['position'] = 'sticky',
['top'] = '10px',
['left'] = 'calc(100% - 130px)',
['width'] = '115px',
['z-index'] = '10',
['background'] = 'rgba(255,255,255,0.88)',
['border'] = '1px solid #d0d7de',
['border-radius'] = '16px',
['padding'] = '3px 8px',
['font-size'] = '10px',
['color'] = '#57606a',
['display'] = 'flex',
['align-items'] = 'center',
['gap'] = '4px',
['pointer-events'] = 'none',
['box-shadow'] = '0 1px 4px rgba(0,0,0,0.05)',
['backdrop-filter'] = 'blur(2px)'
})
:wikitext('⌨ ⇄ ⇅ Скролл')
end
if data.lines then
-- 1. Слой путей
for _, line in ipairs(data.lines) do
local color = line.color or '#888888'
local stations = line.stations or {}
for i = 1, #stations - 1 do
local st1, st2 = stations[i], stations[i+1]
local x1, y1 = parseCoords(st1.coords)
local x2, y2 = parseCoords(st2.coords)
if x1 and y1 and x2 and y2 then
local dx = x2 - x1
local dy = y2 - y1
local length = math.sqrt(dx * dx + dy * dy)
local angle = math.deg(math.atan2(dy, dx))
local isFuture = (st1.future and st2.future)
canvas:tag('div')
:css({
['position'] = 'absolute',
['left'] = x1 .. 'px',
['top'] = (y1 - 3) .. 'px',
['width'] = length .. 'px',
['height'] = isFuture and '0px' or '6px',
['background-color'] = isFuture and 'transparent' or color,
['border-top'] = isFuture and ('5px dashed ' .. color) or 'none',
['transform-origin'] = '0 50%',
['transform'] = 'rotate(' .. angle .. 'deg)',
['border-radius'] = '3px',
['z-index'] = '1',
['pointer-events'] = 'none'
})
end
end
end
-- 2. Слой станций и подписей
for _, line in ipairs(data.lines) do
local color = line.color or '#888888'
for _, st in ipairs(line.stations or {}) do
local x, y = parseCoords(st.coords)
if x and y then
local name = st.name or st.article or ''
local article = st.article or name
local isFuture = (st.future == true)
local node = canvas:tag('div')
:css({
['position'] = 'absolute',
['left'] = (x - 7) .. 'px',
['top'] = (y - 7) .. 'px',
['width'] = '14px',
['height'] = '14px',
['border-radius'] = '50%',
['background-color'] = '#ffffff',
['border'] = isFuture and ('2.5px dashed ' .. color) or ('3px solid ' .. color),
['box-sizing'] = 'border-box',
['box-shadow'] = '0 1px 3px rgba(0,0,0,0.25)',
['z-index'] = '3',
['cursor'] = 'pointer'
})
node:wikitext('[[' .. article .. '|<span style="display:block;width:100%;height:100%;" title="' .. name .. '"></span>]]')
local isRightSide = (x >= (origW / 2))
local label = canvas:tag('div')
:css({
['position'] = 'absolute',
['left'] = isRightSide and ((x + 11) .. 'px') or ((x - 11) .. 'px'),
['top'] = y .. 'px',
['transform'] = isRightSide and 'translateY(-50%)' or 'translate(-100%, -50%)',
['text-align'] = isRightSide and 'left' or 'right',
['white-space'] = 'nowrap',
['font-size'] = '11.5px',
['font-weight'] = '500',
['z-index'] = '4',
['text-shadow'] = '0 0 3px #ffffff, 0 0 3px #ffffff, 0 0 3px #ffffff, 0 0 4px #ffffff'
})
local labelStyle = isFuture and 'color: #6e7781; font-style: italic; text-decoration: none;' or 'color: #1f2328; text-decoration: none;'
label:wikitext('[[' .. article .. '|<span style="' .. labelStyle .. '">' .. name .. '</span>]]')
end
end
end
end
return tostring(wrapper)
end
return p