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

(Install package: clbiwiki-portal-frame-display-fix-20260717 / js/BottomGuideNav.js)
(Install package: clbiwiki-portal-frame-fixed / js/BottomGuideNav.js)
태그: 수동 되돌리기
 
(같은 사용자의 중간 판 6개는 보이지 않습니다)
1번째 줄: 1번째 줄:
/* =========================================
/* =========================================
BottomGuideNav
BottomGuideNav
대문 하단 「길라잡이」 SVG 버튼
대문 하단 「길라잡이」 canvas 픽셀 버튼
========================================= */
========================================= */
/*
상단 CategoryNav와 분리해 로드한다.
FrameGeometry.js가 공유한 설정–프로젝트–기업 및 공동체 seam을 사용하며,
상단 SVG나 화면 rect를 다시 읽지 않는다.
*/


(function (window, document, mw) {
(function (window, document, mw) {
     'use strict';
     '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 COMPONENT = 'category-guide-nav';
    var FRAME_API = window.CLBI && window.CLBI.frame;
    var HEIGHT = FRAME_API.FRAME.BUTTON_FACE;
    var TOP_FRAME = FRAME_API.FRAME.FRAME_FACE;
    var BOTTOM_FRAME = FRAME_API.FRAME.FRAME_FACE;
    var GAP = FRAME_API.FRAME.GAP;
    var ROW_HEIGHT = TOP_FRAME + GAP + HEIGHT + GAP + BOTTOM_FRAME;
    var BUTTON_TOP = TOP_FRAME + GAP;
    var BUTTON_BOTTOM = BUTTON_TOP + HEIGHT;
    var COMPACT_HEIGHT = HEIGHT;
    var COMPACT_TOP_FRAME = TOP_FRAME;
    var COMPACT_BOTTOM_FRAME = BOTTOM_FRAME;
     var retryTimer = 0;
     var retryTimer = 0;
    var geometryWaitTimer = 0;
     var resizeTimer = 0;
     var resizeTimer = 0;
    var guideLayoutAnimationFrame = 0;
     var observer = null;
     var observer = null;
     var manifestoIntroPromise = null;
     var manifestoIntroPromise = null;
35번째 줄: 16번째 줄:
     var MANIFESTO_INTRO_VERSION = '20260713-divider-equal-gap-005';
     var MANIFESTO_INTRO_VERSION = '20260713-divider-equal-gap-005';
     var GUIDE_DUMMY_ITEMS = [
     var GUIDE_DUMMY_ITEMS = [
         { key: 'start', label: '처음 오셨다면' },
         { key:'start', label:'처음 오셨다면' },
         { key: 'world', label: '세계관' },
         { key:'world', label:'세계관' },
         { key: 'era', label: '시대' },
         { key:'era', label:'시대' },
         { key: 'people', label: '인물' },
         { key:'people', label:'인물' },
         { key: 'anecdote', label: '에넥도트' },
         { key:'anecdote', label:'에넥도트' },
         { key: 'participate', label: '참여 안내' }
         { key:'participate', label:'참여 안내' }
     ];
     ];
    function api() {
        return window.CLBI && window.CLBI.frame;
    }


     function revealManifesto(root) {
     function revealManifesto(root) {
51번째 줄: 36번째 줄:
         var copy;
         var copy;


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


61번째 줄: 44번째 줄:
         title = portal.querySelector('.main-manifesto-title');
         title = portal.querySelector('.main-manifesto-title');
         copy = portal.querySelector('.main-manifesto-copy');
         copy = portal.querySelector('.main-manifesto-copy');
         if (title) {
         if (title) {
             title.style.opacity = '1';
             title.style.opacity = '1';
67번째 줄: 49번째 줄:
         }
         }
         if (copy) copy.style.visibility = 'visible';
         if (copy) copy.style.visibility = 'visible';
         Array.prototype.forEach.call(portal.querySelectorAll('.manifesto-line'), function (line) {
         Array.prototype.forEach.call(portal.querySelectorAll('.manifesto-line'), function (line) {
             line.style.height = '26px';
             line.style.height = '26px';
97번째 줄: 78번째 줄:
             }
             }
         }
         }
         if (manifestoIntroPromise) return manifestoIntroPromise;
         if (manifestoIntroPromise) return manifestoIntroPromise;


104번째 줄: 84번째 줄:
             var settled = false;
             var settled = false;
             var timeout = 0;
             var timeout = 0;
             function settle(ok) {
             function settle(ok) {
                 if (settled) return;
                 if (settled) return;
115번째 줄: 94번째 줄:
                 resolve(ok);
                 resolve(ok);
             }
             }
             script.src = '/index.php?title=' + encodeURIComponent('MediaWiki:ManifestoIntro.js') +
             script.src = '/index.php?title=' + encodeURIComponent('MediaWiki:ManifestoIntro.js') +
                 '&action=raw&ctype=text/javascript&v=' + encodeURIComponent(MANIFESTO_INTRO_VERSION) +
                 '&action=raw&ctype=text/javascript&v=' + encodeURIComponent(MANIFESTO_INTRO_VERSION) +
134번째 줄: 112번째 줄:
                 }
                 }
             };
             };
             script.onerror = function () {
             script.onerror = function () { settle(false); };
                settle(false);
            };
 
             timeout = window.setTimeout(function () {
             timeout = window.setTimeout(function () {
                 if (script.parentNode) script.parentNode.removeChild(script);
                 if (script.parentNode) script.parentNode.removeChild(script);
                 settle(false);
                 settle(false);
             }, MANIFESTO_LOAD_TIMEOUT_MS);
             }, MANIFESTO_LOAD_TIMEOUT_MS);
             (document.head || document.documentElement).appendChild(script);
             (document.head || document.documentElement).appendChild(script);
         });
         });
         return manifestoIntroPromise;
         return manifestoIntroPromise;
    }
    revealManifesto(document);
    function createSvgElement(name) {
        return FRAME_API.create(name);
    }
    function buildWikiPath(title) {
        if (typeof window.buildWikiPath === 'function') {
            return window.buildWikiPath(title);
        }
        return '/index.php/' + encodeURI(String(title || '').replace(/ /g, '_'));
    }
    function createPolygon(className, points) {
        return FRAME_API.polygon(className, points);
    }
    function createLine(className, start, end) {
        return FRAME_API.line(className, start, end);
    }
    function createRect(className, x, y, width, height) {
        return FRAME_API.rect(className, x, y, width, height);
    }
    function centerOfPoints(points) {
        return [FRAME_API.centerX(points), Math.round((points[0][1] + points[2][1]) / 2)];
    }
    function shiftY(points, amount) {
        return points.map(function (point) {
            return [Math.round(point[0]), Math.round(point[1] + amount)];
        });
    }
    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 buildGuideButton(geometry) {
        var seams = FRAME_API.seams;
        var leftTop;
        var rightTop;
        var leftDir;
        var rightDir;
        if (!seams || !seams.width) return null;
        leftTop = FRAME_API.slantX(
            seams.settingsLeftTopX,
            seams.buttonBottom,
            seams.settingsLeftDir,
            0
        );
        rightTop = FRAME_API.slantX(
            seams.corpRightTopX,
            seams.buttonBottom,
            seams.corpRightDir,
            0
        );
        leftDir = -seams.settingsLeftDir;
        rightDir = -seams.corpRightDir;
        return {
            leftTopX:leftTop,
            rightTopX:rightTop,
            leftDir:leftDir,
            rightDir:rightDir,
            points:shiftY(
                FRAME_API.segment(leftTop, rightTop, geometry.height, leftDir, rightDir, 0),
                geometry.buttonTop
            )
        };
    }
    function guideLayer(button, geometry, amount) {
        return shiftY(
            FRAME_API.segment(
                button.leftTopX + amount,
                button.rightTopX - amount,
                geometry.height - amount * 2,
                button.leftDir,
                button.rightDir,
                amount % FRAME_API.FRAME.SLANT_RISE
            ),
            geometry.buttonTop + amount
        );
    }
    function appendContinuousBottomFrame(svg, button, width, geometry) {
        var frame = FRAME_API.FRAME;
        var group = createSvgElement('g');
        var leftTarget = button.points[0][0] - frame.FRAME_FACE - frame.GAP;
        var rightTarget = button.points[1][0] + frame.FRAME_FACE + frame.GAP;
        var leftBase = leftTarget - FRAME_API.slantX(0, geometry.buttonTop, button.leftDir, 0);
        var rightBase = rightTarget - FRAME_API.slantX(0, geometry.buttonTop, button.rightDir, 0);
        var outer = FRAME_API.segment(
            leftBase,
            rightBase,
            geometry.rowHeight,
            button.leftDir,
            button.rightDir,
            0
        );
        var well = FRAME_API.segmentAt(
            leftBase,
            rightBase,
            geometry.topFrame,
            geometry.rowHeight - geometry.bottomFrame,
            button.leftDir,
            button.rightDir,
            0,
            frame.FRAME_FACE,
            frame.FRAME_FACE
        );
        var bevel = FRAME_API.segmentAt(
            leftBase,
            rightBase,
            frame.BORDER + frame.BEVEL,
            geometry.rowHeight - frame.BORDER - frame.BEVEL,
            button.leftDir,
            button.rightDir,
            0,
            frame.BORDER + frame.BEVEL,
            frame.BORDER + frame.BEVEL
        );
        group.setAttribute('class', 'portal-guide-arch-frame');
        group.setAttribute('pointer-events', 'none');
        group.appendChild(createPolygon('portal-guide-arch-outer', outer));
        group.appendChild(createPolygon('portal-guide-arch-well', well));
        group.appendChild(createLine('portal-guide-arch-edge', outer[3], outer[0]));
        group.appendChild(createLine('portal-guide-arch-edge', outer[0], outer[1]));
        group.appendChild(createLine('portal-guide-arch-edge', outer[1], outer[2]));
        group.appendChild(createLine('portal-guide-arch-highlight', bevel[0], bevel[1]));
        group.appendChild(createLine('portal-guide-arch-highlight', bevel[1], bevel[2]));
        group.appendChild(createLine('portal-guide-arch-shadow', bevel[3], bevel[0]));
        group.appendChild(createLine('portal-guide-arch-inner-highlight', well[0], well[1]));
        group.appendChild(createLine('portal-guide-arch-inner-highlight', well[1], well[2]));
        group.appendChild(createLine('portal-guide-arch-inner-shadow', well[3], well[0]));
        svg.appendChild(group);
     }
     }


