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

(Install package: clbiwiki-mainpage-category-project-pillar-shifted-insets-20260716 / js/CategoryPillar.js)
(Install package: clbiwiki-frame-geometry-normalized-20260717 / js/CategoryPillar.js)
3번째 줄: 3번째 줄:
대문 상단 카테고리의 중앙 「프로젝트」 기둥
대문 상단 카테고리의 중앙 「프로젝트」 기둥
========================================= */
========================================= */
/*
CategoryNav.js가 만든 네 개의 실제 버튼은 그대로 둔다.
중앙 project 항목의 SVG 좌표만 읽어서 클릭 가능한 버튼을 제거하고,
상하 우물을 관통해 #1d1d1d 외곽 프레임에 이어지는 비조작 기둥으로 바꾼다.
*/


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


    var SVG_NS = 'http://www.w3.org/2000/svg';
     var VERSION = '20260717-shared-frame-geometry-001';
     var VERSION = '20260716-category-project-pillar-shifted-insets-022';
    var resizeTimer = 0;
     var renderTimer = 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) {
     function api() {
         return points.map(function (point) {
         return window.CLBI && window.CLBI.frame;
            return point[0] + ',' + point[1];
        }).join(' ');
     }
     }


     function findProjectSource(svg) {
     function renderSvg(svg, seams) {
        var direct = svg.querySelector(
         var frame = api();
            '.portal-cat-anchor[data-category-key="project"]'
         var source;
        );
         var previous;
        var match = null;
         var points;
 
        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 group;
        var surface;
        var leftInset;
        var rightInset;
         var label;
         var label;
        var centerY;
        var leftCenter;
        var rightCenter;


         if (!source || points.length < 4 || !viewBox) return false;
         if (!frame || !svg || !seams || Number(svg.getAttribute('data-frame-width')) !== seams.width) return false;
         if (previous && previous.parentNode) previous.parentNode.removeChild(previous);
         source = svg.querySelector('.portal-cat-anchor[data-category-key="project"]');
        if (!source) return false;


         extension = getVerticalExtension(svg, viewBox);
         previous = svg.querySelector('.portal-category-pillar');
        top = viewBox.y - extension.top;
         if (previous) previous.remove();
         bottom = viewBox.y + viewBox.height + extension.bottom;


         /*
         points = frame.segmentAt(
        CategoryNav의 점 순서는 좌상→우상→우하→좌하다.
            seams.projectLeftTopX,
        원래 project 버튼의 좌우 사선을 그대로 사용하면 양옆 버튼이 이미 확보한
            seams.projectRightTopX,
        2px 간격도 전 높이에서 그대로 유지된다. 기둥 구간은 같은 기울기로
            0,
        위·아래 프레임까지 직선 연장한다.
            seams.rowHeight,
        */
            seams.projectLeftDir,
        leftTop = points[0][0];
            seams.projectRightDir,
        rightTop = points[1][0];
            0,
        rightBottom = points[2][0];
             0,
        leftBottom = points[3][0];
             0
        pillarLeftTop = lineXAtY(leftTop, leftBottom, top, viewBox);
         );
        pillarLeftBottom = lineXAtY(leftTop, leftBottom, bottom, viewBox);
         group = frame.create('g', 'portal-category-pillar');
        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('data-category-key', 'project');
         group.setAttribute('aria-label', sourceLabel ? sourceLabel.textContent : '프로젝트');
         group.setAttribute('aria-label', '프로젝트');
         group.setAttribute('pointer-events', 'none');
         group.setAttribute('pointer-events', 'none');
        group.appendChild(frame.polygon('portal-category-pillar-surface', points));
        group.appendChild(frame.line('portal-category-pillar-line portal-category-pillar-shadow', points[3], points[0]));
        group.appendChild(frame.line('portal-category-pillar-line portal-category-pillar-highlight', points[1], points[2]));


         surface = createSvgElement('polygon', 'portal-category-pillar-surface');
         centerY = Math.round((seams.buttonTop + seams.buttonBottom) / 2);
         surface.setAttribute('points', pointString([
         leftCenter = frame.slantX(seams.projectLeftTopX, centerY, seams.projectLeftDir, 0);
            [pillarLeftTop, top],
         rightCenter = frame.slantX(seams.projectRightTopX, centerY, seams.projectRightDir, 0);
            [pillarRightTop, top],
         label = frame.create('text', 'portal-cat-label portal-category-pillar-label');
            [pillarRightBottom, bottom],
         label.setAttribute('x', Math.round((leftCenter + rightCenter) / 2));
            [pillarLeftBottom, bottom]
         label.setAttribute('y', centerY + 1);
        ]));
         label.setAttribute('font-size', '12');
         group.appendChild(surface);
         label.setAttribute('font-weight', '700');
 
         label.setAttribute('text-anchor', 'middle');
        /*
         label.setAttribute('dominant-baseline', 'middle');
        상하선은 만들지 않는다. 그래야 같은 #1d1d1d인 외곽 프레임과 기둥이
         label.textContent = '프로젝트';
        끊기지 않는다. 버튼과 접하는 좌우에도 #050505 외곽선을 두지 않고,
         기존 #050505 좌표로 방향성 inset을 한 칸 바깥 이동한다.
        inset이 있던 안쪽 한 칸은 별도 선을 두지 않아 #1d1d1d 본체로 돌아간다.
        */
        leftInset = createSvgElement('line', 'portal-category-pillar-line portal-category-pillar-shadow');
         setLine(leftInset, pillarLeftTop + 0.5, top, pillarLeftBottom + 0.5, bottom);
         group.appendChild(leftInset);
 
         rightInset = createSvgElement('line', 'portal-category-pillar-line portal-category-pillar-highlight');
         setLine(rightInset, pillarRightTop - 0.5, top, pillarRightBottom - 0.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);
         group.appendChild(label);


223번째 줄: 72번째 줄:
     }
     }


     function collectSvgs(root) {
     function renderAll(root, seams) {
         var scope = root && root.querySelectorAll ? root : document;
         var scope = root && root.querySelectorAll ? root : document;
         var output = [];
         var current = seams || (api() && api().seams);
        var rendered = 0;


         if (scope.matches && scope.matches('.portal-category-svg')) output.push(scope);
         if (!current || !current.width) return 0;
         Array.prototype.forEach.call(
         Array.prototype.forEach.call(
             scope.querySelectorAll('.main-portal [data-component="category-nav"] .portal-category-svg'),
             scope.querySelectorAll('.main-portal [data-component="category-nav"] .portal-category-svg'),
             function (svg) { output.push(svg); }
             function (svg) {
                if (renderSvg(svg, current)) rendered += 1;
            }
         );
         );
        return output;
    }
    function renderAll(root) {
        var rendered = 0;
        collectSvgs(root).forEach(function (svg) {
            if (renderSvg(svg)) rendered += 1;
        });
         return rendered;
         return rendered;
     }
     }


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


     function observe() {
     if (api()) api().onSeams(function (seams) { schedule(document, seams); });
        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) {
     if (mw && mw.hook) {
         mw.hook('wikipage.content').add(function (content) {
         mw.hook('wikipage.content').add(function (content) {
             scheduleRender(content && content[0] ? content[0] : document);
             schedule(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 = {
     window.CategoryPillar = {
         version:VERSION,
         version:VERSION,
         init:init,
         init:renderAll,
         render:renderSvg,
         render:renderSvg,
         renderAll:renderAll
         renderAll:renderAll
     };
     };
}(window, document, window.mw));
}(window, document, window.mw));

2026년 7월 17일 (금) 10:15 판

/* =========================================
CategoryPillar
대문 상단 카테고리의 중앙 「프로젝트」 기둥
========================================= */

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

    var VERSION = '20260717-shared-frame-geometry-001';
    var renderTimer = 0;

    function api() {
        return window.CLBI && window.CLBI.frame;
    }

    function renderSvg(svg, seams) {
        var frame = api();
        var source;
        var previous;
        var points;
        var group;
        var label;
        var centerY;
        var leftCenter;
        var rightCenter;

        if (!frame || !svg || !seams || Number(svg.getAttribute('data-frame-width')) !== seams.width) return false;
        source = svg.querySelector('.portal-cat-anchor[data-category-key="project"]');
        if (!source) return false;

        previous = svg.querySelector('.portal-category-pillar');
        if (previous) previous.remove();

        points = frame.segmentAt(
            seams.projectLeftTopX,
            seams.projectRightTopX,
            0,
            seams.rowHeight,
            seams.projectLeftDir,
            seams.projectRightDir,
            0,
            0,
            0
        );
        group = frame.create('g', 'portal-category-pillar');
        group.setAttribute('data-category-key', 'project');
        group.setAttribute('aria-label', '프로젝트');
        group.setAttribute('pointer-events', 'none');
        group.appendChild(frame.polygon('portal-category-pillar-surface', points));
        group.appendChild(frame.line('portal-category-pillar-line portal-category-pillar-shadow', points[3], points[0]));
        group.appendChild(frame.line('portal-category-pillar-line portal-category-pillar-highlight', points[1], points[2]));

        centerY = Math.round((seams.buttonTop + seams.buttonBottom) / 2);
        leftCenter = frame.slantX(seams.projectLeftTopX, centerY, seams.projectLeftDir, 0);
        rightCenter = frame.slantX(seams.projectRightTopX, centerY, seams.projectRightDir, 0);
        label = frame.create('text', 'portal-cat-label portal-category-pillar-label');
        label.setAttribute('x', Math.round((leftCenter + rightCenter) / 2));
        label.setAttribute('y', centerY + 1);
        label.setAttribute('font-size', '12');
        label.setAttribute('font-weight', '700');
        label.setAttribute('text-anchor', 'middle');
        label.setAttribute('dominant-baseline', 'middle');
        label.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 renderAll(root, seams) {
        var scope = root && root.querySelectorAll ? root : document;
        var current = seams || (api() && api().seams);
        var rendered = 0;

        if (!current || !current.width) return 0;
        Array.prototype.forEach.call(
            scope.querySelectorAll('.main-portal [data-component="category-nav"] .portal-category-svg'),
            function (svg) {
                if (renderSvg(svg, current)) rendered += 1;
            }
        );
        return rendered;
    }

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

    if (api()) api().onSeams(function (seams) { schedule(document, seams); });
    if (mw && mw.hook) {
        mw.hook('wikipage.content').add(function (content) {
            schedule(content && content[0] ? content[0] : document);
        });
    }

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