미디어위키:BottomGuideNav.js: 두 판 사이의 차이

(Install package: clbiwiki-category-bottomguide-restore / js/BottomGuideNav.js)
태그: 수동 되돌리기
(Install package: clbiwiki-portal-section-fit-20260721-008 / js/BottomGuideNav.js)
 
(같은 사용자의 중간 판 4개는 보이지 않습니다)
16번째 줄: 16번째 줄:
     var XLINK_NS = 'http://www.w3.org/1999/xlink';
     var XLINK_NS = 'http://www.w3.org/1999/xlink';
     var COMPONENT = 'category-guide-nav';
     var COMPONENT = 'category-guide-nav';
    var FRAME_BUILD = '20260721-portal-frame-section-fit-008';
     /* 42px 본체·하이라이트·섀도우 + 상하 1px #050505 최외각선 */
     /* 42px 본체·하이라이트·섀도우 + 상하 1px #050505 최외각선 */
     var HEIGHT = 44;
     var HEIGHT = 44;
36번째 줄: 37번째 줄:
     var OUTER_BORDER = 1;
     var OUTER_BORDER = 1;
     var INSET_OFFSET = 1.5;
     var INSET_OFFSET = 1.5;
    var retryTimer = 0;
    var resizeTimer = 0;
     var guideLayoutAnimationFrame = 0;
     var guideLayoutAnimationFrame = 0;
    var observer = null;
     var manifestoIntroPromise = null;
     var manifestoIntroPromise = null;
     var MANIFESTO_LOAD_TIMEOUT_MS = 5000;
     var MANIFESTO_LOAD_TIMEOUT_MS = 5000;
51번째 줄: 49번째 줄:
         { key: 'participate', label: '참여 안내' }
         { key: 'participate', label: '참여 안내' }
     ];
     ];
    function getFrameApi() {
        var api = window.PortalFrame;
        return api && typeof api.getGeometry === 'function' && typeof api.mapXToElement === 'function' ? api : null;
    }


     function revealManifesto(root) {
     function revealManifesto(root) {
179번째 줄: 182번째 줄:
             return roundCoordinate(point[0]) + ',' + roundCoordinate(point[1]);
             return roundCoordinate(point[0]) + ',' + roundCoordinate(point[1]);
         }).join(' ');
         }).join(' ');
    }
    function parsePoints(value) {
        return String(value || '').trim().split(/\s+/).map(function (pair) {
            var parts = pair.split(',');
            return [Number(parts[0]), Number(parts[1])];
        }).filter(function (point) {
            return Number.isFinite(point[0]) && Number.isFinite(point[1]);
        });
     }
     }


393번째 줄: 387번째 줄:
     }
     }


     function fallbackGeometry(width, geometry) {
     function readGuidePoints(portal, mount, geometry) {
         var usableWidth = Math.max(320, Math.floor(width || 0));
         var frame = getFrameApi();
         var count = 5;
        var topMount = portal.querySelector('[data-component="category-nav"]');
         var cut = Math.round((usableWidth / count) * 0.045);
         var topGeometry;
         var top = [];
        var settingsEdge;
         var bottom = [];
         var corporationEdge;
         var half = count / 2;
         var leftTop;
         var i;
         var rightTop;
         var rightBottom;
         var leftBottom;


         cut = Math.max(6, Math.min(10, cut));
         if (!frame || !topMount || typeof frame.getGeometry !== 'function') return null;
        topGeometry = frame.getGeometry(topMount);
        if (!topGeometry) return null;


         for (i = 0; i <= count; i++) {
         settingsEdge = frame.getSegmentEdge('settings', 'left', topGeometry);
            var x = Math.round((usableWidth * i) / count);
         corporationEdge = frame.getSegmentEdge('corporations', 'right', topGeometry);
 
         if (!settingsEdge || !corporationEdge) return null;
            if (i === 0 || i === count || i === half) {
                top.push(x);
                bottom.push(x);
            } else if (i < half) {
                top.push(x - cut);
                bottom.push(x);
            } else {
                top.push(x);
                bottom.push(x - cut);
            }
        }
 
        return [
            [bottom[1] + 1, geometry.buttonTop],
            [bottom[4] - 1, geometry.buttonTop],
            [top[4] - 1, geometry.buttonBottom],
            [top[1] + 1, geometry.buttonBottom]
        ];
    }
 
    function readGuidePoints(portal, mountRect, geometry) {
        var topNav = portal.querySelector('[data-component="category-nav"]');
        var topSvg = topNav && topNav.querySelector('.portal-category-svg');
         var settings = topNav && topNav.querySelector(
            '.portal-cat-anchor[data-category-key="settings"] .portal-cat-edge'
        );
        var corporations = topNav && topNav.querySelector(
            '.portal-cat-anchor[data-category-key="corporations"] .portal-cat-edge'
        );
        var settingsPoints = settings ? parsePoints(settings.getAttribute('points')) : [];
         var corporationPoints = corporations ? parsePoints(corporations.getAttribute('points')) : [];
        var svgRect;
        var viewBox;
        var scaleX;
 
        function localX(svgX) {
            return Math.round(
                svgRect.left +
                (svgX - viewBox.x) * scaleX -
                mountRect.left
            );
        }


         /*
         /*
         상단 SVG의 실제 화면 좌표를 길라잡이 행의 로컬 좌표로 변환한다.
         길라잡이는 상단 중앙 세 칸의 경사를 뒤집어 사용한다.
         프레임·2px 우물 간격이 추가되어도 설정–프로젝트–기업 및 공동체
         좌표의 출처는 공통 경계 모델 하나뿐이며, DOM polygon을 다시 읽지 않는다.
        경계와 정확히 일치한다.
         */
         */
         if (
         leftTop = frame.mapXToElement(topMount, mount, settingsEdge[1][0]);
            topSvg &&
        rightTop = frame.mapXToElement(topMount, mount, corporationEdge[1][0]);
            settingsPoints.length === 4 &&
         rightBottom = frame.mapXToElement(topMount, mount, corporationEdge[0][0]);
            corporationPoints.length === 4
        leftBottom = frame.mapXToElement(topMount, mount, settingsEdge[0][0]);
         ) {
            svgRect = topSvg.getBoundingClientRect();
            viewBox = topSvg.viewBox && topSvg.viewBox.baseVal;


            if (viewBox && viewBox.width > 0 && svgRect.width > 0) {
        if (![leftTop, rightTop, rightBottom, leftBottom].every(Number.isFinite)) return null;
                scaleX = svgRect.width / viewBox.width;


                return [
        return [
                    [localX(settingsPoints[3][0]), geometry.buttonTop],
            [Math.round(leftTop), geometry.buttonTop],
                    [localX(corporationPoints[2][0]), geometry.buttonTop],
            [Math.round(rightTop), geometry.buttonTop],
                    [localX(corporationPoints[1][0]), geometry.buttonBottom],
            [Math.round(rightBottom), geometry.buttonBottom],
                    [localX(settingsPoints[0][0]), geometry.buttonBottom]
            [Math.round(leftBottom), geometry.buttonBottom]
                ];
        ];
            }
        }
 
        return fallbackGeometry(Math.floor(mountRect.width), geometry);
     }
     }


