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

편집 요약 없음
편집 요약 없음
17번째 줄: 17번째 줄:
   - MediaWiki가 내부에 <a>를 자동 생성한다.
   - MediaWiki가 내부에 <a>를 자동 생성한다.
   - span은 모양을 담당하고, a는 클릭을 담당하게 된다.
   - span은 모양을 담당하고, a는 클릭을 담당하게 된다.
   - 실제 클릭 영역, 호버 영역, 시각 형태가 서로 분리된다.
   - 실제 클릭 영역, 호버 영역, 시각 형태가 서로 분리되었다.


2. <a class="...">를 본문에 직접 넣는 방식
2. <a class="...">를 본문에 직접 넣는 방식
49번째 줄: 49번째 줄:
이번 수정
이번 수정
-----------------------------------------
-----------------------------------------
이전 버전에서는 mount를 비우고 nav를 만든 직후 nav 폭을 측정했다.
이전 버전은 mount를 비우고 nav를 만든 직후 nav 폭을 측정했다.
MediaWiki/SPA 환경에서는 그 순간 nav 폭이 0으로 잡힐 수 있다.


MediaWiki/SPA 환경에서는 순간 nav 폭이 0으로 잡힐 수 있다.
결과 nav 배경만 남고 SVG가 만들어지지 않아 검은 줄처럼 보였다.
그 상태에서 SVG 생성을 return하면 빈 nav만 남아 카테고리 네비가 사라진다.


이번 버전은 다음을 적용한다.
이번 버전은 다음을 적용한다.


1. 폭이 0이면 바로 포기하지 않고 재시도한다.
1. nav 폭이 0이어도 렌더를 포기하지 않는다.
2. nav 자체 폭이 0이면 mount, 부모, main-portal 폭까지 fallback으로 확인한다.
2. mount, 부모, main-portal, liberty-content, viewport 순서로 폭을 fallback한다.
3. 새 SVG가 완성되기 전에는 기존 SVG를 지우지 않는다.
3. 그래도 폭이 없으면 임시 1200px 기준으로 렌더한다.
4. 같은 폭으로 이미 렌더링된 경우에는 불필요하게 다시 그리지 않는다.
4. ResizeObserver가 실제 폭을 감지하면 SVG를 다시 만든다.
5. SVG 텍스트에 CSS 클래스뿐 아니라 fill/font 속성도 직접 넣어,
  CSS 로딩 순서가 늦어도 최소한 글자는 보이게 한다.
*/
*/


72번째 줄: 74번째 줄:
             key: 'history',
             key: 'history',
             label: '역사적 사건',
             label: '역사적 사건',
             title: '역사적_사건',
             title: '역사적_사건'
            type: 'left-start'
         },
         },
         {
         {
             key: 'settings',
             key: 'settings',
             label: '설정',
             label: '설정',
             title: '설정',
             title: '설정'
            type: 'left-mid'
         },
         },
         {
         {
             key: 'nations',
             key: 'nations',
             label: '국가 및 조합',
             label: '국가 및 조합',
             title: '국가_및_조합',
             title: '국가_및_조합'
            type: 'left-end'
         },
         },
         {
         {
             key: 'project',
             key: 'project',
             label: '프로젝트',
             label: '프로젝트',
             title: '프로젝트:소개',
             title: '프로젝트:소개'
            type: 'project'
         },
         },
         {
         {
             key: 'corporations',
             key: 'corporations',
             label: '기업 및 공동체',
             label: '기업 및 공동체',
             title: '기업_및_공동체',
             title: '기업_및_공동체'
            type: 'right-start'
         },
         },
         {
         {
             key: 'military',
             key: 'military',
             label: '군, 정치집단',
             label: '군, 정치집단',
             title: '군_정치집단',
             title: '군_정치집단'
            type: 'right-mid'
         },
         },
         {
         {
             key: 'personnel',
             key: 'personnel',
             label: '인물',
             label: '인물',
             title: '인물',
             title: '인물'
            type: 'right-end'
         }
         }
     ];
     ];
