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

편집 요약 없음
(Install package: clbiwiki-mainpage-trim-v3-renewal-20260711 (1) / js/CategoryNav.js)
1번째 줄: 1번째 줄:
/* =========================================
/* =========================================
COASTLINE: BLACK ICE - CategoryNav
COASTLINE: BLACK ICE - CategoryNav
대문 카테고리 네비 SVG 생성기
대문 카테고리 SVG 네비 생성기
========================================= */
========================================= */


/*
/*
설계 목적
새 공통 기능에는 프로젝트 접두사를 사용하지 않는다.
-----------------------------------------
window.CLBI.categoryNav는 기존 Common.js 호출과의 호환을 위해서만 유지한다.
이 파일은 대문 카테고리 네비를 MediaWiki 본문 위키문법이 아니라
JavaScript + SVG로 생성한다.
 
왜 SVG를 쓰는가
-----------------------------------------
CSS 박스 기반 구현에서 다음 문제가 반복되었다.
 
1. span 안에 [[문서|라벨]]을 넣는 방식
  - MediaWiki가 내부에 <a>를 자동 생성한다.
  - span은 모양을 담당하고, a는 클릭을 담당하게 된다.
  - 실제 클릭 영역, 호버 영역, 시각 형태가 서로 분리되었다.
 
2. <a class="...">를 본문에 직접 넣는 방식
  - MediaWiki 본문에서 안정적으로 먹히지 않았다.
  - 위키 문법 처리와 충돌해 코드가 무효화되는 문제가 있었다.
 
3. clip-path + ::after 방식
  - clip-path가 검은 획 pseudo-element까지 잘라먹었다.
  - 왼쪽 항목의 획이 사라지거나 일부만 보였다.
 
4. 버튼을 음수 margin으로 겹치는 방식
  - 사선 빈 영역은 메웠지만, 호버가 옆 칸으로 넘어갔다.
 
5. hover overlay / divider overlay 방식
  - CSS grid, absolute 좌표, 프로젝트 가변폭이 서로 다른 기준을 가져서
    호버 면과 검은 획이 계속 어긋났다.
 
SVG 방식의 장점
-----------------------------------------
SVG에서는 각 항목을 polygon으로 만든다.
따라서 다음 요소들이 모두 같은 좌표계를 사용한다.
 
- 보이는 버튼 면
- 실제 hover 면
- 실제 click 면
- 검은 구분선
 
즉, 이전처럼 사각형 박스를 사선처럼 보이게 흉내 내는 방식이 아니라,
처음부터 사선 polygon을 생성한다.
 
15% 확장 처리
-----------------------------------------
프로젝트를 제외한 6개 카테고리 버튼의 폭은 이 파일의 idealCell 값으로 정한다.
기존 기준은 163px이었고, 여기서 약 15% 확대한 값은 다음과 같다.
 
163 * 1.15 = 187.45
 
렌더링 기준값은 188px로 둔다.
 
중요:
MainPage.css는 SVG 표시 규칙만 담당한다.
좌표성 변수나 버튼 폭 계산은 MainPage.css에 두지 않는다.
*/
*/


