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

(Install package: clbiwiki-mainpage-category-project-pillar-20260716 / js/CategoryPillar.js)
 
(Install package: clbiwiki-mainpage-category-project-pillar-slope-gap-20260716 (1) / js/CategoryPillar.js)
14번째 줄: 14번째 줄:


     var SVG_NS = 'http://www.w3.org/2000/svg';
     var SVG_NS = 'http://www.w3.org/2000/svg';
     var VERSION = '20260716-category-project-pillar-019';
     var VERSION = '20260716-category-project-pillar-slope-gap-020';
     var resizeTimer = 0;
     var resizeTimer = 0;
     var renderTimer = 0;
     var renderTimer = 0;
98번째 줄: 98번째 줄:
         line.setAttribute('x2', String(x2));
         line.setAttribute('x2', String(x2));
         line.setAttribute('y2', String(y2));
         line.setAttribute('y2', String(y2));
    }
    function lineXAtY(topX, bottomX, y, viewBox) {
        return topX + (bottomX - topX) * ((y - viewBox.y) / viewBox.height);
    }
    function pointString(points) {
        return points.map(function (point) {
            return point[0] + ',' + point[1];
        }).join(' ');
     }
     }


128번째 줄: 138번째 줄:
         var previous = svg.querySelector('.portal-category-pillar');
         var previous = svg.querySelector('.portal-category-pillar');
         var extension;
         var extension;
         var left;
         var leftTop;
         var right;
         var leftBottom;
        var rightTop;
        var rightBottom;
        var pillarLeftTop;
        var pillarLeftBottom;
        var pillarRightTop;
        var pillarRightBottom;
         var top;
         var top;
         var bottom;
         var bottom;
142번째 줄: 158번째 줄:
         if (!source || points.length < 4 || !viewBox) return false;
         if (!source || points.length < 4 || !viewBox) return false;
         if (previous && previous.parentNode) previous.parentNode.removeChild(previous);
         if (previous && previous.parentNode) previous.parentNode.removeChild(previous);
        extension = getVerticalExtension(svg, viewBox);
        top = viewBox.y - extension.top;
        bottom = viewBox.y + viewBox.height + extension.bottom;


         /*
         /*
        기존 project 다각형의 상·하가 모두 차지하던 공통 폭만 사용한다.
        사선의 가장 넓은 폭을 직사각형으로 펴면 이웃 버튼 위를 덮게 되므로,
        좌측 두 점 중 안쪽 값과 우측 두 점 중 안쪽 값을 기둥 경계로 삼는다.
         CategoryNav의 점 순서는 좌상→우상→우하→좌하다.
         CategoryNav의 점 순서는 좌상→우상→우하→좌하다.
        원래 project 버튼의 좌우 사선을 그대로 사용하면 양옆 버튼이 이미 확보한
        2px 간격도 전 높이에서 그대로 유지된다. 기둥 구간은 같은 기울기로
        위·아래 프레임까지 직선 연장한다.
         */
         */
         left = Math.max(points[0][0], points[3][0]);
         leftTop = points[0][0];
         right = Math.min(points[1][0], points[2][0]);
        rightTop = points[1][0];
         if (!(right > left)) {
         rightBottom = points[2][0];
            left = Math.min.apply(null, points.map(function (point) { return point[0]; }));
        leftBottom = points[3][0];
            right = Math.max.apply(null, points.map(function (point) { return point[0]; }));
         pillarLeftTop = lineXAtY(leftTop, leftBottom, top, viewBox);
         }
        pillarLeftBottom = lineXAtY(leftTop, leftBottom, bottom, viewBox);
        if (!(right > left)) return false;
        pillarRightTop = lineXAtY(rightTop, rightBottom, top, viewBox);
         pillarRightBottom = lineXAtY(rightTop, rightBottom, bottom, viewBox);


         extension = getVerticalExtension(svg, viewBox);
         if (
        top = viewBox.y - extension.top;
            !(pillarRightTop > pillarLeftTop) ||
         bottom = viewBox.y + viewBox.height + extension.bottom;
            !(pillarRightBottom > pillarLeftBottom)
         ) return false;


         group = createSvgElement('g', 'portal-category-pillar');
         group = createSvgElement('g', 'portal-category-pillar');
166번째 줄: 188번째 줄:
         group.setAttribute('pointer-events', 'none');
         group.setAttribute('pointer-events', 'none');


         surface = createSvgElement('rect', 'portal-category-pillar-surface');
         surface = createSvgElement('polygon', 'portal-category-pillar-surface');
         surface.setAttribute('x', String(left));
         surface.setAttribute('points', pointString([
        surface.setAttribute('y', String(top));
            [pillarLeftTop, top],
        surface.setAttribute('width', String(right - left));
            [pillarRightTop, top],
        surface.setAttribute('height', String(bottom - top));
            [pillarRightBottom, bottom],
            [pillarLeftBottom, bottom]
        ]));
         group.appendChild(surface);
         group.appendChild(surface);


178번째 줄: 202번째 줄:
         */
         */
         leftEdge = createSvgElement('line', 'portal-category-pillar-line portal-category-pillar-edge');
         leftEdge = createSvgElement('line', 'portal-category-pillar-line portal-category-pillar-edge');
         setLine(leftEdge, left + 0.5, top, left + 0.5, bottom);
         setLine(leftEdge, pillarLeftTop + 0.5, top, pillarLeftBottom + 0.5, bottom);
         group.appendChild(leftEdge);
         group.appendChild(leftEdge);


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


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


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


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

2026년 7월 16일 (목) 14:47 판

/* =========================================
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-slope-gap-020';
    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 lineXAtY(topX, bottomX, y, viewBox) {
        return topX + (bottomX - topX) * ((y - viewBox.y) / viewBox.height);
    }

    function pointString(points) {
        return points.map(function (point) {
            return point[0] + ',' + point[1];
        }).join(' ');
    }

    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 leftTop;
        var leftBottom;
        var rightTop;
        var rightBottom;
        var pillarLeftTop;
        var pillarLeftBottom;
        var pillarRightTop;
        var pillarRightBottom;
        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);

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

        /*
        CategoryNav의 점 순서는 좌상→우상→우하→좌하다.
        원래 project 버튼의 좌우 사선을 그대로 사용하면 양옆 버튼이 이미 확보한
        2px 간격도 전 높이에서 그대로 유지된다. 기둥 구간은 같은 기울기로
        위·아래 프레임까지 직선 연장한다.
        */
        leftTop = points[0][0];
        rightTop = points[1][0];
        rightBottom = points[2][0];
        leftBottom = points[3][0];
        pillarLeftTop = lineXAtY(leftTop, leftBottom, top, viewBox);
        pillarLeftBottom = lineXAtY(leftTop, leftBottom, bottom, viewBox);
        pillarRightTop = lineXAtY(rightTop, rightBottom, top, viewBox);
        pillarRightBottom = lineXAtY(rightTop, rightBottom, bottom, viewBox);

        if (
            !(pillarRightTop > pillarLeftTop) ||
            !(pillarRightBottom > pillarLeftBottom)
        ) return false;

        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('polygon', 'portal-category-pillar-surface');
        surface.setAttribute('points', pointString([
            [pillarLeftTop, top],
            [pillarRightTop, top],
            [pillarRightBottom, bottom],
            [pillarLeftBottom, bottom]
        ]));
        group.appendChild(surface);

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

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

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

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

        label = createSvgElement('text', 'portal-cat-label portal-category-pillar-label');
        label.setAttribute('x', String((leftTop + rightTop + rightBottom + leftBottom) / 4));
        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));