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

(새 문서: →‎========================================= COASTLINE: BLACK ICE - CategoryNav 대문 카테고리 네비 DOM 생성기 =========================================: /* 설계 목적 ----------------------------------------- 이 파일은 대문 카테고리 네비를 MediaWiki 본문 위키문법이 아니라 JavaScript로 생성한다. 왜 JS를 쓰는가 ----------------------------------------- 테스트 단계에서는 다음 방식들을 시도했다. 1. 본문에 <span...)
 
편집 요약 없음
1번째 줄: 1번째 줄:
/* =========================================
/* =========================================
COASTLINE: BLACK ICE - CategoryNav
COASTLINE: BLACK ICE - CategoryNav
대문 카테고리 네비 DOM 생성기
대문 카테고리 네비 SVG 생성기
========================================= */
========================================= */


8번째 줄: 8번째 줄:
-----------------------------------------
-----------------------------------------
이 파일은 대문 카테고리 네비를 MediaWiki 본문 위키문법이 아니라
이 파일은 대문 카테고리 네비를 MediaWiki 본문 위키문법이 아니라
JavaScript로 생성한다.
JavaScript + SVG로 생성한다.


JS를 쓰는가
SVG를 쓰는가
-----------------------------------------
-----------------------------------------
테스트 단계에서는 다음 방식들을 시도했다.
CSS 박스 기반 구현에서 다음 문제가 반복되었다.


1. 본문에 <span class="dir-link">[[문서|라벨]]</span>직접 작성
1. span 안에 [[문서|라벨]]을 넣는 방식
   - MediaWiki가 내부에 <a>를 자동 생성한다.
   - MediaWiki가 내부에 <a>를 자동 생성한다.
   - span은 모양을 담당하고, a는 클릭을 담당하게 된다.
   - span은 모양, a는 클릭을 담당하게 된다.
   - 결과적으로 보이는 면, 호버 , 클릭 면이 서로 분리되었다.
   - 실제 클릭 영역, 호버 영역, 시각 형태가 서로 분리된다.


2. span에 clip-path를 주고, 검은 획은 span::after로 처리
2. <a class="...">를 본문에 직접 넣는 방식
   - clip-path가 ::after까지 같이 잘라먹었다.
   - MediaWiki 본문에서 안정적으로 먹히지 않았다.
   - 왼쪽 항목의 구분 획이 사라지거나, 일부만 보였다.
   - 위키 문법 처리와 충돌해 코드가 무효화되는 문제가 있었다.


3. 검은 획과 호버를 별도 overlay로 분리
3. clip-path + ::after 방식
   - 호버 좌표와 구분선 좌표를 직접 계산해야 했다.
   - clip-path가 검은 획 pseudo-element까지 잘라먹었다.
  - 프로젝트 버튼은 가운데 남은 공간을 차지하므로 폭이 유동적이다.
   - 왼쪽 항목의 획이 사라지거나 일부만 보였다.
   - 수동 좌표는 화면 폭이 바뀔 때 다시 틀어질 가능성이 높았다.


4. 버튼들을 음수 margin으로 겹침
4. 버튼을 음수 margin으로 겹치는 방식
   - 잘린 사선 영역의 공간은 메웠다.
   - 사선 빈 영역은 메웠지만, 호버가 옆 칸으로 넘어갔다.
  - 대신 호버 배경이 옆 칸으로 살짝 넘어갔다.


결론
5. hover overlay / divider overlay 방식
  - CSS grid, absolute 좌표, 프로젝트 가변폭이 서로 다른 기준을 가져서
    호버 면과 검은 획이 계속 어긋났다.
 
SVG 방식의 장점
-----------------------------------------
-----------------------------------------
카테고리 항목 하나가 다음 역할을 모두 맡아야 한다.
SVG에서는 각 항목을 polygon으로 만든다.
따라서 다음 요소들이 모두 같은 좌표계를 사용한다.


- 실제 링크
- 보이는 버튼 면
- 실제 클릭 영역
- 실제 hover 면
- 실제 호버 영역
- 실제 click 면
- 실제 사선 형태
- 검은 구분선


그러려면 JS가 <a class="portal-cat-link ...">를 직접 만들어야 한다.
즉, 이전처럼 사각형 박스를 사선처럼 보이게 흉내 내는 방식이 아니라,
CSS는 이 실제 a 요소에 clip-path와 hover를 적용한다.
처음부터 사선 polygon을 생성한다.


JS의 역할
JS의 책임
-----------------------------------------
-----------------------------------------
JS는 구조만 만든다.
JS는 구조와 좌표만 만든다.
색, 표면, 호버, 사선 모양은 MainPage.css가 담당한다.
색상, 호버 , 글자 스타일은 MainPage.css가 담당한다.
JS에서 좌표 계산이나 시각 보정은 하지 않는다.
 
좌표는 현재 nav 폭을 읽어서 계산한다.
프로젝트 버튼은 좌우 카테고리 3개씩을 제외한 남은 중앙 폭을 차지한다.
*/
*/


(function () {
(function () {
     'use strict';
     'use strict';
    var SVG_NS = 'http://www.w3.org/2000/svg';
    var XLINK_NS = 'http://www.w3.org/1999/xlink';


     /*
     /*
     기본 라벨과 링크
     기본 카테고리 구성
     -----------------------------------------
     -----------------------------------------
     여기서는 한국어를 기본값으로 둔다.
     현재는 한국어 대문 기준이다.
 
    나중에 다국어 구조가 안정되면
    window.LANG, window.CAT_LINKS 또는 별도 MainPageConfig.js에서
    라벨과 링크를 받아오게 확장할 수 있다.
 
    현재 Lang.js에는 일부 카테고리 라벨과 CAT_LINKS가 있지만,
    '설정'과 '프로젝트'처럼 이번 대문 네비에 새로 들어간 항목은
    기존 CAT_LINKS에 완전히 대응되어 있지 않을 수 있다.


     따라서 이 파일은 자체 fallback을 가진다.
     다국어 확장 시에는 여기의 label/title을 직접 바꾸기보다
    getLabel(), getTitle()에서 window.LANG, window.CAT_LINKS 또는
    별도 MainPageConfig.js를 읽게 하면 된다.
     */
     */


75번째 줄: 77번째 줄:
             label: '역사적 사건',
             label: '역사적 사건',
             title: '역사적_사건',
             title: '역사적_사건',
             className: 'portal-cat-left-start'
             type: 'left-start'
         },
         },
         {
         {
81번째 줄: 83번째 줄:
             label: '설정',
             label: '설정',
             title: '설정',
             title: '설정',
             className: 'portal-cat-left-mid'
             type: 'left-mid'
         },
         },
         {
         {
87번째 줄: 89번째 줄:
             label: '국가 및 조합',
             label: '국가 및 조합',
             title: '국가_및_조합',
             title: '국가_및_조합',
             className: 'portal-cat-left-end'
             type: 'left-end'
         },
         },
         {
         {
93번째 줄: 95번째 줄:
             label: '프로젝트',
             label: '프로젝트',
             title: '프로젝트:소개',
             title: '프로젝트:소개',
             className: 'portal-cat-project'
             type: 'project'
         },
         },
         {
         {
99번째 줄: 101번째 줄:
             label: '기업 및 공동체',
             label: '기업 및 공동체',
             title: '기업_및_공동체',
             title: '기업_및_공동체',
             className: 'portal-cat-right-start'
             type: 'right-start'
         },
         },
         {
         {
105번째 줄: 107번째 줄:
             label: '군, 정치집단',
             label: '군, 정치집단',
             title: '군_정치집단',
             title: '군_정치집단',
             className: 'portal-cat-right-mid'
             type: 'right-mid'
         },
         },
         {
         {
111번째 줄: 113번째 줄:
             label: '인물',
             label: '인물',
             title: '인물',
             title: '인물',
             className: 'portal-cat-right-end'
             type: 'right-end'
         }
         }
     ];
     ];
119번째 줄: 121번째 줄:
     -----------------------------------------
     -----------------------------------------
     기존 Common.js에 getCurrentLang() 함수가 있을 수 있다.
     기존 Common.js에 getCurrentLang() 함수가 있을 수 있다.
     하지만 이 파일은 단독으로 로드되어도 깨지지 않아야 하므로
     이 파일은 단독으로도 깨지지 않아야 하므로 fallback을 둔다.
    함수 존재 여부를 확인하고 fallback을 둔다.
     */
     */


138번째 줄: 139번째 줄:


     /*
     /*
     링크 생성
     경로 생성
     -----------------------------------------
     -----------------------------------------
     MediaWiki 내부 링크는 /index.php/문서명 형식으로 맞춘다.
     기존 Common.js에 buildWikiPath(title)가 있으면 그것을 사용한다.
 
     없으면 /index.php/문서명 형식으로 fallback한다.
    buildWikiPath()가 이미 전역에 있으면 그것을 사용한다.
     기존 Common.js에는 buildWikiPath(title) 함수가 존재할 수 있으므로
    중복 구현을 피한다.
 
    단, 이 파일은 단독 테스트도 가능해야 하므로 fallback도 제공한다.
     */
     */


160번째 줄: 156번째 줄:
     라벨 선택
     라벨 선택
     -----------------------------------------
     -----------------------------------------
     지금은 한국어 대문 테스트를 우선한다.
     현재는 한국어를 기준으로 둔다.


     향후 다국어 확장 시에는 이 함수에서
     기존 LANG.js와 CAT_LINKS에는 모든 새 항목이 정리되어 있지 않을 수 있다.
     window.LANG[lang][key] 또는 별도 navLabels를 읽으면 된다.
     예를 들어 '설정', '프로젝트'는 기존 세계관 카테고리 세트와 다르다.


     현재는 기존 LANG 키와 완전히 맞지 않는 항목이 있으므로,
     부분 번역을 섞으면 언어별 네비 폭과 의미가 흔들릴 수 있으므로,
     섣불리 LANG만 신뢰하지 않는다.
     다국어 구조가 확정되기 전까지는 fallback label을 유지한다.
     */
     */


173번째 줄: 169번째 줄:
             return item.label;
             return item.label;
         }
         }
        return item.label;
    }
    /*
    좌표 계산
    -----------------------------------------
    이상적인 카테고리 폭은 163px이다.
    이것은 이전 테스트에서 '프로젝트를 제외한 버튼 폭 10% 증가'를 반영한 값이다.
    다만 실제 본문 폭이 좁으면 163px 6개 + 프로젝트 최소폭이 들어가지 않는다.
    그럴 때는 cellW를 줄여서 전체가 깨지지 않게 한다.
    계산 원칙:
    - 좌측 3개와 우측 3개는 같은 폭이다.
    - 프로젝트는 남은 중앙 폭을 차지한다.
    - 사선 cut 값은 cellW에 따라 너무 커지거나 작아지지 않게 제한한다.
    */
    function calculateGeometry(width) {
        var h = 30;
        var idealCell = 163;
        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;
        }
        /*
        projectW를 보정했을 경우 전체 폭이 넘칠 수 있다.
        그 상황에서는 viewBox를 전체 필요 폭으로 잡고,
        SVG가 가로로 스케일되게 둔다.
        일반 대문 폭에서는 이 분기가 거의 발생하지 않는다.
        */
        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 /
    오른쪽군: / / /
    좌표 설명:
    - \ 경계는 위쪽 x가 왼쪽, 아래쪽 x가 오른쪽이다.
    - / 경계는 위쪽 x가 오른쪽, 아래쪽 x가 왼쪽이다.
    예시:
    왼쪽 첫 경계 \ :
    top    x1 - cut
    bottom x1
    오른쪽 첫 경계 / :
    top    x4
    bottom x4 - cut
    */
    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);
    }
    /*
    SVG 링크 생성
    -----------------------------------------
    SVG <a> 안에 polygon과 text를 함께 넣는다.
    polygon이 hover/click 면이고, text는 pointer-events:none으로 둔다.
    */
    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)));
        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.textContent = label;
        anchor.appendChild(polygon);
        anchor.appendChild(text);
        return anchor;
    }
    /*
    구분선 생성
    -----------------------------------------
    polygon 경계와 같은 x 값을 사용해 line을 만든다.
    그러므로 호버 면과 검은 획이 다른 좌표계를 쓰지 않는다.
    */
    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);
        return line;
    }
    function appendDividers(svg, g) {
        var x = g.x;
        var c = g.cut;
        var h = g.height;


         /*
         /*
         다국어 임시 fallback.
         왼쪽 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개 구분선: / 방향
        - 오른쪽 보조 라벨에 영어를 넣었다가 다국어 일관성 문제가 생겼던 것처럼,
          아직 전체 번역 체계가 확정되지 않은 상태에서는 부분 번역을 피한다.
         */
         */
         return item.label;
         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);
        }
     }
     }


     /*
     /*
     단일 mount 렌더링
     실제 SVG 렌더링
     -----------------------------------------
     -----------------------------------------
     data-component="category-nav"가 붙은 요소를 찾아
     nav의 현재 폭을 읽고, 그 폭에 맞춰 viewBox와 polygon을 재생성한다.
     내부를 비우고 nav를 생성한다.
     ResizeObserver가 이 함수를 다시 호출하면 폭 변화에도 대응한다.
    */


     같은 페이지에서 SPA 이동이나 wikipage.content 훅으로
     function renderSvgIntoNav(nav) {
    여러 번 호출될 수 있으므로 매번 내부를 재생성해도 안전하게 한다.
        var rect = nav.getBoundingClientRect();
    */
        var measuredWidth = Math.floor(rect.width || nav.clientWidth || 0);


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


         var lang = getCurrentLanguageSafe();
         var lang = getCurrentLanguageSafe();
         var nav = document.createElement('nav');
         var g = calculateGeometry(measuredWidth);
        var svg = createSvgElement('svg');
 
        svg.setAttribute('class', 'portal-category-svg');
        svg.setAttribute('viewBox', '0 0 ' + g.width + ' ' + g.height);
        svg.setAttribute('width', g.width);
        svg.setAttribute('height', g.height);
        svg.setAttribute('preserveAspectRatio', 'none');
        svg.setAttribute('aria-hidden', 'false');


         nav.className = 'portal-category-nav';
         CATEGORY_NAV_ITEMS.forEach(function (item, index) {
        nav.setAttribute('aria-label', 'Main categories');
            svg.appendChild(createSegment(item, index, g, lang));
        });


         CATEGORY_NAV_ITEMS.forEach(function (item) {
         appendDividers(svg, g);
            var link = document.createElement('a');


            link.className = 'portal-cat-link ' + item.className;
        clearNode(nav);
            link.href = buildPath(item.title);
        nav.appendChild(svg);
            link.textContent = getLabel(item, lang);
    }


            /*
    function scheduleRender(nav) {
            data-key는 CSS가 아니라 디버깅과 향후 다국어 갱신용이다.
        if (!nav || nav.__categoryNavFrame) {
             나중에 라벨만 다시 바꿔야 할 때 이 key를 기준으로 찾을 수 있다.
             return;
            */
        }
            link.setAttribute('data-category-key', item.key);


             nav.appendChild(link);
        nav.__categoryNavFrame = window.requestAnimationFrame(function () {
             nav.__categoryNavFrame = null;
            renderSvgIntoNav(nav);
         });
         });
        mount.innerHTML = '';
        mount.appendChild(nav);
        mount.setAttribute('data-category-nav-rendered', '1');
     }
     }


     /*
     /*
     전체 렌더링
     mount 렌더링
     -----------------------------------------
     -----------------------------------------
     대문 본문에는 아래처럼 자리만 남긴다.
     본문에는 다음 자리만 둔다.


     <div data-component="category-nav"></div>
     <div data-component="category-nav"></div>


     이 함수는 mount를 모두 찾아 렌더링한다.
     JS는 안에 nav.portal-category-nav를 만들고,
    SVG를 렌더링한다.
     */
     */
    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);
        /*
        nav 폭이 바뀌면 SVG 좌표를 다시 계산한다.
        이것이 CSS absolute overlay 방식과 다른 점이다.
        폭 변화가 생겨도 JS가 polygon 좌표를 다시 맞춘다.
        */
        if (window.ResizeObserver) {
            var observer = new ResizeObserver(function () {
                scheduleRender(nav);
            });
            observer.observe(nav);
            mount.__categoryNavObserver = observer;
        } else {
            window.addEventListener('resize', function () {
                scheduleRender(nav);
            });
        }
    }


     function renderAllCategoryNavs(root) {
     function renderAllCategoryNavs(root) {
248번째 줄: 539번째 줄:
     초기화
     초기화
     -----------------------------------------
     -----------------------------------------
     1. 일반 페이지 로드 시 DOMContentLoaded에서 실행한다.
     1. 일반 페이지 로드 시 실행
     2. MediaWiki SPA/동적 본문 갱신을 고려해 wikipage.content 훅에서도 실행한다.
     2. MediaWiki SPA/동적 본문 갱신 시 실행
     3. 전역 객체에도 init 함수를 노출해 디버깅이나 수동 재실행이 가능하게 한다.
     3. 필요할 때 window.CLBI.categoryNav.init()으로 수동 재실행 가능
     */
     */



2026년 5월 19일 (화) 07:46 판

/* =========================================
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을 생성한다.

JS의 책임
-----------------------------------------
JS는 구조와 좌표만 만든다.
색상, 호버 색, 글자 스타일은 MainPage.css가 담당한다.

좌표는 현재 nav 폭을 읽어서 계산한다.
프로젝트 버튼은 좌우 카테고리 3개씩을 제외한 남은 중앙 폭을 차지한다.
*/

(function () {
    'use strict';

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

    /*
    기본 카테고리 구성
    -----------------------------------------
    현재는 한국어 대문 기준이다.

    다국어 확장 시에는 여기의 label/title을 직접 바꾸기보다
    getLabel(), getTitle()에서 window.LANG, window.CAT_LINKS 또는
    별도 MainPageConfig.js를 읽게 하면 된다.
    */

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

    /*
    언어 감지
    -----------------------------------------
    기존 Common.js에 getCurrentLang() 함수가 있을 수 있다.
    이 파일은 단독으로도 깨지지 않아야 하므로 fallback을 둔다.
    */

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

    /*
    경로 생성
    -----------------------------------------
    기존 Common.js에 buildWikiPath(title)가 있으면 그것을 사용한다.
    없으면 /index.php/문서명 형식으로 fallback한다.
    */

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

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

    /*
    라벨 선택
    -----------------------------------------
    현재는 한국어를 기준으로 둔다.

    기존 LANG.js와 CAT_LINKS에는 모든 새 항목이 정리되어 있지 않을 수 있다.
    예를 들어 '설정', '프로젝트'는 기존 세계관 카테고리 세트와 다르다.

    부분 번역을 섞으면 언어별 네비 폭과 의미가 흔들릴 수 있으므로,
    다국어 구조가 확정되기 전까지는 fallback label을 유지한다.
    */

    function getLabel(item, lang) {
        if (lang === 'ko') {
            return item.label;
        }

        return item.label;
    }

    /*
    좌표 계산
    -----------------------------------------
    이상적인 카테고리 폭은 163px이다.
    이것은 이전 테스트에서 '프로젝트를 제외한 버튼 폭 10% 증가'를 반영한 값이다.

    다만 실제 본문 폭이 좁으면 163px 6개 + 프로젝트 최소폭이 들어가지 않는다.
    그럴 때는 cellW를 줄여서 전체가 깨지지 않게 한다.

    계산 원칙:
    - 좌측 3개와 우측 3개는 같은 폭이다.
    - 프로젝트는 남은 중앙 폭을 차지한다.
    - 사선 cut 값은 cellW에 따라 너무 커지거나 작아지지 않게 제한한다.
    */

    function calculateGeometry(width) {
        var h = 30;
        var idealCell = 163;
        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;
        }

        /*
        projectW를 보정했을 경우 전체 폭이 넘칠 수 있다.
        그 상황에서는 viewBox를 전체 필요 폭으로 잡고,
        SVG가 가로로 스케일되게 둔다.
        일반 대문 폭에서는 이 분기가 거의 발생하지 않는다.
        */
        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 /
    오른쪽군: / / /

    좌표 설명:
    - \ 경계는 위쪽 x가 왼쪽, 아래쪽 x가 오른쪽이다.
    - / 경계는 위쪽 x가 오른쪽, 아래쪽 x가 왼쪽이다.

    예시:
    왼쪽 첫 경계 \ :
    top    x1 - cut
    bottom x1

    오른쪽 첫 경계 / :
    top    x4
    bottom x4 - cut
    */

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

    /*
    SVG 링크 생성
    -----------------------------------------
    SVG <a> 안에 polygon과 text를 함께 넣는다.
    polygon이 hover/click 면이고, text는 pointer-events:none으로 둔다.
    */

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

        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.textContent = label;

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

        return anchor;
    }

    /*
    구분선 생성
    -----------------------------------------
    polygon 경계와 같은 x 값을 사용해 line을 만든다.
    그러므로 호버 면과 검은 획이 다른 좌표계를 쓰지 않는다.
    */

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

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

    /*
    실제 SVG 렌더링
    -----------------------------------------
    nav의 현재 폭을 읽고, 그 폭에 맞춰 viewBox와 polygon을 재생성한다.
    ResizeObserver가 이 함수를 다시 호출하면 폭 변화에도 대응한다.
    */

    function renderSvgIntoNav(nav) {
        var rect = nav.getBoundingClientRect();
        var measuredWidth = Math.floor(rect.width || nav.clientWidth || 0);

        if (!measuredWidth) {
            return;
        }

        var lang = getCurrentLanguageSafe();
        var g = calculateGeometry(measuredWidth);
        var svg = createSvgElement('svg');

        svg.setAttribute('class', 'portal-category-svg');
        svg.setAttribute('viewBox', '0 0 ' + g.width + ' ' + g.height);
        svg.setAttribute('width', g.width);
        svg.setAttribute('height', g.height);
        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);

        clearNode(nav);
        nav.appendChild(svg);
    }

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

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

    /*
    mount 렌더링
    -----------------------------------------
    본문에는 다음 자리만 둔다.

    <div data-component="category-nav"></div>

    JS는 그 안에 nav.portal-category-nav를 만들고,
    SVG를 렌더링한다.
    */

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

        /*
        nav 폭이 바뀌면 SVG 좌표를 다시 계산한다.
        이것이 CSS absolute overlay 방식과 다른 점이다.
        폭 변화가 생겨도 JS가 polygon 좌표를 다시 맞춘다.
        */
        if (window.ResizeObserver) {
            var observer = new ResizeObserver(function () {
                scheduleRender(nav);
            });

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

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

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

    /*
    초기화
    -----------------------------------------
    1. 일반 페이지 로드 시 실행
    2. MediaWiki SPA/동적 본문 갱신 시 실행
    3. 필요할 때 window.CLBI.categoryNav.init()으로 수동 재실행 가능
    */

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