67번째 줄: 15번째 줄:
     var XLINK_NS = 'http://www.w3.org/1999/xlink';
     var XLINK_NS = 'http://www.w3.org/1999/xlink';


     var CATEGORY_NAV_ITEMS = [
     var ITEMS = [
         {
         { key: 'era', label: '시대', title: '시대' },
            key: 'history',
         { key: 'settings', label: '설정', title: '설정' },
            label: '역사적 사건',
         { key: 'project', label: '프로젝트', title: '프로젝트:소개' },
            title: '역사적_사건'
         { key: 'corporations', label: '기업 및 공동체', title: '기업_및_공동체' },
        },
         { key: 'military', label: '군, 정치집단', title: '군_정치집단' },
         {
         { key: 'personnel', label: '인물', title: '인물' }
            key: 'settings',
            label: '설정',
            title: '설정'
        },
         {
            key: 'nations',
            label: '국가 및 조합',
            title: '국가_및_조합'
        },
        {
            key: 'project',
            label: '프로젝트',
            title: '프로젝트:소개'
        },
         {
            key: 'corporations',
            label: '기업 및 공동체',
            title: '기업_및_공동체'
        },
         {
            key: 'military',
            label: '군, 정치집단',
            title: '군_정치집단'
        },
         {
            key: 'personnel',
            label: '인물',
            title: '인물'
        }
     ];
     ];
    function getCurrentLanguageSafe() {
        if (typeof window.getCurrentLang === 'function') {
            return window.getCurrentLang();
        }
        var langData = document.getElementById('clbi-lang-data');
        if (langData) {
            return langData.getAttribute('data-lang') || 'ko';
        }
        return 'ko';
    }


     function buildPath(title) {
     function buildPath(title) {
127번째 줄: 32번째 줄:
     }
     }


     function getLabel(item, lang) {
     function readWidth(element) {
         /*
         if (!element) return 0;
        현재는 한국어 대문 테스트를 기준으로 한다.


        다국어 구조가 확정되기 전까지는 한국어 fallback을 유지한다.
         var rect = element.getBoundingClientRect ? element.getBoundingClientRect() : null;
        일부 항목만 번역하면 네비 폭과 의미가 언어별로 흔들릴 수 있다.
        */
        return item.label;
    }
 
    function readWidth(el) {
        if (!el) return 0;
 
         var rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
         var width = rect ? Math.floor(rect.width || 0) : 0;
         var width = rect ? Math.floor(rect.width || 0) : 0;


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


         return width || 0;
         return width || 0;
     }
     }
    /*
    폭 측정
    -----------------------------------------
    nav 자체 폭이 0이면 상위 요소들을 차례로 확인한다.
    마지막에는 viewport 폭을 사용한다.
    이 fallback은 nav 배경만 보이고 SVG가 생성되지 않는 상태를 막기 위한 안전장치다.
    실제 레이아웃 폭이 나중에 잡히면 ResizeObserver가 다시 렌더링한다.
    */


     function getRenderableWidth(nav, mount) {
     function getRenderableWidth(nav, mount) {
166번째 줄: 51번째 줄:
             mount ? mount.parentElement : null,
             mount ? mount.parentElement : null,
             mount ? mount.closest('.main-portal') : null,
             mount ? mount.closest('.main-portal') : null,
            mount ? mount.closest('.stylelab') : null,
             document.querySelector('.liberty-content-main .mw-parser-output'),
             document.querySelector('.liberty-content-main .mw-parser-output'),
             document.querySelector('.liberty-content-main'),
             document.querySelector('.liberty-content-main'),
175번째 줄: 59번째 줄:
             var width = readWidth(candidates[i]);
             var width = readWidth(candidates[i]);


             if (width > 0) {
             if (width > 0) return width;
                return width;
            }
         }
         }