715번째 줄: 662번째 줄:
             well: well
             well: well
         };
         };
    }
    function setAttributeIfChanged(node, name, value) {
        var next = String(value);
        if (!node || !node.getAttribute || node.getAttribute(name) === next) return false;
        node.setAttribute(name, next);
        return true;
    }
    function toggleClassIfChanged(node, name, enabled) {
        if (!node || !node.classList || node.classList.contains(name) === !!enabled) return false;
        node.classList.toggle(name, !!enabled);
        return true;
    }
    function setTextIfChanged(node, value) {
        var next = String(value);
        if (!node || node.textContent === next) return false;
        node.textContent = next;
        return true;
     }
     }


720번째 줄: 687번째 줄:
         if (!screen) return;
         if (!screen) return;


         screen.classList.toggle('is-active', !hidden);
         toggleClassIfChanged(screen, 'is-active', !hidden);
         screen.hidden = hidden;
         if (screen.hidden !== !!hidden) screen.hidden = !!hidden;
         screen.setAttribute('aria-hidden', hidden ? 'true' : 'false');
         setAttributeIfChanged(screen, 'aria-hidden', hidden ? 'true' : 'false');
     }
     }


732번째 줄: 699번째 줄:


         Array.prototype.forEach.call(portal.querySelectorAll('.portal-guide-anchor'), function (anchor) {
         Array.prototype.forEach.call(portal.querySelectorAll('.portal-guide-anchor'), function (anchor) {
             anchor.setAttribute('href', href);
             setAttributeIfChanged(anchor, 'href', href);
             anchor.setAttributeNS(XLINK_NS, 'xlink:href', href);
             if (anchor.getAttributeNS(XLINK_NS, 'href') !== href) {
             anchor.setAttribute('aria-label', label);
                anchor.setAttributeNS(XLINK_NS, 'xlink:href', href);
             anchor.setAttribute('aria-pressed', guideActive ? 'true' : 'false');
             }
             anchor.setAttribute('data-main-body-target', target);
            setAttributeIfChanged(anchor, 'aria-label', label);
             anchor.removeAttribute('aria-current');
             setAttributeIfChanged(anchor, 'aria-pressed', guideActive ? 'true' : 'false');
             setAttributeIfChanged(anchor, 'data-main-body-target', target);
             if (anchor.hasAttribute('aria-current')) anchor.removeAttribute('aria-current');
         });
         });


         Array.prototype.forEach.call(portal.querySelectorAll('.portal-guide-label'), function (text) {
         Array.prototype.forEach.call(portal.querySelectorAll('.portal-guide-label'), function (text) {
             text.textContent = label;
             setTextIfChanged(text, label);
         });
         });


         Array.prototype.forEach.call(portal.querySelectorAll('.portal-guide-nav'), function (nav) {
         Array.prototype.forEach.call(portal.querySelectorAll('.portal-guide-nav'), function (nav) {
             nav.setAttribute('aria-label', label);
             setAttributeIfChanged(nav, 'aria-label', label);
         });
         });
     }
     }
821번째 줄: 790번째 줄:


         if (manifesto) {
         if (manifesto) {
             manifesto.classList.add('main-body-screen');
             toggleClassIfChanged(manifesto, 'main-body-screen', true);
             manifesto.setAttribute('data-main-body-screen', 'manifesto');
             setAttributeIfChanged(manifesto, 'data-main-body-screen', 'manifesto');
             if (!manifesto.id) manifesto.id = 'main-manifesto-screen';
             if (!manifesto.id) manifesto.id = 'main-manifesto-screen';
         }
         }
836번째 줄: 805번째 줄:
             layout.well.appendChild(guide);
             layout.well.appendChild(guide);
         } else {
         } else {
             guide.classList.add('main-body-screen');
             toggleClassIfChanged(guide, 'main-body-screen', true);
             guide.classList.add('main-guide-screen');
             toggleClassIfChanged(guide, 'main-guide-screen', true);
             if (!guide.id) guide.id = 'main-guide-screen';
             if (!guide.id) guide.id = 'main-guide-screen';
         }
         }
861번째 줄: 830번째 줄:
         });
         });


         layout.well.setAttribute('data-main-body-screen-controller-ready', '1');
         setAttributeIfChanged(layout.well, 'data-main-body-screen-controller-ready', '1');
         layout.well.setAttribute('data-active-main-body-screen', active);
         setAttributeIfChanged(layout.well, 'data-active-main-body-screen', active);
         portal.setAttribute('data-main-body-screen', active);
         setAttributeIfChanged(portal, 'data-main-body-screen', active);
         portal.classList.toggle('is-guide-screen-active', active === 'guide');
         toggleClassIfChanged(portal, 'is-guide-screen-active', active === 'guide');
         updateGuideButtonState(portal, active);
         updateGuideButtonState(portal, active);
         if (active === 'guide' && !guide.classList.contains('is-guide-menu-entering')) {
         if (active === 'guide' && !guide.classList.contains('is-guide-menu-entering')) {
903번째 줄: 872번째 줄:
         });
         });


         screens.well.setAttribute('data-active-main-body-screen', active);
         setAttributeIfChanged(screens.well, 'data-active-main-body-screen', active);
         portal.setAttribute('data-main-body-screen', active);
         setAttributeIfChanged(portal, 'data-main-body-screen', active);
         portal.classList.toggle('is-guide-screen-active', active === 'guide');
         toggleClassIfChanged(portal, 'is-guide-screen-active', active === 'guide');
         updateGuideButtonState(portal, active);
         updateGuideButtonState(portal, active);


