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

(Install package: clbi-categorynav-portalsection / js/CategoryNav.js)
(Install package: clbiwiki-portal-frame-foundation-20260720-001 / js/CategoryNav.js)
1번째 줄: 1번째 줄:
/* =========================================
/* =========================================
COASTLINE: BLACK ICE - CategoryNav
COASTLINE: BLACK ICE - CategoryNav
전체 재구현: 상단 5버튼 + 프로젝트 기둥 (canvas, 경계 공유 + 정수 계단)
대문 프레임 공통 기하 모델과 상단 카테고리 SVG 네비
버전: 20260720-canvas-full-001
========================================= */
========================================= */
/*
새 공통 기능에는 프로젝트 접두사를 사용하지 않는다.
window.CLBI.categoryNav는 기존 Common.js 호출과의 호환을 위해서만 유지한다.
이 파일은 대문 프레임 기하의 단일 소유자다.
CategoryPillar.js, PortalSectionNav.js, BottomGuideNav.js는 SVG DOM을 다시 파싱하지 않고
window.PortalFrame이 제공하는 동일한 경계와 좌표 변환만 사용한다.
*/


(function (window, document, mw) {
(function (window, document, mw) {
     'use strict';
     'use strict';
    var SVG_NS = 'http://www.w3.org/2000/svg';
    var XLINK_NS = 'http://www.w3.org/1999/xlink';
    var FRAME_BUILD = '20260720-portal-frame-foundation-001';


     var ITEMS = [
     var ITEMS = [
16번째 줄: 28번째 줄:
     ];
     ];


     /* ---- 고정 규격 (HANDOVER §B-5) ---- */
     /*
     var CUT = 10;
    대문 프레임의 공유 규격.
     var GAP = 2;
    cut은 폭에 따라 변하지 않는다. 1880px 디자인 폭과 축소 셸에서 같은 경사를 유지한다.
     var BTN_H = 24;
    */
     var FRAME_H = 11;
     var SPEC = Object.freeze ? Object.freeze({
    var WELL = 2;
        buttonHeight:22,
    var ROW_HEIGHT = FRAME_H + WELL + BTN_H + WELL + FRAME_H; /* 50 */
        gap:2,
    var BTN_TOP = FRAME_H + WELL; /* 13 */
        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;
        }


    var BORDER = '#050505';
        if (document.documentElement && document.documentElement.clientWidth) {
    var HI = '#555555';
            return Math.max(320, Math.floor(document.documentElement.clientWidth - 48));
    var SH = '#101010';
        }
    var SURFACE = '#1d1d1d';
        return 1200;
     var WELL_BG = '#080808';
     }


     var renderTimer = 0;
     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;
        }


    function buildPath(title) {
        key = String(keyOrIndex || '');
        if (typeof window.buildWikiPath === 'function') return window.buildWikiPath(title);
        for (i = 0; i < ITEMS.length; i++) {
         return '/index.php/' + encodeURI(String(title || '').replace(/ /g, '_'));
            if (ITEMS[i].key === key) return i;
        }
         return -1;
     }
     }


     /* §B-1 — 경계 배열. 인접 항목이 같은 경계를 공유 = 구멍 원천 불가.
     function calculateGeometry(width) {
      half를 정수로 고정하지 않는다(원본 SVG와 동일하게 count/2). 그래야 내부 경계 어느 것도
        var usableWidth = Math.max(320, Math.floor(width || 0));
      강제로 수직이 되지 않고, 프로젝트도 다른 버튼과 완전히 동일하게 양쪽 사선을 갖는다.
        var count = ITEMS.length;
      (이전 버전은 half를 정수로 만들어 프로젝트를 수직 기둥으로 강제했으나, 실제 참조 화면과
      달라 오류로 확인되어 제거함.) */
    function calculateBoundaries(width, count, cut) {
         var top = [];
         var top = [];
         var bottom = [];
         var bottom = [];
         var half = count / 2;
         var center = count / 2;
         var i;
         var i;
         var x;
         var x;


        /*
        사선은 버튼의 속성이 아니라 count + 1개의 공유 경계의 속성이다.
        버튼 i의 우변과 버튼 i+1의 좌변은 반드시 같은 경계를 사용한다.
        */
         for (i = 0; i <= count; i++) {
         for (i = 0; i <= count; i++) {
             x = Math.round((width * i) / count);
             x = Math.round((usableWidth * i) / count);
             if (i === 0 || i === count || i === half) {
 
             if (i === 0 || i === count) {
                 top.push(x);
                 top.push(x);
                 bottom.push(x);
                 bottom.push(x);
             } else if (i < half) {
             } else if (i < center) {
                 top.push(x - cut);
                 top.push(x - SPEC.cut);
                 bottom.push(x);
                 bottom.push(x);
             } else {
             } else {
                 top.push(x);
                 top.push(x);
                 bottom.push(x - cut);
                 bottom.push(x - SPEC.cut);
             }
             }
         }
         }
         return { top:top, bottom:bottom, width:width };
 
         return {
            version:FRAME_BUILD,
            width:usableWidth,
            height:SPEC.buttonHeight,
            count:count,
            cut:SPEC.cut,
            gap:SPEC.gap,
            top:top,
            bottom:bottom
        };
     }
     }


     /* §B-2 — 정수 계단. clamp 없이 이어지는 버전(하단 프레임까지 연장 가능하도록) */
     function clonePoints(points) {
    function slantEdge(topX, bottomX, row, unitH) {
        return (points || []).map(function (point) {
        var total = bottomX - topX;
            return [Number(point[0]), Number(point[1])];
         var stepSize;
         });
        var k;
    }


         if (total === 0) return topX;
    function getSegmentPoints(keyOrIndex, geometry) {
         stepSize = unitH / Math.abs(total);
        var index = itemIndex(keyOrIndex);
         k = Math.floor(row / stepSize);
        var halfGap;
         return topX + (total > 0 ? k : -k);
        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]
        ];
     }
     }


    /* §B-3 */
     function getSegmentEdge(keyOrIndex, side, geometry) {
     function buttonEdges(i, count, bounds, row, btnH, gap) {
         var points = getSegmentPoints(keyOrIndex, geometry);
         var hg = gap / 2;
 
         var lo = (i === 0) ? 0 : hg;
         if (!points) return null;
         var ro = (i === count - 1) ? 0 : hg;
         if (side === 'left') return [points[0], points[3]];
        var lx = slantEdge(bounds.top[i], bounds.bottom[i], row, btnH) + lo;
         if (side === 'right') return [points[1], points[2]];
         var rx = slantEdge(bounds.top[i + 1], bounds.bottom[i + 1], row, btnH) - ro;
         return null;
         return { lx:lx, rx:rx };
     }
     }


     /* §B-4 */
     function lineXAtY(pointA, pointB, y) {
     function paintButtonRow(ctx, i, count, bounds, row, yTop, btnH, gap) {
        var dy;
         var e = buttonEdges(i, count, bounds, row, btnH, gap);
 
        var x;
        if (!pointA || !pointB) return NaN;
        var c;
        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;
            }


        for (x = e.lx; x < e.rx; x++) {
            shiftedLines.push({
            if (row === 0 || row === btnH - 1) c = BORDER;
                point:[
            else if (x === e.lx || x === e.rx - 1) c = BORDER;
                    start[0] + normal[0] * distance,
            else if (row === 1) c = HI;
                    start[1] + normal[1] * distance
            else if (row === btnH - 2) c = SH;
                ],
            else if (x === e.lx + 1) c = SH;
                direction:[dx, dy]
            else if (x === e.rx - 2) c = HI;
             });
            else c = SURFACE;
            ctx.fillStyle = c;
             ctx.fillRect(x, yTop + row, 1, 1);
         }
         }
        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 paintFrameBandFull(ctx, width, yTop, height) {
     function createSvgElement(name) {
         var row;
         return document.createElementNS(SVG_NS, name);
        var c;
    }


        for (row = 0; row < height; row++) {
    function createPolygon(className, points) {
            if (row === 0 || row === height - 1) c = BORDER;
        var polygon = createSvgElement('polygon');
            else if (row === 1) c = HI;
        polygon.setAttribute('class', className);
            else if (row === height - 2) c = SH;
        polygon.setAttribute('points', pointsToString(points));
            else c = SURFACE;
         return polygon;
            ctx.fillStyle = c;
            ctx.fillRect(0, yTop + row, width, 1);
         }
     }
     }


     function calculateLayout(width) {
     function createEdgeLine(className, start, end) {
         var count = ITEMS.length;
         var line = createSvgElement('line');
         var bounds = calculateBoundaries(width, count, CUT);
 
         return { width:width, count:count, bounds:bounds };
        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;
     }
     }


     /* 프로젝트 — 경계(2,3)의 사선을, 버튼 높이(24px) 기준이 아니라 이 행 전체 높이(50px)
     function createSegment(item, index, geometry) {
      기준으로 다시 계산해 처음부터 끝까지 하나의 매끄러운 사선으로 통일한다.
        var anchor = createSvgElement('a');
      (버튼 높이 구간만 사선이고 위아래 프레임 띠에서 뚝 끊겨 수직이 되는 방식은 "허리가 잘록한"
        var outerPoints = getSegmentPoints(index, geometry);
      부자연스러운 모양이 되어 폐기함.) */
        var surfacePoints = insetPolygon(outerPoints, SPEC.outerBorder);
    function paintProjectFused(ctx, bounds, rowHeight) {
        var insetPoints = insetPolygon(outerPoints, SPEC.insetLineOffset);
         var row;
        var outer = createPolygon('portal-cat-edge', outerPoints);
         var localRow;
        var surface = createPolygon('portal-cat-surface', surfacePoints);
         var lx;
        var topEdge = createEdgeLine('portal-cat-inset portal-cat-inset-top', insetPoints[0], insetPoints[1]);
         var rx;
        var rightEdge = createEdgeLine('portal-cat-inset portal-cat-inset-right', insetPoints[1], insetPoints[2]);
         var c;
        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');


         for (row = 0; row < rowHeight; row++) {
         text.setAttribute('class', 'portal-cat-label');
            localRow = row - BTN_TOP;
        text.setAttribute('x', Math.round(center[0]));
            lx = slantEdge(bounds.top[2], bounds.bottom[2], localRow, BTN_H);
        text.setAttribute('y', Math.round(geometry.height / 2) + 1);
            rx = slantEdge(bounds.top[3], bounds.bottom[3], localRow, BTN_H);
        text.setAttribute('font-size', '12');
            c = (row === 0 || row === rowHeight - 1) ? BORDER : SURFACE;
        text.setAttribute('font-weight', '700');
            ctx.fillStyle = c;
        text.setAttribute('text-anchor', 'middle');
            ctx.fillRect(lx, row, rx - lx, 1);
        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 draw(canvas, layout) {
     function clearNode(node) {
         var ctx = canvas.getContext('2d');
         while (node && node.firstChild) node.removeChild(node.firstChild);
        var width = layout.width;
    }
        var bounds = layout.bounds;
        var row;
        var i;
        var idx;
        var lx;
        var rx;
        var cx;
        var cy;


        ctx.clearRect(0, 0, width, ROW_HEIGHT);
    function findTopSvg(node) {
         ctx.fillStyle = WELL_BG;
         var current = node;
         ctx.fillRect(0, 0, width, ROW_HEIGHT);
         var svg;


         paintFrameBandFull(ctx, width, 0, FRAME_H);
         if (!current) return null;
        if (current.matches && current.matches('.portal-category-svg:not(.portal-guide-svg)')) {
            return current;
        }


         for (row = 0; row < BTN_H; row++) {
         if (current.querySelector) {
             for (i = 0; i < layout.count; i++) {
             svg = current.querySelector('[data-component="category-nav"] .portal-category-svg');
                if (ITEMS[i].key === 'project') continue;
            if (!svg) svg = current.querySelector('.portal-category-svg:not(.portal-guide-svg)');
                paintButtonRow(ctx, i, layout.count, bounds, row, BTN_TOP, BTN_H, GAP);
            if (svg) return svg;
            }
         }
         }


         paintFrameBandFull(ctx, width, BTN_TOP + BTN_H + WELL, FRAME_H);
         while (current && current !== document) {
         paintProjectFused(ctx, bounds, ROW_HEIGHT);
            if (current.__portalFrameSvg) return current.__portalFrameSvg;
            current = current.parentNode;
         }
        return null;
    }


        ctx.font = '700 11px sans-serif';
    function getGeometry(node) {
         ctx.textAlign = 'center';
         var current = node;
         ctx.textBaseline = 'middle';
         var svg;
        ctx.lineJoin = 'round';
 
         for (idx = 0; idx < layout.count; idx++) {
         while (current && current !== document) {
             if (ITEMS[idx].key === 'project') {
             if (current.__portalFrameGeometry && current.__portalFrameGeometry.version === FRAME_BUILD) {
                lx = slantEdge(bounds.top[2], bounds.bottom[2], Math.floor(ROW_HEIGHT / 2) - BTN_TOP, BTN_H);
                 return current.__portalFrameGeometry;
                rx = slantEdge(bounds.top[3], bounds.bottom[3], Math.floor(ROW_HEIGHT / 2) - BTN_TOP, BTN_H);
                cx = Math.round((lx + rx) / 2);
                cy = Math.round(ROW_HEIGHT / 2);
            } else {
                 lx = slantEdge(bounds.top[idx], bounds.bottom[idx], Math.floor(BTN_H / 2), BTN_H);
                rx = slantEdge(bounds.top[idx + 1], bounds.bottom[idx + 1], Math.floor(BTN_H / 2), BTN_H);
                cx = Math.round((lx + rx) / 2);
                cy = BTN_TOP + Math.round(BTN_H / 2) + 1;
             }
             }
             ctx.lineWidth = 2;
             current = current.parentNode;
            ctx.strokeStyle = BORDER;
            ctx.strokeText(ITEMS[idx].label, cx, cy);
            ctx.fillStyle = '#e2e2e2';
            ctx.fillText(ITEMS[idx].label, cx, cy);
         }
         }
        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) node.setAttribute('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 buildHitLayer(layout) {
     function mapXToElement(topNode, targetElement, x) {
         var wrap = document.createElement('div');
         var svg = findTopSvg(topNode);
         var bounds = layout.bounds;
        var geometry = getGeometry(svg);
         var i;
        var sourceRect;
         var item;
        var targetRect;
         var t;
        var scaleX;
         var b;
 
         var leftMin;
        if (!svg || !geometry || !targetElement || !targetElement.getBoundingClientRect) return NaN;
         var rightMax;
 
         var a;
        sourceRect = svg.getBoundingClientRect();
        targetRect = targetElement.getBoundingClientRect();
        if (!(sourceRect.width > 0) || !(targetRect.width > 0)) return NaN;
 
        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 scaleX;
         var scaleY;
         var screenA;
         var screenB;
 
        if (!svg || !geometry || !edge || !targetElement || !targetElement.getBoundingClientRect) {
            return null;
        }


         wrap.className = 'portal-frame-hit-layer';
         sourceRect = svg.getBoundingClientRect();
         wrap.style.position = 'absolute';
         targetRect = targetElement.getBoundingClientRect();
         wrap.style.left = '0';
         if (!(sourceRect.width > 0) || !(sourceRect.height > 0) || !(targetRect.width > 0)) {
        wrap.style.top = '0';
            return null;
        wrap.style.width = layout.width + 'px';
         }
         wrap.style.height = ROW_HEIGHT + 'px';


         for (i = 0; i < layout.count; i++) {
         scaleX = sourceRect.width / geometry.width;
            item = ITEMS[i];
        scaleY = sourceRect.height / geometry.height;
            if (item.key === 'project') continue; /* 비조작 */
        screenA = [sourceRect.left + edge[0][0] * scaleX, sourceRect.top + edge[0][1] * scaleY];
            t = slantEdge(bounds.top[i], bounds.bottom[i], 0, BTN_H);
        screenB = [sourceRect.left + edge[1][0] * scaleX, sourceRect.top + edge[1][1] * scaleY];
            b = slantEdge(bounds.top[i], bounds.bottom[i], BTN_H - 1, BTN_H);
            leftMin = Math.min(t, b);
            t = slantEdge(bounds.top[i + 1], bounds.bottom[i + 1], 0, BTN_H);
            b = slantEdge(bounds.top[i + 1], bounds.bottom[i + 1], BTN_H - 1, BTN_H);
            rightMax = Math.max(t, b);


            a = document.createElement('a');
        function localPoint(y) {
             a.href = buildPath(item.title);
             var targetScreenY = targetRect.top + Number(y);
             a.setAttribute('aria-label', item.label);
             var screenX = lineXAtY(screenA, screenB, targetScreenY);
             a.setAttribute('data-category-key', item.key);
             return [screenX - targetRect.left, Number(y)];
            a.style.position = 'absolute';
            a.style.left = leftMin + 'px';
            a.style.top = BTN_TOP + 'px';
            a.style.width = (rightMax - leftMin) + 'px';
            a.style.height = BTN_H + 'px';
            a.style.display = 'block';
            wrap.appendChild(a);
         }
         }
         return wrap;
 
         return [localPoint(startY), localPoint(endY)];
     }
     }


     function readWidth(mount) {
     function dispatchFrameUpdate(mount, nav, svg, geometry) {
         var rect = mount.getBoundingClientRect();
         var detail = { mount:mount, nav:nav, svg:svg, geometry:geometry, version:FRAME_BUILD };
         var width = Math.floor(rect.width || 0);
         var event;
         if (!width && mount.clientWidth) width = Math.floor(mount.clientWidth);
 
         return width || 0;
        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 applyCounterScale(el) {
     function buildSvg(width) {
         var scale = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--clbi-shell-scale')) || 1;
         var geometry = calculateGeometry(width);
         el.style.transformOrigin = 'top left';
        var svg = createSvgElement('svg');
         el.style.transform = scale && scale !== 1 ? 'scale(' + (1 / scale) + ')' : '';
 
        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 renderMount(mount) {
     function renderSvgIntoNav(nav, mount) {
         var width;
         var result = buildSvg(getRenderableWidth(nav, mount));
         var layout;
         var existing = nav.querySelector('svg.portal-category-svg');
        var wrap;
        var canvas;
        var ctx;
        var dpr;
        var hitLayer;


         if (!mount) return false;
         if (nav.__categoryNavRenderKey === result.key && existing) {
        width = readWidth(mount);
             attachGeometry(mount, nav, existing, nav.__portalFrameGeometry || result.geometry);
        if (!width) {
             if (!mount.__categoryNavRetry) {
                mount.__categoryNavRetry = window.setTimeout(function () {
                    mount.__categoryNavRetry = 0;
                    renderMount(mount);
                }, 60);
            }
             return false;
             return false;
         }
         }


         layout = calculateLayout(width);
         nav.__categoryNavRenderKey = result.key;
        clearNode(nav);
        nav.appendChild(result.svg);
        attachGeometry(mount, nav, result.svg, result.geometry);
        dispatchFrameUpdate(mount, nav, result.svg, result.geometry);
        return true;
    }
 
    function scheduleRender(nav, mount) {
        if (!nav || nav.__categoryNavFrame) return;
 
        nav.__categoryNavFrame = window.requestAnimationFrame(function () {
            nav.__categoryNavFrame = null;
            renderSvgIntoNav(nav, mount);
        });
    }


        wrap = document.createElement('div');
    function renderCategoryNavMount(mount) {
         wrap.className = 'portal-category-canvas-nav';
         var nav;
         wrap.style.position = 'relative';
         var observer;
        wrap.style.width = width + 'px';
        wrap.style.height = ROW_HEIGHT + 'px';
        applyCounterScale(wrap);


         canvas = document.createElement('canvas');
         if (!mount) return false;
        canvas.className = 'portal-category-canvas';
        dpr = window.devicePixelRatio || 1;
        canvas.width = width * dpr;
        canvas.height = ROW_HEIGHT * dpr;
        canvas.style.width = width + 'px';
        canvas.style.height = ROW_HEIGHT + 'px';
        canvas.style.display = 'block';
        canvas.style.imageRendering = 'pixelated';


         ctx = canvas.getContext('2d');
         if (mount.__categoryNavObserver) {
        ctx.imageSmoothingEnabled = false;
            mount.__categoryNavObserver.disconnect();
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
            mount.__categoryNavObserver = null;
         draw(canvas, layout);
         }


         hitLayer = buildHitLayer(layout);
         clearNode(mount);
        nav = document.createElement('nav');
        nav.className = 'portal-category-nav';
        nav.setAttribute('aria-label', 'Main categories');
        nav.setAttribute('data-portal-frame-build', FRAME_BUILD);


         while (mount.firstChild) mount.removeChild(mount.firstChild);
         mount.appendChild(nav);
        wrap.appendChild(canvas);
         mount.setAttribute('data-category-nav-rendered', '1');
        wrap.appendChild(hitLayer);
         mount.setAttribute('data-portal-frame-build', FRAME_BUILD);
        mount.appendChild(wrap);
         mount.setAttribute('data-category-nav-width', String(width));
         mount.setAttribute('data-category-nav-rendered', 'canvas-full');


         /* 하단 2단이 읽어갈 공유 레이아웃 (DOM 실측 대신 변수 전달, §8-2) */
         scheduleRender(nav, mount);
         window.CLBI = window.CLBI || {};
         window.setTimeout(function () { scheduleRender(nav, mount); }, 80);
        window.CLBI.categoryNav = window.CLBI.categoryNav || {};
         window.setTimeout(function () { scheduleRender(nav, mount); }, 250);
         window.CLBI.categoryNav.lastLayout = {
            width:width,
            bounds:layout.bounds,
            rowHeight:ROW_HEIGHT,
            frameH:FRAME_H,
            well:WELL,
            btnH:BTN_H,
            gap:GAP,
            btnTop:BTN_TOP
        };
        if (mw && mw.hook) mw.hook('clbi.categoryNav.rendered').fire(mount);


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


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


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


     function schedule(root) {
     var frameApi = {
        window.clearTimeout(renderTimer);
        version:FRAME_BUILD,
         renderTimer = window.setTimeout(function () {
        spec:SPEC,
             renderTimer = 0;
        items:ITEMS.slice(),
             renderAll(root || document);
        calculateGeometry:calculateGeometry,
         }, 0);
        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,
        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') {
     if (document.readyState === 'loading') {
         document.addEventListener('DOMContentLoaded', function () { renderAll(document); });
         document.addEventListener('DOMContentLoaded', function () {
            renderAllCategoryNavs(document);
        });
     } else {
     } else {
         renderAll(document);
         renderAllCategoryNavs(document);
     }
     }


     if (window.mw && mw.hook) {
     if (mw && mw.hook) {
         mw.hook('wikipage.content').add(function ($content) {
         mw.hook('wikipage.content').add(function ($content) {
             schedule($content && $content[0] ? $content[0] : document);
             var root = $content && $content[0] ? $content[0] : document;
            renderAllCategoryNavs(root);
         });
         });
     }
     }
    window.addEventListener('resize', function () { schedule(document); });
    window.CategoryNav = {
        version:'20260720-canvas-full-001',
        init:renderAll,
        renderAll:renderAll,
        renderMount:renderMount,
        ROW_HEIGHT:ROW_HEIGHT,
        FRAME_H:FRAME_H,
        WELL:WELL,
        BTN_H:BTN_H,
        GAP:GAP,
        CUT:CUT,
        slantEdge:slantEdge
    };
    window.CLBI = window.CLBI || {};
    window.CLBI.categoryNav = window.CLBI.categoryNav || {};
    window.CLBI.categoryNav.init = renderAll;
    window.CLBI.categoryNav.renderMount = renderMount;
}(window, document, window.mw));
}(window, document, window.mw));

2026년 7월 20일 (월) 21:39 판

/* =========================================
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 = '20260720-portal-frame-foundation-001';

    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 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) node.setAttribute('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 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;

        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 scaleX;
        var scaleY;
        var screenA;
        var screenB;

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

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

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

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

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

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

        if (mount.__categoryNavObserver) {
            mount.__categoryNavObserver.disconnect();
            mount.__categoryNavObserver = null;
        }

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

        mount.appendChild(nav);
        mount.setAttribute('data-category-nav-rendered', '1');
        mount.setAttribute('data-portal-frame-build', FRAME_BUILD);

        scheduleRender(nav, mount);
        window.setTimeout(function () { scheduleRender(nav, mount); }, 80);
        window.setTimeout(function () { scheduleRender(nav, mount); }, 250);

        if (window.ResizeObserver) {
            observer = new window.ResizeObserver(function () {
                scheduleRender(nav, mount);
            });
            observer.observe(mount);
            observer.observe(nav);
            mount.__categoryNavObserver = observer;
        } else if (!mount.__categoryNavResizeBound) {
            mount.__categoryNavResizeBound = true;
            window.addEventListener('resize', function () {
                scheduleRender(nav, mount);
            });
        }
        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,
        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));