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

мНет описания правки
мНет описания правки
Метка: ручная отмена
 
(не показано 9 промежуточных версий этого же участника)
Строка 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 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 candidates = {}
     if mw.ustring.find(dataParam, ':') or mw.ustring.find(dataParam, '%.json$') then
     local function addCandidate(title)
        table.insert(candidates, dataParam)
        if title and title ~= '' then
    else
            for _, existing in ipairs(candidates) do
        table.insert(candidates, 'Шаблон:Интерактивная схема ' .. dataParam .. ' метрополитена/data.json')
                if existing == title then return end
        table.insert(candidates, 'Шаблон:Интерактивная схема ' .. dataParam .. '/data.json')
            end
         table.insert(candidates, 'Шаблон:' .. dataParam .. '/data.json')
            table.insert(candidates, title)
        table.insert(candidates, dataParam)
         end
     end
     end
      
      
     for _, title in ipairs(candidates) do
    addCandidate(dataParam)
         local success, jsonData = pcall(mw.loadJsonData, title)
    addCandidate('Шаблон:Интерактивная схема ' .. cleanName .. '/data.json')
         if success and jsonData then return jsonData end
    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(title)
         local titleObj = mw.title.new(pageTitle)
         if titleObj and titleObj.exists then
         if titleObj then
             local raw = titleObj:getContent()
             local rawText = titleObj:getContent()
             if raw and raw ~= '' then
             if rawText and rawText ~= '' then
                 raw = mw.ustring.gsub(raw, '<noinclude>.-</noinclude>', '')
                 rawText = cleanJsonText(rawText)
                raw = mw.ustring.gsub(raw, '<!--.-?-->', '')
                 local decOk, decoded = pcall(mw.text.jsonDecode, rawText)
                 local ok, decoded = pcall(mw.text.jsonDecode, mw.text.trim(raw))
                 if decOk and type(decoded) == 'table' then
                 if ok and decoded then return decoded end
                    return decoded
                else
                    table.insert(jsonErrors, '[[' .. pageTitle .. ']] (' .. tostring(decoded) .. ')')
                end
             end
             end
         end
         end
     end
     end
     error('Страница данных не найдена: ' .. dataParam)
   
    if #jsonErrors > 0 then
        error('Ошибка в синтаксисе JSON:\n* ' .. table.concat(jsonErrors, '\n* '))
    end
   
     error('Страница данных не найдена ни по одному из путей:\n* [[' .. table.concat(candidates, ']]\n* [[') .. ']]')
end
end


-- Функция парсинга координат "X Y"
-- Парсер координат "X Y"
local function parseCoords(str)
local function parseCoords(str)
     if not str then return nil, nil end
     if not str then return nil, nil end
Строка 58: Строка 94:
end
end