402번째 줄: 132번째 줄:
             panel.className = 'main-body-panel';
             panel.className = 'main-body-panel';
             panel.setAttribute('data-main-body-panel-generated', '1');
             panel.setAttribute('data-main-body-panel-generated', '1');
             if (topMount && topMount.parentNode) {
             if (topMount && topMount.parentNode) {
                 if (topMount.nextSibling) {
                 if (topMount.nextSibling) topMount.parentNode.insertBefore(panel, topMount.nextSibling);
                    topMount.parentNode.insertBefore(panel, topMount.nextSibling);
                 else topMount.parentNode.appendChild(panel);
                 } else {
                    topMount.parentNode.appendChild(panel);
                }
             } else {
             } else {
                 portal.appendChild(panel);
                 portal.appendChild(panel);
             }
             }
         }
         }
         if (!well) {
         if (!well) {
             well = document.createElement('div');
             well = document.createElement('div');
422번째 줄: 147번째 줄:
             panel.insertBefore(well, panel.firstChild);
             panel.insertBefore(well, panel.firstChild);
         }
         }
 
         if (manifesto && manifesto.parentNode !== well) well.insertBefore(manifesto, well.firstChild);
         if (manifesto && manifesto.parentNode !== well) {
         return { panel:panel, well:well };
            well.insertBefore(manifesto, well.firstChild);
        }
 
         return {
            panel: panel,
            well: well
        };
     }
     }


     function setBodyScreenHidden(screen, hidden) {
     function setBodyScreenHidden(screen, hidden) {
         if (!screen) return;
         if (!screen) return;
 
         screen.hidden = !!hidden;
        screen.classList.toggle('is-active', !hidden);
         screen.hidden = hidden;
         screen.setAttribute('aria-hidden', hidden ? 'true' : 'false');
         screen.setAttribute('aria-hidden', hidden ? 'true' : 'false');
        screen.classList.toggle('is-main-body-screen-hidden', !!hidden);
     }
     }