136번째 줄: 131번째 줄:


     function getLabel(item, lang) {
     function getLabel(item, lang) {
         if (lang === 'ko') {
         /*
            return item.label;
        현재는 한국어 대문 테스트를 기준으로 한다.
        }


        /*
         다국어 구조가 확정되기 전까지는 한국어 fallback을 유지한다.
         다국어 구조가 확정되기 전까지는 한국어 fallback을 유지한다.
         부분 번역을 섞으면 네비 폭과 의미가 언어별로 흔들릴 수 있다.
         일부 항목만 번역하면 네비 폭과 의미가 언어별로 흔들릴 수 있다.
         */
         */
         return item.label;
         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;
        if (!width && el.clientWidth) {
            width = Math.floor(el.clientWidth);
        }
        return width || 0;
     }
     }


150번째 줄: 156번째 줄:
     폭 측정
     폭 측정
     -----------------------------------------
     -----------------------------------------
     nav.getBoundingClientRect().width가 0일 수 있으므로
     nav 자체 폭이 0이면 상위 요소들을 차례로 확인한다.
    여러 단계로 fallback을 둔다.


     이 함수가 0을 반환하면 아직 레이아웃이 완성되지 않은 상태로 보고
    마지막에는 viewport 폭을 사용한다.
     렌더링을 잠시 미룬다.
     이 fallback은 '검은 줄만 보이는 상태'를 막기 위한 안전장치다.
     실제 레이아웃 폭이 나중에 잡히면 ResizeObserver가 다시 렌더링한다.
     */
     */


163번째 줄: 169번째 줄:
             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'),
            document.querySelector('.liberty-content')
         ];
         ];


         for (var i = 0; i < candidates.length; i++) {
         for (var i = 0; i < candidates.length; i++) {
             var el = candidates[i];
             var width = readWidth(candidates[i]);
 
            if (!el) continue;
 
            var rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
            var width = rect ? Math.floor(rect.width || 0) : 0;
 
            if (!width && el.clientWidth) {
                width = Math.floor(el.clientWidth);
            }


             if (width > 0) {
             if (width > 0) {
184번째 줄: 183번째 줄:
         }
         }


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


353번째 줄: 356번째 줄:
         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, g)));
        polygon.setAttribute('fill', 'transparent');


         text.setAttribute(
         text.setAttribute(
363번째 줄: 367번째 줄:
         text.setAttribute('x', getTextX(index, g));
         text.setAttribute('x', getTextX(index, g));
         text.setAttribute('y', g.height / 2 + 1);
         text.setAttribute('y', g.height / 2 + 1);
        text.setAttribute('fill', '#e2e2e2');
        text.setAttribute('font-size', item.key === 'project' ? '11' : '10');
        text.setAttribute('font-weight', '700');
        text.setAttribute('text-anchor', 'middle');
        text.setAttribute('dominant-baseline', 'middle');
        text.setAttribute('pointer-events', 'none');
         text.textContent = label;
         text.textContent = label;


379번째 줄: 389번째 줄:
         line.setAttribute('x2', x2);
         line.setAttribute('x2', x2);
         line.setAttribute('y2', y2);
         line.setAttribute('y2', y2);
        line.setAttribute('stroke', '#050505');
        line.setAttribute('stroke-width', '1');
        line.setAttribute('pointer-events', 'none');
        line.setAttribute('vector-effect', 'non-scaling-stroke');


         return line;
         return line;
402번째 줄: 416번째 줄:
         }
         }
     }
     }
    /*
    SVG 생성
    -----------------------------------------
    기존 SVG를 즉시 지우지 않고, 새 SVG를 완성한 뒤 교체한다.
    폭 측정이 실패하면 빈 상태로 만들지 않고 재시도한다.
    */


     function buildSvg(width) {
     function buildSvg(width) {
430번째 줄: 437번째 줄:
         return {
         return {
             svg: svg,
             svg: svg,
             key: g.width + '|' + g.height + '|' + g.cellW + '|' + g.projectW + '|' + g.cut
             key: width + '|' + g.width + '|' + g.height + '|' + g.cellW + '|' + g.projectW + '|' + g.cut
         };
         };
     }
     }


     function renderSvgIntoNav(nav, mount, attempt) {
     function renderSvgIntoNav(nav, mount) {
         var measuredWidth = getRenderableWidth(nav, mount);
         var measuredWidth = getRenderableWidth(nav, mount);
        var retry = typeof attempt === 'number' ? attempt : 0;
        if (!measuredWidth) {
            if (retry < 12) {
                window.setTimeout(function () {
                    renderSvgIntoNav(nav, mount, retry + 1);
                }, 80);
            }
            return;
        }
         var result = buildSvg(measuredWidth);
         var result = buildSvg(measuredWidth);


466번째 줄: 461번째 줄:
         nav.__categoryNavFrame = window.requestAnimationFrame(function () {
         nav.__categoryNavFrame = window.requestAnimationFrame(function () {
             nav.__categoryNavFrame = null;
             nav.__categoryNavFrame = null;
             renderSvgIntoNav(nav, mount, 0);
             renderSvgIntoNav(nav, mount);
         });
         });
     }
     }
