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

편집 요약 없음
태그: 수동 되돌리기
(Install package: clbiwiki-portal-section-fit-20260721-008 / js/CategoryNav.js)
 
(같은 사용자의 중간 판 20개는 보이지 않습니다)
1번째 줄: 1번째 줄:
/* =========================================
/* =========================================
COASTLINE: BLACK ICE - CategoryNav
COASTLINE: BLACK ICE - CategoryNav
대문 카테고리 네비 SVG 생성기
대문 프레임 공통 기하 모델과 상단 카테고리 SVG 네비
========================================= */
========================================= */


/*
/*
설계 목적
새 공통 기능에는 프로젝트 접두사를 사용하지 않는다.
-----------------------------------------
window.CLBI.categoryNav는 기존 Common.js 호출과의 호환을 위해서만 유지한다.
이 파일은 대문 카테고리 네비를 MediaWiki 본문 위키문법이 아니라
JavaScript + SVG로 생성한다.


왜 SVG를 쓰는가
이 파일은 대문 프레임 기하의 단일 소유자다.
-----------------------------------------
CategoryPillar.js, PortalSectionNav.js, BottomGuideNav.js는 SVG DOM을 다시 파싱하지 않고
CSS 박스 기반 구현에서 다음 문제가 반복되었다.
window.PortalFrame이 제공하는 동일한 경계와 좌표 변환만 사용한다.
 
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 () {
(function (window, document, mw) {
     'use strict';
     'use strict';


     var SVG_NS = 'http://www.w3.org/2000/svg';
     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 FRAME_BUILD = '20260721-portal-frame-section-fit-008';


     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: '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();
    cut은 폭에 따라 변하지 않는다. 1880px 디자인 폭과 축소 셸에서 같은 경사를 유지한다.
         }
    */
 
    var SPEC = Object.freeze ? Object.freeze({
         var langData = document.getElementById('clbi-lang-data');
         buttonHeight:22,
 
        gap:2,
         if (langData) {
        cut:10,
            return langData.getAttribute('data-lang') || 'ko';
         outerBorder:1,
         }
        insetLineOffset:1.5,
 
         count:ITEMS.length
         return 'ko';
    }) : {
     }
        buttonHeight:22,
         gap:2,
        cut:10,
         outerBorder:1,
        insetLineOffset:1.5,
         count:ITEMS.length
     };


     function buildPath(title) {
     function buildPath(title) {
126번째 줄: 52번째 줄:
             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 getLabel(item, lang) {
     function readWidth(element) {
         /*
         var rect;
        현재는 한국어 대문 테스트를 기준으로 한다.
         var width;
 
        다국어 구조가 확정되기 전까지는 한국어 fallback을 유지한다.
        일부 항목만 번역하면 네비 폭과 의미가 언어별로 흔들릴 수 있다.
        */
        return item.label;
    }
 
    function readWidth(el) {
         if (!el) return 0;


         var rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
         if (!element) return 0;
         var width = rect ? Math.floor(rect.width || 0) : 0;
        rect = element.getBoundingClientRect ? element.getBoundingClientRect() : null;
 
         width = rect ? Math.floor(rect.width || 0) : 0;
        if (!width && el.clientWidth) {
            width = Math.floor(el.clientWidth);
        }


        if (!width && element.clientWidth) width = Math.floor(element.clientWidth);
         return width || 0;
         return width || 0;
     }
     }
    /*
    폭 측정
    -----------------------------------------
    nav 자체 폭이 0이면 상위 요소들을 차례로 확인한다.
    마지막에는 viewport 폭을 사용한다.
    이 fallback은 '검은 줄만 보이는 상태'를 막기 위한 안전장치다.
    실제 레이아웃 폭이 나중에 잡히면 ResizeObserver가 다시 렌더링한다.
    */


     function getRenderableWidth(nav, mount) {
     function getRenderableWidth(nav, mount) {
168번째 줄: 72번째 줄:
             mount,
             mount,
             mount ? mount.parentElement : null,
             mount ? mount.parentElement : null,
             mount ? mount.closest('.main-portal') : null,
             mount && mount.closest ? 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')
             document.querySelector('.liberty-content')
         ];
         ];
        var i;
        var width;


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


186번째 줄: 88번째 줄:
             return Math.max(320, Math.floor(document.documentElement.clientWidth - 48));
             return Math.max(320, Math.floor(document.documentElement.clientWidth - 48));
         }
         }
         return 1200;
         return 1200;
     }
     }


     /*
     function itemIndex(keyOrIndex) {
    좌표 계산
        var key;
    -----------------------------------------
        var i;
    프로젝트를 제외한 6개 버튼의 기준 폭은 188px이다.
 
    163px 기준에서 15% 확대한 값이다.
        if (typeof keyOrIndex === 'number' && isFinite(keyOrIndex)) {
            i = Math.floor(keyOrIndex);
            return i >= 0 && i < ITEMS.length ? i : -1;
        }


    163 * 1.15 = 187.45 이므로 실제 기준은 188px로 둔다.
        key = String(keyOrIndex || '');
     */
        for (i = 0; i < ITEMS.length; i++) {
            if (ITEMS[i].key === key) return i;
        }
        return -1;
     }


     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 top = [];
         var minProject = 180;
         var bottom = [];
         var usableWidth = Math.max(320, width);
         var center = count / 2;
        var i;
        var x;


         var cellW = idealCell;
         /*
        사선은 버튼의 속성이 아니라 count + 1개의 공유 경계의 속성이다.
        버튼 i의 우변과 버튼 i+1의 좌변은 반드시 같은 경계를 사용한다.
        */
        for (i = 0; i <= count; i++) {
            x = Math.round((usableWidth * i) / count);


        if ((cellW * 6) + minProject > usableWidth) {
            if (i === 0 || i === count) {
            cellW = Math.floor((usableWidth - minProject) / 6);
                top.push(x);
             cellW = Math.max(minCell, cellW);
                bottom.push(x);
            } else if (i < center) {
                top.push(x - SPEC.cut);
                bottom.push(x);
             } else {
                top.push(x);
                bottom.push(x - SPEC.cut);
            }
         }
         }


         var projectW = usableWidth - (cellW * 6);
         return {
            version:FRAME_BUILD,
            width:usableWidth,
            height:SPEC.buttonHeight,
            count:count,
            cut:SPEC.cut,
            gap:SPEC.gap,
            top:top,
            bottom:bottom
        };
    }
 
    function clonePoints(points) {
        return (points || []).map(function (point) {
            return [Number(point[0]), Number(point[1])];
        });
    }


         if (projectW < 100) {
    function getSegmentPoints(keyOrIndex, geometry) {
            projectW = 100;
        var index = itemIndex(keyOrIndex);
         }
        var halfGap;
        var leftOffset;
        var rightOffset;
 
         if (!geometry || index < 0 || index >= geometry.count) return null;
 
        halfGap = geometry.gap / 2;
        leftOffset = index === 0 ? 0 : halfGap;
        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 getSegmentEdge(keyOrIndex, side, geometry) {
        var points = getSegmentPoints(keyOrIndex, geometry);
 
        if (!points) return null;
        if (side === 'left') return [points[0], points[3]];
        if (side === 'right') return [points[1], points[2]];
         return null;
    }


         var totalW = (cellW * 6) + projectW;
    function lineXAtY(pointA, pointB, y) {
         var dy;


         var cut = Math.round(cellW * 0.061);
         if (!pointA || !pointB) return NaN;
         cut = Math.max(6, Math.min(10, cut));
        dy = pointB[1] - pointA[1];
        if (Math.abs(dy) < 0.000001) return pointA[0];
         return pointA[0] + (y - pointA[1]) * (pointB[0] - pointA[0]) / dy;
    }


         var x0 = 0;
    function getCentroid(points) {
         var x1 = cellW;
         var totalX = 0;
        var x2 = cellW * 2;
         var totalY = 0;
        var x3 = cellW * 3;
        var x4 = x3 + projectW;
        var x5 = x4 + cellW;
        var x6 = x4 + (cellW * 2);
        var x7 = x4 + (cellW * 3);


         return {
         points.forEach(function (point) {
             width: totalW,
             totalX += point[0];
             height: h,
             totalY += point[1];
            cellW: cellW,
        });
            projectW: projectW,
        return [totalX / points.length, totalY / points.length];
            cut: cut,
            x: [x0, x1, x2, x3, x4, x5, x6, x7]
        };
     }
     }


     /*
     function intersectLines(lineA, lineB) {
    polygon 좌표
        var p = lineA.point;
    -----------------------------------------
        var r = lineA.direction;
    요구 형태:
        var q = lineB.point;
    왼쪽군:  \ \ \
        var s = lineB.direction;
    프로젝트: \ PROJECT /
        var cross = r[0] * s[1] - r[1] * s[0];
    오른쪽군: / / /
        var qMinusP;
    */
        var t;


    function getSegmentPoints(index, g) {
        if (Math.abs(cross) < 0.000001) return [p[0], p[1]];
        var x = g.x;
        var c = g.cut;
        var h = g.height;


         if (index === 0) {
         qMinusP = [q[0] - p[0], q[1] - p[1]];
            return [
        t = (qMinusP[0] * s[1] - qMinusP[1] * s[0]) / cross;
                [x[0], 0],
        return [p[0] + t * r[0], p[1] + t * r[1]];
                [x[1] - c, 0],
    }
                [x[1], h],
                [x[0], h]
            ];
        }


        if (index === 1) {
    function insetPolygon(points, distance) {
            return [
        var center = getCentroid(points);
                [x[1] - c, 0],
        var shiftedLines = [];
                [x[2] - c, 0],
        var count = points.length;
                [x[2], h],
         var i;
                [x[1], h]
            ];
         }


         if (index === 2) {
         for (i = 0; i < count; i++) {
             return [
             var start = points[i];
                [x[2] - c, 0],
            var end = points[(i + 1) % count];
                [x[3] - c, 0],
            var dx = end[0] - start[0];
                [x[3], h],
            var dy = end[1] - start[1];
                [x[2], h]
            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 (index === 3) {
            if (normal[0] * towardCenter[0] + normal[1] * towardCenter[1] < 0) {
            return [
                 normal[0] *= -1;
                [x[3] - c, 0],
                 normal[1] *= -1;
                [x[4], 0],
             }
                 [x[4] - c, h],
                 [x[3], h]
             ];
        }


        if (index === 4) {
            shiftedLines.push({
            return [
                point:[
                [x[4], 0],
                    start[0] + normal[0] * distance,
                [x[5], 0],
                    start[1] + normal[1] * distance
                 [x[5] - c, h],
                 ],
                 [x[4] - c, h]
                 direction:[dx, dy]
             ];
             });
         }
         }


         if (index === 5) {
         return shiftedLines.map(function (line, index) {
             return [
             var previous = shiftedLines[(index + count - 1) % count];
                [x[5], 0],
            return intersectLines(previous, line);
                [x[6], 0],
        });
                [x[6] - c, h],
    }
                [x[5] - c, h]
            ];
        }


         return [
    function roundCoordinate(value) {
            [x[6], 0],
         return Math.round(Number(value) * 100) / 100;
            [x[7], 0],
            [x[7], h],
            [x[6] - c, h]
        ];
     }
     }


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


     function getTextX(index, g) {
     function createSvgElement(name) {
         var x = g.x;
         return document.createElementNS(SVG_NS, name);
    }


        if (index === 0) return (x[0] + x[1]) / 2;
    function createPolygon(className, points) {
         if (index === 1) return (x[1] + x[2]) / 2;
         var polygon = createSvgElement('polygon');
         if (index === 2) return (x[2] + x[3]) / 2;
         polygon.setAttribute('class', className);
         if (index === 3) return (x[3] + x[4]) / 2;
         polygon.setAttribute('points', pointsToString(points));
        if (index === 4) return (x[4] + x[5]) / 2;
         return polygon;
         if (index === 5) return (x[5] + x[6]) / 2;
        return (x[6] + x[7]) / 2;
     }
     }


     function createSvgElement(name) {
     function createEdgeLine(className, start, end) {
         return document.createElementNS(SVG_NS, name);
        var line = createSvgElement('line');
 
        line.setAttribute('class', className);
        line.setAttribute('x1', roundCoordinate(start[0]));
        line.setAttribute('y1', roundCoordinate(start[1]));
        line.setAttribute('x2', roundCoordinate(end[0]));
        line.setAttribute('y2', roundCoordinate(end[1]));
        line.setAttribute('vector-effect', 'non-scaling-stroke');
         line.setAttribute('pointer-events', 'none');
        return line;
     }
     }


     function createSegment(item, index, g, lang) {
     function createSegment(item, index, geometry) {
         var anchor = createSvgElement('a');
         var anchor = createSvgElement('a');
         var polygon = createSvgElement('polygon');
         var outerPoints = getSegmentPoints(index, geometry);
        var surfacePoints = insetPolygon(outerPoints, SPEC.outerBorder);
        var insetPoints = insetPolygon(outerPoints, SPEC.insetLineOffset);
        var outer = createPolygon('portal-cat-edge', outerPoints);
        var surface = createPolygon('portal-cat-surface', surfacePoints);
        var topEdge = createEdgeLine('portal-cat-inset portal-cat-inset-top', insetPoints[0], insetPoints[1]);
        var rightEdge = createEdgeLine('portal-cat-inset portal-cat-inset-right', insetPoints[1], insetPoints[2]);
        var bottomEdge = createEdgeLine('portal-cat-inset portal-cat-inset-bottom', insetPoints[2], insetPoints[3]);
        var leftEdge = createEdgeLine('portal-cat-inset portal-cat-inset-left', insetPoints[3], insetPoints[0]);
        var hit = createPolygon('portal-cat-hit', outerPoints);
         var text = createSvgElement('text');
         var text = createSvgElement('text');
         var href = buildPath(item.title);
         var href = buildPath(item.title);
         var label = getLabel(item, lang);
         var center = getCentroid(surfacePoints);


         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');
         outer.setAttribute('pointer-events', 'none');
         polygon.setAttribute('points', pointsToString(getSegmentPoints(index, g)));
         surface.setAttribute('pointer-events', 'none');
        polygon.setAttribute('fill', 'transparent');
         hit.setAttribute('pointer-events', 'all');
 
         text.setAttribute(
            'class',
            item.key === 'project'
                ? 'portal-cat-label portal-cat-project-label'
                : 'portal-cat-label'
        );


         text.setAttribute('x', getTextX(index, g));
         text.setAttribute('class', 'portal-cat-label');
         text.setAttribute('y', g.height / 2 + 1);
         text.setAttribute('x', Math.round(center[0]));
         text.setAttribute('fill', '#e2e2e2');
         text.setAttribute('y', Math.round(geometry.height / 2) + 1);
         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');
         text.setAttribute('dominant-baseline', 'middle');
         text.setAttribute('dominant-baseline', 'middle');
         text.setAttribute('pointer-events', 'none');
         text.setAttribute('pointer-events', 'none');
         text.textContent = label;
         text.textContent = item.label;


         anchor.appendChild(polygon);
         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;
    }
    function clearNode(node) {
        while (node && node.firstChild) node.removeChild(node.firstChild);
    }
    function setAttributeIfChanged(node, name, value) {
        var next = String(value);
        if (!node || !node.getAttribute || node.getAttribute(name) === next) return false;
        node.setAttribute(name, next);
        return true;
    }
    function findTopSvg(node) {
        var current = node;
        var svg;
        if (!current) return null;
        if (current.matches && current.matches('.portal-category-svg:not(.portal-guide-svg)')) {
            return current;
        }
        if (current.querySelector) {
            svg = current.querySelector('[data-component="category-nav"] .portal-category-svg');
            if (!svg) svg = current.querySelector('.portal-category-svg:not(.portal-guide-svg)');
            if (svg) return svg;
        }
        while (current && current !== document) {
            if (current.__portalFrameSvg) return current.__portalFrameSvg;
            current = current.parentNode;
        }
        return null;
    }
    function getGeometry(node) {
        var current = node;
        var svg;
        while (current && current !== document) {
            if (current.__portalFrameGeometry && current.__portalFrameGeometry.version === FRAME_BUILD) {
                return current.__portalFrameGeometry;
            }
            current = current.parentNode;
        }
        svg = findTopSvg(node);
        return svg && svg.__portalFrameGeometry && svg.__portalFrameGeometry.version === FRAME_BUILD
            ? svg.__portalFrameGeometry
            : null;
    }
    function attachGeometry(mount, nav, svg, geometry) {
        [mount, nav, svg].forEach(function (node) {
            if (!node) return;
            node.__portalFrameGeometry = geometry;
            node.__portalFrameSvg = svg;
            if (node.setAttribute) setAttributeIfChanged(node, 'data-portal-frame-build', FRAME_BUILD);
        });
    }
    function getSvgPadding(svg) {
        var geometry = getGeometry(svg);
        var nav = svg && svg.closest ? svg.closest('.portal-category-nav') : null;
        var style;
        var rect;
        var renderedHeight;
        var scale;
        var top;
        var bottom;


         return anchor;
         if (!geometry || !nav || !window.getComputedStyle) return { top:3, bottom:3 };
 
        style = window.getComputedStyle(nav);
        rect = svg.getBoundingClientRect ? svg.getBoundingClientRect() : null;
        renderedHeight = rect && rect.height ? rect.height : Number(svg.clientHeight || 0);
        scale = renderedHeight > 0 ? geometry.height / renderedHeight : 1;
        top = parseFloat(style.paddingTop) || 0;
        bottom = parseFloat(style.paddingBottom) || 0;
 
        return {
            top:top > 0 ? top * scale : 3,
            bottom:bottom > 0 ? bottom * scale : 3
        };
    }
 
    function getElementScale(element) {
        var rect;
        var width;
        var height;
 
        if (!element || !element.getBoundingClientRect) {
            return { x:1, y:1 };
        }
 
        rect = element.getBoundingClientRect();
        width = Number(element.clientWidth || element.offsetWidth || 0);
        height = Number(element.clientHeight || element.offsetHeight || 0);
 
        return {
            x:width > 0 && rect.width > 0 ? rect.width / width : 1,
            y:height > 0 && rect.height > 0 ? rect.height / height : 1
        };
     }
     }


     function createDivider(x1, y1, x2, y2, side) {
     function mapXToElement(topNode, targetElement, x) {
         var line = createSvgElement('line');
         var svg = findTopSvg(topNode);
        var geometry = getGeometry(svg);
        var sourceRect;
        var targetRect;
        var scaleX;
 
        if (!svg || !geometry || !targetElement || !targetElement.getBoundingClientRect) return NaN;


         line.setAttribute('class', 'portal-cat-divider portal-cat-divider-' + side);
         sourceRect = svg.getBoundingClientRect();
         line.setAttribute('x1', x1);
         targetRect = targetElement.getBoundingClientRect();
         line.setAttribute('y1', y1);
         if (!(sourceRect.width > 0) || !(targetRect.width > 0)) return NaN;
        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;
        /*
        길라잡이는 001에서 승인된 화면 좌표 폭을 그대로 유지한다.
        이 함수는 의도적으로 화면 px 차이를 반환하며, 하단 작품 섹션의
        로컬 좌표 투영은 projectSegmentEdgeToElement()가 별도로 담당한다.
        */
        scaleX = sourceRect.width / geometry.width;
         return sourceRect.left + Number(x) * scaleX - targetRect.left;
     }
     }


     function appendDividers(svg, g) {
     function projectSegmentEdgeToElement(topNode, keyOrIndex, side, targetElement, startY, endY) {
         var x = g.x;
        var svg = findTopSvg(topNode);
         var c = g.cut;
        var geometry = getGeometry(svg);
         var h = g.height;
        var edge = getSegmentEdge(keyOrIndex, side, geometry);
        var sourceRect;
        var targetRect;
        var targetScale;
        var sourceScaleX;
        var sourceScaleY;
        var screenA;
        var screenB;
 
        if (!svg || !geometry || !edge || !targetElement || !targetElement.getBoundingClientRect) {
            return null;
         }
 
        sourceRect = svg.getBoundingClientRect();
         targetRect = targetElement.getBoundingClientRect();
         targetScale = getElementScale(targetElement);
        if (
            !(sourceRect.width > 0) ||
            !(sourceRect.height > 0) ||
            !(targetRect.width > 0) ||
            !(targetScale.x > 0) ||
            !(targetScale.y > 0)
        ) {
            return null;
        }


         svg.appendChild(createDivider(x[1] - c, 0, x[1], h, 'left'));
         sourceScaleX = sourceRect.width / geometry.width;
         svg.appendChild(createDivider(x[2] - c, 0, x[2], h, 'left'));
        sourceScaleY = sourceRect.height / geometry.height;
        svg.appendChild(createDivider(x[3] - c, 0, x[3], h, 'left'));
        screenA = [sourceRect.left + edge[0][0] * sourceScaleX, sourceRect.top + edge[0][1] * sourceScaleY];
         screenB = [sourceRect.left + edge[1][0] * sourceScaleX, sourceRect.top + edge[1][1] * sourceScaleY];


         svg.appendChild(createDivider(x[4], 0, x[4] - c, h, 'right'));
         function localPoint(y) {
        svg.appendChild(createDivider(x[5], 0, x[5] - c, h, 'right'));
            var localY = Number(y);
         svg.appendChild(createDivider(x[6], 0, x[6] - c, h, 'right'));
            var targetScreenY = targetRect.top + localY * targetScale.y;
            var screenX = lineXAtY(screenA, screenB, targetScreenY);
            return [(screenX - targetRect.left) / targetScale.x, localY];
         }
 
        return [localPoint(startY), localPoint(endY)];
     }
     }


     function clearNode(node) {
     function syncDependents(mount) {
         while (node.firstChild) {
         var portal = mount && mount.closest ? mount.closest('.main-portal') : null;
             node.removeChild(node.firstChild);
 
        if (window.CategoryPillar && typeof window.CategoryPillar.renderAll === 'function') {
            window.CategoryPillar.renderAll(mount || document);
        }
        if (window.PortalSectionNav && typeof window.PortalSectionNav.renderAll === 'function') {
            window.PortalSectionNav.renderAll(portal || document);
        }
        if (window.BottomGuideNav && typeof window.BottomGuideNav.render === 'function') {
            window.BottomGuideNav.render(portal || document);
        }
    }
 
    function dispatchFrameUpdate(mount, nav, svg, geometry) {
        var detail = { mount:mount, nav:nav, svg:svg, geometry:geometry, version:FRAME_BUILD };
        var event;
 
        try {
            event = new window.CustomEvent('portalframe:updated', { detail:detail });
        } catch (err) {
             event = document.createEvent('CustomEvent');
            event.initCustomEvent('portalframe:updated', false, false, detail);
         }
         }
        document.dispatchEvent(event);
     }
     }


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


         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%');
         svg.setAttribute('preserveAspectRatio', 'none');
         svg.setAttribute('preserveAspectRatio', 'none');
         svg.setAttribute('aria-hidden', 'false');
         svg.setAttribute('aria-hidden', 'false');
        svg.setAttribute('data-portal-frame-build', FRAME_BUILD);


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


         return {
         return {
             svg: svg,
             svg:svg,
             key: width + '|' + g.width + '|' + g.height + '|' + g.cellW + '|' + g.projectW + '|' + g.cut
            geometry:geometry,
             key:[geometry.width, geometry.height, geometry.cut, geometry.gap, ITEMS.length, FRAME_BUILD].join('|')
         };
         };
     }
     }


     function renderSvgIntoNav(nav, mount) {
     function renderSvgIntoNav(nav, mount) {
         var measuredWidth = getRenderableWidth(nav, mount);
         var result = buildSvg(getRenderableWidth(nav, mount));
         var result = buildSvg(measuredWidth);
         var existing = nav.querySelector('svg.portal-category-svg');


         if (nav.__categoryNavRenderKey === result.key && nav.querySelector('svg')) {
         if (nav.__categoryNavRenderKey === result.key && existing) {
             return;
            attachGeometry(mount, nav, existing, nav.__portalFrameGeometry || result.geometry);
             return false;
         }
         }


452번째 줄: 566번째 줄:
         clearNode(nav);
         clearNode(nav);
         nav.appendChild(result.svg);
         nav.appendChild(result.svg);
        attachGeometry(mount, nav, result.svg, result.geometry);
        dispatchFrameUpdate(mount, nav, result.svg, result.geometry);
        syncDependents(mount);
        return true;
     }
     }


     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 () {
466번째 줄: 582번째 줄:


     function renderCategoryNavMount(mount) {
     function renderCategoryNavMount(mount) {
         if (!mount) return;
         var nav;
        var observer;


         if (mount.__categoryNavObserver) {
         if (!mount) return false;
             mount.__categoryNavObserver.disconnect();
 
             mount.__categoryNavObserver = null;
        nav = mount.querySelector(':scope > .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);
         }
         }


         clearNode(mount);
         setAttributeIfChanged(nav, 'data-portal-frame-build', FRAME_BUILD);
 
         setAttributeIfChanged(mount, 'data-category-nav-rendered', '1');
        var nav = document.createElement('nav');
         setAttributeIfChanged(mount, 'data-portal-frame-build', FRAME_BUILD);
         nav.className = 'portal-category-nav';
        nav.setAttribute('aria-label', 'Main categories');
 
         mount.appendChild(nav);
        mount.setAttribute('data-category-nav-rendered', '1');
 
         scheduleRender(nav, mount);
         scheduleRender(nav, mount);


         /*
         if (!mount.__categoryNavObserver && window.ResizeObserver) {
        첫 렌더 직후 한 번 더 예약한다.
             observer = new window.ResizeObserver(function () {
        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);
                 scheduleRender(nav, mount);
             });
             });
             observer.observe(mount);
             observer.observe(mount);
            observer.observe(nav);
             mount.__categoryNavObserver = observer;
             mount.__categoryNavObserver = observer;
         } else {
         } else if (!window.ResizeObserver && !mount.__categoryNavResizeBound) {
            mount.__categoryNavResizeBound = true;
             window.addEventListener('resize', function () {
             window.addEventListener('resize', function () {
                 scheduleRender(nav, mount);
                 scheduleRender(nav, mount);
             });
             }, { passive:true });
         }
         }
        return true;
     }
     }


     function renderAllCategoryNavs(root) {
     function renderAllCategoryNavs(root) {
         var scope = root || document;
         var scope = root && root.querySelectorAll ? root : document;
         var mounts = scope.querySelectorAll('[data-component="category-nav"]');
         var mounts = [];
 
        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);
            }
        );


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


    var frameApi = {
        version:FRAME_BUILD,
        spec:SPEC,
        items:ITEMS.slice(),
        calculateGeometry:calculateGeometry,
        getGeometry:getGeometry,
        getTopSvg:findTopSvg,
        getSegmentPoints:function (keyOrIndex, geometry) {
            return clonePoints(getSegmentPoints(keyOrIndex, geometry));
        },
        getSegmentEdge:function (keyOrIndex, side, geometry) {
            return clonePoints(getSegmentEdge(keyOrIndex, side, geometry));
        },
        lineXAtY:lineXAtY,
        insetPolygon:function (points, distance) {
            return clonePoints(insetPolygon(points, distance));
        },
        roundCoordinate:roundCoordinate,
        getSvgPadding:getSvgPadding,
        getElementScale:getElementScale,
        mapXToElement:mapXToElement,
        projectSegmentEdgeToElement:projectSegmentEdgeToElement
    };
    var categoryApi = {
        version:FRAME_BUILD,
        frameVersion:FRAME_BUILD,
        init:renderAllCategoryNavs,
        renderMount:renderCategoryNavMount,
        renderAll:renderAllCategoryNavs,
        geometry:frameApi
    };
    window.PortalFrame = frameApi;
    window.CategoryNav = categoryApi;
     window.CLBI = window.CLBI || {};
     window.CLBI = window.CLBI || {};
     window.CLBI.categoryNav = {
     window.CLBI.categoryNav = categoryApi;
        init: renderAllCategoryNavs,
        renderMount: renderCategoryNavMount
    };


     if (document.readyState === 'loading') {
     if (document.readyState === 'loading') {
535번째 줄: 678번째 줄:
     }
     }


     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;
             var root = $content && $content[0] ? $content[0] : document;
541번째 줄: 684번째 줄:
         });
         });
     }
     }
}());
}(window, document, window.mw));

2026년 7월 21일 (화) 12:29 기준 최신판

/* =========================================
COASTLINE: BLACK ICE - CategoryNav
대문 프레임 공통 기하 모델과 상단 카테고리 SVG 네비
========================================= */

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

이 파일은 대문 프레임 기하의 단일 소유자다.
CategoryPillar.js, PortalSectionNav.js, BottomGuideNav.js는 SVG DOM을 다시 파싱하지 않고
window.PortalFrame이 제공하는 동일한 경계와 좌표 변환만 사용한다.
*/

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

    var SVG_NS = 'http://www.w3.org/2000/svg';
    var XLINK_NS = 'http://www.w3.org/1999/xlink';
    var FRAME_BUILD = '20260721-portal-frame-section-fit-008';

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

    /*
    대문 프레임의 공유 규격.
    cut은 폭에 따라 변하지 않는다. 1880px 디자인 폭과 축소 셸에서 같은 경사를 유지한다.
    */
    var SPEC = Object.freeze ? Object.freeze({
        buttonHeight:22,
        gap:2,
        cut:10,
        outerBorder:1,
        insetLineOffset:1.5,
        count:ITEMS.length
    }) : {
        buttonHeight:22,
        gap:2,
        cut:10,
        outerBorder:1,
        insetLineOffset:1.5,
        count:ITEMS.length
    };

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

    function readWidth(element) {
        var rect;
        var width;

        if (!element) return 0;
        rect = element.getBoundingClientRect ? element.getBoundingClientRect() : null;
        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 ? mount.closest('.main-portal') : null,
            document.querySelector('.liberty-content-main .mw-parser-output'),
            document.querySelector('.liberty-content-main'),
            document.querySelector('.liberty-content')
        ];
        var i;
        var width;

        for (i = 0; i < candidates.length; i++) {
            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 itemIndex(keyOrIndex) {
        var key;
        var i;

        if (typeof keyOrIndex === 'number' && isFinite(keyOrIndex)) {
            i = Math.floor(keyOrIndex);
            return i >= 0 && i < ITEMS.length ? i : -1;
        }

        key = String(keyOrIndex || '');
        for (i = 0; i < ITEMS.length; i++) {
            if (ITEMS[i].key === key) return i;
        }
        return -1;
    }

    function calculateGeometry(width) {
        var usableWidth = Math.max(320, Math.floor(width || 0));
        var count = ITEMS.length;
        var top = [];
        var bottom = [];
        var center = count / 2;
        var i;
        var x;

        /*
        사선은 버튼의 속성이 아니라 count + 1개의 공유 경계의 속성이다.
        버튼 i의 우변과 버튼 i+1의 좌변은 반드시 같은 경계를 사용한다.
        */
        for (i = 0; i <= count; i++) {
            x = Math.round((usableWidth * i) / count);

            if (i === 0 || i === count) {
                top.push(x);
                bottom.push(x);
            } else if (i < center) {
                top.push(x - SPEC.cut);
                bottom.push(x);
            } else {
                top.push(x);
                bottom.push(x - SPEC.cut);
            }
        }

        return {
            version:FRAME_BUILD,
            width:usableWidth,
            height:SPEC.buttonHeight,
            count:count,
            cut:SPEC.cut,
            gap:SPEC.gap,
            top:top,
            bottom:bottom
        };
    }

    function clonePoints(points) {
        return (points || []).map(function (point) {
            return [Number(point[0]), Number(point[1])];
        });
    }

    function getSegmentPoints(keyOrIndex, geometry) {
        var index = itemIndex(keyOrIndex);
        var halfGap;
        var leftOffset;
        var rightOffset;

        if (!geometry || index < 0 || index >= geometry.count) return null;

        halfGap = geometry.gap / 2;
        leftOffset = index === 0 ? 0 : halfGap;
        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 getSegmentEdge(keyOrIndex, side, geometry) {
        var points = getSegmentPoints(keyOrIndex, geometry);

        if (!points) return null;
        if (side === 'left') return [points[0], points[3]];
        if (side === 'right') return [points[1], points[2]];
        return null;
    }

    function lineXAtY(pointA, pointB, y) {
        var dy;

        if (!pointA || !pointB) return NaN;
        dy = pointB[1] - pointA[1];
        if (Math.abs(dy) < 0.000001) return pointA[0];
        return pointA[0] + (y - pointA[1]) * (pointB[0] - pointA[0]) / dy;
    }

    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];
        var qMinusP;
        var t;

        if (Math.abs(cross) < 0.000001) return [p[0], p[1]];

        qMinusP = [q[0] - p[0], q[1] - p[1]];
        t = (qMinusP[0] * s[1] - qMinusP[1] * s[0]) / cross;
        return [p[0] + t * r[0], p[1] + t * r[1]];
    }

    function insetPolygon(points, distance) {
        var center = getCentroid(points);
        var shiftedLines = [];
        var count = points.length;
        var i;

        for (i = 0; i < count; i++) {
            var start = points[i];
            var end = points[(i + 1) % count];
            var dx = end[0] - start[0];
            var dy = end[1] - start[1];
            var length = Math.sqrt(dx * dx + dy * dy) || 1;
            var normal = [-dy / length, dx / length];
            var midpoint = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2];
            var towardCenter = [center[0] - midpoint[0], center[1] - midpoint[1]];

            if (normal[0] * towardCenter[0] + normal[1] * towardCenter[1] < 0) {
                normal[0] *= -1;
                normal[1] *= -1;
            }

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

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

    function roundCoordinate(value) {
        return Math.round(Number(value) * 100) / 100;
    }

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

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

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

    function createEdgeLine(className, start, end) {
        var line = createSvgElement('line');

        line.setAttribute('class', className);
        line.setAttribute('x1', roundCoordinate(start[0]));
        line.setAttribute('y1', roundCoordinate(start[1]));
        line.setAttribute('x2', roundCoordinate(end[0]));
        line.setAttribute('y2', roundCoordinate(end[1]));
        line.setAttribute('vector-effect', 'non-scaling-stroke');
        line.setAttribute('pointer-events', 'none');
        return line;
    }

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

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

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

        text.setAttribute('class', 'portal-cat-label');
        text.setAttribute('x', Math.round(center[0]));
        text.setAttribute('y', Math.round(geometry.height / 2) + 1);
        text.setAttribute('font-size', '12');
        text.setAttribute('font-weight', '700');
        text.setAttribute('text-anchor', 'middle');
        text.setAttribute('dominant-baseline', 'middle');
        text.setAttribute('pointer-events', 'none');
        text.textContent = item.label;

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

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

    function setAttributeIfChanged(node, name, value) {
        var next = String(value);
        if (!node || !node.getAttribute || node.getAttribute(name) === next) return false;
        node.setAttribute(name, next);
        return true;
    }

    function findTopSvg(node) {
        var current = node;
        var svg;

        if (!current) return null;
        if (current.matches && current.matches('.portal-category-svg:not(.portal-guide-svg)')) {
            return current;
        }

        if (current.querySelector) {
            svg = current.querySelector('[data-component="category-nav"] .portal-category-svg');
            if (!svg) svg = current.querySelector('.portal-category-svg:not(.portal-guide-svg)');
            if (svg) return svg;
        }

        while (current && current !== document) {
            if (current.__portalFrameSvg) return current.__portalFrameSvg;
            current = current.parentNode;
        }
        return null;
    }

    function getGeometry(node) {
        var current = node;
        var svg;

        while (current && current !== document) {
            if (current.__portalFrameGeometry && current.__portalFrameGeometry.version === FRAME_BUILD) {
                return current.__portalFrameGeometry;
            }
            current = current.parentNode;
        }

        svg = findTopSvg(node);
        return svg && svg.__portalFrameGeometry && svg.__portalFrameGeometry.version === FRAME_BUILD
            ? svg.__portalFrameGeometry
            : null;
    }

    function attachGeometry(mount, nav, svg, geometry) {
        [mount, nav, svg].forEach(function (node) {
            if (!node) return;
            node.__portalFrameGeometry = geometry;
            node.__portalFrameSvg = svg;
            if (node.setAttribute) setAttributeIfChanged(node, 'data-portal-frame-build', FRAME_BUILD);
        });
    }

    function getSvgPadding(svg) {
        var geometry = getGeometry(svg);
        var nav = svg && svg.closest ? svg.closest('.portal-category-nav') : null;
        var style;
        var rect;
        var renderedHeight;
        var scale;
        var top;
        var bottom;

        if (!geometry || !nav || !window.getComputedStyle) return { top:3, bottom:3 };

        style = window.getComputedStyle(nav);
        rect = svg.getBoundingClientRect ? svg.getBoundingClientRect() : null;
        renderedHeight = rect && rect.height ? rect.height : Number(svg.clientHeight || 0);
        scale = renderedHeight > 0 ? geometry.height / renderedHeight : 1;
        top = parseFloat(style.paddingTop) || 0;
        bottom = parseFloat(style.paddingBottom) || 0;

        return {
            top:top > 0 ? top * scale : 3,
            bottom:bottom > 0 ? bottom * scale : 3
        };
    }

    function getElementScale(element) {
        var rect;
        var width;
        var height;

        if (!element || !element.getBoundingClientRect) {
            return { x:1, y:1 };
        }

        rect = element.getBoundingClientRect();
        width = Number(element.clientWidth || element.offsetWidth || 0);
        height = Number(element.clientHeight || element.offsetHeight || 0);

        return {
            x:width > 0 && rect.width > 0 ? rect.width / width : 1,
            y:height > 0 && rect.height > 0 ? rect.height / height : 1
        };
    }

    function mapXToElement(topNode, targetElement, x) {
        var svg = findTopSvg(topNode);
        var geometry = getGeometry(svg);
        var sourceRect;
        var targetRect;
        var scaleX;

        if (!svg || !geometry || !targetElement || !targetElement.getBoundingClientRect) return NaN;

        sourceRect = svg.getBoundingClientRect();
        targetRect = targetElement.getBoundingClientRect();
        if (!(sourceRect.width > 0) || !(targetRect.width > 0)) return NaN;

        /*
        길라잡이는 001에서 승인된 화면 좌표 폭을 그대로 유지한다.
        이 함수는 의도적으로 화면 px 차이를 반환하며, 하단 작품 섹션의
        로컬 좌표 투영은 projectSegmentEdgeToElement()가 별도로 담당한다.
        */
        scaleX = sourceRect.width / geometry.width;
        return sourceRect.left + Number(x) * scaleX - targetRect.left;
    }

    function projectSegmentEdgeToElement(topNode, keyOrIndex, side, targetElement, startY, endY) {
        var svg = findTopSvg(topNode);
        var geometry = getGeometry(svg);
        var edge = getSegmentEdge(keyOrIndex, side, geometry);
        var sourceRect;
        var targetRect;
        var targetScale;
        var sourceScaleX;
        var sourceScaleY;
        var screenA;
        var screenB;

        if (!svg || !geometry || !edge || !targetElement || !targetElement.getBoundingClientRect) {
            return null;
        }

        sourceRect = svg.getBoundingClientRect();
        targetRect = targetElement.getBoundingClientRect();
        targetScale = getElementScale(targetElement);
        if (
            !(sourceRect.width > 0) ||
            !(sourceRect.height > 0) ||
            !(targetRect.width > 0) ||
            !(targetScale.x > 0) ||
            !(targetScale.y > 0)
        ) {
            return null;
        }

        sourceScaleX = sourceRect.width / geometry.width;
        sourceScaleY = sourceRect.height / geometry.height;
        screenA = [sourceRect.left + edge[0][0] * sourceScaleX, sourceRect.top + edge[0][1] * sourceScaleY];
        screenB = [sourceRect.left + edge[1][0] * sourceScaleX, sourceRect.top + edge[1][1] * sourceScaleY];

        function localPoint(y) {
            var localY = Number(y);
            var targetScreenY = targetRect.top + localY * targetScale.y;
            var screenX = lineXAtY(screenA, screenB, targetScreenY);
            return [(screenX - targetRect.left) / targetScale.x, localY];
        }

        return [localPoint(startY), localPoint(endY)];
    }

    function syncDependents(mount) {
        var portal = mount && mount.closest ? mount.closest('.main-portal') : null;

        if (window.CategoryPillar && typeof window.CategoryPillar.renderAll === 'function') {
            window.CategoryPillar.renderAll(mount || document);
        }
        if (window.PortalSectionNav && typeof window.PortalSectionNav.renderAll === 'function') {
            window.PortalSectionNav.renderAll(portal || document);
        }
        if (window.BottomGuideNav && typeof window.BottomGuideNav.render === 'function') {
            window.BottomGuideNav.render(portal || document);
        }
    }

    function dispatchFrameUpdate(mount, nav, svg, geometry) {
        var detail = { mount:mount, nav:nav, svg:svg, geometry:geometry, version:FRAME_BUILD };
        var event;

        try {
            event = new window.CustomEvent('portalframe:updated', { detail:detail });
        } catch (err) {
            event = document.createEvent('CustomEvent');
            event.initCustomEvent('portalframe:updated', false, false, detail);
        }
        document.dispatchEvent(event);
    }

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

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

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

        return {
            svg:svg,
            geometry:geometry,
            key:[geometry.width, geometry.height, geometry.cut, geometry.gap, ITEMS.length, FRAME_BUILD].join('|')
        };
    }

    function renderSvgIntoNav(nav, mount) {
        var result = buildSvg(getRenderableWidth(nav, mount));
        var existing = nav.querySelector('svg.portal-category-svg');

        if (nav.__categoryNavRenderKey === result.key && existing) {
            attachGeometry(mount, nav, existing, nav.__portalFrameGeometry || result.geometry);
            return false;
        }

        nav.__categoryNavRenderKey = result.key;
        clearNode(nav);
        nav.appendChild(result.svg);
        attachGeometry(mount, nav, result.svg, result.geometry);
        dispatchFrameUpdate(mount, nav, result.svg, result.geometry);
        syncDependents(mount);
        return true;
    }

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

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

    function renderCategoryNavMount(mount) {
        var nav;
        var observer;

        if (!mount) return false;

        nav = mount.querySelector(':scope > .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);
        }

        setAttributeIfChanged(nav, 'data-portal-frame-build', FRAME_BUILD);
        setAttributeIfChanged(mount, 'data-category-nav-rendered', '1');
        setAttributeIfChanged(mount, 'data-portal-frame-build', FRAME_BUILD);
        scheduleRender(nav, mount);

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

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

        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(renderCategoryNavMount);
        return mounts.length;
    }

    var frameApi = {
        version:FRAME_BUILD,
        spec:SPEC,
        items:ITEMS.slice(),
        calculateGeometry:calculateGeometry,
        getGeometry:getGeometry,
        getTopSvg:findTopSvg,
        getSegmentPoints:function (keyOrIndex, geometry) {
            return clonePoints(getSegmentPoints(keyOrIndex, geometry));
        },
        getSegmentEdge:function (keyOrIndex, side, geometry) {
            return clonePoints(getSegmentEdge(keyOrIndex, side, geometry));
        },
        lineXAtY:lineXAtY,
        insetPolygon:function (points, distance) {
            return clonePoints(insetPolygon(points, distance));
        },
        roundCoordinate:roundCoordinate,
        getSvgPadding:getSvgPadding,
        getElementScale:getElementScale,
        mapXToElement:mapXToElement,
        projectSegmentEdgeToElement:projectSegmentEdgeToElement
    };

    var categoryApi = {
        version:FRAME_BUILD,
        frameVersion:FRAME_BUILD,
        init:renderAllCategoryNavs,
        renderMount:renderCategoryNavMount,
        renderAll:renderAllCategoryNavs,
        geometry:frameApi
    };

    window.PortalFrame = frameApi;
    window.CategoryNav = categoryApi;
    window.CLBI = window.CLBI || {};
    window.CLBI.categoryNav = categoryApi;

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

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