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

(Install package: clbiwiki-mainpage-approved-up1-byte-exact-with-isolated-font-20260713 / js/BottomGuideNav.js)
태그: 수동 되돌리기
(Install package: clbiwiki-mainpage-bottom-join-single-source-20260713 / js/BottomGuideNav.js)
352번째 줄: 352번째 줄:


         /*
         /*
         아치 프레임의 하단 안쪽 면.
         본문 우물의 기존 하단 반사광을 CSS에서 제거하고,
         버튼 polygon과 분리된 1px rect이며, 기존 패널 하단 프레임 위에 놓인다.
        정확히 같은 1px 행을 SVG가 단일 소스로 다시 그린다.
 
        - 아치 바깥 좌/우: #555555 반사광 유지
         - 아치가 하단 프레임과 용접되는 중앙: #1d1d1d 연속면
 
        별도 행이나 덮개를 추가하지 않으므로 중앙 접합부에
        #555555가 남거나 #1d1d1d가 한 줄 더 생기지 않는다.
         */
         */
         group.appendChild(createRect(
         group.appendChild(createRect(
             'portal-guide-arch-inner-bottom',
             'portal-guide-base-reflection',
             innerLeftBottom[0],
            0,
            baseTop,
            Math.max(0, outerLeftBottom[0]),
            1
        ));
        group.appendChild(createRect(
            'portal-guide-base-join',
            outerLeftBottom[0],
            baseTop,
            Math.max(0, outerRightBottom[0] - outerLeftBottom[0]),
            1
        ));
        group.appendChild(createRect(
            'portal-guide-base-reflection',
             outerRightBottom[0],
             baseTop,
             baseTop,
             Math.max(0, innerRightBottom[0] - innerLeftBottom[0]),
             Math.max(0, width - outerRightBottom[0]),
             1
             1
         ));
         ));
572번째 줄: 592번째 줄:


     window.BottomGuideNav = {
     window.BottomGuideNav = {
         version: '20260712-arch-frame-bottom-inner-001',
         version: '20260713-bottom-join-single-source-001',
         render: function () {
         render: function () {
             var portal = document.querySelector('.main-portal');
             var portal = document.querySelector('.main-portal');

2026년 7월 13일 (월) 04:02 판

/* =========================================
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 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
        );
        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와 inner의 하단 좌표를 같게 해 별도 하단 프레임 면이 생기지 않는다.
        */
        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, 좌측 #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
        ));

        /*
        본문 우물의 기존 하단 반사광을 CSS에서 제거하고,
        정확히 같은 1px 행을 SVG가 단일 소스로 다시 그린다.

        - 아치 바깥 좌/우: #555555 반사광 유지
        - 아치가 하단 프레임과 용접되는 중앙: #1d1d1d 연속면

        별도 행이나 덮개를 추가하지 않으므로 중앙 접합부에
        #555555가 남거나 #1d1d1d가 한 줄 더 생기지 않는다.
        */
        group.appendChild(createRect(
            'portal-guide-base-reflection',
            0,
            baseTop,
            Math.max(0, outerLeftBottom[0]),
            1
        ));
        group.appendChild(createRect(
            'portal-guide-base-join',
            outerLeftBottom[0],
            baseTop,
            Math.max(0, outerRightBottom[0] - outerLeftBottom[0]),
            1
        ));
        group.appendChild(createRect(
            'portal-guide-base-reflection',
            outerRightBottom[0],
            baseTop,
            Math.max(0, width - outerRightBottom[0]),
            1
        ));

        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-bottom-join-single-source-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));