1,001번째 줄: 970번째 줄:
     function render(portal, suppliedGeometry) {
     function render(portal, suppliedGeometry) {
         var mount = ensureMount(portal);
         var mount = ensureMount(portal);
        var topMount = portal.querySelector('[data-component="category-nav"]');
         var mountRect = mount.getBoundingClientRect();
         var mountRect = mount.getBoundingClientRect();
         var width = Math.floor(mountRect.width);
         var width = Math.floor(mountRect.width);
1,011번째 줄: 979번째 줄:
         portal.GuideNavGeometry = geometry;
         portal.GuideNavGeometry = geometry;


         var points = readGuidePoints(portal, mountRect, geometry);
         var points = readGuidePoints(portal, mount, geometry);
        if (!points) return false;
         var surfacePoints = insetPolygon(points, OUTER_BORDER);
         var surfacePoints = insetPolygon(points, OUTER_BORDER);
         var insetPoints = insetPolygon(points, INSET_OFFSET);
         var insetPoints = insetPolygon(points, INSET_OFFSET);
1,026번째 줄: 995번째 줄:
         var surface = createPolygon('portal-cat-surface', surfacePoints);
         var surface = createPolygon('portal-cat-surface', surfacePoints);
         var hit = createPolygon('portal-cat-hit', points);
         var hit = createPolygon('portal-cat-hit', points);
        var renderKey = [
            width,
            pointsToString(points),
            geometry.rowHeight,
            geometry.buttonTop,
            geometry.buttonBottom,
            guideActive ? 'guide' : 'manifesto',
            FRAME_BUILD
        ].join('|');
        if (mount.__bottomGuideRenderKey === renderKey && mount.querySelector('.portal-guide-svg')) {
            applyGuideRowHeight(
                mount,
                mount.querySelector('.portal-guide-nav'),
                mount.querySelector('.portal-guide-svg'),
                geometry
            );
            return false;
        }


         nav.className = 'portal-category-nav portal-guide-nav is-continuous-weld';
         nav.className = 'portal-category-nav portal-guide-nav is-continuous-weld';
        nav.setAttribute('data-portal-frame-build', FRAME_BUILD);
         nav.setAttribute('aria-label', label);
         nav.setAttribute('aria-label', label);


         svg.setAttribute('class', 'portal-category-svg portal-guide-svg');
         svg.setAttribute('class', 'portal-category-svg portal-guide-svg');
        svg.setAttribute('data-portal-frame-build', FRAME_BUILD);
         svg.setAttribute('viewBox', '0 0 ' + width + ' ' + geometry.rowHeight);
         svg.setAttribute('viewBox', '0 0 ' + width + ' ' + geometry.rowHeight);
         svg.setAttribute('width', '100%');
         svg.setAttribute('width', '100%');
1,105번째 줄: 1,095번째 줄:


         mount.replaceChildren(nav);
         mount.replaceChildren(nav);
        mount.__bottomGuideRenderKey = renderKey;
         applyGuideRowHeight(mount, nav, svg, geometry);
         applyGuideRowHeight(mount, nav, svg, geometry);
         mount.setAttribute('data-bottom-guide-rendered', '1');
         mount.setAttribute('data-bottom-guide-rendered', '1');
        mount.setAttribute('data-portal-frame-build', FRAME_BUILD);
         mount.setAttribute('data-bottom-guide-width', String(width));
         mount.setAttribute('data-bottom-guide-width', String(width));


1,117번째 줄: 1,109번째 줄:
             ? scope
             ? scope
             : scope.querySelector('.main-portal');
             : scope.querySelector('.main-portal');
    }
    function schedule(root, attempt) {
        window.clearTimeout(retryTimer);
        retryTimer = window.setTimeout(function () {
            var portal = findPortal(root);
            if (!portal) return;
            if (!render(portal) && attempt < 20) {
                schedule(root, attempt + 1);
                return;
            }
            if (observer) observer.disconnect();
            if (window.ResizeObserver) {
                observer = new ResizeObserver(function () {
                    window.clearTimeout(resizeTimer);
                    resizeTimer = window.setTimeout(function () {
                        render(portal);
                    }, 60);
                });
                observer.observe(portal);
                var topMount = portal.querySelector('[data-component="category-nav"]');
                var guideMount = portal.querySelector('[data-component="' + COMPONENT + '"]');
                if (topMount) observer.observe(topMount);
                if (guideMount) observer.observe(guideMount);
            }
        }, attempt ? 60 : 0);
     }
     }


     function boot(root) {
     function boot(root) {
         var scope = root || document;
         var scope = root || document;
         schedule(scope, 0);
         var portal = findPortal(scope);
 
        if (portal) render(portal);
         loadManifestoIntro(scope);
         loadManifestoIntro(scope);
    }
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', function () {
            boot(document);
        }, { once: true });
    } else {
        boot(document);
    }
    /* 일반 링크 진입과 새로고침의 최종 로드 시점도 명시적으로 보장한다. */
    window.addEventListener('load', function () {
        boot(document);
    }, { once: true });
    /* bfcache 복원과 브라우저 뒤로/앞으로 이동에서도 새 DOM만 초기화한다. */
    window.addEventListener('pageshow', function () {
        boot(document);
    });
    if (mw && mw.hook) {
        mw.hook('wikipage.content').add(function ($content) {
            boot($content && $content[0] ? $content[0] : document);
        });
     }
     }


     window.BottomGuideNav = {
     window.BottomGuideNav = {
         version: '20260715-guide-button-collapse-unified-frame-018',
         version:FRAME_BUILD,
         render: function () {
        frameVersion:FRAME_BUILD,
             var portal = document.querySelector('.main-portal');
         render: function (root) {
             var portal = root && root.matches && root.matches('.main-portal')
                ? root
                : findPortal(root || document);
             return portal ? render(portal) : false;
             return portal ? render(portal) : false;
         },
         },
1,215번째 줄: 1,156번째 줄:
         }
         }
     };
     };
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', function () { boot(document); }, { once:true });
    } else {
        boot(document);
    }
    if (mw && mw.hook) {
        mw.hook('wikipage.content').add(function ($content) {
            boot($content && $content[0] ? $content[0] : document);
        });
    }
}(window, document, window.mw));
}(window, document, window.mw));

2026년 7월 21일 (화) 12:29 기준 최신판

/* =========================================
BottomGuideNav
대문 하단 「길라잡이」 SVG 버튼
========================================= */

/*
새 기능과 공개 이름에는 프로젝트 접두사를 사용하지 않는다.
상단 CategoryNav와 분리해 로드하며, 상단 SVG의 실제 좌표를 읽어
설정–프로젝트–기업 및 공동체의 합산 경계와 정확히 맞춘다.
*/

