미디어위키:CategoryPillar.js

Nxdsxn (토론 | 기여)님의 2026년 7월 16일 (목) 14:41 판 (Install package: clbiwiki-mainpage-category-project-pillar-20260716 / js/CategoryPillar.js)
(차이) ← 이전 판 | 최신판 (차이) | 다음 판 → (차이)

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

  • 파이어폭스 / 사파리: Shift 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5 또는 Ctrl-R을 입력 (Mac에서는 ⌘-R)
  • 구글 크롬: Ctrl-Shift-R키를 입력 (Mac에서는 ⌘-Shift-R)
  • 인터넷 익스플로러 / 엣지: Ctrl 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5를 입력.
  • 오페라: Ctrl-F5를 입력.
/* =========================================
CategoryPillar
대문 상단 카테고리의 중앙 「프로젝트」 기둥
========================================= */

/*
CategoryNav.js가 만든 네 개의 실제 버튼은 그대로 둔다.
중앙 project 항목의 SVG 좌표만 읽어서 클릭 가능한 버튼을 제거하고,
상하 우물을 관통해 #1d1d1d 외곽 프레임에 이어지는 비조작 기둥으로 바꾼다.
*/

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

    var SVG_NS = 'http://www.w3.org/2000/svg';
    var VERSION = '20260716-category-project-pillar-019';
    var resizeTimer = 0;
    var renderTimer = 0;
    var observer = null;

    function createSvgElement(name, className) {
        var node = document.createElementNS(SVG_NS, name);
        if (className) node.setAttribute('class', className);
        return node;
    }

    function parsePoints(value) {
        var points = [];

        String(value || '').trim().split(/\s+/).forEach(function (pair) {
            var xy = pair.split(',');
            var x = Number(xy[0]);
            var y = Number(xy[1]);

            if (xy.length === 2 && Number.isFinite(x) && Number.isFinite(y)) {
                points.push([x, y]);
            }
        });

        return points;
    }

    function getViewBox(svg) {
        var base = svg && svg.viewBox && svg.viewBox.baseVal;
        var raw;
        var values;

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

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

        return null;
    }

    function getVerticalExtension(svg, viewBox) {
        var nav = svg.closest ? svg.closest('.portal-category-nav') : svg.parentNode;
        var style;
        var rect;
        var renderedHeight;
        var paddingTop;
        var paddingBottom;
        var scale;

        if (!nav || !window.getComputedStyle) return { top:3, bottom:3 };

        style = window.getComputedStyle(nav);
        rect = svg.getBoundingClientRect ? svg.getBoundingClientRect() : null;
        renderedHeight = rect && rect.height ? rect.height : Number(svg.clientHeight || 0);
        paddingTop = parseFloat(style.paddingTop) || 0;
        paddingBottom = parseFloat(style.paddingBottom) || 0;
        scale = renderedHeight > 0 ? viewBox.height / renderedHeight : 1;

        return {
            top:paddingTop > 0 ? paddingTop * scale : 3,
            bottom:paddingBottom > 0 ? paddingBottom * scale : 3
        };
    }

    function setLine(line, x1, y1, x2, y2) {
        line.setAttribute('x1', String(x1));
        line.setAttribute('y1', String(y1));
        line.setAttribute('x2', String(x2));
        line.setAttribute('y2', String(y2));
    }

    function findProjectSource(svg) {
        var direct = svg.querySelector(
            '.portal-cat-anchor[data-category-key="project"]'
        );
        var match = null;

        if (direct) return direct;

        Array.prototype.some.call(svg.querySelectorAll('.portal-cat-anchor'), function (anchor) {
            var label = anchor.querySelector('.portal-cat-label');
            if (label && String(label.textContent || '').trim() === '프로젝트') {
                match = anchor;
                return true;
            }
            return false;
        });

        return match;
    }

    function renderSvg(svg) {
        var source = findProjectSource(svg);
        var edge = source && source.querySelector('.portal-cat-edge');
        var sourceLabel = source && source.querySelector('.portal-cat-label');
        var points = edge ? parsePoints(edge.getAttribute('points')) : [];
        var viewBox = getViewBox(svg);
        var previous = svg.querySelector('.portal-category-pillar');
        var extension;
        var left;
        var right;
        var top;
        var bottom;
        var group;
        var surface;
        var leftEdge;
        var leftInset;
        var rightEdge;
        var rightInset;
        var label;

        if (!source || points.length < 4 || !viewBox) return false;
        if (previous && previous.parentNode) previous.parentNode.removeChild(previous);

        /*
        기존 project 다각형의 상·하가 모두 차지하던 공통 폭만 사용한다.
        사선의 가장 넓은 폭을 직사각형으로 펴면 이웃 버튼 위를 덮게 되므로,
        좌측 두 점 중 안쪽 값과 우측 두 점 중 안쪽 값을 기둥 경계로 삼는다.
        CategoryNav의 점 순서는 좌상→우상→우하→좌하다.
        */
        left = Math.max(points[0][0], points[3][0]);
        right = Math.min(points[1][0], points[2][0]);
        if (!(right > left)) {
            left = Math.min.apply(null, points.map(function (point) { return point[0]; }));
            right = Math.max.apply(null, points.map(function (point) { return point[0]; }));
        }
        if (!(right > left)) return false;

        extension = getVerticalExtension(svg, viewBox);
        top = viewBox.y - extension.top;
        bottom = viewBox.y + viewBox.height + extension.bottom;

        group = createSvgElement('g', 'portal-category-pillar');
        group.setAttribute('data-category-key', 'project');
        group.setAttribute('aria-label', sourceLabel ? sourceLabel.textContent : '프로젝트');
        group.setAttribute('pointer-events', 'none');

        surface = createSvgElement('rect', 'portal-category-pillar-surface');
        surface.setAttribute('x', String(left));
        surface.setAttribute('y', String(top));
        surface.setAttribute('width', String(right - left));
        surface.setAttribute('height', String(bottom - top));
        group.appendChild(surface);

        /*
        상하선은 만들지 않는다. 그래야 같은 #1d1d1d인 외곽 프레임과 기둥이
        끊기지 않는다. 좌우에만 #050505 경계와 방향성 inset을 둔다.
        */
        leftEdge = createSvgElement('line', 'portal-category-pillar-line portal-category-pillar-edge');
        setLine(leftEdge, left + 0.5, top, left + 0.5, bottom);
        group.appendChild(leftEdge);

        leftInset = createSvgElement('line', 'portal-category-pillar-line portal-category-pillar-shadow');
        setLine(leftInset, left + 1.5, top, left + 1.5, bottom);
        group.appendChild(leftInset);

        rightEdge = createSvgElement('line', 'portal-category-pillar-line portal-category-pillar-edge');
        setLine(rightEdge, right - 0.5, top, right - 0.5, bottom);
        group.appendChild(rightEdge);

        rightInset = createSvgElement('line', 'portal-category-pillar-line portal-category-pillar-highlight');
        setLine(rightInset, right - 1.5, top, right - 1.5, bottom);
        group.appendChild(rightInset);

        label = createSvgElement('text', 'portal-cat-label portal-category-pillar-label');
        label.setAttribute('x', String((left + right) / 2));
        label.setAttribute('y', String(viewBox.y + viewBox.height / 2));
        label.textContent = sourceLabel ? sourceLabel.textContent : '프로젝트';
        group.appendChild(label);

        source.setAttribute('aria-hidden', 'true');
        source.setAttribute('tabindex', '-1');
        source.classList.add('portal-category-pillar-source');
        svg.appendChild(group);
        svg.classList.add('has-category-pillar');
        return true;
    }

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

        if (scope.matches && scope.matches('.portal-category-svg')) output.push(scope);
        Array.prototype.forEach.call(
            scope.querySelectorAll('.main-portal [data-component="category-nav"] .portal-category-svg'),
            function (svg) { output.push(svg); }
        );
        return output;
    }

    function renderAll(root) {
        var rendered = 0;
        collectSvgs(root).forEach(function (svg) {
            if (renderSvg(svg)) 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.CategoryPillar = {
        version:VERSION,
        init:init,
        render:renderSvg,
        renderAll:renderAll
    };
}(window, document, window.mw));