449번째 줄: 166번째 줄:
         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);
             anchor.setAttribute('href', href);
            anchor.setAttributeNS(XLINK_NS, 'xlink:href', href);
             anchor.setAttribute('aria-label', label);
             anchor.setAttribute('aria-label', label);
             anchor.setAttribute('aria-pressed', guideActive ? 'true' : 'false');
             anchor.setAttribute('aria-pressed', guideActive ? 'true' : 'false');
455번째 줄: 171번째 줄:
             anchor.removeAttribute('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;
             text.textContent = 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);
             nav.setAttribute('aria-label', label);
467번째 줄: 181번째 줄:
     function ensureGuideDummyMenu(guide) {
     function ensureGuideDummyMenu(guide) {
         var menu;
         var menu;
         if (!guide) return null;
         if (!guide) return null;
         menu = guide.querySelector('[data-guide-dummy-menu="1"]');
         menu = guide.querySelector('[data-guide-dummy-menu="1"]');
         if (menu) return menu;
         if (menu) return menu;
477번째 줄: 189번째 줄:
         menu.setAttribute('data-guide-dummy-menu', '1');
         menu.setAttribute('data-guide-dummy-menu', '1');
         menu.setAttribute('aria-label', '길라잡이 항목 예시');
         menu.setAttribute('aria-label', '길라잡이 항목 예시');
         GUIDE_DUMMY_ITEMS.forEach(function (item, index) {
         GUIDE_DUMMY_ITEMS.forEach(function (item, index) {
             var wrapper = document.createElement('div');
             var wrapper = document.createElement('div');
484번째 줄: 195번째 줄:
             var label = document.createElement('span');
             var label = document.createElement('span');
             var arrow = document.createElement('span');
             var arrow = document.createElement('span');
             wrapper.className = 'main-guide-menu-item';
             wrapper.className = 'main-guide-menu-item';
             wrapper.style.setProperty('--guide-menu-order', String(index));
             wrapper.style.setProperty('--guide-menu-order', String(index));
             button.type = 'button';
             button.type = 'button';
             button.className = 'main-guide-menu-button';
             button.className = 'main-guide-menu-button';
             button.setAttribute('data-guide-dummy-button', item.key);
             button.setAttribute('data-guide-dummy-button', item.key);
             button.setAttribute('aria-label', item.label + ' (더미 버튼)');
             button.setAttribute('aria-label', item.label + ' (더미 버튼)');
             number.className = 'main-guide-menu-number';
             number.className = 'main-guide-menu-number';
             number.setAttribute('aria-hidden', 'true');
             number.setAttribute('aria-hidden', 'true');
             number.textContent = String(index + 1).padStart(2, '0');
             number.textContent = String(index + 1).padStart(2, '0');
             label.className = 'main-guide-menu-label';
             label.className = 'main-guide-menu-label';
             label.textContent = item.label;
             label.textContent = item.label;
             arrow.className = 'main-guide-menu-arrow';
             arrow.className = 'main-guide-menu-arrow';
             arrow.setAttribute('aria-hidden', 'true');
             arrow.setAttribute('aria-hidden', 'true');
             arrow.textContent = '›';
             arrow.textContent = '›';
             button.appendChild(number);
             button.appendChild(number);
             button.appendChild(label);
             button.appendChild(label);
             button.appendChild(arrow);
             button.appendChild(arrow);
             button.addEventListener('click', function (event) {
             button.addEventListener('click', function (event) { event.preventDefault(); });
                event.preventDefault();
            });
             wrapper.appendChild(button);
             wrapper.appendChild(button);
             menu.appendChild(wrapper);
             menu.appendChild(wrapper);
         });
         });
         guide.insertBefore(menu, guide.firstChild);
         guide.insertBefore(menu, guide.firstChild);
         return menu;
         return menu;
520번째 줄: 222번째 줄:
     function restartGuideMenuAnimation(guide) {
     function restartGuideMenuAnimation(guide) {
         if (!guide || !guide.querySelector('[data-guide-dummy-menu="1"]')) return;
         if (!guide || !guide.querySelector('[data-guide-dummy-menu="1"]')) return;
         guide.classList.remove('is-guide-menu-entering');
         guide.classList.remove('is-guide-menu-entering');
        /* 같은 화면에 재진입해도 첫 프레임부터 순차 진입을 다시 시작한다. */
         void guide.offsetWidth;
         void guide.offsetWidth;
         guide.classList.add('is-guide-menu-entering');
         guide.classList.add('is-guide-menu-entering');
533번째 줄: 233번째 줄:
         var panels;
         var panels;
         var active = layout.well.getAttribute('data-active-main-body-screen') ||
         var active = layout.well.getAttribute('data-active-main-body-screen') ||
             portal.getAttribute('data-main-body-screen') ||
             portal.getAttribute('data-main-body-screen') || 'manifesto';
            'manifesto';


         if (manifesto) {
         if (manifesto) {
541번째 줄: 240번째 줄:
             if (!manifesto.id) manifesto.id = 'main-manifesto-screen';
             if (!manifesto.id) manifesto.id = 'main-manifesto-screen';
         }
         }
         if (!guide) {
         if (!guide) {
             guide = document.createElement('section');
             guide = document.createElement('section');
552번째 줄: 250번째 줄:
             layout.well.appendChild(guide);
             layout.well.appendChild(guide);
         } else {
         } else {
             guide.classList.add('main-body-screen');
             guide.classList.add('main-body-screen', 'main-guide-screen');
            guide.classList.add('main-guide-screen');
             if (!guide.id) guide.id = 'main-guide-screen';
             if (!guide.id) guide.id = 'main-guide-screen';
         }
         }
         ensureGuideDummyMenu(guide);
         ensureGuideDummyMenu(guide);
         panels = Array.prototype.slice.call(
         panels = Array.prototype.slice.call(
             layout.well.querySelectorAll('.main-body-screen[data-main-body-screen]')
             layout.well.querySelectorAll('.main-body-screen[data-main-body-screen]')
         );
         );
         if (active !== 'guide') active = 'manifesto';
         if (active !== 'guide') active = 'manifesto';
         if (!panels.some(function (panel) {
         if (!panels.some(function (panel) {
             return panel.getAttribute('data-main-body-screen') === active;
             return panel.getAttribute('data-main-body-screen') === active;
         })) {
         })) active = 'manifesto';
            active = 'manifesto';
        }
 
         panels.forEach(function (panel) {
         panels.forEach(function (panel) {
             setBodyScreenHidden(
             setBodyScreenHidden(panel, panel.getAttribute('data-main-body-screen') !== active);
                panel,
                panel.getAttribute('data-main-body-screen') !== active
            );
         });
         });
         layout.well.setAttribute('data-main-body-screen-controller-ready', '1');
         layout.well.setAttribute('data-main-body-screen-controller-ready', '1');
         layout.well.setAttribute('data-active-main-body-screen', active);
         layout.well.setAttribute('data-active-main-body-screen', active);
585번째 줄: 272번째 줄:
             restartGuideMenuAnimation(guide);
             restartGuideMenuAnimation(guide);
         }
         }
         return {
         return {
             well: layout.well,
             well:layout.well,
             panels: panels,
             panels:panels,
             manifesto: manifesto,
             manifesto:manifesto,
             guide: guide,
             guide:guide,
             active: active
             active:active
         };
         };
     }
     }
