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

(Install package: clbiwiki-portal-frame-fixed / js/CategoryNav.js)
태그: 수동 되돌리기 되돌려진 기여
(Install package: clbiwiki-category-bottomguide-restore / js/CategoryNav.js)
태그: 수동 되돌리기
1번째 줄: 1번째 줄:
/* =========================================
/* =========================================
COASTLINE: BLACK ICE - CategoryNav
COASTLINE: BLACK ICE - CategoryNav
대문 카테고리 canvas 픽셀 네비 생성기
대문 카테고리 SVG 네비 생성기
========================================= */
========================================= */


(function (window, document, mw) {
/*
새 공통 기능에는 프로젝트 접두사를 사용하지 않는다.
window.CLBI.categoryNav는 기존 Common.js 호출과의 호환을 위해서만 유지한다.
*/
 
(function () {
     'use strict';
     'use strict';
    var SVG_NS = 'http://www.w3.org/2000/svg';
    var XLINK_NS = 'http://www.w3.org/1999/xlink';


     var ITEMS = [
     var ITEMS = [
         { key:'era', label:'시대', title:'시대', slant:'right' },
         { key: 'era', label: '시대', title: '시대' },
         { key:'settings', label:'설정', title:'설정', slant:'right' },
         { key: 'settings', label: '설정', title: '설정' },
         { key:'project', label:'프로젝트', title:'프로젝트:소개', slant:'both', pillar:true },
         { key: 'project', label: '프로젝트', title: '프로젝트:소개' },
         { key:'corporations', label:'기업 및 공동체', title:'기업_및_공동체', slant:'left' },
         { key: 'corporations', label: '기업 및 공동체', title: '기업_및_공동체' },
         { key:'military', label:'군, 정치집단', title:'군_정치집단', slant:'left' }
         { key: 'military', label: '군, 정치집단', title: '군_정치집단' }
     ];
     ];
    var renderTimer = 0;


     function api() {
     var BUTTON_GAP = 2;
        return window.CLBI && window.CLBI.frame;
    var OUTER_BORDER = 1;
     }
    var INSET_LINE_OFFSET = 1.5;
 
    /*
    상단 우물의 최종 2px 간격을 확보하기 위해 실제 버튼 높이는 22px이다.
    프레임 안쪽의 순수 #080808 간격 2px을 유지하면서
    실제 SVG 버튼 높이는 28px로 둔다.
     */


     function buildPath(title) {
     function buildPath(title) {
         if (typeof window.buildWikiPath === 'function') return window.buildWikiPath(title);
         if (typeof window.buildWikiPath === 'function') {
            return window.buildWikiPath(title);
        }
 
         return '/index.php/' + encodeURI(String(title || '').replace(/ /g, '_'));
         return '/index.php/' + encodeURI(String(title || '').replace(/ /g, '_'));
     }
     }


     function clearNode(node) {
     function readWidth(element) {
         while (node && node.firstChild) node.removeChild(node.firstChild);
         if (!element) return 0;
 
        var rect = element.getBoundingClientRect ? element.getBoundingClientRect() : null;
        var width = rect ? Math.floor(rect.width || 0) : 0;
 
        if (!width && element.clientWidth) {
            width = Math.floor(element.clientWidth);
        }
 
        return width || 0;
     }
     }


     function calculateLayout(width) {
     function getRenderableWidth(nav, mount) {
         var frame = api();
         var candidates = [
        var f = frame.FRAME;
            nav,
        var d = frame.DIMENSIONS;
            mount,
        var contentX = d.WELL_TOP;
            mount ? mount.parentElement : null,
        var contentWidth = Math.max(ITEMS.length * 8, width - contentX * 2);
            mount ? mount.closest('.main-portal') : null,
        var btnW = Math.max(4, Math.floor((contentWidth - f.GAP * 4) / ITEMS.length));
            document.querySelector('.liberty-content-main .mw-parser-output'),
         var segments = [];
            document.querySelector('.liberty-content-main'),
         var xoff = contentX;
            document.querySelector('.liberty-content')
        var i;
         ];
 
         for (var i = 0; i < candidates.length; i++) {
            var width = readWidth(candidates[i]);
 
            if (width > 0) return width;
        }


         for (i = 0; i < ITEMS.length; i++) {
         if (document.documentElement && document.documentElement.clientWidth) {
             segments.push({
             return Math.max(320, Math.floor(document.documentElement.clientWidth - 48));
                index:i,
                key:ITEMS[i].key,
                label:ITEMS[i].label,
                slant:ITEMS[i].slant,
                x:xoff,
                y:d.BUTTON_EDGE_TOP,
                btnW:btnW,
                pixelWidth:btnW + 2,
                pixelHeight:d.BUTTON_TOTAL
            });
            xoff += btnW + f.GAP;
         }
         }


         return {
         return 1200;
            width:width,
            height:d.ROW_HEIGHT,
            contentX:contentX,
            contentWidth:contentWidth,
            buttonWidth:btnW,
            usedRight:xoff - f.GAP,
            segments:segments
        };
     }
     }


     function paintProjectPillar(image, width, height, segment) {
     function calculateGeometry(width) {
         var frame = api();
         var usableWidth = Math.max(320, Math.floor(width || 0));
         var d = frame.DIMENSIONS;
         var count = ITEMS.length;
         var topStep = Math.floor(d.BUTTON_EDGE_TOP / frame.FRAME.SLANT_RISE);
        var height = 22;
         var leftTop = segment.x - topStep;
         var cut = Math.round((usableWidth / count) * 0.045);
         var rightTop = segment.x + segment.btnW + topStep;
         var top = [];
         var y;
        var bottom = [];
        var x;
        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);


        for (y = 0; y < height; y++) {
            if (i === 0 || i === count || i === half) {
            var step = Math.floor(y / frame.FRAME.SLANT_RISE);
                top.push(x);
            var left = leftTop + step;
                bottom.push(x);
             var right = rightTop - step;
             } else if (i < half) {
            for (x = left; x <= right; x++) frame.setPixel(image, width, height, x, y, 'S');
                top.push(x - cut);
             frame.setPixel(image, width, height, left, y, 'D');
                bottom.push(x);
            frame.setPixel(image, width, height, right, y, 'H');
             } else {
                top.push(x);
                bottom.push(x - cut);
            }
         }
         }
         return {
         return {
             leftTop:leftTop,
             width: usableWidth,
             rightTop:rightTop,
             height: height,
             leftAtButton:segment.x,
             count: count,
             rightAtButton:segment.x + segment.btnW
             cut: cut,
            top: top,
            bottom: bottom,
            gap: BUTTON_GAP
         };
         };
     }
     }


     function labelCenter(segment) {
     function createSvgElement(name) {
         var frame = api();
         return document.createElementNS(SVG_NS, name);
        var ys = Math.floor(frame.FRAME.BUTTON_FACE / 2);
        var mask = frame.buttonMask(segment.btnW, ys, segment.slant, 0);
        return segment.x + Math.round((mask.left + mask.right) / 2);
     }
     }


     function draw(canvas, layout, hoverIndex) {
     function roundCoordinate(value) {
         var frame = api();
         return Math.round(value * 100) / 100;
        var ctx = canvas.getContext('2d');
    }
        var image = frame.createImage(ctx, layout.width, layout.height, 'BG');
        var projectGeometry = null;


        frame.paintStandardFrame(image, layout.width, layout.height);
    function pointsToString(points) {
         layout.segments.forEach(function (segment) {
         return points.map(function (point) {
             var grid = frame.buildButtonPixels(
             return roundCoordinate(point[0]) + ',' + roundCoordinate(point[1]);
                segment.btnW,
        }).join(' ');
                frame.FRAME.BUTTON_FACE,
    }
                segment.slant,
                0
            );
            frame.paintGrid(
                image,
                layout.width,
                layout.height,
                grid,
                segment.x,
                segment.y,
                true
            );
        });


        projectGeometry = paintProjectPillar(
    function getSegmentPoints(index, geometry) {
            image,
        var halfGap = geometry.gap / 2;
            layout.width,
         var leftOffset = index === 0 ? 0 : halfGap;
            layout.height,
         var rightOffset = index === geometry.count - 1 ? 0 : halfGap;
            layout.segments[2]
         );
         ctx.putImageData(image, 0, 0);


         layout.segments.forEach(function (segment, index) {
         return [
             var center = index === 2
             [geometry.top[index] + leftOffset, 0],
                ? Math.round((projectGeometry.leftAtButton + projectGeometry.rightAtButton) / 2)
             [geometry.top[index + 1] - rightOffset, 0],
                : labelCenter(segment);
            [geometry.bottom[index + 1] - rightOffset, geometry.height],
             frame.drawLabel(
            [geometry.bottom[index] + leftOffset, geometry.height]
                ctx,
         ];
                segment.label,
                center,
                Math.round((frame.DIMENSIONS.BUTTON_SURFACE_TOP + frame.DIMENSIONS.BUTTON_SURFACE_BOTTOM) / 2) + 1,
                hoverIndex === index && index !== 2,
                layout.width <= 820 ? 11 : 12
            );
        });
         canvas.__projectGeometry = projectGeometry;
     }
     }


     function publishSeams(layout) {
     function getCentroid(points) {
         var frame = api();
         var totalX = 0;
        var d = frame.DIMENSIONS;
         var totalY = 0;
        var settings = layout.segments[1];
        var project = layout.segments[2];
         var corporations = layout.segments[3];
        var topStep = Math.floor(d.BUTTON_EDGE_TOP / frame.FRAME.SLANT_RISE);
        var settingsLeftOriginX = settings.x - topStep;
        var corpRightOriginX = corporations.x + corporations.btnW + topStep;
        var projectLeftOriginX = project.x - topStep;
        var projectRightOriginX = project.x + project.btnW + topStep;
        var seamStep = Math.floor(d.BOTTOM_START_ABS_Y / frame.FRAME.SLANT_RISE);


         frame.setSeams({
         points.forEach(function (point) {
             width:layout.width,
             totalX += point[0];
            rowHeight:d.ROW_HEIGHT,
             totalY += point[1];
            buttonTop:d.BUTTON_EDGE_TOP,
            buttonBottom:d.BUTTON_EDGE_BOTTOM,
            overlap:d.OVERLAP,
            bottomStartAbsY:d.BOTTOM_START_ABS_Y,
            phase:d.BOTTOM_START_ABS_Y % frame.FRAME.SLANT_RISE,
            settingsLeftOriginX:settingsLeftOriginX,
            settingsLeftTopX:settings.x,
            settingsLeftSeamX:settingsLeftOriginX + seamStep,
            settingsLeftDir:1,
            corpRightOriginX:corpRightOriginX,
            corpRightTopX:corporations.x + corporations.btnW,
            corpRightSeamX:corpRightOriginX - seamStep,
            corpRightDir:-1,
            projectLeftOriginX:projectLeftOriginX,
            projectRightOriginX:projectRightOriginX,
            projectLeftTopX:project.x,
             projectRightTopX:project.x + project.btnW,
            projectLeftDir:1,
            projectRightDir:-1,
            buttonWidth:layout.buttonWidth,
            segments:layout.segments.map(function (segment) {
                return {
                    key:segment.key,
                    x:segment.x,
                    y:segment.y,
                    btnW:segment.btnW,
                    pixelWidth:segment.pixelWidth,
                    slant:segment.slant
                };
            }),
            shellScale:frame.getShellScale()
         });
         });
        return [totalX / points.length, totalY / points.length];
     }
     }


     function createHit(item, index, segment, nav, redraw) {
     function intersectLines(lineA, lineB) {
         var hit;
         var p = lineA.point;
         if (item.pillar) {
         var r = lineA.direction;
            hit = document.createElement('span');
         var q = lineB.point;
            hit.setAttribute('aria-label', item.label);
        var s = lineB.direction;
            hit.setAttribute('role', 'presentation');
        var cross = r[0] * s[1] - r[1] * s[0];
            hit.className = 'portal-frame-hit portal-category-pillar-hit';
 
         } else {
        if (Math.abs(cross) < 0.000001) {
            hit = document.createElement('a');
             return [p[0], p[1]];
            hit.href = buildPath(item.title);
            hit.setAttribute('aria-label', item.label);
            hit.className = 'portal-frame-hit portal-cat-anchor';
            hit.setAttribute('data-category-key', item.key);
            hit.addEventListener('mouseenter', function () {
                nav.__frameHoverIndex = index;
                redraw();
            });
            hit.addEventListener('mouseleave', function () {
                if (nav.__frameHoverIndex === index) nav.__frameHoverIndex = -1;
                redraw();
            });
            hit.addEventListener('focus', function () {
                nav.__frameHoverIndex = index;
                redraw();
            });
             hit.addEventListener('blur', function () {
                if (nav.__frameHoverIndex === index) nav.__frameHoverIndex = -1;
                redraw();
            });
         }
         }
        hit.style.left = segment.x + 'px';
 
         hit.style.top = segment.y + 'px';
         var qMinusP = [q[0] - p[0], q[1] - p[1]];
        hit.style.width = segment.pixelWidth + 'px';
         var t = (qMinusP[0] * s[1] - qMinusP[1] * s[0]) / cross;
         hit.style.height = segment.pixelHeight + 'px';
 
        hit.appendChild(document.createElement('span'));
         return [p[0] + t * r[0], p[1] + t * r[1]];
        hit.lastChild.className = 'portal-frame-sr-label';
        hit.lastChild.textContent = item.label;
         return hit;
     }
     }


     function renderMount(mount) {
     function insetPolygon(points, distance) {
         var frame = api();
         var center = getCentroid(points);
         var width;
         var shiftedLines = [];
         var layout;
         var count = points.length;
        var nav;
         var i;
        var inner;
        var canvas;
        var hitLayer;
         var redraw;


         if (!mount || !frame) return false;
         for (i = 0; i < count; i++) {
        frame.setShellScale(frame.getShellScale());
            var start = points[i];
        width = frame.readRenderWidth(mount);
            var end = points[(i + 1) % count];
        if (!width) {
            var dx = end[0] - start[0];
             if (!mount.__categoryNavRetry) {
            var dy = end[1] - start[1];
                mount.__categoryNavRetry = window.setTimeout(function () {
            var length = Math.sqrt(dx * dx + dy * dy) || 1;
                    mount.__categoryNavRetry = 0;
            var normal = [-dy / length, dx / length];
                    renderMount(mount);
             var midpoint = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2];
                 }, 60);
            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;
             }
             }
             return false;
 
             shiftedLines.push({
                point: [
                    start[0] + normal[0] * distance,
                    start[1] + normal[1] * distance
                ],
                direction: [dx, dy]
            });
         }
         }


         layout = calculateLayout(width);
         return shiftedLines.map(function (line, index) {
         if (
            var previous = shiftedLines[(index + count - 1) % count];
            mount.getAttribute('data-category-nav-width') === String(width) &&
            return intersectLines(previous, line);
            mount.getAttribute('data-category-nav-scale') === String(frame.getShellScale()) &&
        });
            mount.querySelector('.portal-category-canvas')
    }
         ) {
 
            publishSeams(layout);
    function createPolygon(className, points) {
            return true;
        var polygon = createSvgElement('polygon');
         }
        polygon.setAttribute('class', className);
         nav = document.createElement('nav');
        polygon.setAttribute('points', pointsToString(points));
         nav.className = 'portal-category-nav portal-frame-canvas-nav';
        return polygon;
         nav.setAttribute('aria-label', 'Main categories');
    }
         nav.__frameHoverIndex = -1;
 
    function createEdgeLine(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 createSegment(item, index, geometry) {
        var anchor = createSvgElement('a');
        var outerPoints = getSegmentPoints(index, geometry);
        var surfacePoints = insetPolygon(outerPoints, OUTER_BORDER);
        var insetPoints = insetPolygon(outerPoints, INSET_LINE_OFFSET);
        var outer = createPolygon('portal-cat-edge', outerPoints);
        var surface = createPolygon('portal-cat-surface', surfacePoints);
        var topEdge = createEdgeLine('portal-cat-inset portal-cat-inset-top', insetPoints[0], insetPoints[1]);
        var rightEdge = createEdgeLine('portal-cat-inset portal-cat-inset-right', insetPoints[1], insetPoints[2]);
        var bottomEdge = createEdgeLine('portal-cat-inset portal-cat-inset-bottom', insetPoints[2], insetPoints[3]);
        var leftEdge = createEdgeLine('portal-cat-inset portal-cat-inset-left', insetPoints[3], insetPoints[0]);
        var hit = createPolygon('portal-cat-hit', outerPoints);
        var text = createSvgElement('text');
        var href = buildPath(item.title);
        var center = getCentroid(surfacePoints);
 
        anchor.setAttribute('class', 'portal-cat-anchor');
        anchor.setAttribute('href', href);
        anchor.setAttributeNS(XLINK_NS, 'xlink:href', href);
        anchor.setAttribute('aria-label', item.label);
        anchor.setAttribute('data-category-key', item.key);
 
        outer.setAttribute('pointer-events', 'none');
         surface.setAttribute('pointer-events', 'none');
        hit.setAttribute('pointer-events', 'all');
 
        text.setAttribute('class', 'portal-cat-label');
         text.setAttribute('x', Math.round(center[0]));
         text.setAttribute('y', Math.round(geometry.height / 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 = item.label;
 
        anchor.appendChild(outer);
        anchor.appendChild(surface);
        anchor.appendChild(topEdge);
        anchor.appendChild(rightEdge);
        anchor.appendChild(bottomEdge);
        anchor.appendChild(leftEdge);
        anchor.appendChild(hit);
        anchor.appendChild(text);
 
        return anchor;
    }
 
    function clearNode(node) {
        while (node.firstChild) node.removeChild(node.firstChild);
    }


        inner = document.createElement('div');
    function buildSvg(width) {
         inner.className = 'portal-frame-counter-inner portal-category-counter-inner';
         var geometry = calculateGeometry(width);
         inner.style.width = width + 'px';
         var svg = createSvgElement('svg');


         canvas = frame.createCanvas('portal-frame-canvas portal-category-canvas', width, layout.height);
         svg.setAttribute('class', 'portal-category-svg');
         canvas.setAttribute('data-frame-width', String(width));
        svg.setAttribute('viewBox', '0 0 ' + geometry.width + ' ' + geometry.height);
         canvas.setAttribute('data-frame-height', String(layout.height));
         svg.setAttribute('width', '100%');
        svg.setAttribute('height', '100%');
        svg.setAttribute('preserveAspectRatio', 'none');
         svg.setAttribute('aria-hidden', 'false');


         hitLayer = document.createElement('div');
         ITEMS.forEach(function (item, index) {
        hitLayer.className = 'portal-frame-hit-layer';
            svg.appendChild(createSegment(item, index, geometry));
         hitLayer.style.width = width + 'px';
         });
        hitLayer.style.height = layout.height + 'px';


         redraw = function () {
         return {
             draw(canvas, layout, nav.__frameHoverIndex);
             svg: svg,
            key: geometry.width + '|' + geometry.height + '|' + geometry.cut + '|' + geometry.gap + '|' + ITEMS.length
         };
         };
         ITEMS.forEach(function (item, index) {
    }
            hitLayer.appendChild(createHit(item, index, layout.segments[index], nav, redraw));
 
    function renderSvgIntoNav(nav, mount) {
        var result = buildSvg(getRenderableWidth(nav, mount));
 
        if (nav.__categoryNavRenderKey === result.key && nav.querySelector('svg')) return;
 
        nav.__categoryNavRenderKey = result.key;
        clearNode(nav);
         nav.appendChild(result.svg);
    }
 
    function scheduleRender(nav, mount) {
        if (!nav || nav.__categoryNavFrame) return;
 
        nav.__categoryNavFrame = window.requestAnimationFrame(function () {
            nav.__categoryNavFrame = null;
            renderSvgIntoNav(nav, mount);
         });
         });
    }
    function renderCategoryNavMount(mount) {
        if (!mount) return;
        if (mount.__categoryNavObserver) {
            mount.__categoryNavObserver.disconnect();
            mount.__categoryNavObserver = null;
        }


        inner.appendChild(canvas);
        inner.appendChild(hitLayer);
        nav.appendChild(inner);
         clearNode(mount);
         clearNode(mount);
        var nav = document.createElement('nav');
        nav.className = 'portal-category-nav';
        nav.setAttribute('aria-label', 'Main categories');
         mount.appendChild(nav);
         mount.appendChild(nav);
         frame.applyCounterScale([mount, nav], inner, layout.height);
         mount.setAttribute('data-category-nav-rendered', '1');
         redraw();
 
         publishSeams(layout);
        scheduleRender(nav, mount);
         window.setTimeout(function () { scheduleRender(nav, mount); }, 80);
         window.setTimeout(function () { scheduleRender(nav, mount); }, 250);


         mount.setAttribute('data-category-nav-rendered', 'canvas');
         if (window.ResizeObserver) {
        mount.setAttribute('data-category-nav-width', String(width));
            var observer = new ResizeObserver(function () {
        mount.setAttribute('data-category-nav-scale', String(frame.getShellScale()));
                scheduleRender(nav, mount);
            });


        if (window.ResizeObserver && !mount.__categoryNavObserver) {
            observer.observe(mount);
             mount.__categoryNavObserver = new window.ResizeObserver(function () {
            observer.observe(nav);
                 schedule(mount);
             mount.__categoryNavObserver = observer;
        } else {
            window.addEventListener('resize', function () {
                 scheduleRender(nav, mount);
             });
             });
            mount.__categoryNavObserver.observe(mount);
         }
         }
        return true;
     }
     }


     function renderAll(root) {
     function renderAllCategoryNavs(root) {
         var scope = root && root.querySelectorAll ? root : document;
         var scope = root || document;
         var mounts = [];
         var mounts = scope.querySelectorAll('[data-component="category-nav"]');
        var rendered = 0;
 
        if (scope.matches && scope.matches('[data-component="category-nav"]')) mounts.push(scope);
         Array.prototype.forEach.call(mounts, function (mount) {
         Array.prototype.forEach.call(scope.querySelectorAll('[data-component="category-nav"]'), function (mount) {
             renderCategoryNavMount(mount);
             if (mounts.indexOf(mount) === -1) mounts.push(mount);
         });
         });
        mounts.forEach(function (mount) {
            if (renderMount(mount)) rendered += 1;
        });
        return rendered;
     }
     }


     function schedule(root) {
     window.CLBI = window.CLBI || {};
        window.clearTimeout(renderTimer);
    window.CLBI.categoryNav = {
        renderTimer = window.setTimeout(function () {
        init: renderAllCategoryNavs,
            renderTimer = 0;
         renderMount: renderCategoryNavMount
            renderAll(root || document);
     };
         }, 0);
     }


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


     if (api()) api().onScale(function () { schedule(document); });
     if (window.mw && mw.hook) {
    if (mw && mw.hook) {
         mw.hook('wikipage.content').add(function ($content) {
         mw.hook('wikipage.content').add(function (content) {
             var root = $content && $content[0] ? $content[0] : document;
             schedule(content && content[0] ? content[0] : document);
            renderAllCategoryNavs(root);
         });
         });
     }
     }
    window.addEventListener('resize', function () { schedule(document); });
}());
 
    window.CategoryNav = {
        version:'20260717-canvas-pixel-001',
        init:renderAll,
        renderAll:renderAll,
        renderMount:renderMount
    };
    window.CLBI = window.CLBI || {};
    window.CLBI.categoryNav = {
        init:renderAll,
        renderMount:renderMount
    };
}(window, document, window.mw));