-- Главная функция отрисовки векторной схемы
-- Генерация интерактивной Canvas-схемы с панорамированием и управлением
function p.vector(frame)
function p.canvas(frame)
     local args = getArgs(frame)
     local args = getArgs(frame)
     local data = loadData(args)
     local data = loadData(args)
      
      
     local width = tonumber(args['ширина_svg'] or 850) or 850
     local origW = tonumber(args['ширина_оригинала'] or 850) or 850
     local height = tonumber(args['высота_svg'] or 620) or 620
     local origH = tonumber(args['высота_оригинала'] or 620) or 620
     local viewBox = args['viewbox'] or ('0 0 ' .. width .. ' ' .. height)
   
    -- Кадрирование
     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 svg = {}
     local boxW = tonumber(args['ширина_блока'] or args['ширина_карточки'] or args['ширина'])
    table.insert(svg, '<svg xmlns="http://www.w3.org/2000/svg" viewBox="' .. viewBox .. '" width="100%" height="auto" style="max-width:' .. width .. 'px; background:#ffffff; border-radius:8px; font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif; user-select:none;">')
    local scale = tonumber(args['масштаб'] or args['scale'])
      
      
     -- Стили подсветки и анимации при наведении (CSS)
     local isScrollMode = (args['прокрутка'] == 'да' or args['скролл'] == 'да' or args['scroll'] == 'yes')
     table.insert(svg, '<style>')
      
     table.insert(svg, '.ts-v-line { transition: opacity 0.25s ease, stroke-width 0.2s ease; cursor: pointer; }')
    if not isScrollMode and boxW and not scale then
    table.insert(svg, '.ts-v-station { transition: transform 0.2s ease, r 0.2s ease; cursor: pointer; }')
        scale = boxW / targetW
    table.insert(svg, '.ts-v-station:hover circle { r: 6.5px; stroke-width: 3.5px; }')
    elseif not scale then
    table.insert(svg, '.ts-v-label { font-size: 11px; fill: #24292f; font-weight: 500; pointer-events: all; text-shadow: 0 0 3px #ffffff, 0 0 3px #ffffff, 0 0 3px #ffffff; transition: fill 0.2s ease; }')
        scale = 1.0
    table.insert(svg, '.ts-v-label:hover { fill: #0969da; font-weight: bold; }')
    end
    table.insert(svg, 'svg:hover .ts-v-group:not(:hover) { opacity: 0.35; }')
   
    table.insert(svg, '.ts-v-group { transition: opacity 0.25s ease; }')
    local finalBoxW = boxW or (targetW * scale)
    table.insert(svg, '</style>')
    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


     -- 1. Слой сетки/фона (декоративный)
     -- Индикатор подсказки управления
    table.insert(svg, '<rect width="100%" height="100%" fill="#fafbfc" rx="8" />')
    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


    -- 2. Слой линий и путей
     if data.lines then
     if data.lines then
         for i, line in ipairs(data.lines) do
        -- 1. Слой путей
         for _, line in ipairs(data.lines) do
             local color = line.color or '#888888'
             local color = line.color or '#888888'
             local points = {}
             local stations = line.stations or {}
            local futurePoints = {}
           
            table.insert(svg, '<g class="ts-v-group ts-v-line-' .. (line.number or i) .. '">')
              
              
             -- Сбор координат станций
             for i = 1, #stations - 1 do
            if line.stations then
                local st1, st2 = stations[i], stations[i+1]
                 for _, st in ipairs(line.stations) do
                 local x1, y1 = parseCoords(st1.coords)
                    local x, y = parseCoords(st.coords)
                local x2, y2 = parseCoords(st2.coords)
                    if x and y then
               
                         table.insert(points, x .. ',' .. y)
                if x1 and y1 and x2 and y2 then
                    end
                    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
            if #points > 1 then
                local x, y = parseCoords(st.coords)
                table.insert(svg, '<polyline class="ts-v-line" points="' .. table.concat(points, ' ') .. '" fill="none" stroke="' .. color .. '" stroke-width="5.5" stroke-linecap="round" stroke-linejoin="round" />')
                if x and y then
            end
                    local name = st.name or st.article or ''
           
                    local article = st.article or name
            -- Отрисовка станций и подписей
                    local isFuture = (st.future == true)
            if line.stations then
                   
                for _, st in ipairs(line.stations) do
                    local node = canvas:tag('div')
                    local x, y = parseCoords(st.coords)
                         :css({
                    if x and y then
                            ['position'] = 'absolute',
                        local stName = st.name or st.article or ''
                            ['left'] = (x - 7) .. 'px',
                        local article = st.article or stName
                            ['top'] = (y - 7) .. 'px',
                        local isFuture = st.future == true
                            ['width'] = '14px',
                       
                            ['height'] = '14px',
                         table.insert(svg, '<a href="/wiki/' .. mw.uri.encode(article, 'WIKI') .. '" title="' .. mw.text.nowiki(stName) .. '">')
                            ['border-radius'] = '50%',
                        table.insert(svg, '<g class="ts-v-station">')
                             ['background-color'] = '#ffffff',
                       
                            ['border'] = isFuture and ('2.5px dashed ' .. color) or ('3px solid ' .. color),
                        -- Кружок станции
                            ['box-sizing'] = 'border-box',
                        if isFuture then
                            ['box-shadow'] = '0 1px 3px rgba(0,0,0,0.25)',
                             table.insert(svg, '<circle cx="' .. x .. '" cy="' .. y .. '" r="4.5" fill="#ffffff" stroke="' .. color .. '" stroke-width="2.5" stroke-dasharray="2,2" />')
                            ['z-index'] = '3',
                        else
                             ['cursor'] = 'pointer'
                             table.insert(svg, '<circle cx="' .. x .. '" cy="' .. y .. '" r="5" fill="#ffffff" stroke="' .. color .. '" stroke-width="3" />')
                        })
                        end
                    node:wikitext('[[' .. article .. '|<span style="display:block;width:100%;height:100%;" title="' .. name .. '"></span>]]')
                       
                   
                        -- Текстовая подпись станции (с небольшим смещением)
                    local isRightSide = (x >= (origW / 2))
                        local textOffset = (x > (width / 2)) and (x + 9) or (x - 9)
                    local label = canvas:tag('div')
                        local textAnchor = (x > (width / 2)) and 'start' or 'end'
                        :css({
                        local textColor = isFuture and '#6e7781' or '#24292f'
                            ['position'] = 'absolute',
                       
                            ['left'] = isRightSide and ((x + 11) .. 'px') or ((x - 11) .. 'px'),
                        table.insert(svg, '<text class="ts-v-label" x="' .. textOffset .. '" y="' .. (y + 3.5) .. '" text-anchor="' .. textAnchor .. '" fill="' .. textColor .. '">' .. mw.text.nowiki(stName) .. '</text>')
                            ['top'] = y .. 'px',
                        table.insert(svg, '</g></a>')
                            ['transform'] = isRightSide and 'translateY(-50%)' or 'translate(-100%, -50%)',
                    end
                            ['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
           
            table.insert(svg, '</g>')
         end
         end
     end
     end


    table.insert(svg, '</svg>')
     return tostring(wrapper)
     return table.concat(svg, '\n')
end
end


return p
return p