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

(Install package: clbiwiki-mainpage-attached-section-buttons-20260716 / js/PortalSectionNav.js)
 
(Install package: clbiwiki-mainpage-attached-section-buttons-rotated-weld-20260716 / js/PortalSectionNav.js)
10번째 줄: 10번째 줄:
     var XLINK_NS = 'http://www.w3.org/1999/xlink';
     var XLINK_NS = 'http://www.w3.org/1999/xlink';
     var COMPONENT = 'portal-section-nav';
     var COMPONENT = 'portal-section-nav';
     var VERSION = '20260716-portal-section-attached-frame-023';
     var VERSION = '20260716-portal-section-rotated-weld-024';
     var BUTTON_HEIGHT = 24;
     var BUTTON_HEIGHT = 24;
     var TOP_FRAME = 9;
     var TOP_FRAME = 9;
184번째 줄: 184번째 줄:
         var cut = Math.max(6, Math.min(10, Math.round((width / 5) * 0.045)));
         var cut = Math.max(6, Math.min(10, Math.round((width / 5) * 0.045)));
         return [
         return [
             [Math.round(width * 0.2), 0],
             [Math.round(width * 0.2) - cut, 0],
             [Math.round(width * 0.8) - cut, 0],
             [Math.round(width * 0.8), 0],
             [Math.round(width * 0.8), ROW_HEIGHT],
             [Math.round(width * 0.8) - cut, ROW_HEIGHT],
             [Math.round(width * 0.2) - cut, ROW_HEIGHT]
             [Math.round(width * 0.2), ROW_HEIGHT]
         ];
         ];
     }
     }
225번째 줄: 225번째 줄:
         }
         }


         /* 상단은 버튼 하단 경계, 하단은 버튼 상단 경계를 받아 같은 사선을 잇는다. */
         /*
        * 하단 길라잡이 형상을 그대로 복제하지 않는다.
        * 기존 상단 프레임과 접하는 쪽을 넓게, 아래쪽을 좁게 뒤집어
        * 설정·기업 버튼의 사선이 새 프레임 안쪽으로 자연스럽게 이어지게 한다.
        */
         return [
         return [
             [localX(settingsPoints[3][0]), 0],
             [localX(settingsPoints[0][0]), 0],
             [localX(corporationPoints[2][0]), 0],
             [localX(corporationPoints[1][0]), 0],
             [localX(corporationPoints[1][0]), ROW_HEIGHT],
             [localX(corporationPoints[2][0]), ROW_HEIGHT],
             [localX(settingsPoints[0][0]), ROW_HEIGHT]
             [localX(settingsPoints[3][0]), ROW_HEIGHT]
         ];
         ];
     }
     }

2026년 7월 16일 (목) 19:18 판

/* =========================================
PortalSectionNav
대문 상단 카테고리 하단 부착형 섹션 버튼
========================================= */