600번째 줄: 286번째 줄:
         var active;
         var active;
         var event;
         var event;
         if (!portal) return false;
         if (!portal) return false;
         screens = ensureBodyScreens(portal);
         screens = ensureBodyScreens(portal);
         screenName = screenName === 'guide' ? 'guide' : 'manifesto';
         screenName = screenName === 'guide' ? 'guide' : 'manifesto';
608번째 줄: 292번째 줄:
             return panel.getAttribute('data-main-body-screen') === screenName;
             return panel.getAttribute('data-main-body-screen') === screenName;
         })) return false;
         })) return false;
         previous = screens.active;
         previous = screens.active;
         active = screenName;
         active = screenName;
         screens.panels.forEach(function (panel) {
         screens.panels.forEach(function (panel) {
             setBodyScreenHidden(
             setBodyScreenHidden(panel, panel.getAttribute('data-main-body-screen') !== active);
                panel,
                panel.getAttribute('data-main-body-screen') !== active
            );
         });
         });
         screens.well.setAttribute('data-active-main-body-screen', active);
         screens.well.setAttribute('data-active-main-body-screen', active);
         portal.setAttribute('data-main-body-screen', active);
         portal.setAttribute('data-main-body-screen', active);
         portal.classList.toggle('is-guide-screen-active', active === 'guide');
         portal.classList.toggle('is-guide-screen-active', active === 'guide');
         updateGuideButtonState(portal, active);
         updateGuideButtonState(portal, active);
 
         if (active === 'guide' && previous !== active) restartGuideMenuAnimation(screens.guide);
         if (active === 'guide' && previous !== active) {
         else if (active !== 'guide' && screens.guide) screens.guide.classList.remove('is-guide-menu-entering');
            restartGuideMenuAnimation(screens.guide);
         render(portal);
         } else if (active !== 'guide' && screens.guide) {
            screens.guide.classList.remove('is-guide-menu-entering');
         }
 
        if (previous !== active) {
            animateGuideLayout(portal, active === 'guide');
        }
 
         if (previous !== active) {
         if (previous !== active) {
             try {
             try {
                 event = new CustomEvent('mainbodyscreenchange', {
                 event = new CustomEvent('mainbodyscreenchange', {
                     bubbles: true,
                     bubbles:true,
                     detail: { screen: active, previous: previous }
                     detail:{ screen:active, previous:previous }
                 });
                 });
                 portal.dispatchEvent(event);
                 portal.dispatchEvent(event);
             } catch (error) {}
             } catch (error) {}
         }
         }
         return true;
         return true;
     }
     }
649번째 줄: 318번째 줄:
     function toggleBodyScreen(portal) {
     function toggleBodyScreen(portal) {
         var screens;
         var screens;
        var target;
         if (!portal) return false;
         if (!portal) return false;
         screens = ensureBodyScreens(portal);
         screens = ensureBodyScreens(portal);
         target = screens.active === 'guide' ? 'manifesto' : 'guide';
         return activateBodyScreen(portal, screens.active === 'guide' ? 'manifesto' : 'guide');
        return activateBodyScreen(portal, target);
     }
     }


