미디어위키:BottomGuideNav.js

Nxdsxn (토론 | 기여)님의 2026년 7월 13일 (월) 05:49 판 (Install package: clbiwiki-mainpage-bottom-addon-facing-highlight-fix-20260713 / js/BottomGuideNav.js)

참고: 설정을 저장한 후에 바뀐 점을 확인하기 위해서는 브라우저의 캐시를 새로 고쳐야 합니다.

  • 파이어폭스 / 사파리: Shift 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5 또는 Ctrl-R을 입력 (Mac에서는 ⌘-R)
  • 구글 크롬: Ctrl-Shift-R키를 입력 (Mac에서는 ⌘-Shift-R)
  • 인터넷 익스플로러 / 엣지: Ctrl 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5를 입력.
  • 오페라: Ctrl-F5를 입력.
/* =========================================
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 ROW_HEIGHT = 44;
    var HEIGHT = 24;
    var FRAME = 8;
    var GAP = 2;
    var BUTTON_TOP = FRAME + GAP;
    var BUTTON_BOTTOM = BUTTON_TOP + HEIGHT;
    var SHARED_BOTTOM_TOP = BUTTON_BOTTOM + GAP;
    var FRAME_JOIN_EXTENSION = 1;
    var OUTER_BORDER = 1;
    var INSET_OFFSET = 1.5;
    var retryTimer = 0;
    var resizeTimer = 0;
    var observer = null;

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

    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 fallbackGeometry(width) {
        var usableWidth = Math.max(320, Math.floor(width || 0));
        var count = 5;
        var cut = Math.round((usableWidth / count) * 0.045);
        var top = [];
        var bottom = [];
        var half = count / 2;
        var i;

        cut = Math.max(6, Math.min(10, cut));

        for (i = 0; i <= count; i++) {
            var x = Math.round((usableWidth * i) / count);

            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, BUTTON_TOP],
            [bottom[4] - 1, BUTTON_TOP],
            [top[4] - 1, BUTTON_BOTTOM],
            [top[1] + 1, BUTTON_BOTTOM]
        ];
    }

    function readGuidePoints(portal, mountRect) {
        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 우물 간격이 추가되어도 설정–프로젝트–기업 및 공동체
        경계와 정확히 일치한다.
        */
        if (
            topSvg &&
            settingsPoints.length === 4 &&
            corporationPoints.length === 4
        ) {
            svgRect = topSvg.getBoundingClientRect();
            viewBox = topSvg.viewBox && topSvg.viewBox.baseVal;

            if (viewBox && viewBox.width > 0 && svgRect.width > 0) {
                scaleX = svgRect.width / viewBox.width;

                return [
                    [localX(settingsPoints[3][0]), BUTTON_TOP],
                    [localX(corporationPoints[2][0]), BUTTON_TOP],
                    [localX(corporationPoints[1][0]), BUTTON_BOTTOM],
                    [localX(settingsPoints[0][0]), BUTTON_BOTTOM]
                ];
            }
        }

        return fallbackGeometry(Math.floor(mountRect.width));
    }

    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) {
        var group = createSvgElement('g');
        var baseTop = ROW_HEIGHT - FRAME;
        var outer = forceFrameY(
            insetPolygon(buttonPoints, -(FRAME + GAP)),
            0,
            baseTop + FRAME_JOIN_EXTENSION
        );
        var inner = forceFrameY(
            insetPolygon(buttonPoints, -GAP),
            FRAME,
            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(ROW_HEIGHT, Math.round(point[1])));
            return point;
        }

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

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

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

        /* 최외곽: 상단 + 양쪽 사선. 하단선 없음. */
        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.
        중앙 접합부에는 선을 만들지 않고,
        길라잡이와 마주 보는 좌·우 구간만 별도로 그린다.
        이렇게 하면 #1d1d1d 연결을 가로막지 않으면서
        원래 하단 프레임의 상단 광원을 복원할 수 있다.
        */
        if (outerLeftBottom[0] > 0) {
            group.appendChild(createLine(
                'portal-guide-panel-facing-highlight',
                [0, baseTop],
                [outerLeftBottom[0], baseTop]
            ));
        }
        if (outerRightBottom[0] < width) {
            group.appendChild(createLine(
                'portal-guide-panel-facing-highlight',
                [outerRightBottom[0], baseTop],
                [width, baseTop]
            ));
        }

        /*
        프레임 안쪽 우물 경계.
        상단/우측 #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 ensureMount(portal) {
        var layout = ensureBodyPanel(portal);
        var mount = portal.querySelector('[data-component="' + COMPONENT + '"]');

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

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

        return mount;
    }

    function render(portal) {
        var mount = ensureMount(portal);
        var topMount = portal.querySelector('[data-component="category-nav"]');
        var mountRect = mount.getBoundingClientRect();
        var width = Math.floor(mountRect.width);

        if (!width) return false;

        var points = readGuidePoints(portal, mountRect);
        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 href = buildWikiPath('길라잡이');
        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';
        nav.setAttribute('aria-label', '길라잡이');

        svg.setAttribute('class', 'portal-category-svg portal-guide-svg');
        svg.setAttribute('viewBox', '0 0 ' + width + ' ' + ROW_HEIGHT);
        svg.setAttribute('width', '100%');
        svg.setAttribute('height', '100%');
        svg.setAttribute('preserveAspectRatio', 'none');

        appendContinuousBottomFrame(svg, points, width);

        anchor.setAttribute('class', 'portal-cat-anchor portal-guide-anchor');
        anchor.setAttribute('href', href);
        anchor.setAttributeNS(XLINK_NS, 'xlink:href', href);
        anchor.setAttribute('aria-label', '길라잡이');
        anchor.setAttribute('data-category-key', 'guide');

        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((BUTTON_TOP + BUTTON_BOTTOM) / 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 = '길라잡이';

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

        mount.replaceChildren(nav);
        mount.setAttribute('data-bottom-guide-rendered', '1');
        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 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) {
        schedule(root || document, 0);
    }

    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.BottomGuideNav = {
        version: '20260713-addon-frame-extend1-001',
        version: '20260713-integrated-bottom-frame-down1-001',
        render: function () {
            var portal = document.querySelector('.main-portal');
            return portal ? render(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')),
                width: mount ? mount.getAttribute('data-bottom-guide-width') || '' : ''
            };
        }
    };
}(window, document, window.mw));