(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 = 'portal-section-nav';
    var VERSION = '20260716-portal-section-rotated-weld-024';
    var BUTTON_HEIGHT = 24;
    var TOP_FRAME = 9;
    var BOTTOM_FRAME = 8;
    var GAP = 2;
    var ROW_HEIGHT = TOP_FRAME + GAP + BUTTON_HEIGHT + GAP + BOTTOM_FRAME;
    var BUTTON_TOP = TOP_FRAME + GAP;
    var BUTTON_BOTTOM = BUTTON_TOP + BUTTON_HEIGHT;
    var OUTER_BORDER = 1;
    var INSET_OFFSET = 1.5;
    var resizeTimer = 0;
    var renderTimer = 0;
    var observer = null;
    var ITEMS = [
        { key:'black-ice', label:'블랙 아이스', title:'대문' },
        {
            key:'anecdote',
            label:'에넥도트',
            href:'/index.php/특수:모든문서?namespace=3000'
        }
    ];

    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];
        var qp;
        var t;

        if (Math.abs(cross) < 0.000001) return [p[0], p[1]];
        qp = [q[0] - p[0], q[1] - p[1]];
        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 forceY(points, top, bottom) {
        var output = points.map(function (point) { return [point[0], point[1]]; });
        output[0][1] = top;
        output[1][1] = top;
        output[2][1] = bottom;
        output[3][1] = bottom;
        return output;
    }

    function createPolygon(className, points) {
        var polygon = createSvgElement('polygon');
        polygon.setAttribute('class', className);
        polygon.setAttribute('points', pointsToString(points));
        polygon.setAttribute('pointer-events', 'none');
        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 getViewBox(svg) {
        var base = svg && svg.viewBox && svg.viewBox.baseVal;
        var values;

        if (base && base.width > 0 && base.height > 0) {
            return { x:base.x, y:base.y, width:base.width, height:base.height };
        }

        values = String(svg.getAttribute('viewBox') || '').trim().split(/[\s,]+/).map(Number);
        if (values.length === 4 && values[2] > 0 && values[3] > 0) {
            return { x:values[0], y:values[1], width:values[2], height:values[3] };
        }
        return null;
    }

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

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

        if (mount.parentNode !== topMount.parentNode || topMount.nextSibling !== mount) {
            topMount.parentNode.insertBefore(mount, topMount.nextSibling);
        }
        return mount;
    }

    function fallbackOuter(width) {
        var cut = Math.max(6, Math.min(10, Math.round((width / 5) * 0.045)));
        return [
            [Math.round(width * 0.2) - cut, 0],
            [Math.round(width * 0.8), 0],
            [Math.round(width * 0.8) - cut, ROW_HEIGHT],
            [Math.round(width * 0.2), ROW_HEIGHT]
        ];
    }

    function readOuterPoints(topMount, mount, width) {
        var topSvg = topMount.querySelector('.portal-category-svg');
        var settings = topMount.querySelector(
            '.portal-cat-anchor[data-category-key="settings"] .portal-cat-edge'
        );
        var corporations = topMount.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 mountRect;
        var viewBox;
        var scaleX;

        if (!topSvg || settingsPoints.length !== 4 || corporationPoints.length !== 4) {
            return fallbackOuter(width);
        }

        svgRect = topSvg.getBoundingClientRect();
        mountRect = mount.getBoundingClientRect();
        viewBox = getViewBox(topSvg);
        if (!viewBox || !(svgRect.width > 0) || !(mountRect.width > 0)) {
            return fallbackOuter(width);
        }

        scaleX = svgRect.width / viewBox.width;

        function localX(svgX) {
            return Math.round(
                svgRect.left + (svgX - viewBox.x) * scaleX - mountRect.left
            );
        }

        /*
         * 하단 길라잡이 형상을 그대로 복제하지 않는다.
         * 기존 상단 프레임과 접하는 쪽을 넓게, 아래쪽을 좁게 뒤집어
         * 설정·기업 버튼의 사선이 새 프레임 안쪽으로 자연스럽게 이어지게 한다.
         */
        return [
            [localX(settingsPoints[0][0]), 0],
            [localX(corporationPoints[1][0]), 0],
            [localX(corporationPoints[2][0]), ROW_HEIGHT],
            [localX(settingsPoints[3][0]), ROW_HEIGHT]
        ];
    }

    function appendFrame(svg, outer) {
        var surface = forceY(insetPolygon(outer, OUTER_BORDER), 0, ROW_HEIGHT - 1);
        var well = forceY(insetPolygon(outer, TOP_FRAME), TOP_FRAME, ROW_HEIGHT - BOTTOM_FRAME);
        var group = createSvgElement('g');

        group.setAttribute('class', 'portal-section-frame');
        group.appendChild(createPolygon('portal-section-frame-edge', outer));
        group.appendChild(createPolygon('portal-section-frame-surface', surface));
        group.appendChild(createPolygon('portal-section-frame-well', well));

        /* 상단은 기존 카테고리 프레임에 열어 두고 좌·우·하단 방향성만 그린다. */
        group.appendChild(createLine('portal-section-frame-shadow', surface[3], surface[0]));
        group.appendChild(createLine('portal-section-frame-highlight', surface[1], surface[2]));
        group.appendChild(createLine('portal-section-frame-shadow', surface[2], surface[3]));

        /* 오목한 우물: 상·우 어두움, 하·좌 밝음. */
        group.appendChild(createLine('portal-section-well-shadow', well[0], well[1]));
        group.appendChild(createLine('portal-section-well-shadow', well[1], well[2]));
        group.appendChild(createLine('portal-section-well-highlight', well[2], well[3]));
        group.appendChild(createLine('portal-section-well-highlight', well[3], well[0]));
        svg.appendChild(group);
    }

    function splitButtons(outer) {
        var union = forceY(
            insetPolygon(outer, TOP_FRAME + GAP),
            BUTTON_TOP,
            BUTTON_BOTTOM
        );
        var topMid = (union[0][0] + union[1][0]) / 2;
        var bottomMid = (union[3][0] + union[2][0]) / 2;

        return [
            [
                union[0],
                [topMid - GAP / 2, BUTTON_TOP],
                [bottomMid - GAP / 2, BUTTON_BOTTOM],
                union[3]
            ],
            [
                [topMid + GAP / 2, BUTTON_TOP],
                union[1],
                union[2],
                [bottomMid + GAP / 2, BUTTON_BOTTOM]
            ]
        ];
    }

    function appendButton(svg, item, points) {
        var anchor = createSvgElement('a');
        var geometry = createSvgElement('g');
        var surfacePoints = insetPolygon(points, OUTER_BORDER);
        var insetPoints = insetPolygon(points, INSET_OFFSET);
        var center = getCentroid(surfacePoints);
        var text = createSvgElement('text');
        var hit = createPolygon('portal-cat-hit', points);
        var href = item.href || buildWikiPath(item.title);

        anchor.setAttribute('class', 'portal-cat-anchor portal-section-anchor');
        anchor.setAttribute('data-category-key', item.key);
        anchor.setAttribute('href', href);
        anchor.setAttributeNS(XLINK_NS, 'xlink:href', href);
        anchor.setAttribute('aria-label', item.label);

        geometry.setAttribute('class', 'portal-section-button-geometry');
        geometry.appendChild(createPolygon('portal-cat-edge', points));
        geometry.appendChild(createPolygon('portal-cat-surface', surfacePoints));
        geometry.appendChild(createLine('portal-cat-inset portal-cat-inset-top', insetPoints[0], insetPoints[1]));
        geometry.appendChild(createLine('portal-cat-inset portal-cat-inset-right', insetPoints[1], insetPoints[2]));
        geometry.appendChild(createLine('portal-cat-inset portal-cat-inset-bottom', insetPoints[2], insetPoints[3]));
        geometry.appendChild(createLine('portal-cat-inset portal-cat-inset-left', insetPoints[3], insetPoints[0]));
        hit.setAttribute('pointer-events', 'all');
        geometry.appendChild(hit);

        text.setAttribute('class', 'portal-cat-label portal-section-label');
        text.setAttribute('x', roundCoordinate(center[0]));
        text.setAttribute('y', roundCoordinate((BUTTON_TOP + BUTTON_BOTTOM) / 2 + 1));
        text.textContent = item.label;

        anchor.appendChild(geometry);
        anchor.appendChild(text);
        svg.appendChild(anchor);
    }

    function renderPortal(portal) {
        var topMount = portal.querySelector('[data-component="category-nav"]');
        var mount;
        var rect;
        var width;
        var outer;
        var buttons;
        var nav;
        var svg;

        if (!topMount || !topMount.querySelector('.portal-category-svg')) return false;
        mount = ensureMount(portal, topMount);
        rect = mount.getBoundingClientRect();
        width = Math.floor(rect.width || topMount.getBoundingClientRect().width || 0);
        if (!width) return false;

        outer = readOuterPoints(topMount, mount, width);
        buttons = splitButtons(outer);
        nav = document.createElement('nav');
        svg = createSvgElement('svg');

        nav.className = 'portal-section-nav';
        nav.setAttribute('aria-label', '작품 영역');
        svg.setAttribute('class', 'portal-section-svg');
        svg.setAttribute('viewBox', '0 0 ' + width + ' ' + ROW_HEIGHT);
        svg.setAttribute('width', '100%');
        svg.setAttribute('height', '100%');
        svg.setAttribute('preserveAspectRatio', 'none');

        appendFrame(svg, outer);
        ITEMS.forEach(function (item, index) {
            appendButton(svg, item, buttons[index]);
        });

        nav.appendChild(svg);
        mount.replaceChildren(nav);
        mount.setAttribute('data-portal-section-rendered', '1');
        mount.setAttribute('data-portal-section-width', String(width));
        return true;
    }

    function collectPortals(root) {
        var scope = root && root.querySelectorAll ? root : document;
        var portals = [];

        if (scope.matches && scope.matches('.main-portal')) portals.push(scope);
        Array.prototype.forEach.call(scope.querySelectorAll('.main-portal'), function (portal) {
            if (portals.indexOf(portal) === -1) portals.push(portal);
        });
        return portals;
    }

    function renderAll(root) {
        var rendered = 0;
        collectPortals(root).forEach(function (portal) {
            if (renderPortal(portal)) rendered += 1;
        });
        return rendered;
    }

    function scheduleRender(root) {
        window.clearTimeout(renderTimer);
        renderTimer = window.setTimeout(function () {
            renderTimer = 0;
            renderAll(root || document);
        }, 0);
    }

    function observe() {
        if (observer || !window.MutationObserver || !document.documentElement) return;
        observer = new window.MutationObserver(function (records) {
            var relevant = records.some(function (record) {
                return Array.prototype.some.call(record.addedNodes || [], function (node) {
                    return node && node.nodeType === 1 && (
                        (node.matches && node.matches('.portal-category-svg')) ||
                        (node.querySelector && node.querySelector('.portal-category-svg'))
                    );
                });
            });
            if (relevant) scheduleRender(document);
        });
        observer.observe(document.documentElement, { childList:true, subtree:true });
    }

    function init(root) {
        renderAll(root || document);
        observe();
    }

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

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', function () { init(document); });
    } else {
        init(document);
    }

    window.addEventListener('resize', function () {
        window.clearTimeout(resizeTimer);
        resizeTimer = window.setTimeout(function () {
            resizeTimer = 0;
            renderAll(document);
        }, 80);
    });

    window.PortalSectionNav = {
        version:VERSION,
        init:init,
        render:renderPortal,
        renderAll:renderAll
    };
}(window, document, window.mw));