2026년 7월 20일 (월) 11:08 판

/* =========================================
COASTLINE: BLACK ICE - CategoryNav
대문 카테고리 SVG 네비 생성기
========================================= */

/*
새 공통 기능에는 프로젝트 접두사를 사용하지 않는다.
window.CLBI.categoryNav는 기존 Common.js 호출과의 호환을 위해서만 유지한다.
*/

(function () {
    'use strict';

    var SVG_NS = 'http://www.w3.org/2000/svg';
    var XLINK_NS = 'http://www.w3.org/1999/xlink';

    var ITEMS = [
        { key: 'era', label: '시대', title: '시대' },
        { key: 'settings', label: '설정', title: '설정' },
        { key: 'project', label: '프로젝트', title: '프로젝트:소개' },
        { key: 'corporations', label: '기업 및 공동체', title: '기업_및_공동체' },
        { key: 'military', label: '군, 정치집단', title: '군_정치집단' }
    ];

    var BUTTON_GAP = 2;
    var OUTER_BORDER = 1;
    var INSET_LINE_OFFSET = 1.5;

    /*
    상단 우물의 최종 2px 간격을 확보하기 위해 실제 버튼 높이는 22px이다.
    프레임 안쪽의 순수 #080808 간격 2px을 유지하면서
    실제 SVG 버튼 높이는 28px로 둔다.
    */

    function buildPath(title) {
        if (typeof window.buildWikiPath === 'function') {
            return window.buildWikiPath(title);
        }

        return '/index.php/' + encodeURI(String(title || '').replace(/ /g, '_'));
    }

    function readWidth(element) {
        if (!element) return 0;

        var rect = element.getBoundingClientRect ? element.getBoundingClientRect() : null;
        var width = rect ? Math.floor(rect.width || 0) : 0;

        if (!width && element.clientWidth) {
            width = Math.floor(element.clientWidth);
        }

        return width || 0;
    }

    function getRenderableWidth(nav, mount) {
        var candidates = [
            nav,
            mount,
            mount ? mount.parentElement : null,
            mount ? mount.closest('.main-portal') : null,
            document.querySelector('.liberty-content-main .mw-parser-output'),
            document.querySelector('.liberty-content-main'),
            document.querySelector('.liberty-content')
        ];

        for (var i = 0; i < candidates.length; i++) {
            var width = readWidth(candidates[i]);

            if (width > 0) return width;
        }

        if (document.documentElement && document.documentElement.clientWidth) {
            return Math.max(320, Math.floor(document.documentElement.clientWidth - 48));
        }

        return 1200;
    }

    function calculateGeometry(width) {
        var usableWidth = Math.max(320, Math.floor(width || 0));
        var count = ITEMS.length;
        var height = 22;
        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 {
            width: usableWidth,
            height: height,
            count: count,
            cut: cut,
            top: top,
            bottom: bottom,
            gap: BUTTON_GAP
        };
    }

    function createSvgElement(name) {
        return document.createElementNS(SVG_NS, name);
    }

    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 getSegmentPoints(index, geometry) {
        var halfGap = geometry.gap / 2;
        var leftOffset = index === 0 ? 0 : halfGap;
        var rightOffset = index === geometry.count - 1 ? 0 : halfGap;

        return [
            [geometry.top[index] + leftOffset, 0],
            [geometry.top[index + 1] - rightOffset, 0],
            [geometry.bottom[index + 1] - rightOffset, geometry.height],
            [geometry.bottom[index] + leftOffset, geometry.height]
        ];
    }

    function getCentroid(points) {
        var totalX = 0;
        var totalY = 0;

        points.forEach(function (point) {
            totalX += point[0];
            totalY += point[1];
        });

        return [totalX / points.length, totalY / 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 qMinusP = [q[0] - p[0], q[1] - p[1]];
        var t = (qMinusP[0] * s[1] - qMinusP[1] * s[0]) / cross;

        return [p[0] + t * r[0], p[1] + t * r[1]];
    }

    function insetPolygon(points, distance) {
        var center = getCentroid(points);
        var shiftedLines = [];
        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;
            }

            shiftedLines.push({
                point: [
                    start[0] + normal[0] * distance,
                    start[1] + normal[1] * distance
                ],
                direction: [dx, dy]
            });
        }

        return shiftedLines.map(function (line, index) {
            var previous = shiftedLines[(index + count - 1) % count];
            return intersectLines(previous, line);
        });
    }

    function createPolygon(className, points) {
        var polygon = createSvgElement('polygon');
        polygon.setAttribute('class', className);
        polygon.setAttribute('points', pointsToString(points));
        return polygon;
    }

    function createEdgeLine(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 createSegment(item, index, geometry) {
        var anchor = createSvgElement('a');
        var outerPoints = getSegmentPoints(index, geometry);
        var surfacePoints = insetPolygon(outerPoints, OUTER_BORDER);
        var insetPoints = insetPolygon(outerPoints, INSET_LINE_OFFSET);
        var outer = createPolygon('portal-cat-edge', outerPoints);
        var surface = createPolygon('portal-cat-surface', surfacePoints);
        var topEdge = createEdgeLine('portal-cat-inset portal-cat-inset-top', insetPoints[0], insetPoints[1]);
        var rightEdge = createEdgeLine('portal-cat-inset portal-cat-inset-right', insetPoints[1], insetPoints[2]);
        var bottomEdge = createEdgeLine('portal-cat-inset portal-cat-inset-bottom', insetPoints[2], insetPoints[3]);
        var leftEdge = createEdgeLine('portal-cat-inset portal-cat-inset-left', insetPoints[3], insetPoints[0]);
        var hit = createPolygon('portal-cat-hit', outerPoints);
        var text = createSvgElement('text');
        var href = buildPath(item.title);
        var center = getCentroid(surfacePoints);

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

        outer.setAttribute('pointer-events', 'none');
        surface.setAttribute('pointer-events', 'none');
        hit.setAttribute('pointer-events', 'all');

        text.setAttribute('class', 'portal-cat-label');
        text.setAttribute('x', Math.round(center[0]));
        text.setAttribute('y', Math.round(geometry.height / 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 = item.label;

        anchor.appendChild(outer);
        anchor.appendChild(surface);
        anchor.appendChild(topEdge);
        anchor.appendChild(rightEdge);
        anchor.appendChild(bottomEdge);
        anchor.appendChild(leftEdge);
        anchor.appendChild(hit);
        anchor.appendChild(text);

        return anchor;
    }

    function clearNode(node) {
        while (node.firstChild) node.removeChild(node.firstChild);
    }

    function buildSvg(width) {
        var geometry = calculateGeometry(width);
        var svg = createSvgElement('svg');

        svg.setAttribute('class', 'portal-category-svg');
        svg.setAttribute('viewBox', '0 0 ' + geometry.width + ' ' + geometry.height);
        svg.setAttribute('width', '100%');
        svg.setAttribute('height', '100%');
        svg.setAttribute('preserveAspectRatio', 'none');
        svg.setAttribute('aria-hidden', 'false');

        ITEMS.forEach(function (item, index) {
            svg.appendChild(createSegment(item, index, geometry));
        });

        return {
            svg: svg,
            key: geometry.width + '|' + geometry.height + '|' + geometry.cut + '|' + geometry.gap + '|' + ITEMS.length
        };
    }

    function renderSvgIntoNav(nav, mount) {
        var result = buildSvg(getRenderableWidth(nav, mount));

        if (nav.__categoryNavRenderKey === result.key && nav.querySelector('svg')) return;

        nav.__categoryNavRenderKey = result.key;
        clearNode(nav);
        nav.appendChild(result.svg);
    }

    function scheduleRender(nav, mount) {
        if (!nav || nav.__categoryNavFrame) return;

        nav.__categoryNavFrame = window.requestAnimationFrame(function () {
            nav.__categoryNavFrame = null;
            renderSvgIntoNav(nav, mount);
        });
    }

    function renderCategoryNavMount(mount) {
        if (!mount) return;

        if (mount.__categoryNavObserver) {
            mount.__categoryNavObserver.disconnect();
            mount.__categoryNavObserver = null;
        }

        clearNode(mount);

        var nav = document.createElement('nav');
        nav.className = 'portal-category-nav';
        nav.setAttribute('aria-label', 'Main categories');

        mount.appendChild(nav);
        mount.setAttribute('data-category-nav-rendered', '1');

        scheduleRender(nav, mount);
        window.setTimeout(function () { scheduleRender(nav, mount); }, 80);
        window.setTimeout(function () { scheduleRender(nav, mount); }, 250);

        if (window.ResizeObserver) {
            var observer = new ResizeObserver(function () {
                scheduleRender(nav, mount);
            });

            observer.observe(mount);
            observer.observe(nav);
            mount.__categoryNavObserver = observer;
        } else {
            window.addEventListener('resize', function () {
                scheduleRender(nav, mount);
            });
        }
    }

    function renderAllCategoryNavs(root) {
        var scope = root || document;
        var mounts = scope.querySelectorAll('[data-component="category-nav"]');

        Array.prototype.forEach.call(mounts, function (mount) {
            renderCategoryNavMount(mount);
        });
    }

    window.CLBI = window.CLBI || {};
    window.CLBI.categoryNav = {
        init: renderAllCategoryNavs,
        renderMount: renderCategoryNavMount
    };

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

    if (window.mw && mw.hook) {
        mw.hook('wikipage.content').add(function ($content) {
            var root = $content && $content[0] ? $content[0] : document;
            renderAllCategoryNavs(root);
        });
    }
}());