661번째 줄: 327번째 줄:
         var anchor;
         var anchor;
         var portal;
         var portal;
         if (!event || event.__mainGuideScreenHandled) return;
         if (!event || event.__mainGuideScreenHandled) return;
         if (event.button > 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
         if (event.button > 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
         target = event.target;
         target = event.target;
         anchor = target && target.closest
         anchor = target && target.closest
670번째 줄: 334번째 줄:
             : null;
             : null;
         if (!anchor) return;
         if (!anchor) return;
         portal = anchor.closest ? anchor.closest('.main-portal') : null;
         portal = anchor.closest ? anchor.closest('.main-portal') : null;
         if (!portal || !toggleBodyScreen(portal)) return;
         if (!portal || !toggleBodyScreen(portal)) return;
         event.__mainGuideScreenHandled = true;
         event.__mainGuideScreenHandled = true;
         event.preventDefault();
         event.preventDefault();
         event.stopPropagation();
         event.stopPropagation();
         if (typeof event.stopImmediatePropagation === 'function') {
         if (typeof event.stopImmediatePropagation === 'function') event.stopImmediatePropagation();
            event.stopImmediatePropagation();
        }
     }
     }


    /*
    이전 실행본의 ResizeObserver가 오래된 문서 링크를 다시 그려도
    전역 SPA 링크 처리보다 먼저 화면 전환으로 고정한다.
    */
     if (window.BottomGuideNavScreenCaptureHandler) {
     if (window.BottomGuideNavScreenCaptureHandler) {
         document.removeEventListener('click', window.BottomGuideNavScreenCaptureHandler, true);
         document.removeEventListener('click', window.BottomGuideNavScreenCaptureHandler, true);
695번째 줄: 351번째 줄:
         var layout = ensureBodyPanel(portal);
         var layout = ensureBodyPanel(portal);
         var mount = portal.querySelector('[data-component="' + COMPONENT + '"]');
         var mount = portal.querySelector('[data-component="' + COMPONENT + '"]');
         ensureBodyScreens(portal);
         ensureBodyScreens(portal);
         if (!mount) {
         if (!mount) {
             mount = document.createElement('div');
             mount = document.createElement('div');
703번째 줄: 357번째 줄:
             mount.setAttribute('data-bottom-guide-generated', '1');
             mount.setAttribute('data-bottom-guide-generated', '1');
         }
         }
        if (mount.parentNode !== layout.panel) layout.panel.appendChild(mount);
        return mount;
    }
    function guideButton(seams) {
        var frame = api();
        var d = frame.DIMENSIONS;
        var step = Math.floor(d.BUTTON_EDGE_BOTTOM / frame.FRAME.SLANT_RISE);
        var left = seams.settingsLeftOriginX + step;
        var right = seams.corpRightOriginX - step;
        return {
            x:Math.round(left),
            y:d.BUTTON_EDGE_TOP,
            btnW:Math.max(4, Math.round(right - left)),
            pixelHeight:d.BUTTON_TOTAL,
            slant:'both'
        };
    }


        /*
    function draw(canvas, button, label, hover) {
         길라잡이는 우물 안의 독립 버튼이 아니라
         var frame = api();
         main-body-panel 하단 프레임에 융합된 돌출부다.
         var ctx = canvas.getContext('2d');
         */
         var image = frame.createImage(ctx, canvas.width, canvas.height, 'BG');
        if (mount.parentNode !== layout.panel) {
        var grid;
            layout.panel.appendChild(mount);
        var mask;
         }
         var center;


         return mount;
         frame.paintStandardFrame(image, canvas.width, canvas.height);
        grid = frame.buildReverseButtonPixels(
            button.btnW,
            frame.FRAME.BUTTON_FACE,
            button.slant,
            0
        );
        frame.paintGrid(image, canvas.width, canvas.height, grid, button.x, button.y, true);
        ctx.putImageData(image, 0, 0);
        mask = frame.buttonMask(
            button.btnW,
            frame.FRAME.BUTTON_FACE - 1 - Math.floor(frame.FRAME.BUTTON_FACE / 2),
            button.slant,
            0
        );
        center = button.x + Math.round((mask.left + mask.right) / 2);
        frame.drawLabel(
            ctx,
            label,
            center,
            Math.round((frame.DIMENSIONS.BUTTON_SURFACE_TOP + frame.DIMENSIONS.BUTTON_SURFACE_BOTTOM) / 2) + 1,
            hover,
            canvas.width <= 820 ? 11 : 12
        );
     }
     }


     function render(portal, suppliedGeometry) {
     function render(portal) {
         var mount = ensureMount(portal);
         var frame = api();
         var width = FRAME_API.readWidth(mount);
        var seams;
         var guideActive = portal.getAttribute('data-main-body-screen') === 'guide';
        var mount;
         var geometry = suppliedGeometry || portal.GuideNavGeometry || getGuideGeometry(guideActive);
         var width;
         var guideActive;
         var label;
        var targetScreen;
        var href;
        var button;
        var nav;
        var inner;
        var canvas;
        var hitLayer;
        var anchor;
        var hiddenLabel;
        var redraw;


        if (!frame || !frame.FRAME || typeof frame.readRenderWidth !== 'function') {
            window.clearTimeout(geometryWaitTimer);
            geometryWaitTimer = window.setTimeout(function () {
                geometryWaitTimer = 0;
                render(portal);
            }, 50);
            return false;
        }
        seams = frame.seams;
        if (!seams || !seams.width) return false;
        mount = ensureMount(portal);
        width = seams.width || frame.readRenderWidth(mount);
         if (!width) return false;
         if (!width) return false;


         portal.GuideNavGeometry = geometry;
         guideActive = portal.getAttribute('data-main-body-screen') === 'guide';
        label = guideActive ? '돌아가기' : '길라잡이';
        targetScreen = guideActive ? 'manifesto' : 'guide';
        href = guideActive ? '#main-manifesto-screen' : '#main-guide-screen';
        button = guideButton(seams);
        if (
            mount.getAttribute('data-bottom-guide-width') === String(width) &&
            mount.getAttribute('data-bottom-guide-label') === label &&
            mount.getAttribute('data-bottom-guide-scale') === String(frame.getShellScale()) &&
            mount.querySelector('.portal-guide-canvas')
        ) {
            return true;
        }


         var button = buildGuideButton(geometry);
         nav = document.createElement('nav');
        var points;
         nav.className = 'portal-category-nav portal-guide-nav portal-frame-canvas-nav is-continuous-weld';
        var surfacePoints;
         nav.setAttribute('aria-label', label);
        var insetPoints;
         nav.__frameHover = false;
        var center;
 
        if (!button) return false;
        points = button.points;
        surfacePoints = guideLayer(button, geometry, FRAME_API.FRAME.BORDER);
        insetPoints = guideLayer(
            button,
            geometry,
            FRAME_API.FRAME.BORDER + FRAME_API.FRAME.BEVEL
        );
        center = centerOfPoints(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);


         nav.className = 'portal-category-nav portal-guide-nav is-continuous-weld';
         inner = document.createElement('div');
         nav.setAttribute('aria-label', label);
        inner.className = 'portal-frame-counter-inner portal-guide-counter-inner';
         inner.style.width = width + 'px';


         svg.setAttribute('class', 'portal-category-svg portal-guide-svg');
         canvas = frame.createCanvas('portal-frame-canvas portal-guide-canvas', width, frame.DIMENSIONS.ROW_HEIGHT);
        svg.setAttribute('viewBox', '0 0 ' + width + ' ' + geometry.rowHeight);
         canvas.setAttribute('data-frame-width', String(width));
         svg.setAttribute('width', '100%');
         canvas.setAttribute('data-frame-height', String(frame.DIMENSIONS.ROW_HEIGHT));
         svg.setAttribute('height', geometry.rowHeight);
        svg.setAttribute('preserveAspectRatio', 'none');


         appendContinuousBottomFrame(svg, button, width, geometry);
         hitLayer = document.createElement('div');
        hitLayer.className = 'portal-frame-hit-layer';
        hitLayer.style.width = width + 'px';
        hitLayer.style.height = frame.DIMENSIONS.ROW_HEIGHT + 'px';


         anchor.setAttribute('class', 'portal-cat-anchor portal-guide-anchor');
         anchor = document.createElement('a');
         anchor.setAttribute('href', href);
        anchor.className = 'portal-frame-hit portal-guide-anchor';
        anchor.setAttributeNS(XLINK_NS, 'xlink:href', href);
         anchor.href = href;
         anchor.setAttribute('aria-label', label);
         anchor.setAttribute('aria-label', label);
         anchor.setAttribute('role', 'button');
         anchor.setAttribute('role', 'button');
772번째 줄: 483번째 줄:
         anchor.setAttribute('data-main-body-target', targetScreen);
         anchor.setAttribute('data-main-body-target', targetScreen);
         anchor.setAttribute('aria-controls', 'main-manifesto-screen main-guide-screen');
         anchor.setAttribute('aria-controls', 'main-manifesto-screen main-guide-screen');
         buttonGeometry.setAttribute('class', 'portal-guide-button-geometry');
         anchor.style.left = button.x + 'px';
        anchor.style.top = button.y + 'px';
        anchor.style.width = (button.btnW + 2) + 'px';
        anchor.style.height = button.pixelHeight + 'px';
        hiddenLabel = document.createElement('span');
        hiddenLabel.className = 'portal-frame-sr-label portal-guide-label';
        hiddenLabel.textContent = label;
        anchor.appendChild(hiddenLabel);


        redraw = function () { draw(canvas, button, label, nav.__frameHover); };
        anchor.addEventListener('mouseenter', function () {
            nav.__frameHover = true;
            redraw();
        });
        anchor.addEventListener('mouseleave', function () {
            nav.__frameHover = false;
            redraw();
        });
        anchor.addEventListener('focus', function () {
            nav.__frameHover = true;
            redraw();
        });
        anchor.addEventListener('blur', function () {
            nav.__frameHover = false;
            redraw();
        });
         anchor.addEventListener('click', function (event) {
         anchor.addEventListener('click', function (event) {
             if (
             if (event && (event.button > 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)) return;
                event &&
             if (toggleBodyScreen(portal) && event) event.preventDefault();
                (event.button > 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
            ) {
                return;
            }
 
             if (toggleBodyScreen(portal)) {
                if (event) event.preventDefault();
            }
         });
         });


         outer.setAttribute('pointer-events', 'none');
         hitLayer.appendChild(anchor);
         surface.setAttribute('pointer-events', 'none');
         inner.appendChild(canvas);
         hit.setAttribute('pointer-events', 'all');
         inner.appendChild(hitLayer);
 
         nav.appendChild(inner);
         text.setAttribute('class', 'portal-cat-label portal-guide-label');
         mount.replaceChildren(nav);
         text.setAttribute('x', Math.round(center[0]));
         frame.applyCounterScale(
         text.setAttribute('y', Math.round((geometry.buttonTop + geometry.buttonBottom) / 2) + 1);
            [mount, nav],
        text.setAttribute('font-size', '12');
            inner,
        text.setAttribute('font-weight', '700');
            frame.DIMENSIONS.ROW_HEIGHT
        text.setAttribute('text-anchor', 'middle');
         );
         text.setAttribute('dominant-baseline', 'middle');
         redraw();
         text.setAttribute('pointer-events', 'none');
        text.textContent = label;


        buttonGeometry.appendChild(outer);
         mount.setAttribute('data-bottom-guide-rendered', 'canvas');
        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);
        applyGuideRowHeight(mount, nav, svg, geometry);
         mount.setAttribute('data-bottom-guide-rendered', '1');
         mount.setAttribute('data-bottom-guide-width', String(width));
         mount.setAttribute('data-bottom-guide-width', String(width));
 
        mount.setAttribute('data-bottom-guide-label', label);
        mount.setAttribute('data-bottom-guide-scale', String(frame.getShellScale()));
         return true;
         return true;
     }
     }
