Модуль:ТранспортнаяСхема: различия между версиями

мНет описания правки
мНет описания правки
Метка: ручная отмена
 
(не показано 15 промежуточных версий этого же участника)
Строка 1: Строка 1:
local p = {}
local p = {}


-- Универсальное слияние аргументов (из инфобокса, шаблона и вызова)
-- Извлечение аргументов
local function getArgs(frame)
local function getArgs(frame)
     local args = {}
     local args = {}
Строка 16: Строка 16:
end
end


-- Безопасная загрузка JSON
-- Безопасная очистка текста 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 'Муроморского'
    if not dataParam or dataParam == '' then
     dataParam = mw.text.trim(dataParam)
        error('Не указано имя базы данных или страница JSON (параметр 1 или |данные=)')
     end
   
    local dataPageTitle = dataParam
    if not mw.ustring.find(dataParam, ':') then
        dataPageTitle = 'Шаблон:Интерактивная схема ' .. dataParam .. '/data.json'
    end
      
      
     local success, jsonData = pcall(mw.loadJsonData, dataPageTitle)
     local cleanName = mw.ustring.gsub(dataParam, '^[Шш][Аа][Бб][Лл][Оо][Нн]:', '')
     if success and jsonData then
    cleanName = mw.ustring.gsub(cleanName, '^[Tt][Ee][Mm][Pp][Ll][Aa][Tt][Ee]:', '')
        return jsonData
    cleanName = mw.ustring.gsub(cleanName, '/data%.json$', '')
     end
     cleanName = mw.ustring.gsub(cleanName, '%.json$', '')
    cleanName = mw.ustring.gsub(cleanName, '^[Ии]нтерактивная схема%s*', '')
     cleanName = mw.text.trim(cleanName)
      
      
     local titleObj = mw.title.new(dataPageTitle)
     local candidates = {}
     if titleObj and titleObj.exists then
     local function addCandidate(title)
        local rawText = titleObj:getContent()
         if title and title ~= '' then
         if rawText and rawText ~= '' then
             for _, existing in ipairs(candidates) do
             rawText = mw.ustring.gsub(rawText, '<noinclude>.-</noinclude>', '')
                if existing == title then return end
            rawText = mw.ustring.gsub(rawText, '<!--.--->', '')
            rawText = mw.text.trim(rawText)
           
            local decodeSuccess, decodedData = pcall(mw.text.jsonDecode, rawText)
            if decodeSuccess and decodedData then
                return decodedData
            else
                error('Ошибка в синтаксисе JSON на странице [[' .. dataPageTitle .. ']]!')
             end
             end
            table.insert(candidates, title)
         end
         end
     end
     end
      
      
     error('Страница данных [[' .. dataPageTitle .. ']] не найдена.')
     addCandidate(dataParam)
end
    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)


-- Генерация кода <imagemap> с учётом ширины
     local jsonErrors = {}
local function buildImageMap(jsonData, imageFile, width, defaultRadius)
     local radius = defaultRadius or '16'
    local mapText = 'Файл:' .. imageFile .. '|' .. width .. '|center|alt=' .. (jsonData.title or 'Схема транспорта') .. '\n'
      
      
     for _, line in ipairs(jsonData.lines or {}) do
     for _, pageTitle in ipairs(candidates) do
         if line.box and line.article then
        local ok, data = pcall(mw.loadJsonData, pageTitle)
            mapText = mapText .. 'rect ' .. line.box .. ' [[' .. line.article .. '|' .. (line.name or line.article) .. ']]\n'
         if ok and type(data) == 'table' then
            return data
         end
         end
          
          
         for _, station in ipairs(line.stations or {}) do
         local titleObj = mw.title.new(pageTitle)
             if station.coords and station.article then
        if titleObj then
                 local stRadius = station.radius or radius
            local rawText = titleObj:getContent()
                 mapText = mapText .. 'circle ' .. station.coords .. ' ' .. stRadius .. ' [[' .. station.article .. '|' .. (station.name or station.article) .. ']]\n'
             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
      
      
     mapText = mapText .. 'desc none\n'
     if #jsonErrors > 0 then
     return mapText
        error('Ошибка в синтаксисе JSON:\n* ' .. table.concat(jsonErrors, '\n* '))
     end
   
    error('Страница данных не найдена ни по одному из путей:\n* [[' .. table.concat(candidates, ']]\n* [[') .. ']]')
end
end