488번째 줄: 483번째 줄:


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


         if (window.ResizeObserver) {
         if (window.ResizeObserver) {
495번째 줄: 503번째 줄:


             observer.observe(mount);
             observer.observe(mount);
            observer.observe(nav);
             mount.__categoryNavObserver = observer;
             mount.__categoryNavObserver = observer;
         } else {
         } else {

2026년 5월 19일 (화) 08:00 판

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

/*
설계 목적
-----------------------------------------
이 파일은 대문 카테고리 네비를 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을 생성한다.

이번 수정
-----------------------------------------
이전 버전은 mount를 비우고 nav를 만든 직후 nav 폭을 측정했다.
MediaWiki/SPA 환경에서는 그 순간 nav 폭이 0으로 잡힐 수 있다.

그 결과 nav 배경만 남고 SVG가 만들어지지 않아 검은 줄처럼 보였다.

이번 버전은 다음을 적용한다.

1. nav 폭이 0이어도 렌더를 포기하지 않는다.
2. mount, 부모, main-portal, liberty-content, viewport 순서로 폭을 fallback한다.
3. 그래도 폭이 없으면 임시 1200px 기준으로 렌더한다.
4. ResizeObserver가 실제 폭을 감지하면 SVG를 다시 만든다.
5. SVG 텍스트에 CSS 클래스뿐 아니라 fill/font 속성도 직접 넣어,
   CSS 로딩 순서가 늦어도 최소한 글자는 보이게 한다.
*/

(function () {
    'use strict';

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

    var CATEGORY_NAV_ITEMS = [
        {
            key: 'history',
            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) {
        if (typeof window.buildWikiPath === 'function') {
            return window.buildWikiPath(title);
        }

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

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

        다국어 구조가 확정되기 전까지는 한국어 fallback을 유지한다.
        일부 항목만 번역하면 네비 폭과 의미가 언어별로 흔들릴 수 있다.
        */
        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;

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

        return width || 0;
    }

    /*
    폭 측정
    -----------------------------------------
    nav 자체 폭이 0이면 상위 요소들을 차례로 확인한다.

    마지막에는 viewport 폭을 사용한다.
    이 fallback은 '검은 줄만 보이는 상태'를 막기 위한 안전장치다.
    실제 레이아웃 폭이 나중에 잡히면 ResizeObserver가 다시 렌더링한다.
    */

    function getRenderableWidth(nav, mount) {
        var candidates = [
            nav,
            mount,
            mount ? mount.parentElement : 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'),
            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;
    }

    /*
    좌표 계산
    -----------------------------------------
    프로젝트를 제외한 6개 버튼의 기준 폭은 188px이다.
    163px 기준에서 15% 확대한 값이다.

    163 * 1.15 = 187.45 이므로 실제 기준은 188px로 둔다.
    */

    function calculateGeometry(width) {
        var h = 30;
        var idealCell = 188;
        var minCell = 96;
        var minProject = 180;
        var usableWidth = Math.max(320, width);

        var cellW = idealCell;

        if ((cellW * 6) + minProject > usableWidth) {
            cellW = Math.floor((usableWidth - minProject) / 6);
            cellW = Math.max(minCell, cellW);
        }

        var projectW = usableWidth - (cellW * 6);

        if (projectW < 100) {
            projectW = 100;
        }

        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 {
            width: totalW,
            height: h,
            cellW: cellW,
            projectW: projectW,
            cut: cut,
            x: [x0, x1, x2, x3, x4, x5, x6, x7]
        };
    }

    /*
    polygon 좌표
    -----------------------------------------
    요구 형태:
    왼쪽군:   \ \ \
    프로젝트: \ PROJECT /
    오른쪽군: / / /
    */

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

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

    function getTextX(index, g) {
        var x = g.x;

        if (index === 0) return (x[0] + x[1]) / 2;
        if (index === 1) return (x[1] + x[2]) / 2;
        if (index === 2) return (x[2] + x[3]) / 2;
        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) {
        return document.createElementNS(SVG_NS, name);
    }

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

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

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

        text.setAttribute(
            'class',
            item.key === 'project'
                ? '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('font-size', item.key === 'project' ? '11' : '10');
        text.setAttribute('font-weight', '700');
        text.setAttribute('text-anchor', 'middle');
        text.setAttribute('dominant-baseline', 'middle');
        text.setAttribute('pointer-events', 'none');
        text.textContent = label;

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

        return anchor;
    }

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

        line.setAttribute('class', 'portal-cat-divider portal-cat-divider-' + side);
        line.setAttribute('x1', x1);
        line.setAttribute('y1', y1);
        line.setAttribute('x2', x2);
        line.setAttribute('y2', y2);
        line.setAttribute('stroke', '#050505');
        line.setAttribute('stroke-width', '1');
        line.setAttribute('pointer-events', 'none');
        line.setAttribute('vector-effect', 'non-scaling-stroke');

        return line;
    }

    function appendDividers(svg, g) {
        var x = g.x;
        var c = g.cut;
        var h = g.height;

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

        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) {
        while (node.firstChild) {
            node.removeChild(node.firstChild);
        }
    }

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

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

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

        appendDividers(svg, g);

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

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

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

        /*
        첫 렌더 직후 한 번 더 예약한다.
        MediaWiki/Liberty/SPA 환경에서는 본문이 삽입된 직후와
        실제 레이아웃 폭이 확정된 시점이 다를 수 있다.
        */
        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);
        });
    }
}());