(function (window, document, mw) {
    'use strict';

    var SVG_NS = 'http://www.w3.org/2000/svg';
    var XLINK_NS = 'http://www.w3.org/1999/xlink';
    var COMPONENT = 'category-guide-nav';
    var FRAME_BUILD = '20260721-portal-frame-section-fit-008';
    /* 42px 본체·하이라이트·섀도우 + 상하 1px #050505 최외각선 */
    var HEIGHT = 44;
    /* 최외각선·광원선을 제외한 #1d1d1d 상단 실색 영역이 7px이 되도록 9px 좌표를 쓴다. */
    var TOP_FRAME = 9;
    var BOTTOM_FRAME = 8;
    var GAP = 2;
    var ROW_HEIGHT = TOP_FRAME + GAP + HEIGHT + GAP + BOTTOM_FRAME;
    var BUTTON_TOP = TOP_FRAME + GAP;
    var BUTTON_BOTTOM = BUTTON_TOP + HEIGHT;
    /*
    길라잡이 화면의 최종 규격.
    버튼만 24px로 줄이고, #1d1d1d 실면 7px을 만드는 기존 9/8px 프레임은 유지한다.
    */
    var COMPACT_HEIGHT = 24;
    var COMPACT_TOP_FRAME = 9;
    var COMPACT_BOTTOM_FRAME = 8;
    var SHARED_BOTTOM_TOP = BUTTON_BOTTOM + GAP;
    var FRAME_JOIN_EXTENSION = 1;
    var OUTER_BORDER = 1;
    var INSET_OFFSET = 1.5;
    var guideLayoutAnimationFrame = 0;
    var manifestoIntroPromise = null;
    var MANIFESTO_LOAD_TIMEOUT_MS = 5000;
    var MANIFESTO_INTRO_VERSION = '20260713-divider-equal-gap-005';
    var GUIDE_DUMMY_ITEMS = [
        { key: 'start', label: '처음 오셨다면' },
        { key: 'world', label: '세계관' },
        { key: 'era', label: '시대' },
        { key: 'people', label: '인물' },
        { key: 'anecdote', label: '에넥도트' },
        { key: 'participate', label: '참여 안내' }
    ];

    function getFrameApi() {
        var api = window.PortalFrame;
        return api && typeof api.getGeometry === 'function' && typeof api.mapXToElement === 'function' ? api : null;
    }

    function revealManifesto(root) {
        var scope = root && root.querySelector ? root : document;
        var portal = scope.matches && scope.matches('.main-portal')
            ? scope
            : scope.querySelector('.main-portal') || document.querySelector('.main-portal');
        var title;
        var copy;

        /* 이전 캐시에서 남은 전역 잠금을 즉시 해제한다. */
        document.documentElement.classList.remove('manifesto-intro-pending');
        document.documentElement.classList.remove('manifesto-intro-active');

        if (!portal) return;

        portal.classList.remove('manifesto-intro-prepared');
        portal.classList.remove('manifesto-intro-running');
        title = portal.querySelector('.main-manifesto-title');
        copy = portal.querySelector('.main-manifesto-copy');

        if (title) {
            title.style.opacity = '1';
            title.style.borderBottom = '0';
        }
        if (copy) copy.style.visibility = 'visible';

        Array.prototype.forEach.call(portal.querySelectorAll('.manifesto-line'), function (line) {
            line.style.height = '26px';
            line.style.maxHeight = '26px';
            line.style.opacity = '1';
            line.style.overflow = 'visible';
            line.style.clipPath = 'inset(0 0 0 0)';
        });
        Array.prototype.forEach.call(portal.querySelectorAll('.manifesto-line-inner'), function (inner) {
            inner.style.opacity = '1';
            inner.style.transform = 'translateY(0)';
        });
    }

    function loadManifestoIntro(root) {
        if (
            window.ManifestoIntro &&
            window.ManifestoIntro.version === MANIFESTO_INTRO_VERSION &&
            typeof window.ManifestoIntro.init === 'function'
        ) {
            try {
                return Promise.resolve(window.ManifestoIntro.init(root || document)).catch(function () {
                    revealManifesto(root);
                    return false;
                });
            } catch (error) {
                revealManifesto(root);
                return Promise.resolve(false);
            }
        }

        if (manifestoIntroPromise) return manifestoIntroPromise;

        manifestoIntroPromise = new Promise(function (resolve) {
            var script = document.createElement('script');
            var settled = false;
            var timeout = 0;

            function settle(ok) {
                if (settled) return;
                settled = true;
                window.clearTimeout(timeout);
                if (!ok) {
                    revealManifesto(root);
                    manifestoIntroPromise = null;
                }
                resolve(ok);
            }

            script.src = '/index.php?title=' + encodeURIComponent('MediaWiki:ManifestoIntro.js') +
                '&action=raw&ctype=text/javascript&v=' + encodeURIComponent(MANIFESTO_INTRO_VERSION) +
                '&_manifestoFresh=' + encodeURIComponent(String(Date.now()));
            script.async = false;
            script.setAttribute('data-manifesto-intro-script', '1');
            script.onload = function () {
                if (
                    window.ManifestoIntro &&
                    window.ManifestoIntro.version === MANIFESTO_INTRO_VERSION &&
                    typeof window.ManifestoIntro.init === 'function'
                ) {
                    Promise.resolve(window.ManifestoIntro.init(root || document))
                        .then(function () { settle(true); })
                        .catch(function () { settle(false); });
                } else {
                    settle(false);
                }
            };
            script.onerror = function () {
                settle(false);
            };

            timeout = window.setTimeout(function () {
                if (script.parentNode) script.parentNode.removeChild(script);
                settle(false);
            }, MANIFESTO_LOAD_TIMEOUT_MS);

            (document.head || document.documentElement).appendChild(script);
        });

        return manifestoIntroPromise;
    }

    revealManifesto(document);

    function createSvgElement(name) {
        return document.createElementNS(SVG_NS, name);
    }

    function buildWikiPath(title) {
        if (typeof window.buildWikiPath === 'function') {
            return window.buildWikiPath(title);
        }
        return '/index.php/' + encodeURI(String(title || '').replace(/ /g, '_'));
    }

    function roundCoordinate(value) {
        return Math.round(value * 100) / 100;
    }

    function pointsToString(points) {
        return points.map(function (point) {
            return roundCoordinate(point[0]) + ',' + roundCoordinate(point[1]);
        }).join(' ');
    }

    function getCentroid(points) {
        var x = 0;
        var y = 0;

        points.forEach(function (point) {
            x += point[0];
            y += point[1];
        });

        return [x / points.length, y / points.length];
    }

    function intersectLines(lineA, lineB) {
        var p = lineA.point;
        var r = lineA.direction;
        var q = lineB.point;
        var s = lineB.direction;
        var cross = r[0] * s[1] - r[1] * s[0];

        if (Math.abs(cross) < 0.000001) {
            return [p[0], p[1]];
        }

        var qp = [q[0] - p[0], q[1] - p[1]];
        var t = (qp[0] * s[1] - qp[1] * s[0]) / cross;

        return [p[0] + t * r[0], p[1] + t * r[1]];
    }

    function insetPolygon(points, distance) {
        var center = getCentroid(points);
        var lines = [];
        var count = points.length;
        var i;

        for (i = 0; i < count; i++) {
            var start = points[i];
            var end = points[(i + 1) % count];
            var dx = end[0] - start[0];
            var dy = end[1] - start[1];
            var length = Math.sqrt(dx * dx + dy * dy) || 1;
            var normal = [-dy / length, dx / length];
            var midpoint = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2];
            var towardCenter = [center[0] - midpoint[0], center[1] - midpoint[1]];

            if (normal[0] * towardCenter[0] + normal[1] * towardCenter[1] < 0) {
                normal[0] *= -1;
                normal[1] *= -1;
            }

            lines.push({
                point: [
                    start[0] + normal[0] * distance,
                    start[1] + normal[1] * distance
                ],
                direction: [dx, dy]
            });
        }

        return lines.map(function (line, index) {
            return intersectLines(lines[(index + count - 1) % count], line);
        });
    }

    function createPolygon(className, points) {
        var polygon = createSvgElement('polygon');
        polygon.setAttribute('class', className);
        polygon.setAttribute('points', pointsToString(points));
        return polygon;
    }

    function createLine(className, start, end) {
        var line = createSvgElement('line');

        line.setAttribute('class', className);
        line.setAttribute('x1', roundCoordinate(start[0]));
        line.setAttribute('y1', roundCoordinate(start[1]));
        line.setAttribute('x2', roundCoordinate(end[0]));
        line.setAttribute('y2', roundCoordinate(end[1]));
        line.setAttribute('vector-effect', 'non-scaling-stroke');
        line.setAttribute('pointer-events', 'none');

        return line;
    }

    function createRect(className, x, y, width, height) {
        var rect = createSvgElement('rect');

        rect.setAttribute('class', className);
        rect.setAttribute('x', roundCoordinate(x));
        rect.setAttribute('y', roundCoordinate(y));
        rect.setAttribute('width', roundCoordinate(width));
        rect.setAttribute('height', roundCoordinate(height));
        rect.setAttribute('pointer-events', 'none');

        return rect;
    }

    function makeGuideGeometry(height, topFrame, bottomFrame, sideFrameProgress) {
        height = Math.round(height);
        topFrame = Math.round(topFrame);
        bottomFrame = Math.round(bottomFrame);

        return {
            height: height,
            topFrame: topFrame,
            bottomFrame: bottomFrame,
            buttonTop: topFrame + GAP,
            buttonBottom: topFrame + GAP + height,
            rowHeight: topFrame + GAP + height + GAP + bottomFrame,
            sideFrameProgress: Math.max(0, Math.min(1, Number(sideFrameProgress || 0)))
        };
    }

    function getGuideGeometry(compact) {
        return compact
            ? makeGuideGeometry(COMPACT_HEIGHT, COMPACT_TOP_FRAME, COMPACT_BOTTOM_FRAME, 1)
            : makeGuideGeometry(HEIGHT, TOP_FRAME, BOTTOM_FRAME, 0);
    }

    function interpolateGuideGeometry(from, to, progress) {
        function between(start, end) {
            return start + (end - start) * progress;
        }

        return makeGuideGeometry(
            between(from.height, to.height),
            between(from.topFrame, to.topFrame),
            between(from.bottomFrame, to.bottomFrame),
            between(from.sideFrameProgress, to.sideFrameProgress)
        );
    }

    function applyGuideRowHeight(mount, nav, svg, geometry) {
        var height = geometry.rowHeight + 'px';

        [mount, nav, svg].forEach(function (node) {
            if (!node || !node.style) return;
            node.style.setProperty('height', height, 'important');
            node.style.setProperty('min-height', height, 'important');
            node.style.setProperty('max-height', height, 'important');
        });
    }

    function requestGuideFrame(callback) {
        if (typeof window.requestAnimationFrame === 'function') {
            return window.requestAnimationFrame(callback);
        }
        return window.setTimeout(function () { callback(Date.now()); }, 16);
    }

    function cancelGuideFrame(handle) {
        if (!handle) return;
        if (typeof window.cancelAnimationFrame === 'function') {
            window.cancelAnimationFrame(handle);
        } else {
            window.clearTimeout(handle);
        }
    }

    function animateGuideLayout(portal, compact) {
        var from = portal && portal.GuideNavGeometry
            ? portal.GuideNavGeometry
            : getGuideGeometry(!compact);
        var to = getGuideGeometry(compact);
        var startedAt = null;
        var duration = 340;

        if (!portal) return;
        if (guideLayoutAnimationFrame) {
            cancelGuideFrame(guideLayoutAnimationFrame);
            guideLayoutAnimationFrame = 0;
        }

        if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
            portal.GuideNavGeometry = to;
            render(portal, to);
            return;
        }

        function step(now) {
            var elapsed;
            var progress;
            var eased;

            if (startedAt === null) startedAt = now;
            elapsed = Math.min(1, (now - startedAt) / duration);
            eased = 1 - Math.pow(1 - elapsed, 3);
            progress = interpolateGuideGeometry(from, to, eased);
            portal.GuideNavGeometry = progress;
            render(portal, progress);

            if (elapsed < 1) {
                guideLayoutAnimationFrame = requestGuideFrame(step);
            } else {
                portal.GuideNavGeometry = to;
                guideLayoutAnimationFrame = 0;
            }
        }

        guideLayoutAnimationFrame = requestGuideFrame(step);
    }

    function readGuidePoints(portal, mount, geometry) {
        var frame = getFrameApi();
        var topMount = portal.querySelector('[data-component="category-nav"]');
        var topGeometry;
        var settingsEdge;
        var corporationEdge;
        var leftTop;
        var rightTop;
        var rightBottom;
        var leftBottom;

        if (!frame || !topMount || typeof frame.getGeometry !== 'function') return null;
        topGeometry = frame.getGeometry(topMount);
        if (!topGeometry) return null;

        settingsEdge = frame.getSegmentEdge('settings', 'left', topGeometry);
        corporationEdge = frame.getSegmentEdge('corporations', 'right', topGeometry);
        if (!settingsEdge || !corporationEdge) return null;

        /*
        길라잡이는 상단 중앙 세 칸의 경사를 뒤집어 사용한다.
        좌표의 출처는 공통 경계 모델 하나뿐이며, DOM polygon을 다시 읽지 않는다.
        */
        leftTop = frame.mapXToElement(topMount, mount, settingsEdge[1][0]);
        rightTop = frame.mapXToElement(topMount, mount, corporationEdge[1][0]);
        rightBottom = frame.mapXToElement(topMount, mount, corporationEdge[0][0]);
        leftBottom = frame.mapXToElement(topMount, mount, settingsEdge[0][0]);

        if (![leftTop, rightTop, rightBottom, leftBottom].every(Number.isFinite)) return null;

        return [
            [Math.round(leftTop), geometry.buttonTop],
            [Math.round(rightTop), geometry.buttonTop],
            [Math.round(rightBottom), geometry.buttonBottom],
            [Math.round(leftBottom), geometry.buttonBottom]
        ];
    }

    function forceFrameY(points, topY, bottomY) {
        var output = points.map(function (point) {
            return [point[0], point[1]];
        });

        output[0][1] = topY;
        output[1][1] = topY;
        output[2][1] = bottomY;
        output[3][1] = bottomY;

        return output;
    }

    function appendContinuousBottomFrame(svg, buttonPoints, width, geometry) {
        var group = createSvgElement('g');
        var baseTop = geometry.rowHeight - geometry.bottomFrame;
        var outer = forceFrameY(
            insetPolygon(buttonPoints, -(geometry.topFrame + GAP)),
            0,
            baseTop + FRAME_JOIN_EXTENSION
        );
        var inner = forceFrameY(
            insetPolygon(buttonPoints, -GAP),
            geometry.topFrame,
            baseTop
        );
        var outerLeftBottom = outer[3];
        var outerLeftTop = outer[0];
        var outerRightTop = outer[1];
        var outerRightBottom = outer[2];
        var innerLeftBottom = inner[3];
        var innerLeftTop = inner[0];
        var innerRightTop = inner[1];
        var innerRightBottom = inner[2];

        function clampPoint(point) {
            point[0] = Math.max(0, Math.min(width, Math.round(point[0])));
            point[1] = Math.max(0, Math.min(geometry.rowHeight, Math.round(point[1])));
            return point;
        }

        function appendRisingMainFrame() {
            var frame;
            var innerWidth;
            var innerHeight;

            if (width < 3) return;

            frame = createSvgElement('g');
            frame.setAttribute('class', 'portal-guide-rising-main-frame');
            frame.setAttribute('pointer-events', 'none');
            innerWidth = width - 2;
            innerHeight = geometry.rowHeight - 2;

            /* 본문 메인 프레임과 같은 단일 #050505 / #1d1d1d / 광원·그림자 구조. */
            frame.appendChild(createRect(
                'portal-guide-rising-main-frame-edge',
                0,
                0,
                width,
                geometry.rowHeight
            ));
            frame.appendChild(createRect(
                'portal-guide-rising-main-frame-surface',
                1,
                1,
                innerWidth,
                innerHeight
            ));
            frame.appendChild(createRect(
                'portal-guide-rising-main-frame-highlight-top',
                1,
                1,
                innerWidth,
                1
            ));
            frame.appendChild(createRect(
                'portal-guide-rising-main-frame-highlight-left',
                1,
                1,
                1,
                innerHeight
            ));
            frame.appendChild(createRect(
                'portal-guide-rising-main-frame-shadow-right',
                width - 2,
                1,
                1,
                innerHeight
            ));
            frame.appendChild(createRect(
                'portal-guide-rising-main-frame-shadow-bottom',
                1,
                geometry.rowHeight - 2,
                innerWidth,
                1
            ));
            if (geometry.sideFrameProgress < 1) {
                frame.setAttribute(
                    'transform',
                    'translate(0 ' + roundCoordinate(geometry.rowHeight * (1 - geometry.sideFrameProgress)) +
                    ') scale(1 ' + roundCoordinate(geometry.sideFrameProgress) + ')'
                );
            }
            group.appendChild(frame);
        }

        outer.forEach(clampPoint);
        inner.forEach(clampPoint);

        group.setAttribute('class', 'portal-guide-arch-frame');
        group.setAttribute('pointer-events', 'none');

        /* 좌우 공백은 독립 상자가 아니라 중앙 버튼과 접합되는 단일 메인 프레임으로 채운다. */
        appendRisingMainFrame();

        /*
        기존 main-body-panel의 하단 프레임은 CSS가 그대로 그린다.
        여기서는 버튼과 좌·우·상단 아치 프레임 조각만 애드온으로 얹는다.
        outer의 하단만 1px 연장해 원래 우물 하단 #555555가 중앙 접합을 가로막는
        동일한 픽셀 행을 #1d1d1d 프레임 면으로 덮는다. 하단 프레임 자체는 재구성하지 않는다.
        */
        group.appendChild(createPolygon('portal-guide-arch-outer', outer));
        group.appendChild(createPolygon('portal-guide-arch-well', inner));

        /*
        버튼과 마주 보는 본문 하단 프레임의 상단 반사광.
        기존 본문 프레임이나 우물 inset을 다시 그리지 않는다.
        아치의 좌·우 접합부는 #1d1d1d로 그대로 이어 두고,
        두 안쪽 접점 사이의 동일한 1px 행에만 #555555를 놓는다.
        */
        if (innerRightBottom[0] > innerLeftBottom[0]) {
            group.appendChild(createRect(
                'portal-guide-facing-highlight',
                innerLeftBottom[0],
                baseTop,
                innerRightBottom[0] - innerLeftBottom[0],
                1
            ));
        }

        /* 최외곽: 상단 + 양쪽 사선. 하단선 없음. */
        group.appendChild(createLine(
            'portal-guide-arch-edge',
            outerLeftBottom,
            outerLeftTop
        ));
        group.appendChild(createLine(
            'portal-guide-arch-edge',
            outerLeftTop,
            outerRightTop
        ));
        group.appendChild(createLine(
            'portal-guide-arch-edge',
            outerRightTop,
            outerRightBottom
        ));

        /* 바깥 돌출면 광원: 상단/우측 밝음, 좌측 어두움. */
        group.appendChild(createLine(
            'portal-guide-arch-highlight',
            [outerLeftTop[0] + 1, outerLeftTop[1] + 1],
            [outerRightTop[0] - 1, outerRightTop[1] + 1]
        ));
        group.appendChild(createLine(
            'portal-guide-arch-highlight',
            [outerRightTop[0] - 1, outerRightTop[1] + 1],
            [outerRightBottom[0] - 1, outerRightBottom[1] - 1]
        ));
        group.appendChild(createLine(
            'portal-guide-arch-shadow',
            [outerLeftBottom[0] + 1, outerLeftBottom[1] - 1],
            [outerLeftTop[0] + 1, outerLeftTop[1] + 1]
        ));

        /*
        프레임 안쪽 우물 경계.
        상단/우측 #555555, 좌측 #101010이며 하단은 패널 프레임으로 열어 둔다.
        */
        group.appendChild(createLine(
            'portal-guide-arch-inner-highlight',
            innerLeftTop,
            innerRightTop
        ));
        group.appendChild(createLine(
            'portal-guide-arch-inner-highlight',
            innerRightTop,
            innerRightBottom
        ));
        group.appendChild(createLine(
            'portal-guide-arch-inner-shadow',
            innerLeftBottom,
            innerLeftTop
        ));

        svg.appendChild(group);
    }

    function ensureBodyPanel(portal) {
        var panel = portal.querySelector('.main-body-panel');
        var well = portal.querySelector('.main-body-well');
        var topMount = portal.querySelector('[data-component="category-nav"]');
        var manifesto = portal.querySelector('.main-manifesto');

        if (!panel) {
            panel = document.createElement('div');
            panel.className = 'main-body-panel';
            panel.setAttribute('data-main-body-panel-generated', '1');

            if (topMount && topMount.parentNode) {
                if (topMount.nextSibling) {
                    topMount.parentNode.insertBefore(panel, topMount.nextSibling);
                } else {
                    topMount.parentNode.appendChild(panel);
                }
            } else {
                portal.appendChild(panel);
            }
        }

        if (!well) {
            well = document.createElement('div');
            well.className = 'main-body-well';
            well.setAttribute('data-main-body-well-generated', '1');
            panel.insertBefore(well, panel.firstChild);
        } else if (well.parentNode !== panel) {
            panel.insertBefore(well, panel.firstChild);
        }

        if (manifesto && manifesto.parentNode !== well) {
            well.insertBefore(manifesto, well.firstChild);
        }

        return {
            panel: panel,
            well: well
        };
    }

    function setAttributeIfChanged(node, name, value) {
        var next = String(value);
        if (!node || !node.getAttribute || node.getAttribute(name) === next) return false;
        node.setAttribute(name, next);
        return true;
    }

    function toggleClassIfChanged(node, name, enabled) {
        if (!node || !node.classList || node.classList.contains(name) === !!enabled) return false;
        node.classList.toggle(name, !!enabled);
        return true;
    }

    function setTextIfChanged(node, value) {
        var next = String(value);
        if (!node || node.textContent === next) return false;
        node.textContent = next;
        return true;
    }

    function setBodyScreenHidden(screen, hidden) {
        if (!screen) return;

        toggleClassIfChanged(screen, 'is-active', !hidden);
        if (screen.hidden !== !!hidden) screen.hidden = !!hidden;
        setAttributeIfChanged(screen, 'aria-hidden', hidden ? 'true' : 'false');
    }

    function updateGuideButtonState(portal, active) {
        var guideActive = active === 'guide';
        var label = guideActive ? '돌아가기' : '길라잡이';
        var target = guideActive ? 'manifesto' : 'guide';
        var href = guideActive ? '#main-manifesto-screen' : '#main-guide-screen';

        Array.prototype.forEach.call(portal.querySelectorAll('.portal-guide-anchor'), function (anchor) {
            setAttributeIfChanged(anchor, 'href', href);
            if (anchor.getAttributeNS(XLINK_NS, 'href') !== href) {
                anchor.setAttributeNS(XLINK_NS, 'xlink:href', href);
            }
            setAttributeIfChanged(anchor, 'aria-label', label);
            setAttributeIfChanged(anchor, 'aria-pressed', guideActive ? 'true' : 'false');
            setAttributeIfChanged(anchor, 'data-main-body-target', target);
            if (anchor.hasAttribute('aria-current')) anchor.removeAttribute('aria-current');
        });

        Array.prototype.forEach.call(portal.querySelectorAll('.portal-guide-label'), function (text) {
            setTextIfChanged(text, label);
        });

        Array.prototype.forEach.call(portal.querySelectorAll('.portal-guide-nav'), function (nav) {
            setAttributeIfChanged(nav, 'aria-label', label);
        });
    }

    function ensureGuideDummyMenu(guide) {
        var menu;

        if (!guide) return null;

        menu = guide.querySelector('[data-guide-dummy-menu="1"]');
        if (menu) return menu;

        menu = document.createElement('nav');
        menu.className = 'main-guide-menu';
        menu.setAttribute('data-guide-dummy-menu', '1');
        menu.setAttribute('aria-label', '길라잡이 항목 예시');

        GUIDE_DUMMY_ITEMS.forEach(function (item, index) {
            var wrapper = document.createElement('div');
            var button = document.createElement('button');
            var number = document.createElement('span');
            var label = document.createElement('span');
            var arrow = document.createElement('span');

            wrapper.className = 'main-guide-menu-item';
            wrapper.style.setProperty('--guide-menu-order', String(index));

            button.type = 'button';
            button.className = 'main-guide-menu-button';
            button.setAttribute('data-guide-dummy-button', item.key);
            button.setAttribute('aria-label', item.label + ' (더미 버튼)');

            number.className = 'main-guide-menu-number';
            number.setAttribute('aria-hidden', 'true');
            number.textContent = String(index + 1).padStart(2, '0');

            label.className = 'main-guide-menu-label';
            label.textContent = item.label;

            arrow.className = 'main-guide-menu-arrow';
            arrow.setAttribute('aria-hidden', 'true');
            arrow.textContent = '›';

            button.appendChild(number);
            button.appendChild(label);
            button.appendChild(arrow);
            button.addEventListener('click', function (event) {
                event.preventDefault();
            });
            wrapper.appendChild(button);
            menu.appendChild(wrapper);
        });

        guide.insertBefore(menu, guide.firstChild);
        return menu;
    }

    function restartGuideMenuAnimation(guide) {
        if (!guide || !guide.querySelector('[data-guide-dummy-menu="1"]')) return;

        guide.classList.remove('is-guide-menu-entering');
        /* 같은 화면에 재진입해도 첫 프레임부터 순차 진입을 다시 시작한다. */
        void guide.offsetWidth;
        guide.classList.add('is-guide-menu-entering');
    }

    function ensureBodyScreens(portal) {
        var layout = ensureBodyPanel(portal);
        var manifesto = layout.well.querySelector('.main-manifesto');
        var guide = layout.well.querySelector('[data-main-body-screen="guide"]');
        var panels;
        var active = layout.well.getAttribute('data-active-main-body-screen') ||
            portal.getAttribute('data-main-body-screen') ||
            'manifesto';

        if (manifesto) {
            toggleClassIfChanged(manifesto, 'main-body-screen', true);
            setAttributeIfChanged(manifesto, 'data-main-body-screen', 'manifesto');
            if (!manifesto.id) manifesto.id = 'main-manifesto-screen';
        }

        if (!guide) {
            guide = document.createElement('section');
            guide.className = 'main-body-screen main-guide-screen';
            guide.setAttribute('data-main-body-screen', 'guide');
            guide.setAttribute('data-main-guide-screen-generated', '1');
            guide.setAttribute('aria-label', '길라잡이');
            guide.setAttribute('role', 'region');
            guide.id = 'main-guide-screen';
            layout.well.appendChild(guide);
        } else {
            toggleClassIfChanged(guide, 'main-body-screen', true);
            toggleClassIfChanged(guide, 'main-guide-screen', true);
            if (!guide.id) guide.id = 'main-guide-screen';
        }

        ensureGuideDummyMenu(guide);

        panels = Array.prototype.slice.call(
            layout.well.querySelectorAll('.main-body-screen[data-main-body-screen]')
        );

        if (active !== 'guide') active = 'manifesto';
        if (!panels.some(function (panel) {
            return panel.getAttribute('data-main-body-screen') === active;
        })) {
            active = 'manifesto';
        }

        panels.forEach(function (panel) {
            setBodyScreenHidden(
                panel,
                panel.getAttribute('data-main-body-screen') !== active
            );
        });

        setAttributeIfChanged(layout.well, 'data-main-body-screen-controller-ready', '1');
        setAttributeIfChanged(layout.well, 'data-active-main-body-screen', active);
        setAttributeIfChanged(portal, 'data-main-body-screen', active);
        toggleClassIfChanged(portal, 'is-guide-screen-active', active === 'guide');
        updateGuideButtonState(portal, active);
        if (active === 'guide' && !guide.classList.contains('is-guide-menu-entering')) {
            restartGuideMenuAnimation(guide);
        }

        return {
            well: layout.well,
            panels: panels,
            manifesto: manifesto,
            guide: guide,
            active: active
        };
    }

    function activateBodyScreen(portal, screenName) {
        var screens;
        var previous;
        var active;
        var event;

        if (!portal) return false;

        screens = ensureBodyScreens(portal);
        screenName = screenName === 'guide' ? 'guide' : 'manifesto';
        if (!screens.panels.some(function (panel) {
            return panel.getAttribute('data-main-body-screen') === screenName;
        })) return false;

        previous = screens.active;
        active = screenName;

        screens.panels.forEach(function (panel) {
            setBodyScreenHidden(
                panel,
                panel.getAttribute('data-main-body-screen') !== active
            );
        });

        setAttributeIfChanged(screens.well, 'data-active-main-body-screen', active);
        setAttributeIfChanged(portal, 'data-main-body-screen', active);
        toggleClassIfChanged(portal, 'is-guide-screen-active', active === 'guide');
        updateGuideButtonState(portal, active);

        if (active === 'guide' && previous !== active) {
            restartGuideMenuAnimation(screens.guide);
        } else if (active !== 'guide' && screens.guide) {
            screens.guide.classList.remove('is-guide-menu-entering');
        }

        if (previous !== active) {
            animateGuideLayout(portal, active === 'guide');
        }

        if (previous !== active) {
            try {
                event = new CustomEvent('mainbodyscreenchange', {
                    bubbles: true,
                    detail: { screen: active, previous: previous }
                });
                portal.dispatchEvent(event);
            } catch (error) {}
        }

        return true;
    }

    function toggleBodyScreen(portal) {
        var screens;
        var target;

        if (!portal) return false;
        screens = ensureBodyScreens(portal);
        target = screens.active === 'guide' ? 'manifesto' : 'guide';
        return activateBodyScreen(portal, target);
    }

    function handleGuideScreenCapture(event) {
        var target;
        var anchor;
        var portal;

        if (!event || event.__mainGuideScreenHandled) return;
        if (event.button > 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;

        target = event.target;
        anchor = target && target.closest
            ? target.closest('.portal-guide-anchor[data-category-key="guide"]')
            : null;
        if (!anchor) return;

        portal = anchor.closest ? anchor.closest('.main-portal') : null;
        if (!portal || !toggleBodyScreen(portal)) return;

        event.__mainGuideScreenHandled = true;
        event.preventDefault();
        event.stopPropagation();
        if (typeof event.stopImmediatePropagation === 'function') {
            event.stopImmediatePropagation();
        }
    }

    /*
    이전 실행본의 ResizeObserver가 오래된 문서 링크를 다시 그려도
    전역 SPA 링크 처리보다 먼저 화면 전환으로 고정한다.
    */
    if (window.BottomGuideNavScreenCaptureHandler) {
        document.removeEventListener('click', window.BottomGuideNavScreenCaptureHandler, true);
    }
    window.BottomGuideNavScreenCaptureHandler = handleGuideScreenCapture;
    document.addEventListener('click', handleGuideScreenCapture, true);

    function ensureMount(portal) {
        var layout = ensureBodyPanel(portal);
        var mount = portal.querySelector('[data-component="' + COMPONENT + '"]');

        ensureBodyScreens(portal);

        if (!mount) {
            mount = document.createElement('div');
            mount.setAttribute('data-component', COMPONENT);
            mount.setAttribute('data-bottom-guide-generated', '1');
        }

        /*
        길라잡이는 우물 안의 독립 버튼이 아니라
        main-body-panel 하단 프레임에 융합된 돌출부다.
        */
        if (mount.parentNode !== layout.panel) {
            layout.panel.appendChild(mount);
        }

        return mount;
    }

    function render(portal, suppliedGeometry) {
        var mount = ensureMount(portal);
        var mountRect = mount.getBoundingClientRect();
        var width = Math.floor(mountRect.width);
        var guideActive = portal.getAttribute('data-main-body-screen') === 'guide';
        var geometry = suppliedGeometry || portal.GuideNavGeometry || getGuideGeometry(guideActive);

        if (!width) return false;

        portal.GuideNavGeometry = geometry;

        var points = readGuidePoints(portal, mount, geometry);
        if (!points) return false;
        var surfacePoints = insetPolygon(points, OUTER_BORDER);
        var insetPoints = insetPolygon(points, INSET_OFFSET);
        var center = getCentroid(surfacePoints);
        var nav = document.createElement('nav');
        var svg = createSvgElement('svg');
        var anchor = createSvgElement('a');
        var buttonGeometry = createSvgElement('g');
        var label = guideActive ? '돌아가기' : '길라잡이';
        var targetScreen = guideActive ? 'manifesto' : 'guide';
        var href = guideActive ? '#main-manifesto-screen' : '#main-guide-screen';
        var text = createSvgElement('text');
        var outer = createPolygon('portal-cat-edge', points);
        var surface = createPolygon('portal-cat-surface', surfacePoints);
        var hit = createPolygon('portal-cat-hit', points);
        var renderKey = [
            width,
            pointsToString(points),
            geometry.rowHeight,
            geometry.buttonTop,
            geometry.buttonBottom,
            guideActive ? 'guide' : 'manifesto',
            FRAME_BUILD
        ].join('|');

        if (mount.__bottomGuideRenderKey === renderKey && mount.querySelector('.portal-guide-svg')) {
            applyGuideRowHeight(
                mount,
                mount.querySelector('.portal-guide-nav'),
                mount.querySelector('.portal-guide-svg'),
                geometry
            );
            return false;
        }

        nav.className = 'portal-category-nav portal-guide-nav is-continuous-weld';
        nav.setAttribute('data-portal-frame-build', FRAME_BUILD);
        nav.setAttribute('aria-label', label);

        svg.setAttribute('class', 'portal-category-svg portal-guide-svg');
        svg.setAttribute('data-portal-frame-build', FRAME_BUILD);
        svg.setAttribute('viewBox', '0 0 ' + width + ' ' + geometry.rowHeight);
        svg.setAttribute('width', '100%');
        svg.setAttribute('height', '100%');
        svg.setAttribute('preserveAspectRatio', 'none');

        appendContinuousBottomFrame(svg, points, width, geometry);

        anchor.setAttribute('class', 'portal-cat-anchor portal-guide-anchor');
        anchor.setAttribute('href', href);
        anchor.setAttributeNS(XLINK_NS, 'xlink:href', href);
        anchor.setAttribute('aria-label', label);
        anchor.setAttribute('role', 'button');
        anchor.setAttribute('aria-pressed', guideActive ? 'true' : 'false');
        anchor.setAttribute('data-category-key', 'guide');
        anchor.setAttribute('data-main-body-target', targetScreen);
        anchor.setAttribute('aria-controls', 'main-manifesto-screen main-guide-screen');
        buttonGeometry.setAttribute('class', 'portal-guide-button-geometry');

        anchor.addEventListener('click', function (event) {
            if (
                event &&
                (event.button > 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
            ) {
                return;
            }

            if (toggleBodyScreen(portal)) {
                if (event) event.preventDefault();
            }
        });

        outer.setAttribute('pointer-events', 'none');
        surface.setAttribute('pointer-events', 'none');
        hit.setAttribute('pointer-events', 'all');

        text.setAttribute('class', 'portal-cat-label portal-guide-label');
        text.setAttribute('x', Math.round(center[0]));
        text.setAttribute('y', Math.round((geometry.buttonTop + geometry.buttonBottom) / 2) + 1);
        text.setAttribute('font-size', '12');
        text.setAttribute('font-weight', '700');
        text.setAttribute('text-anchor', 'middle');
        text.setAttribute('dominant-baseline', 'middle');
        text.setAttribute('pointer-events', 'none');
        text.textContent = label;

        buttonGeometry.appendChild(outer);
        buttonGeometry.appendChild(surface);
        buttonGeometry.appendChild(createLine(
            'portal-cat-inset portal-cat-inset-top',
            insetPoints[0],
            insetPoints[1]
        ));
        buttonGeometry.appendChild(createLine(
            'portal-cat-inset portal-cat-inset-right',
            insetPoints[1],
            insetPoints[2]
        ));
        buttonGeometry.appendChild(createLine(
            'portal-cat-inset portal-cat-inset-bottom',
            insetPoints[2],
            insetPoints[3]
        ));
        buttonGeometry.appendChild(createLine(
            'portal-cat-inset portal-cat-inset-left',
            insetPoints[3],
            insetPoints[0]
        ));
        buttonGeometry.appendChild(hit);
        anchor.appendChild(buttonGeometry);
        anchor.appendChild(text);
        svg.appendChild(anchor);
        nav.appendChild(svg);

        mount.replaceChildren(nav);
        mount.__bottomGuideRenderKey = renderKey;
        applyGuideRowHeight(mount, nav, svg, geometry);
        mount.setAttribute('data-bottom-guide-rendered', '1');
        mount.setAttribute('data-portal-frame-build', FRAME_BUILD);
        mount.setAttribute('data-bottom-guide-width', String(width));

        return true;
    }

    function findPortal(root) {
        var scope = root && root.querySelector ? root : document;
        return scope.matches && scope.matches('.main-portal')
            ? scope
            : scope.querySelector('.main-portal');
    }

    function boot(root) {
        var scope = root || document;
        var portal = findPortal(scope);

        if (portal) render(portal);
        loadManifestoIntro(scope);
    }

    window.BottomGuideNav = {
        version:FRAME_BUILD,
        frameVersion:FRAME_BUILD,
        render: function (root) {
            var portal = root && root.matches && root.matches('.main-portal')
                ? root
                : findPortal(root || document);
            return portal ? render(portal) : false;
        },
        activate: function (screenName) {
            var portal = document.querySelector('.main-portal');
            return portal ? activateBodyScreen(portal, screenName) : false;
        },
        toggle: function () {
            var portal = document.querySelector('.main-portal');
            return portal ? toggleBodyScreen(portal) : false;
        },
        status: function () {
            var portal = document.querySelector('.main-portal');
            var mount = portal && portal.querySelector('[data-component="' + COMPONENT + '"]');

            return {
                loaded: true,
                portal: !!portal,
                mount: !!mount,
                rendered: !!(mount && mount.querySelector('.portal-guide-svg')),
                generated: !!(mount && mount.getAttribute('data-bottom-guide-generated')),
                panel: !!(portal && portal.querySelector('.main-body-panel')),
                well: !!(portal && portal.querySelector('.main-body-well')),
                insidePanel: !!(mount && mount.parentNode && mount.parentNode.classList.contains('main-body-panel')),
                welded: !!(mount && mount.querySelector('.portal-guide-nav.is-continuous-weld')),
                activeScreen: portal ? portal.getAttribute('data-main-body-screen') || 'manifesto' : '',
                guideScreen: !!(portal && portal.querySelector('[data-main-body-screen="guide"]')),
                width: mount ? mount.getAttribute('data-bottom-guide-width') || '' : ''
            };
        }
    };


    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', function () { boot(document); }, { once:true });
    } else {
        boot(document);
    }

    if (mw && mw.hook) {
        mw.hook('wikipage.content').add(function ($content) {
            boot($content && $content[0] ? $content[0] : document);
        });
    }
}(window, document, window.mw));