186번째 줄: 68번째 줄:
         return 1200;
         return 1200;
     }
     }
    /*
    좌표 계산
    -----------------------------------------
    idealCell = 188
    프로젝트를 제외한 6개 버튼의 기준 폭이다.
    계산 원칙:
    - 좌측 3개와 우측 3개는 같은 폭이다.
    - 프로젝트는 남은 중앙 폭을 차지한다.
    - 화면이 너무 좁으면 cellW를 줄인다.
    - cut 값은 지나치게 크거나 작아지지 않도록 제한한다.
    */


     function calculateGeometry(width) {
     function calculateGeometry(width) {
         var h = 30;
         var usableWidth = Math.max(320, Math.floor(width || 0));
         var idealCell = 188;
         var count = ITEMS.length;
         var minCell = 96;
         var height = 24;
         var minProject = 180;
         var cut = Math.round((usableWidth / count) * 0.045);
         var usableWidth = Math.max(320, width);
         var top = [];
        var bottom = [];
        var half = count / 2;
        var i;


         var cellW = idealCell;
         cut = Math.max(6, Math.min(10, cut));


         if ((cellW * 6) + minProject > usableWidth) {
         for (i = 0; i <= count; i++) {
             cellW = Math.floor((usableWidth - minProject) / 6);
             var x = Math.round((usableWidth * i) / count);
            cellW = Math.max(minCell, cellW);
        }


        var projectW = usableWidth - (cellW * 6);
            if (i === 0 || i === count || i === half) {
 
                top.push(x);
        if (projectW < 100) {
                bottom.push(x);
             projectW = 100;
            } else if (i < half) {
                top.push(x - cut);
                bottom.push(x);
             } else {
                top.push(x);
                bottom.push(x - cut);
            }
         }
         }
        var totalW = (cellW * 6) + projectW;
        var cut = Math.round(cellW * 0.061);
        cut = Math.max(6, Math.min(10, cut));
        var x0 = 0;
        var x1 = cellW;
        var x2 = cellW * 2;
        var x3 = cellW * 3;
        var x4 = x3 + projectW;
        var x5 = x4 + cellW;
        var x6 = x4 + (cellW * 2);
        var x7 = x4 + (cellW * 3);


         return {
         return {
             width: totalW,
             width: usableWidth,
             height: h,
             height: height,
            cellW: cellW,
             count: count,
             projectW: projectW,
             cut: cut,
             cut: cut,
             x: [x0, x1, x2, x3, x4, x5, x6, x7]
             top: top,
            bottom: bottom
         };
         };
     }
     }


    /*
     function createSvgElement(name) {
    polygon 좌표
         return document.createElementNS(SVG_NS, name);
    -----------------------------------------
    요구 형태:
    왼쪽군:  \ \ \
    프로젝트: \ PROJECT /
    오른쪽군: / / /
 
    좌표 설명:
    - \ 경계는 위쪽 x가 왼쪽, 아래쪽 x가 오른쪽이다.
    - / 경계는 위쪽 x가 오른쪽, 아래쪽 x가 왼쪽이다.
    */
 
     function getSegmentPoints(index, g) {
         var x = g.x;
        var c = g.cut;
        var h = g.height;
 
        if (index === 0) {
            return [
                [x[0], 0],
                [x[1] - c, 0],
                [x[1], h],
                [x[0], h]
            ];
        }
 
        if (index === 1) {
            return [
                [x[1] - c, 0],
                [x[2] - c, 0],
                [x[2], h],
                [x[1], h]
            ];
        }
 
        if (index === 2) {
            return [
                [x[2] - c, 0],
                [x[3] - c, 0],
                [x[3], h],
                [x[2], h]
            ];
        }
 
        if (index === 3) {
            return [
                [x[3] - c, 0],
                [x[4], 0],
                [x[4] - c, h],
                [x[3], h]
            ];
        }
 
        if (index === 4) {
            return [
                [x[4], 0],
                [x[5], 0],
                [x[5] - c, h],
                [x[4] - c, h]
            ];
        }
 
        if (index === 5) {
            return [
                [x[5], 0],
                [x[6], 0],
                [x[6] - c, h],
                [x[5] - c, h]
            ];
        }
 
        return [
            [x[6], 0],
            [x[7], 0],
            [x[7], h],
            [x[6] - c, h]
        ];
     }
     }


