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

(Install package: clbiwiki-mainpage-visual-pixel-fix-20260712 / js/CategoryNav.js)
(Install package: clbiwiki-frame-geometry-normalized-20260717 / js/CategoryNav.js)
4번째 줄: 4번째 줄:
========================================= */
========================================= */


/*
(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 XLINK_NS = 'http://www.w3.org/1999/xlink';
     var ITEMS = [
     var ITEMS = [
         { key: 'era', label: '시대', title: '시대' },
         { key:'era', label:'시대', title:'시대' },
         { key: 'settings', label: '설정', title: '설정' },
         { key:'settings', label:'설정', title:'설정' },
         { key: 'project', label: '프로젝트', title: '프로젝트:소개' },
         { key:'project', label:'프로젝트', title:'프로젝트:소개' },
         { key: 'corporations', label: '기업 및 공동체', title: '기업_및_공동체' },
         { key:'corporations', label:'기업 및 공동체', title:'기업_및_공동체' },
         { key: 'military', label: '군, 정치집단', title: '군_정치집단' }
         { key:'military', label:'군, 정치집단', title:'군_정치집단' }
     ];
     ];
    var BOUNDARY_DIRS = [0, 1, 1, -1, -1, 0];
    var renderTimer = 0;


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


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


     function readWidth(element) {
     function clearNode(node) {
         if (!element) return 0;
         while (node && node.firstChild) node.removeChild(node.firstChild);
 
        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) {
     function calculateGeometry(width) {
         var usableWidth = Math.max(320, Math.floor(width || 0));
         var api = geometryApi();
         var count = ITEMS.length;
        var frame = api.FRAME;
         var height = 22;
         var dimensions = api.DIMENSIONS;
         var cut = Math.round((usableWidth / count) * 0.045);
         var contentLeft = frame.FRAME_FACE;
         var top = [];
         var contentWidth = Math.max(ITEMS.length, width - frame.FRAME_FACE * 2);
         var bottom = [];
         var boundaries = [];
        var half = count / 2;
         var segments = [];
         var i;
         var i;


         cut = Math.max(6, Math.min(10, cut));
         for (i = 0; i <= ITEMS.length; i++) {
            boundaries.push(contentLeft + Math.round(contentWidth * i / ITEMS.length));
        }


         for (i = 0; i <= count; i++) {
         for (i = 0; i < ITEMS.length; i++) {
             var x = Math.round((usableWidth * i) / count);
             var leftTopX = boundaries[i] + (i === 0 ? 0 : frame.GAP / 2);
 
             var rightTopX = boundaries[i + 1] - (i === ITEMS.length - 1 ? 0 : frame.GAP / 2);
             if (i === 0 || i === count || i === half) {
            segments.push({
                top.push(x);
                leftTopX:leftTopX,
                 bottom.push(x);
                 rightTopX:rightTopX,
            } else if (i < half) {
                leftDir:BOUNDARY_DIRS[i],
                 top.push(x - cut);
                 rightDir:BOUNDARY_DIRS[i + 1],
                 bottom.push(x);
                 points:api.segmentAt(
            } else {
                    leftTopX,
                top.push(x);
                    rightTopX,
                 bottom.push(x - cut);
                    dimensions.BUTTON_TOP,
             }
                    dimensions.BUTTON_BOTTOM,
                    BOUNDARY_DIRS[i],
                    BOUNDARY_DIRS[i + 1],
                    0,
                    0,
                    0
                 )
             });
         }
         }


         return {
         return {
             width: usableWidth,
             width:width,
             height: height,
             boundaries:boundaries,
             count: count,
             segments:segments,
             cut: cut,
             dimensions:dimensions
            top: top,
            bottom: bottom,
            gap: BUTTON_GAP
         };
         };
     }
     }


     function createSvgElement(name) {
     function appendFrame(svg, geometry) {
         return document.createElementNS(SVG_NS, name);
         var api = geometryApi();
    }
        var frame = api.FRAME;
        var d = geometry.dimensions;
        var width = geometry.width;
        var group = api.create('g', 'portal-category-frame');


    function roundCoordinate(value) {
        group.appendChild(api.rect('portal-category-frame-edge', 0, 0, width, d.ROW_HEIGHT));
         return Math.round(value * 100) / 100;
        group.appendChild(api.rect('portal-category-frame-surface', 1, 1, width - 2, d.ROW_HEIGHT - 2));
    }
         group.appendChild(api.rect(
            'portal-category-frame-well',
            frame.FRAME_FACE,
            frame.FRAME_FACE,
            width - frame.FRAME_FACE * 2,
            d.ROW_HEIGHT - frame.FRAME_FACE * 2
        ));


    function pointsToString(points) {
         group.appendChild(api.line('portal-category-frame-highlight', [1, 1], [width - 1, 1]));
         return points.map(function (point) {
         group.appendChild(api.line('portal-category-frame-highlight', [width - 1, 1], [width - 1, d.ROW_HEIGHT - 1]));
            return roundCoordinate(point[0]) + ',' + roundCoordinate(point[1]);
         group.appendChild(api.line('portal-category-frame-shadow', [1, d.ROW_HEIGHT - 1], [1, 1]));
         }).join(' ');
         group.appendChild(api.line('portal-category-frame-shadow', [width - 1, d.ROW_HEIGHT - 1], [1, d.ROW_HEIGHT - 1]));
    }
 
    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) {
         group.appendChild(api.line(
             var previous = shiftedLines[(index + count - 1) % count];
            'portal-category-well-shadow',
             return intersectLines(previous, line);
             [frame.FRAME_FACE, frame.FRAME_FACE],
         });
             [width - frame.FRAME_FACE, frame.FRAME_FACE]
    }
         ));
 
         group.appendChild(api.line(
    function createPolygon(className, points) {
            'portal-category-well-shadow',
         var polygon = createSvgElement('polygon');
            [width - frame.FRAME_FACE, frame.FRAME_FACE],
        polygon.setAttribute('class', className);
            [width - frame.FRAME_FACE, d.ROW_HEIGHT - frame.FRAME_FACE]
        polygon.setAttribute('points', pointsToString(points));
         ));
        return polygon;
         group.appendChild(api.line(
    }
            'portal-category-well-highlight',
 
            [frame.FRAME_FACE, d.ROW_HEIGHT - frame.FRAME_FACE],
    function createEdgeLine(className, start, end) {
            [frame.FRAME_FACE, frame.FRAME_FACE]
         var line = createSvgElement('line');
        ));
 
         group.appendChild(api.line(
         line.setAttribute('class', className);
            'portal-category-well-highlight',
        line.setAttribute('x1', roundCoordinate(start[0]));
            [width - frame.FRAME_FACE, d.ROW_HEIGHT - frame.FRAME_FACE],
        line.setAttribute('y1', roundCoordinate(start[1]));
            [frame.FRAME_FACE, d.ROW_HEIGHT - frame.FRAME_FACE]
         line.setAttribute('x2', roundCoordinate(end[0]));
         ));
        line.setAttribute('y2', roundCoordinate(end[1]));
         svg.appendChild(group);
         line.setAttribute('vector-effect', 'non-scaling-stroke');
         line.setAttribute('pointer-events', 'none');
 
        return line;
     }
     }


     function createSegment(item, index, geometry) {
     function createSegment(item, index, geometry) {
         var anchor = createSvgElement('a');
         var api = geometryApi();
         var outerPoints = getSegmentPoints(index, geometry);
         var frame = api.FRAME;
         var surfacePoints = insetPolygon(outerPoints, OUTER_BORDER);
         var data = geometry.segments[index];
         var insetPoints = insetPolygon(outerPoints, INSET_LINE_OFFSET);
         var d = geometry.dimensions;
         var outer = createPolygon('portal-cat-edge', outerPoints);
         var anchor = api.create('a', 'portal-cat-anchor');
         var surface = createPolygon('portal-cat-surface', surfacePoints);
         var outer = api.polygon('portal-cat-edge', data.points);
         var topEdge = createEdgeLine('portal-cat-inset portal-cat-inset-top', insetPoints[0], insetPoints[1]);
         var surfacePoints = api.segmentAt(
         var rightEdge = createEdgeLine('portal-cat-inset portal-cat-inset-right', insetPoints[1], insetPoints[2]);
            data.leftTopX,
        var bottomEdge = createEdgeLine('portal-cat-inset portal-cat-inset-bottom', insetPoints[2], insetPoints[3]);
            data.rightTopX,
        var leftEdge = createEdgeLine('portal-cat-inset portal-cat-inset-left', insetPoints[3], insetPoints[0]);
            d.BUTTON_TOP + frame.BORDER,
         var hit = createPolygon('portal-cat-hit', outerPoints);
            d.BUTTON_BOTTOM - frame.BORDER,
         var text = createSvgElement('text');
            data.leftDir,
            data.rightDir,
            0,
            frame.BORDER,
            frame.BORDER
        );
         var bevelPoints = api.segmentAt(
            data.leftTopX,
            data.rightTopX,
            d.BUTTON_TOP + frame.BORDER + frame.BEVEL,
            d.BUTTON_BOTTOM - frame.BORDER - frame.BEVEL,
            data.leftDir,
            data.rightDir,
            0,
            frame.BORDER + frame.BEVEL,
            frame.BORDER + frame.BEVEL
        );
         var hit = api.polygon('portal-cat-hit', data.points);
         var text = api.create('text', 'portal-cat-label');
         var href = buildPath(item.title);
         var href = buildPath(item.title);
        var center = getCentroid(surfacePoints);


        anchor.setAttribute('class', 'portal-cat-anchor');
         anchor.setAttribute('href', href);
         anchor.setAttribute('href', href);
         anchor.setAttributeNS(XLINK_NS, 'xlink:href', href);
         anchor.setAttributeNS(XLINK_NS, 'xlink:href', href);
251번째 줄: 158번째 줄:
         anchor.setAttribute('data-category-key', item.key);
         anchor.setAttribute('data-category-key', item.key);


         outer.setAttribute('pointer-events', 'none');
         anchor.appendChild(outer);
         surface.setAttribute('pointer-events', 'none');
        anchor.appendChild(api.polygon('portal-cat-surface', surfacePoints));
        anchor.appendChild(api.line('portal-cat-inset portal-cat-inset-top', bevelPoints[0], bevelPoints[1]));
        anchor.appendChild(api.line('portal-cat-inset portal-cat-inset-right', bevelPoints[1], bevelPoints[2]));
         anchor.appendChild(api.line('portal-cat-inset portal-cat-inset-bottom', bevelPoints[2], bevelPoints[3]));
        anchor.appendChild(api.line('portal-cat-inset portal-cat-inset-left', bevelPoints[3], bevelPoints[0]));
         hit.setAttribute('pointer-events', 'all');
         hit.setAttribute('pointer-events', 'all');
        anchor.appendChild(hit);


        text.setAttribute('class', 'portal-cat-label');
         text.setAttribute('x', api.centerX(surfacePoints));
         text.setAttribute('x', Math.round(center[0]));
         text.setAttribute('y', Math.round((d.BUTTON_TOP + d.BUTTON_BOTTOM) / 2) + 1);
         text.setAttribute('y', Math.round(geometry.height / 2) + 1);
         text.setAttribute('font-size', '12');
         text.setAttribute('font-size', '12');
         text.setAttribute('font-weight', '700');
         text.setAttribute('font-weight', '700');
264번째 줄: 175번째 줄:
         text.setAttribute('pointer-events', 'none');
         text.setAttribute('pointer-events', 'none');
         text.textContent = item.label;
         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);
         anchor.appendChild(text);
         return anchor;
         return anchor;
     }
     }


     function clearNode(node) {
     function publishSeams(geometry) {
         while (node.firstChild) node.removeChild(node.firstChild);
         var api = geometryApi();
        var segments = geometry.segments;
        var d = geometry.dimensions;
 
        api.setSeams({
            width:geometry.width,
            rowHeight:d.ROW_HEIGHT,
            buttonTop:d.BUTTON_TOP,
            buttonBottom:d.BUTTON_BOTTOM,
            overlap:d.OVERLAP,
            bottomStartAbsY:d.BOTTOM_START_ABS_Y,
            settingsLeftTopX:segments[1].leftTopX,
            settingsLeftDir:segments[1].leftDir,
            corpRightTopX:segments[3].rightTopX,
            corpRightDir:segments[3].rightDir,
            projectLeftTopX:segments[2].leftTopX,
            projectLeftDir:segments[2].leftDir,
            projectRightTopX:segments[2].rightTopX,
            projectRightDir:segments[2].rightDir
        });
     }
     }


     function buildSvg(width) {
     function buildSvg(width) {
        var api = geometryApi();
         var geometry = calculateGeometry(width);
         var geometry = calculateGeometry(width);
         var svg = createSvgElement('svg');
         var svg = api.create('svg', 'portal-category-svg');


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


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


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


     function renderSvgIntoNav(nav, mount) {
     function renderMount(mount) {
         var result = buildSvg(getRenderableWidth(nav, mount));
         var api = geometryApi();
        var width;
        var nav;
        var result;


         if (nav.__categoryNavRenderKey === result.key && nav.querySelector('svg')) return;
         if (!mount || !api) return false;
        width = api.readWidth(mount);
        if (!width) {
            if (!mount.__categoryNavRetry) {
                mount.__categoryNavRetry = window.setTimeout(function () {
                    mount.__categoryNavRetry = 0;
                    renderMount(mount);
                }, 60);
            }
            return false;
        }


         nav.__categoryNavRenderKey = result.key;
         if (mount.__categoryNavRetry) {
         clearNode(nav);
            window.clearTimeout(mount.__categoryNavRetry);
         nav.appendChild(result.svg);
            mount.__categoryNavRetry = 0;
    }
         }
 
         nav = mount.querySelector('.portal-category-nav');
    function scheduleRender(nav, mount) {
        if (!nav) {
        if (!nav || nav.__categoryNavFrame) return;
            clearNode(mount);
 
            nav = document.createElement('nav');
        nav.__categoryNavFrame = window.requestAnimationFrame(function () {
             nav.className = 'portal-category-nav';
             nav.__categoryNavFrame = null;
             nav.setAttribute('aria-label', 'Main categories');
             renderSvgIntoNav(nav, mount);
            mount.appendChild(nav);
        });
        }
    }


    function renderCategoryNavMount(mount) {
         if (nav.__frameWidth === width && nav.querySelector('.portal-category-svg')) {
         if (!mount) return;
             publishSeams(calculateGeometry(width));
 
             return true;
        if (mount.__categoryNavObserver) {
             mount.__categoryNavObserver.disconnect();
             mount.__categoryNavObserver = null;
         }
         }


         clearNode(mount);
         result = buildSvg(width);
 
         nav.__frameWidth = width;
        var nav = document.createElement('nav');
         clearNode(nav);
         nav.className = 'portal-category-nav';
         nav.appendChild(result.svg);
         nav.setAttribute('aria-label', 'Main categories');
 
         mount.appendChild(nav);
         mount.setAttribute('data-category-nav-rendered', '1');
         mount.setAttribute('data-category-nav-rendered', '1');
        publishSeams(result.geometry);


        scheduleRender(nav, mount);
         if (window.ResizeObserver && !mount.__categoryNavObserver) {
        window.setTimeout(function () { scheduleRender(nav, mount); }, 80);
             mount.__categoryNavObserver = new window.ResizeObserver(function () {
        window.setTimeout(function () { scheduleRender(nav, mount); }, 250);
                 schedule(mount);
 
         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);
             });
             });
            mount.__categoryNavObserver.observe(mount);
         }
         }
        return true;
     }
     }


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


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


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


     if (document.readyState === 'loading') {
     if (document.readyState === 'loading') {
         document.addEventListener('DOMContentLoaded', function () {
         document.addEventListener('DOMContentLoaded', function () { renderAll(document); });
            renderAllCategoryNavs(document);
        });
     } else {
     } else {
         renderAllCategoryNavs(document);
         renderAll(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 = {
        init:renderAll,
        renderAll:renderAll,
        renderMount:renderMount
    };
    window.CLBI = window.CLBI || {};
    window.CLBI.categoryNav = {
        init:renderAll,
        renderMount:renderMount
    };
}(window, document, window.mw));

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

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

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

    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 BOUNDARY_DIRS = [0, 1, 1, -1, -1, 0];
    var renderTimer = 0;

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

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

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

    function calculateGeometry(width) {
        var api = geometryApi();
        var frame = api.FRAME;
        var dimensions = api.DIMENSIONS;
        var contentLeft = frame.FRAME_FACE;
        var contentWidth = Math.max(ITEMS.length, width - frame.FRAME_FACE * 2);
        var boundaries = [];
        var segments = [];
        var i;

        for (i = 0; i <= ITEMS.length; i++) {
            boundaries.push(contentLeft + Math.round(contentWidth * i / ITEMS.length));
        }

        for (i = 0; i < ITEMS.length; i++) {
            var leftTopX = boundaries[i] + (i === 0 ? 0 : frame.GAP / 2);
            var rightTopX = boundaries[i + 1] - (i === ITEMS.length - 1 ? 0 : frame.GAP / 2);
            segments.push({
                leftTopX:leftTopX,
                rightTopX:rightTopX,
                leftDir:BOUNDARY_DIRS[i],
                rightDir:BOUNDARY_DIRS[i + 1],
                points:api.segmentAt(
                    leftTopX,
                    rightTopX,
                    dimensions.BUTTON_TOP,
                    dimensions.BUTTON_BOTTOM,
                    BOUNDARY_DIRS[i],
                    BOUNDARY_DIRS[i + 1],
                    0,
                    0,
                    0
                )
            });
        }

        return {
            width:width,
            boundaries:boundaries,
            segments:segments,
            dimensions:dimensions
        };
    }

    function appendFrame(svg, geometry) {
        var api = geometryApi();
        var frame = api.FRAME;
        var d = geometry.dimensions;
        var width = geometry.width;
        var group = api.create('g', 'portal-category-frame');

        group.appendChild(api.rect('portal-category-frame-edge', 0, 0, width, d.ROW_HEIGHT));
        group.appendChild(api.rect('portal-category-frame-surface', 1, 1, width - 2, d.ROW_HEIGHT - 2));
        group.appendChild(api.rect(
            'portal-category-frame-well',
            frame.FRAME_FACE,
            frame.FRAME_FACE,
            width - frame.FRAME_FACE * 2,
            d.ROW_HEIGHT - frame.FRAME_FACE * 2
        ));

        group.appendChild(api.line('portal-category-frame-highlight', [1, 1], [width - 1, 1]));
        group.appendChild(api.line('portal-category-frame-highlight', [width - 1, 1], [width - 1, d.ROW_HEIGHT - 1]));
        group.appendChild(api.line('portal-category-frame-shadow', [1, d.ROW_HEIGHT - 1], [1, 1]));
        group.appendChild(api.line('portal-category-frame-shadow', [width - 1, d.ROW_HEIGHT - 1], [1, d.ROW_HEIGHT - 1]));

        group.appendChild(api.line(
            'portal-category-well-shadow',
            [frame.FRAME_FACE, frame.FRAME_FACE],
            [width - frame.FRAME_FACE, frame.FRAME_FACE]
        ));
        group.appendChild(api.line(
            'portal-category-well-shadow',
            [width - frame.FRAME_FACE, frame.FRAME_FACE],
            [width - frame.FRAME_FACE, d.ROW_HEIGHT - frame.FRAME_FACE]
        ));
        group.appendChild(api.line(
            'portal-category-well-highlight',
            [frame.FRAME_FACE, d.ROW_HEIGHT - frame.FRAME_FACE],
            [frame.FRAME_FACE, frame.FRAME_FACE]
        ));
        group.appendChild(api.line(
            'portal-category-well-highlight',
            [width - frame.FRAME_FACE, d.ROW_HEIGHT - frame.FRAME_FACE],
            [frame.FRAME_FACE, d.ROW_HEIGHT - frame.FRAME_FACE]
        ));
        svg.appendChild(group);
    }

    function createSegment(item, index, geometry) {
        var api = geometryApi();
        var frame = api.FRAME;
        var data = geometry.segments[index];
        var d = geometry.dimensions;
        var anchor = api.create('a', 'portal-cat-anchor');
        var outer = api.polygon('portal-cat-edge', data.points);
        var surfacePoints = api.segmentAt(
            data.leftTopX,
            data.rightTopX,
            d.BUTTON_TOP + frame.BORDER,
            d.BUTTON_BOTTOM - frame.BORDER,
            data.leftDir,
            data.rightDir,
            0,
            frame.BORDER,
            frame.BORDER
        );
        var bevelPoints = api.segmentAt(
            data.leftTopX,
            data.rightTopX,
            d.BUTTON_TOP + frame.BORDER + frame.BEVEL,
            d.BUTTON_BOTTOM - frame.BORDER - frame.BEVEL,
            data.leftDir,
            data.rightDir,
            0,
            frame.BORDER + frame.BEVEL,
            frame.BORDER + frame.BEVEL
        );
        var hit = api.polygon('portal-cat-hit', data.points);
        var text = api.create('text', 'portal-cat-label');
        var href = buildPath(item.title);

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

        anchor.appendChild(outer);
        anchor.appendChild(api.polygon('portal-cat-surface', surfacePoints));
        anchor.appendChild(api.line('portal-cat-inset portal-cat-inset-top', bevelPoints[0], bevelPoints[1]));
        anchor.appendChild(api.line('portal-cat-inset portal-cat-inset-right', bevelPoints[1], bevelPoints[2]));
        anchor.appendChild(api.line('portal-cat-inset portal-cat-inset-bottom', bevelPoints[2], bevelPoints[3]));
        anchor.appendChild(api.line('portal-cat-inset portal-cat-inset-left', bevelPoints[3], bevelPoints[0]));
        hit.setAttribute('pointer-events', 'all');
        anchor.appendChild(hit);

        text.setAttribute('x', api.centerX(surfacePoints));
        text.setAttribute('y', Math.round((d.BUTTON_TOP + d.BUTTON_BOTTOM) / 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(text);
        return anchor;
    }

    function publishSeams(geometry) {
        var api = geometryApi();
        var segments = geometry.segments;
        var d = geometry.dimensions;

        api.setSeams({
            width:geometry.width,
            rowHeight:d.ROW_HEIGHT,
            buttonTop:d.BUTTON_TOP,
            buttonBottom:d.BUTTON_BOTTOM,
            overlap:d.OVERLAP,
            bottomStartAbsY:d.BOTTOM_START_ABS_Y,
            settingsLeftTopX:segments[1].leftTopX,
            settingsLeftDir:segments[1].leftDir,
            corpRightTopX:segments[3].rightTopX,
            corpRightDir:segments[3].rightDir,
            projectLeftTopX:segments[2].leftTopX,
            projectLeftDir:segments[2].leftDir,
            projectRightTopX:segments[2].rightTopX,
            projectRightDir:segments[2].rightDir
        });
    }

    function buildSvg(width) {
        var api = geometryApi();
        var geometry = calculateGeometry(width);
        var svg = api.create('svg', 'portal-category-svg');

        svg.setAttribute('viewBox', '0 0 ' + geometry.width + ' ' + geometry.dimensions.ROW_HEIGHT);
        svg.setAttribute('width', geometry.width);
        svg.setAttribute('height', geometry.dimensions.ROW_HEIGHT);
        svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
        svg.setAttribute('aria-hidden', 'false');
        svg.setAttribute('data-frame-width', geometry.width);

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

        return { svg:svg, geometry:geometry };
    }

    function renderMount(mount) {
        var api = geometryApi();
        var width;
        var nav;
        var result;

        if (!mount || !api) return false;
        width = api.readWidth(mount);
        if (!width) {
            if (!mount.__categoryNavRetry) {
                mount.__categoryNavRetry = window.setTimeout(function () {
                    mount.__categoryNavRetry = 0;
                    renderMount(mount);
                }, 60);
            }
            return false;
        }

        if (mount.__categoryNavRetry) {
            window.clearTimeout(mount.__categoryNavRetry);
            mount.__categoryNavRetry = 0;
        }
        nav = mount.querySelector('.portal-category-nav');
        if (!nav) {
            clearNode(mount);
            nav = document.createElement('nav');
            nav.className = 'portal-category-nav';
            nav.setAttribute('aria-label', 'Main categories');
            mount.appendChild(nav);
        }

        if (nav.__frameWidth === width && nav.querySelector('.portal-category-svg')) {
            publishSeams(calculateGeometry(width));
            return true;
        }

        result = buildSvg(width);
        nav.__frameWidth = width;
        clearNode(nav);
        nav.appendChild(result.svg);
        mount.setAttribute('data-category-nav-rendered', '1');
        publishSeams(result.geometry);

        if (window.ResizeObserver && !mount.__categoryNavObserver) {
            mount.__categoryNavObserver = new window.ResizeObserver(function () {
                schedule(mount);
            });
            mount.__categoryNavObserver.observe(mount);
        }
        return true;
    }

    function renderAll(root) {
        var scope = root && root.querySelectorAll ? root : document;
        var mounts = [];
        var rendered = 0;

        if (scope.matches && scope.matches('[data-component="category-nav"]')) mounts.push(scope);
        Array.prototype.forEach.call(scope.querySelectorAll('[data-component="category-nav"]'), function (mount) {
            if (mounts.indexOf(mount) === -1) mounts.push(mount);
        });
        mounts.forEach(function (mount) {
            if (renderMount(mount)) rendered += 1;
        });
        return rendered;
    }

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

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

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

    window.addEventListener('resize', function () { schedule(document); });

    window.CategoryNav = {
        init:renderAll,
        renderAll:renderAll,
        renderMount:renderMount
    };
    window.CLBI = window.CLBI || {};
    window.CLBI.categoryNav = {
        init:renderAll,
        renderMount:renderMount
    };
}(window, document, window.mw));