846번째 줄: 542번째 줄:
     function schedule(root, attempt) {
     function schedule(root, attempt) {
         window.clearTimeout(retryTimer);
         window.clearTimeout(retryTimer);
         retryTimer = window.setTimeout(function () {
         retryTimer = window.setTimeout(function () {
             var portal = findPortal(root);
             var portal = findPortal(root);
             if (!portal) return;
             if (!portal) return;
             if (!render(portal) && attempt < 20) {
             if (!render(portal) && attempt < 20) {
                 schedule(root, attempt + 1);
                 schedule(root, attempt + 1);
                 return;
                 return;
             }
             }
             if (observer) observer.disconnect();
             if (observer) observer.disconnect();
             if (window.ResizeObserver) {
             if (window.ResizeObserver) {
                 observer = new ResizeObserver(function () {
                 observer = new ResizeObserver(function () {
                     window.clearTimeout(resizeTimer);
                     window.clearTimeout(resizeTimer);
                     resizeTimer = window.setTimeout(function () {
                     resizeTimer = window.setTimeout(function () { render(portal); }, 60);
                        render(portal);
                    }, 60);
                 });
                 });
                 observer.observe(portal);
                 observer.observe(portal);
                 var topMount = portal.querySelector('[data-component="category-nav"]');
                 var topMount = portal.querySelector('[data-component="category-nav"]');
882번째 줄: 570번째 줄:
     }
     }


    revealManifesto(document);
     if (document.readyState === 'loading') {
     if (document.readyState === 'loading') {
         document.addEventListener('DOMContentLoaded', function () {
         document.addEventListener('DOMContentLoaded', function () { boot(document); }, { once:true });
            boot(document);
        }, { once: true });
     } else {
     } else {
         boot(document);
         boot(document);
     }
     }
 
     window.addEventListener('load', function () { boot(document); }, { once:true });
    /* 일반 링크 진입과 새로고침의 최종 로드 시점도 명시적으로 보장한다. */
     window.addEventListener('pageshow', function () { boot(document); });
     window.addEventListener('load', function () {
    if (api()) {
        boot(document);
         api().onSeams(function () { schedule(document, 0); });
    }, { once: true });
        api().onScale(function () { schedule(document, 0); });
 
    }
    /* bfcache 복원과 브라우저 뒤로/앞으로 이동에서도 새 DOM만 초기화한다. */
     window.addEventListener('pageshow', function () {
         boot(document);
    });
 
     if (mw && mw.hook) {
     if (mw && mw.hook) {
         mw.hook('wikipage.content').add(function ($content) {
         mw.hook('wikipage.content').add(function ($content) {
907번째 줄: 589번째 줄:


     window.BottomGuideNav = {
     window.BottomGuideNav = {
         version: '20260717-shared-frame-reverse-slant-001',
         version:'20260717-canvas-pixel-001',
         render: function () {
         render:function () {
             var portal = document.querySelector('.main-portal');
             var portal = document.querySelector('.main-portal');
             return portal ? render(portal) : false;
             return portal ? render(portal) : false;
         },
         },
         activate: function (screenName) {
         activate:function (screenName) {
             var portal = document.querySelector('.main-portal');
             var portal = document.querySelector('.main-portal');
             return portal ? activateBodyScreen(portal, screenName) : false;
             return portal ? activateBodyScreen(portal, screenName) : false;
         },
         },
         toggle: function () {
         toggle:function () {
             var portal = document.querySelector('.main-portal');
             var portal = document.querySelector('.main-portal');
             return portal ? toggleBodyScreen(portal) : false;
             return portal ? toggleBodyScreen(portal) : false;
         },
         },
         status: function () {
         status:function () {
             var portal = document.querySelector('.main-portal');
             var portal = document.querySelector('.main-portal');
             var mount = portal && portal.querySelector('[data-component="' + COMPONENT + '"]');
             var mount = portal && portal.querySelector('[data-component="' + COMPONENT + '"]');
             return {
             return {
                 loaded: true,
                 loaded:true,
                 portal: !!portal,
                 portal:!!portal,
                 mount: !!mount,
                 mount:!!mount,
                 rendered: !!(mount && mount.querySelector('.portal-guide-svg')),
                 rendered:!!(mount && mount.querySelector('.portal-guide-canvas')),
                 generated: !!(mount && mount.getAttribute('data-bottom-guide-generated')),
                 generated:!!(mount && mount.getAttribute('data-bottom-guide-generated')),
                 panel: !!(portal && portal.querySelector('.main-body-panel')),
                 panel:!!(portal && portal.querySelector('.main-body-panel')),
                 well: !!(portal && portal.querySelector('.main-body-well')),
                 well:!!(portal && portal.querySelector('.main-body-well')),
                 insidePanel: !!(mount && mount.parentNode && mount.parentNode.classList.contains('main-body-panel')),
                 insidePanel:!!(mount && mount.parentNode && mount.parentNode.classList.contains('main-body-panel')),
                 welded: !!(mount && mount.querySelector('.portal-guide-nav.is-continuous-weld')),
                 welded:!!(mount && mount.querySelector('.portal-guide-nav.is-continuous-weld')),
                 activeScreen: portal ? portal.getAttribute('data-main-body-screen') || 'manifesto' : '',
                 activeScreen:portal ? portal.getAttribute('data-main-body-screen') || 'manifesto' : '',
                 guideScreen: !!(portal && portal.querySelector('[data-main-body-screen="guide"]')),
                 guideScreen:!!(portal && portal.querySelector('[data-main-body-screen="guide"]')),
                 width: mount ? mount.getAttribute('data-bottom-guide-width') || '' : ''
                 width:mount ? mount.getAttribute('data-bottom-guide-width') || '' : ''
             };
             };
         }
         }
     };
     };
}(window, document, window.mw));
}(window, document, window.mw));

2026년 7월 17일 (금) 17:49 기준 최신판

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

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

    var COMPONENT = 'category-guide-nav';
    var retryTimer = 0;
    var geometryWaitTimer = 0;
    var resizeTimer = 0;
    var observer = null;
    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 api() {
        return window.CLBI && window.CLBI.frame;
    }

    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;
    }

    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 setBodyScreenHidden(screen, hidden) {
        if (!screen) return;
        screen.hidden = !!hidden;
        screen.setAttribute('aria-hidden', hidden ? 'true' : 'false');
        screen.classList.toggle('is-main-body-screen-hidden', !!hidden);
    }

    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) {
            anchor.setAttribute('href', href);
            anchor.setAttribute('aria-label', label);
            anchor.setAttribute('aria-pressed', guideActive ? 'true' : 'false');
            anchor.setAttribute('data-main-body-target', target);
            anchor.removeAttribute('aria-current');
        });
        Array.prototype.forEach.call(portal.querySelectorAll('.portal-guide-label'), function (text) {
            text.textContent = label;
        });
        Array.prototype.forEach.call(portal.querySelectorAll('.portal-guide-nav'), function (nav) {
            nav.setAttribute('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) {
            manifesto.classList.add('main-body-screen');
            manifesto.setAttribute('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 {
            guide.classList.add('main-body-screen', 'main-guide-screen');
            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);
        });
        layout.well.setAttribute('data-main-body-screen-controller-ready', '1');
        layout.well.setAttribute('data-active-main-body-screen', active);
        portal.setAttribute('data-main-body-screen', active);
        portal.classList.toggle('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);
        });
        screens.well.setAttribute('data-active-main-body-screen', active);
        portal.setAttribute('data-main-body-screen', active);
        portal.classList.toggle('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');
        render(portal);
        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;
        if (!portal) return false;
        screens = ensureBodyScreens(portal);
        return activateBodyScreen(portal, screens.active === 'guide' ? 'manifesto' : 'guide');
    }

    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();
    }

    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');
        }
        if (mount.parentNode !== layout.panel) layout.panel.appendChild(mount);
        return mount;
    }

    function guideButton(seams) {
        var frame = api();
        var d = frame.DIMENSIONS;
        var step = Math.floor(d.BUTTON_EDGE_BOTTOM / frame.FRAME.SLANT_RISE);
        var left = seams.settingsLeftOriginX + step;
        var right = seams.corpRightOriginX - step;
        return {
            x:Math.round(left),
            y:d.BUTTON_EDGE_TOP,
            btnW:Math.max(4, Math.round(right - left)),
            pixelHeight:d.BUTTON_TOTAL,
            slant:'both'
        };
    }

    function draw(canvas, button, label, hover) {
        var frame = api();
        var ctx = canvas.getContext('2d');
        var image = frame.createImage(ctx, canvas.width, canvas.height, 'BG');
        var grid;
        var mask;
        var center;

        frame.paintStandardFrame(image, canvas.width, canvas.height);
        grid = frame.buildReverseButtonPixels(
            button.btnW,
            frame.FRAME.BUTTON_FACE,
            button.slant,
            0
        );
        frame.paintGrid(image, canvas.width, canvas.height, grid, button.x, button.y, true);
        ctx.putImageData(image, 0, 0);
        mask = frame.buttonMask(
            button.btnW,
            frame.FRAME.BUTTON_FACE - 1 - Math.floor(frame.FRAME.BUTTON_FACE / 2),
            button.slant,
            0
        );
        center = button.x + Math.round((mask.left + mask.right) / 2);
        frame.drawLabel(
            ctx,
            label,
            center,
            Math.round((frame.DIMENSIONS.BUTTON_SURFACE_TOP + frame.DIMENSIONS.BUTTON_SURFACE_BOTTOM) / 2) + 1,
            hover,
            canvas.width <= 820 ? 11 : 12
        );
    }

    function render(portal) {
        var frame = api();
        var seams;
        var mount;
        var width;
        var guideActive;
        var label;
        var targetScreen;
        var href;
        var button;
        var nav;
        var inner;
        var canvas;
        var hitLayer;
        var anchor;
        var hiddenLabel;
        var redraw;

        if (!frame || !frame.FRAME || typeof frame.readRenderWidth !== 'function') {
            window.clearTimeout(geometryWaitTimer);
            geometryWaitTimer = window.setTimeout(function () {
                geometryWaitTimer = 0;
                render(portal);
            }, 50);
            return false;
        }
        seams = frame.seams;
        if (!seams || !seams.width) return false;
        mount = ensureMount(portal);
        width = seams.width || frame.readRenderWidth(mount);
        if (!width) return false;

        guideActive = portal.getAttribute('data-main-body-screen') === 'guide';
        label = guideActive ? '돌아가기' : '길라잡이';
        targetScreen = guideActive ? 'manifesto' : 'guide';
        href = guideActive ? '#main-manifesto-screen' : '#main-guide-screen';
        button = guideButton(seams);
        if (
            mount.getAttribute('data-bottom-guide-width') === String(width) &&
            mount.getAttribute('data-bottom-guide-label') === label &&
            mount.getAttribute('data-bottom-guide-scale') === String(frame.getShellScale()) &&
            mount.querySelector('.portal-guide-canvas')
        ) {
            return true;
        }

        nav = document.createElement('nav');
        nav.className = 'portal-category-nav portal-guide-nav portal-frame-canvas-nav is-continuous-weld';
        nav.setAttribute('aria-label', label);
        nav.__frameHover = false;

        inner = document.createElement('div');
        inner.className = 'portal-frame-counter-inner portal-guide-counter-inner';
        inner.style.width = width + 'px';

        canvas = frame.createCanvas('portal-frame-canvas portal-guide-canvas', width, frame.DIMENSIONS.ROW_HEIGHT);
        canvas.setAttribute('data-frame-width', String(width));
        canvas.setAttribute('data-frame-height', String(frame.DIMENSIONS.ROW_HEIGHT));

        hitLayer = document.createElement('div');
        hitLayer.className = 'portal-frame-hit-layer';
        hitLayer.style.width = width + 'px';
        hitLayer.style.height = frame.DIMENSIONS.ROW_HEIGHT + 'px';

        anchor = document.createElement('a');
        anchor.className = 'portal-frame-hit portal-guide-anchor';
        anchor.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');
        anchor.style.left = button.x + 'px';
        anchor.style.top = button.y + 'px';
        anchor.style.width = (button.btnW + 2) + 'px';
        anchor.style.height = button.pixelHeight + 'px';
        hiddenLabel = document.createElement('span');
        hiddenLabel.className = 'portal-frame-sr-label portal-guide-label';
        hiddenLabel.textContent = label;
        anchor.appendChild(hiddenLabel);

        redraw = function () { draw(canvas, button, label, nav.__frameHover); };
        anchor.addEventListener('mouseenter', function () {
            nav.__frameHover = true;
            redraw();
        });
        anchor.addEventListener('mouseleave', function () {
            nav.__frameHover = false;
            redraw();
        });
        anchor.addEventListener('focus', function () {
            nav.__frameHover = true;
            redraw();
        });
        anchor.addEventListener('blur', function () {
            nav.__frameHover = false;
            redraw();
        });
        anchor.addEventListener('click', function (event) {
            if (event && (event.button > 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)) return;
            if (toggleBodyScreen(portal) && event) event.preventDefault();
        });

        hitLayer.appendChild(anchor);
        inner.appendChild(canvas);
        inner.appendChild(hitLayer);
        nav.appendChild(inner);
        mount.replaceChildren(nav);
        frame.applyCounterScale(
            [mount, nav],
            inner,
            frame.DIMENSIONS.ROW_HEIGHT
        );
        redraw();

        mount.setAttribute('data-bottom-guide-rendered', 'canvas');
        mount.setAttribute('data-bottom-guide-width', String(width));
        mount.setAttribute('data-bottom-guide-label', label);
        mount.setAttribute('data-bottom-guide-scale', String(frame.getShellScale()));
        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 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) {
        var scope = root || document;
        schedule(scope, 0);
        loadManifestoIntro(scope);
    }

    revealManifesto(document);
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', function () { boot(document); }, { once:true });
    } else {
        boot(document);
    }
    window.addEventListener('load', function () { boot(document); }, { once:true });
    window.addEventListener('pageshow', function () { boot(document); });
    if (api()) {
        api().onSeams(function () { schedule(document, 0); });
        api().onScale(function () { schedule(document, 0); });
    }
    if (mw && mw.hook) {
        mw.hook('wikipage.content').add(function ($content) {
            boot($content && $content[0] ? $content[0] : document);
        });
    }

    window.BottomGuideNav = {
        version:'20260717-canvas-pixel-001',
        render:function () {
            var portal = document.querySelector('.main-portal');
            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-canvas')),
                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') || '' : ''
            };
        }
    };
}(window, document, window.mw));