330번째 줄: 116번째 줄:
     }
     }


     function getTextX(index, g) {
     function getSegmentPoints(index, geometry) {
         var x = g.x;
         return [
 
            [geometry.top[index], 0],
        if (index === 0) return (x[0] + x[1]) / 2;
            [geometry.top[index + 1], 0],
        if (index === 1) return (x[1] + x[2]) / 2;
            [geometry.bottom[index + 1], geometry.height],
        if (index === 2) return (x[2] + x[3]) / 2;
            [geometry.bottom[index], geometry.height]
        if (index === 3) return (x[3] + x[4]) / 2;
         ];
        if (index === 4) return (x[4] + x[5]) / 2;
        if (index === 5) return (x[5] + x[6]) / 2;
         return (x[6] + x[7]) / 2;
     }
     }


     function createSvgElement(name) {
     function getTextX(index, geometry) {
         return document.createElementNS(SVG_NS, name);
         return Math.round((
            geometry.top[index] +
            geometry.top[index + 1] +
            geometry.bottom[index + 1] +
            geometry.bottom[index]
        ) / 4);
     }
     }


     function createSegment(item, index, g, lang) {
     function createSegment(item, index, geometry) {
         var anchor = createSvgElement('a');
         var anchor = createSvgElement('a');
         var polygon = createSvgElement('polygon');
         var polygon = createSvgElement('polygon');
         var text = createSvgElement('text');
         var text = createSvgElement('text');
         var href = buildPath(item.title);
         var href = buildPath(item.title);
        var label = getLabel(item, lang);


         anchor.setAttribute('class', 'portal-cat-anchor');
         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);
         anchor.setAttribute('aria-label', label);
         anchor.setAttribute('aria-label', item.label);
         anchor.setAttribute('data-category-key', item.key);
         anchor.setAttribute('data-category-key', item.key);


         polygon.setAttribute('class', 'portal-cat-shape');
         polygon.setAttribute('class', 'portal-cat-shape');
         polygon.setAttribute('points', pointsToString(getSegmentPoints(index, g)));
         polygon.setAttribute('points', pointsToString(getSegmentPoints(index, geometry)));
         polygon.setAttribute('fill', 'transparent');
         polygon.setAttribute('fill', 'transparent');
         polygon.setAttribute('pointer-events', 'all');
         polygon.setAttribute('pointer-events', 'all');


         text.setAttribute(
         text.setAttribute('class', 'portal-cat-label');
            'class',
         text.setAttribute('x', getTextX(index, geometry));
            item.key === 'project'
         text.setAttribute('y', Math.round(geometry.height / 2) + 1);
                ? 'portal-cat-label portal-cat-project-label'
                : 'portal-cat-label'
        );
 
         text.setAttribute('x', getTextX(index, g));
         text.setAttribute('y', g.height / 2 + 1);
         text.setAttribute('fill', '#e2e2e2');
         text.setAttribute('fill', '#e2e2e2');
         text.setAttribute('font-size', item.key === 'project' ? '11' : '10');
         text.setAttribute('font-size', '12');
         text.setAttribute('font-weight', '700');
         text.setAttribute('font-weight', '700');
         text.setAttribute('text-anchor', 'middle');
         text.setAttribute('text-anchor', 'middle');
380번째 줄: 161번째 줄:
         text.setAttribute('pointer-events', 'none');
         text.setAttribute('pointer-events', 'none');
         text.setAttribute('paint-order', 'stroke');
         text.setAttribute('paint-order', 'stroke');
         text.setAttribute('stroke', '#000000');
         text.setAttribute('stroke', '#050505');
         text.setAttribute('stroke-width', '2');
         text.setAttribute('stroke-width', '2');
         text.setAttribute('stroke-linejoin', 'round');
         text.setAttribute('stroke-linejoin', 'round');
         text.textContent = label;
         text.textContent = item.label;


         anchor.appendChild(polygon);
         anchor.appendChild(polygon);
391번째 줄: 172번째 줄:
     }
     }


     function createDivider(x1, y1, x2, y2, side) {
     function createDivider(index, geometry) {
         var line = createSvgElement('line');
         var line = createSvgElement('line');


         line.setAttribute('class', 'portal-cat-divider portal-cat-divider-' + side);
         line.setAttribute('class', 'portal-cat-divider');
         line.setAttribute('x1', x1);
         line.setAttribute('x1', geometry.top[index]);
         line.setAttribute('y1', y1);
         line.setAttribute('y1', 0);
         line.setAttribute('x2', x2);
         line.setAttribute('x2', geometry.bottom[index]);
         line.setAttribute('y2', y2);
         line.setAttribute('y2', geometry.height);
         line.setAttribute('stroke', '#050505');
         line.setAttribute('stroke', '#050505');
         line.setAttribute('stroke-width', '1');
         line.setAttribute('stroke-width', '1');
405번째 줄: 186번째 줄:


         return line;
         return line;
    }
    function appendDividers(svg, g) {
        var x = g.x;
        var c = g.cut;
        var h = g.height;
        /*
        왼쪽 3개 구분선: \ 방향
        */
        svg.appendChild(createDivider(x[1] - c, 0, x[1], h, 'left'));
        svg.appendChild(createDivider(x[2] - c, 0, x[2], h, 'left'));
        svg.appendChild(createDivider(x[3] - c, 0, x[3], h, 'left'));
        /*
        오른쪽 3개 구분선: / 방향
        */
        svg.appendChild(createDivider(x[4], 0, x[4] - c, h, 'right'));
        svg.appendChild(createDivider(x[5], 0, x[5] - c, h, 'right'));
        svg.appendChild(createDivider(x[6], 0, x[6] - c, h, 'right'));
     }
     }


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


     function buildSvg(width) {
     function buildSvg(width) {
         var lang = getCurrentLanguageSafe();
         var geometry = calculateGeometry(width);
        var g = calculateGeometry(width);
         var svg = createSvgElement('svg');
         var svg = createSvgElement('svg');
        var i;


         svg.setAttribute('class', 'portal-category-svg');
         svg.setAttribute('class', 'portal-category-svg');
         svg.setAttribute('viewBox', '0 0 ' + g.width + ' ' + g.height);
         svg.setAttribute('viewBox', '0 0 ' + geometry.width + ' ' + geometry.height);
         svg.setAttribute('width', '100%');
         svg.setAttribute('width', '100%');
         svg.setAttribute('height', '100%');
         svg.setAttribute('height', '100%');
445번째 줄: 204번째 줄:
         svg.setAttribute('aria-hidden', 'false');
         svg.setAttribute('aria-hidden', 'false');


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


         appendDividers(svg, g);
         for (i = 1; i < geometry.count; i++) {
            svg.appendChild(createDivider(i, geometry));
        }


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


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


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


         nav.__categoryNavRenderKey = result.key;
         nav.__categoryNavRenderKey = result.key;
471번째 줄: 229번째 줄:


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


         nav.__categoryNavFrame = window.requestAnimationFrame(function () {
         nav.__categoryNavFrame = window.requestAnimationFrame(function () {
499번째 줄: 255번째 줄:


         scheduleRender(nav, mount);
         scheduleRender(nav, mount);
 
         window.setTimeout(function () { scheduleRender(nav, mount); }, 80);
        /*
         window.setTimeout(function () { scheduleRender(nav, mount); }, 250);
        첫 렌더 직후 한 번 더 예약한다.
        MediaWiki/Liberty/SPA 환경에서는 본문이 삽입된 직후와
        실제 레이아웃 폭이 확정된 시점이 다를 수 있다.
        */
         window.setTimeout(function () {
            scheduleRender(nav, mount);
        }, 80);
 
         window.setTimeout(function () {
            scheduleRender(nav, mount);
        }, 250);


         if (window.ResizeObserver) {
         if (window.ResizeObserver) {

2026년 7월 11일 (토) 03:55 판

/* =========================================
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: '군_정치집단' },
        { key: 'personnel', label: '인물', title: '인물' }
    ];

    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 = 24;
        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
        };
    }

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

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

    function getSegmentPoints(index, geometry) {
        return [
            [geometry.top[index], 0],
            [geometry.top[index + 1], 0],
            [geometry.bottom[index + 1], geometry.height],
            [geometry.bottom[index], geometry.height]
        ];
    }

    function getTextX(index, geometry) {
        return Math.round((
            geometry.top[index] +
            geometry.top[index + 1] +
            geometry.bottom[index + 1] +
            geometry.bottom[index]
        ) / 4);
    }

    function createSegment(item, index, geometry) {
        var anchor = createSvgElement('a');
        var polygon = createSvgElement('polygon');
        var text = createSvgElement('text');
        var href = buildPath(item.title);

        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);

        polygon.setAttribute('class', 'portal-cat-shape');
        polygon.setAttribute('points', pointsToString(getSegmentPoints(index, geometry)));
        polygon.setAttribute('fill', 'transparent');
        polygon.setAttribute('pointer-events', 'all');

        text.setAttribute('class', 'portal-cat-label');
        text.setAttribute('x', getTextX(index, geometry));
        text.setAttribute('y', Math.round(geometry.height / 2) + 1);
        text.setAttribute('fill', '#e2e2e2');
        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.setAttribute('paint-order', 'stroke');
        text.setAttribute('stroke', '#050505');
        text.setAttribute('stroke-width', '2');
        text.setAttribute('stroke-linejoin', 'round');
        text.textContent = item.label;

        anchor.appendChild(polygon);
        anchor.appendChild(text);

        return anchor;
    }

    function createDivider(index, geometry) {
        var line = createSvgElement('line');

        line.setAttribute('class', 'portal-cat-divider');
        line.setAttribute('x1', geometry.top[index]);
        line.setAttribute('y1', 0);
        line.setAttribute('x2', geometry.bottom[index]);
        line.setAttribute('y2', geometry.height);
        line.setAttribute('stroke', '#050505');
        line.setAttribute('stroke-width', '1');
        line.setAttribute('pointer-events', 'none');
        line.setAttribute('vector-effect', 'non-scaling-stroke');

        return line;
    }

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

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

        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));
        });

        for (i = 1; i < geometry.count; i++) {
            svg.appendChild(createDivider(i, geometry));
        }

        return {
            svg: svg,
            key: geometry.width + '|' + geometry.height + '|' + geometry.cut + '|' + 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);
        });
    }
}());