local function getModeLabel(mode)
-- Парсер координат "X Y"
     local modes = {
local function parseCoords(str)
        metro = '🚇 Линии Муроморского метрополитена',
    if not str then return nil, nil end
        train = '🚆 Направления Мурской железной дороги (МЖД)',
     local x, y = mw.ustring.match(str, '(%d+%.?%d*)%s+(%d+%.?%d*)')
        other = '🚍 Прочие линии'
     return tonumber(x), tonumber(y)
    }
     return modes[mode] or modes.other
end
end


function p.render(frame)
-- Генерация интерактивной Canvas-схемы с панорамированием и управлением
function p.canvas(frame)
     local args = getArgs(frame)
     local args = getArgs(frame)
     local jsonData = loadData(args)
     local data = loadData(args)
      
      
     local isInfobox = (args['карточка'] == 'да' or args['тип'] == 'карточка')
     local origW = tonumber(args['ширина_оригинала'] or 850) or 850
     local defaultWidth = isInfobox and '280px' or '850px'
     local origH = tonumber(args['высота_оригинала'] or 620) or 620
      
      
     -- Ширина теперь строго приоритезирует параметр вызова, затем режим карточки
     -- Кадрирование
     local width = args['ширина'] or (isInfobox and '280px') or jsonData.width or '850px'
    local cropX, cropY, cropW, cropH = nil, nil, nil, nil
     local imageFile = args['файл'] or jsonData.image or 'Схема_Муроморского_метрополитена.png'
     if args['кадрирование'] or args['crop'] then
     local caption = args['подпись'] or jsonData.caption or ''
        local c1, c2, c3, c4 = mw.ustring.match(args['кадрирование'] or args['crop'], '(%d+%.?%d*)%s+(%d+%.?%d*)%s+(%d+%.?%d*)%s+(%d+%.?%d*)')
     local defaultRadius = args['радиус'] or jsonData.defaultRadius or '16'
        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')
      
      
     local root = mw.html.create('div')
    if not isScrollMode and boxW and not scale then
         :addClass('ts-transport-scheme')
        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({
         :css({
             ['clear'] = isInfobox and 'none' or 'both',
             ['position'] = 'relative',
             ['margin'] = isInfobox and '0 auto' or '1.2em auto',
             ['width'] = string.format('%.1fpx', finalBoxW),
             ['width'] = '100%',
             ['height'] = string.format('%.1fpx', finalBoxH),
            ['text-align'] = 'center'
        })
 
    -- Контейнер карты
    local mapCard = root:tag('div')
        :addClass('ts-transport-map-card')
        :css({
            ['display'] = isInfobox and 'block' or 'inline-block',
             ['max-width'] = '100%',
             ['max-width'] = '100%',
             ['background'] = isInfobox and 'transparent' or 'var(--background-color-base, #ffffff)',
             ['overflow'] = isScrollMode and 'auto' or 'hidden',
             ['border'] = isInfobox and 'none' or '1px solid var(--border-color-base, #d0d7de)',
            ['scroll-behavior'] = 'smooth',
             ['border-radius'] = isInfobox and '0' or '10px',
            ['overscroll-behavior'] = 'contain',
             ['padding'] = isInfobox and '0' or '10px',
            ['-webkit-overflow-scrolling'] = 'touch',
             ['box-shadow'] = isInfobox and 'none' or '0 3px 12px rgba(0,0,0,0.06)'
            ['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 mapContainer = mapCard:tag('div')
     local shiftX = cropX or 0
    local shiftY = cropY or 0
   
    -- Внутренний холст схемы
    local canvas = wrapper:tag('div')
        :addClass('ts-html-transport-canvas')
         :css({
         :css({
             ['overflow-x'] = 'auto',
             ['position'] = isScrollMode and 'relative' or 'absolute',
             ['max-width'] = '100%',
            ['left'] = not isScrollMode and string.format('%.1fpx', -shiftX * scale) or '0',
             ['text-align'] = 'center'
            ['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 imageMapContent = buildImageMap(jsonData, imageFile, width, defaultRadius)
     -- Шапка/логотип
    mapContainer:wikitext(frame:preprocess('{{#tag:imagemap|' .. imageMapContent .. '}}'))
    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 caption ~= '' and not isInfobox then
    -- Индикатор подсказки управления
         mapCard:tag('div')
     if isScrollMode then
         wrapper:tag('div')
             :css({
             :css({
                 ['font-size'] = '12px',
                ['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',
                 ['margin-top'] = '8px',
                 ['display'] = 'flex',
                 ['padding-top'] = '6px',
                ['align-items'] = 'center',
                 ['border-top'] = '1px dashed var(--border-color-base, #e1e4e8)'
                ['gap'] = '4px',
                 ['pointer-events'] = 'none',
                 ['box-shadow'] = '0 1px 4px rgba(0,0,0,0.05)',
                ['backdrop-filter'] = 'blur(2px)'
             })
             })
             :wikitext('🗺 ' .. caption)
             :wikitext('⌨ ⇄ ⇅ Скролл')
     end
     end


    -- Легенда и список выводятся только вне карточки инфобокса
     if data.lines then
     if not isInfobox then
         -- 1. Слой путей
         if jsonData.lines and #jsonData.lines > 0 then
        for _, line in ipairs(data.lines) do
             local legend = root:tag('div')
             local color = line.color or '#888888'
                :addClass('ts-transport-lines-legend')
            local stations = line.stations or {}
                :css({
           
                    ['margin'] = '12px auto',
            for i = 1, #stations - 1 do
                    ['max-width'] = '950px',
                local st1, st2 = stations[i], stations[i+1]
                    ['padding'] = '8px 12px',
                 local x1, y1 = parseCoords(st1.coords)
                    ['background'] = '#f6f8fa',
                local x2, y2 = parseCoords(st2.coords)
                    ['border'] = '1px solid #d0d7de',
                  
                    ['border-radius'] = '8px'
                if x1 and y1 and x2 and y2 then
                 })
                     local dx = x2 - x1
 
                     local dy = y2 - y1
            legend:tag('div')
                     local length = math.sqrt(dx * dx + dy * dy)
                 :css({
                     local angle = math.deg(math.atan2(dy, dx))
                     ['font-size'] = '11px',
                     local isFuture = (st1.future and st2.future)
                     ['font-weight'] = 'bold',
                   
                     ['color'] = '#57606a',
                    canvas:tag('div')
                     ['text-transform'] = 'uppercase',
                        :css({
                     ['margin-bottom'] = '6px'
                            ['position'] = 'absolute',
                })
                            ['left'] = x1 .. 'px',
                :wikitext(jsonData.legendTitle or 'Линии скоростного транспорта')
                            ['top'] = (y1 - 3) .. 'px',
 
                            ['width'] = length .. 'px',
            local legendBadges = legend:tag('div')
                            ['height'] = isFuture and '0px' or '6px',
                :css({
                            ['background-color'] = isFuture and 'transparent' or color,
                    ['display'] = 'flex',
                            ['border-top'] = isFuture and ('5px dashed ' .. color) or 'none',
                    ['flex-wrap'] = 'wrap',
                            ['transform-origin'] = '0 50%',
                    ['justify-content'] = 'center',
                            ['transform'] = 'rotate(' .. angle .. 'deg)',
                    ['gap'] = '5px'
                            ['border-radius'] = '3px',
                })
                            ['z-index'] = '1',
 
                            ['pointer-events'] = 'none'
            for _, line in ipairs(jsonData.lines) do
                         })
                local badge = legendBadges:tag('span')
                 end
                    :css({
                        ['display'] = 'inline-flex',
                        ['align-items'] = 'center',
                        ['padding'] = '2px 8px 2px 4px',
                        ['background'] = '#ffffff',
                        ['border'] = '1px solid #d0d7de',
                        ['border-radius'] = '20px',
                        ['font-size'] = '12px'
                    })
 
                badge:tag('span')
                    :css({
                        ['display'] = 'inline-flex',
                        ['align-items'] = 'center',
                        ['justify-content'] = 'center',
                        ['min-width'] = '16px',
                        ['height'] = '16px',
                        ['padding'] = '0 4px',
                        ['border-radius'] = '999px',
                        ['background-color'] = line.color or '#888888',
                        ['color'] = line.textColor or '#ffffff',
                        ['font-size'] = '9.5px',
                        ['font-weight'] = 'bold',
                         ['margin-right'] = '6px'
                    })
                    :wikitext(line.symbol or line.number or '•')
 
                 badge:tag('span')
                    :wikitext('[[' .. (line.article or '') .. '|' .. (line.displayName or line.name or line.article or '') .. ']]')
             end
             end
         end
         end


         if jsonData.lines and #jsonData.lines > 0 then
         -- 2. Слой станций и подписей
            local listCard = root:tag('div')
        for _, line in ipairs(data.lines) do
                :addClass('mw-collapsible toccolours')
            local color = line.color or '#888888'
                :css({
           
                    ['margin'] = '12px auto 0 auto',
            for _, st in ipairs(line.stations or {}) do
                    ['max-width'] = '950px',
                local x, y = parseCoords(st.coords)
                    ['text-align'] = 'left',
                if x and y then
                    ['background'] = 'var(--background-color-neutral-subtle, #f8f9fa)',
                    local name = st.name or st.article or ''
                    ['border'] = '1px solid #d0d7de',
                    local article = st.article or name
                    ['border-radius'] = '8px',
                    local isFuture = (st.future == true)
                    ['overflow'] = 'hidden'
                   
                })
                    local node = canvas:tag('div')
 
                        :css({
            listCard:tag('div')
                            ['position'] = 'absolute',
                :css({
                            ['left'] = (x - 7) .. 'px',
                    ['font-weight'] = 'bold',
                            ['top'] = (y - 7) .. 'px',
                    ['font-size'] = '13.5px',
                            ['width'] = '14px',
                    ['background'] = 'linear-gradient(180deg, #f6f8fa 0%, #eaeef2 100%)',
                            ['height'] = '14px',
                    ['padding'] = '9px 14px',
                            ['border-radius'] = '50%',
                    ['border-bottom'] = '1px solid #d0d7de'
                            ['background-color'] = '#ffffff',
                })
                            ['border'] = isFuture and ('2.5px dashed ' .. color) or ('3px solid ' .. color),
                :wikitext(jsonData.listTitle or '📋 Реестр станций')
                            ['box-sizing'] = 'border-box',
 
                            ['box-shadow'] = '0 1px 3px rgba(0,0,0,0.25)',
            local listContent = listCard:tag('div')
                            ['z-index'] = '3',
                :addClass('mw-collapsible-content')
                            ['cursor'] = 'pointer'
                :css({
                        })
                    ['padding'] = '14px 18px',
                    node:wikitext('[[' .. article .. '|<span style="display:block;width:100%;height:100%;" title="' .. name .. '"></span>]]')
                    ['column-width'] = '20em',
                   
                    ['column-gap'] = '2em',
                    local isRightSide = (x >= (origW / 2))
                    ['column-rule'] = '1px solid #e1e4e8',
                    local label = canvas:tag('div')
                    ['font-size'] = '13px',
                        :css({
                    ['line-height'] = '1.7'
                            ['position'] = 'absolute',
                })
                            ['left'] = isRightSide and ((x + 11) .. 'px') or ((x - 11) .. 'px'),
 
                            ['top'] = y .. 'px',
            local currentMode = nil
                            ['transform'] = isRightSide and 'translateY(-50%)' or 'translate(-100%, -50%)',
            local listWikitext = ''
                            ['text-align'] = isRightSide and 'left' or 'right',
 
                            ['white-space'] = 'nowrap',
            for _, line in ipairs(jsonData.lines) do
                            ['font-size'] = '11.5px',
                local mode = line.mode or 'other'
                            ['font-weight'] = '500',
                if mode ~= currentMode then
                            ['z-index'] = '4',
                    currentMode = mode
                            ['text-shadow'] = '0 0 3px #ffffff, 0 0 3px #ffffff, 0 0 3px #ffffff, 0 0 4px #ffffff'
                    listWikitext = listWikitext .. "\n; " .. getModeLabel(mode) .. "\n"
                        })
                end
                   
 
                     local labelStyle = isFuture and 'color: #6e7781; font-style: italic; text-decoration: none;' or 'color: #1f2328; text-decoration: none;'
                listWikitext = listWikitext .. "* '''[[" .. (line.article or '') .. '|' .. (line.name or line.article or '') .. "]]'''\n"
                     label:wikitext('[[' .. article .. '|<span style="' .. labelStyle .. '">' .. name .. '</span>]]')
                for _, station in ipairs(line.stations or {}) do
                     local badge = "<span style='display:inline-flex;align-items:center;justify-content:center;min-width:14px;height:14px;padding:0 3px;border-radius:999px;background:" .. (line.color or '#888') .. ";color:" .. (line.textColor or '#fff') .. ";font-size:9px;font-weight:bold;margin-right:4px;'>" .. (line.symbol or line.number or '•') .. "</span>"
                     listWikitext = listWikitext .. '** ' .. badge .. ' [[' .. station.article .. '|' .. (station.name or station.article) .. ']]'
                    if station.transfer then
                        listWikitext = listWikitext .. " <small style='color:#57606a;'>(" .. station.transfer .. ")</small>"
                    end
                    if station.future then
                        listWikitext = listWikitext .. " <small style='color:#8c959f; font-style:italic;'>(строит.)</small>"
                    end
                    listWikitext = listWikitext .. '\n'
                 end
                 end
             end
             end
            listContent:wikitext(listWikitext)
         end
         end
     end
     end


     return tostring(root)
     return tostring(wrapper)
end
end


return p
return p