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

(Install package: clbiwiki-category-bottomguide-restore / js/CategoryNav.js)
태그: 수동 되돌리기
(Install package: clbi-categorynav-portalsection / js/CategoryNav.js)
1번째 줄: 1번째 줄:
/* =========================================
/* =========================================
COASTLINE: BLACK ICE - CategoryNav
COASTLINE: BLACK ICE - CategoryNav
대문 카테고리 SVG 네비 생성기
전체 재구현: 상단 5버튼 + 프로젝트 기둥 (canvas, 경계 공유 + 정수 계단)
버전: 20260720-canvas-full-001
========================================= */
========================================= */


/*
(function (window, document, mw) {
새 공통 기능에는 프로젝트 접두사를 사용하지 않는다.
window.CLBI.categoryNav는 기존 Common.js 호출과의 호환을 위해서만 유지한다.
*/
 
(function () {
     'use strict';
     'use strict';
    var SVG_NS = 'http://www.w3.org/2000/svg';
    var XLINK_NS = 'http://www.w3.org/1999/xlink';


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


     var BUTTON_GAP = 2;
    /* ---- 고정 규격 (HANDOVER §B-5) ---- */
     var OUTER_BORDER = 1;
     var CUT = 10;
     var INSET_LINE_OFFSET = 1.5;
    var GAP = 2;
     var BTN_H = 24;
    var FRAME_H = 11;
     var WELL = 2;
    var ROW_HEIGHT = FRAME_H + WELL + BTN_H + WELL + FRAME_H; /* 50 */
    var BTN_TOP = FRAME_H + WELL; /* 13 */


     /*
     var BORDER = '#050505';
     상단 우물의 최종 2px 간격을 확보하기 위해 실제 버튼 높이는 22px이다.
     var HI = '#555555';
     프레임 안쪽의 순수 #080808 간격 2px을 유지하면서
     var SH = '#101010';
    실제 SVG 버튼 높이는 28px로 둔다.
    var SURFACE = '#1d1d1d';
     */
    var WELL_BG = '#080808';
 
     var renderTimer = 0;


     function buildPath(title) {
     function buildPath(title) {
         if (typeof window.buildWikiPath === 'function') {
         if (typeof window.buildWikiPath === 'function') 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 readWidth(element) {
     /* §B-1 — 경계 배열. 인접 항목이 같은 경계를 공유 = 구멍 원천 불가.
        if (!element) return 0;
      half를 정수로 고정하지 않는다(원본 SVG와 동일하게 count/2). 그래야 내부 경계 어느 것도
 
      강제로 수직이 되지 않고, 프로젝트도 다른 버튼과 완전히 동일하게 양쪽 사선을 갖는다.
        var rect = element.getBoundingClientRect ? element.getBoundingClientRect() : null;
      (이전 버전은 half를 정수로 만들어 프로젝트를 수직 기둥으로 강제했으나, 실제 참조 화면과
        var width = rect ? Math.floor(rect.width || 0) : 0;
      달라 오류로 확인되어 제거함.) */
 
     function calculateBoundaries(width, count, cut) {
        if (!width && element.clientWidth) {
            width = Math.floor(element.clientWidth);
        }
 
        return width || 0;
    }
 
    function getRenderableWidth(nav, mount) {
        var candidates = [
            nav,
            mount,
            mount ? mount.parentElement : null,
            mount ? mount.closest('.main-portal') : null,
            document.querySelector('.liberty-content-main .mw-parser-output'),
            document.querySelector('.liberty-content-main'),
            document.querySelector('.liberty-content')
        ];
 
        for (var i = 0; i < candidates.length; i++) {
            var width = readWidth(candidates[i]);
 
            if (width > 0) return width;
        }
 
        if (document.documentElement && document.documentElement.clientWidth) {
            return Math.max(320, Math.floor(document.documentElement.clientWidth - 48));
        }
 
        return 1200;
    }
 
     function calculateGeometry(width) {
        var usableWidth = Math.max(320, Math.floor(width || 0));
        var count = ITEMS.length;
        var height = 22;
        var cut = Math.round((usableWidth / count) * 0.045);
         var top = [];
         var top = [];
         var bottom = [];
         var bottom = [];
         var half = count / 2;
         var half = count / 2;
         var i;
         var i;
 
         var x;
         cut = Math.max(6, Math.min(10, cut));


         for (i = 0; i <= count; i++) {
         for (i = 0; i <= count; i++) {
             var x = Math.round((usableWidth * i) / count);
             x = Math.round((width * i) / count);
 
             if (i === 0 || i === count || i === half) {
             if (i === 0 || i === count || i === half) {
                 top.push(x);
                 top.push(x);
104번째 줄: 63번째 줄:
             }
             }
         }
         }
 
         return { top:top, bottom:bottom, width:width };
         return {
            width: usableWidth,
            height: height,
            count: count,
            cut: cut,
            top: top,
            bottom: bottom,
            gap: BUTTON_GAP
        };
     }
     }


     function createSvgElement(name) {
    /* §B-2 — 정수 계단. clamp 없이 이어지는 버전(하단 프레임까지 연장 가능하도록) */
         return document.createElementNS(SVG_NS, name);
     function slantEdge(topX, bottomX, row, unitH) {
    }
         var total = bottomX - topX;
        var stepSize;
        var k;


    function roundCoordinate(value) {
        if (total === 0) return topX;
         return Math.round(value * 100) / 100;
         stepSize = unitH / Math.abs(total);
        k = Math.floor(row / stepSize);
        return topX + (total > 0 ? k : -k);
     }
     }


     function pointsToString(points) {
    /* §B-3 */
         return points.map(function (point) {
     function buttonEdges(i, count, bounds, row, btnH, gap) {
            return roundCoordinate(point[0]) + ',' + roundCoordinate(point[1]);
         var hg = gap / 2;
         }).join(' ');
        var lo = (i === 0) ? 0 : hg;
        var ro = (i === count - 1) ? 0 : hg;
        var lx = slantEdge(bounds.top[i], bounds.bottom[i], row, btnH) + lo;
        var rx = slantEdge(bounds.top[i + 1], bounds.bottom[i + 1], row, btnH) - ro;
         return { lx:lx, rx:rx };
     }
     }


     function getSegmentPoints(index, geometry) {
    /* §B-4 */
         var halfGap = geometry.gap / 2;
     function paintButtonRow(ctx, i, count, bounds, row, yTop, btnH, gap) {
         var leftOffset = index === 0 ? 0 : halfGap;
         var e = buttonEdges(i, count, bounds, row, btnH, gap);
         var rightOffset = index === geometry.count - 1 ? 0 : halfGap;
         var x;
         var c;


         return [
         for (x = e.lx; x < e.rx; x++) {
             [geometry.top[index] + leftOffset, 0],
             if (row === 0 || row === btnH - 1) c = BORDER;
             [geometry.top[index + 1] - rightOffset, 0],
             else if (x === e.lx || x === e.rx - 1) c = BORDER;
             [geometry.bottom[index + 1] - rightOffset, geometry.height],
            else if (row === 1) c = HI;
             [geometry.bottom[index] + leftOffset, geometry.height]
            else if (row === btnH - 2) c = SH;
         ];
             else if (x === e.lx + 1) c = SH;
            else if (x === e.rx - 2) c = HI;
            else c = SURFACE;
            ctx.fillStyle = c;
             ctx.fillRect(x, yTop + row, 1, 1);
         }
     }
     }


     function getCentroid(points) {
     function paintFrameBandFull(ctx, width, yTop, height) {
         var totalX = 0;
         var row;
         var totalY = 0;
         var c;


         points.forEach(function (point) {
         for (row = 0; row < height; row++) {
             totalX += point[0];
             if (row === 0 || row === height - 1) c = BORDER;
             totalY += point[1];
            else if (row === 1) c = HI;
         });
             else if (row === height - 2) c = SH;
            else c = SURFACE;
            ctx.fillStyle = c;
            ctx.fillRect(0, yTop + row, width, 1);
         }
    }


         return [totalX / points.length, totalY / points.length];
    function calculateLayout(width) {
         var count = ITEMS.length;
        var bounds = calculateBoundaries(width, count, CUT);
        return { width:width, count:count, bounds:bounds };
     }
     }


     function intersectLines(lineA, lineB) {
    /* 프로젝트 — 경계(2,3)의 사선을, 버튼 높이(24px) 기준이 아니라 이 행 전체 높이(50px)
         var p = lineA.point;
      기준으로 다시 계산해 처음부터 끝까지 하나의 매끄러운 사선으로 통일한다.
         var r = lineA.direction;
      (버튼 높이 구간만 사선이고 위아래 프레임 띠에서 뚝 끊겨 수직이 되는 방식은 "허리가 잘록한"
         var q = lineB.point;
      부자연스러운 모양이 되어 폐기함.) */
         var s = lineB.direction;
     function paintProjectFused(ctx, bounds, rowHeight) {
         var cross = r[0] * s[1] - r[1] * s[0];
         var row;
         var localRow;
         var lx;
         var rx;
         var c;


         if (Math.abs(cross) < 0.000001) {
         for (row = 0; row < rowHeight; row++) {
             return [p[0], p[1]];
             localRow = row - BTN_TOP;
            lx = slantEdge(bounds.top[2], bounds.bottom[2], localRow, BTN_H);
            rx = slantEdge(bounds.top[3], bounds.bottom[3], localRow, BTN_H);
            c = (row === 0 || row === rowHeight - 1) ? BORDER : SURFACE;
            ctx.fillStyle = c;
            ctx.fillRect(lx, row, rx - lx, 1);
         }
         }
        var qMinusP = [q[0] - p[0], q[1] - p[1]];
        var 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) {
     function draw(canvas, layout) {
         var center = getCentroid(points);
         var ctx = canvas.getContext('2d');
         var shiftedLines = [];
         var width = layout.width;
         var count = points.length;
         var bounds = layout.bounds;
        var row;
         var i;
         var i;
        var idx;
        var lx;
        var rx;
        var cx;
        var cy;


         for (i = 0; i < count; i++) {
         ctx.clearRect(0, 0, width, ROW_HEIGHT);
            var start = points[i];
        ctx.fillStyle = WELL_BG;
            var end = points[(i + 1) % count];
        ctx.fillRect(0, 0, width, ROW_HEIGHT);
            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) {
        paintFrameBandFull(ctx, width, 0, FRAME_H);
                 normal[0] *= -1;
 
                 normal[1] *= -1;
        for (row = 0; row < BTN_H; row++) {
            for (i = 0; i < layout.count; i++) {
                 if (ITEMS[i].key === 'project') continue;
                 paintButtonRow(ctx, i, layout.count, bounds, row, BTN_TOP, BTN_H, GAP);
             }
             }
            shiftedLines.push({
                point: [
                    start[0] + normal[0] * distance,
                    start[1] + normal[1] * distance
                ],
                direction: [dx, dy]
            });
         }
         }


         return shiftedLines.map(function (line, index) {
         paintFrameBandFull(ctx, width, BTN_TOP + BTN_H + WELL, FRAME_H);
            var previous = shiftedLines[(index + count - 1) % count];
         paintProjectFused(ctx, bounds, ROW_HEIGHT);
            return intersectLines(previous, line);
        });
    }
 
    function createPolygon(className, points) {
        var polygon = createSvgElement('polygon');
         polygon.setAttribute('class', className);
        polygon.setAttribute('points', pointsToString(points));
        return polygon;
    }


    function createEdgeLine(className, start, end) {
        ctx.font = '700 11px sans-serif';
         var line = createSvgElement('line');
         ctx.textAlign = 'center';
 
        ctx.textBaseline = 'middle';
         line.setAttribute('class', className);
         ctx.lineJoin = 'round';
         line.setAttribute('x1', roundCoordinate(start[0]));
         for (idx = 0; idx < layout.count; idx++) {
        line.setAttribute('y1', roundCoordinate(start[1]));
            if (ITEMS[idx].key === 'project') {
        line.setAttribute('x2', roundCoordinate(end[0]));
                lx = slantEdge(bounds.top[2], bounds.bottom[2], Math.floor(ROW_HEIGHT / 2) - BTN_TOP, BTN_H);
        line.setAttribute('y2', roundCoordinate(end[1]));
                rx = slantEdge(bounds.top[3], bounds.bottom[3], Math.floor(ROW_HEIGHT / 2) - BTN_TOP, BTN_H);
        line.setAttribute('vector-effect', 'non-scaling-stroke');
                cx = Math.round((lx + rx) / 2);
        line.setAttribute('pointer-events', 'none');
                cy = Math.round(ROW_HEIGHT / 2);
 
            } else {
         return line;
                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;
            ctx.strokeStyle = BORDER;
            ctx.strokeText(ITEMS[idx].label, cx, cy);
            ctx.fillStyle = '#e2e2e2';
            ctx.fillText(ITEMS[idx].label, cx, cy);
         }
     }
     }


     function createSegment(item, index, geometry) {
     function buildHitLayer(layout) {
         var anchor = createSvgElement('a');
         var wrap = document.createElement('div');
         var outerPoints = getSegmentPoints(index, geometry);
         var bounds = layout.bounds;
         var surfacePoints = insetPolygon(outerPoints, OUTER_BORDER);
         var i;
         var insetPoints = insetPolygon(outerPoints, INSET_LINE_OFFSET);
         var item;
         var outer = createPolygon('portal-cat-edge', outerPoints);
         var t;
         var surface = createPolygon('portal-cat-surface', surfacePoints);
         var b;
         var topEdge = createEdgeLine('portal-cat-inset portal-cat-inset-top', insetPoints[0], insetPoints[1]);
         var leftMin;
         var rightEdge = createEdgeLine('portal-cat-inset portal-cat-inset-right', insetPoints[1], insetPoints[2]);
         var rightMax;
         var bottomEdge = createEdgeLine('portal-cat-inset portal-cat-inset-bottom', insetPoints[2], insetPoints[3]);
         var a;
        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');
         wrap.className = 'portal-frame-hit-layer';
         anchor.setAttribute('href', href);
         wrap.style.position = 'absolute';
         anchor.setAttributeNS(XLINK_NS, 'xlink:href', href);
         wrap.style.left = '0';
         anchor.setAttribute('aria-label', item.label);
         wrap.style.top = '0';
         anchor.setAttribute('data-category-key', item.key);
        wrap.style.width = layout.width + 'px';
         wrap.style.height = ROW_HEIGHT + 'px';


         outer.setAttribute('pointer-events', 'none');
         for (i = 0; i < layout.count; i++) {
        surface.setAttribute('pointer-events', 'none');
            item = ITEMS[i];
        hit.setAttribute('pointer-events', 'all');
            if (item.key === 'project') continue; /* 비조작 */
            t = slantEdge(bounds.top[i], bounds.bottom[i], 0, BTN_H);
            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);


        text.setAttribute('class', 'portal-cat-label');
            a = document.createElement('a');
        text.setAttribute('x', Math.round(center[0]));
            a.href = buildPath(item.title);
        text.setAttribute('y', Math.round(geometry.height / 2) + 1);
            a.setAttribute('aria-label', item.label);
        text.setAttribute('font-size', '12');
            a.setAttribute('data-category-key', item.key);
        text.setAttribute('font-weight', '700');
            a.style.position = 'absolute';
        text.setAttribute('text-anchor', 'middle');
            a.style.left = leftMin + 'px';
        text.setAttribute('dominant-baseline', 'middle');
            a.style.top = BTN_TOP + 'px';
        text.setAttribute('pointer-events', 'none');
            a.style.width = (rightMax - leftMin) + 'px';
        text.textContent = item.label;
            a.style.height = BTN_H + 'px';
 
            a.style.display = 'block';
        anchor.appendChild(outer);
            wrap.appendChild(a);
        anchor.appendChild(surface);
         }
        anchor.appendChild(topEdge);
         return wrap;
         anchor.appendChild(rightEdge);
        anchor.appendChild(bottomEdge);
        anchor.appendChild(leftEdge);
        anchor.appendChild(hit);
        anchor.appendChild(text);
 
         return anchor;
     }
     }


     function clearNode(node) {
     function readWidth(mount) {
         while (node.firstChild) node.removeChild(node.firstChild);
         var rect = mount.getBoundingClientRect();
        var width = Math.floor(rect.width || 0);
        if (!width && mount.clientWidth) width = Math.floor(mount.clientWidth);
        return width || 0;
     }
     }


     function buildSvg(width) {
     function applyCounterScale(el) {
         var geometry = calculateGeometry(width);
         var scale = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--clbi-shell-scale')) || 1;
        var svg = createSvgElement('svg');
         el.style.transformOrigin = 'top left';
 
         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');
 
        ITEMS.forEach(function (item, index) {
            svg.appendChild(createSegment(item, index, geometry));
        });
 
        return {
            svg: svg,
            key: geometry.width + '|' + geometry.height + '|' + geometry.cut + '|' + geometry.gap + '|' + ITEMS.length
        };
     }
     }


     function renderSvgIntoNav(nav, mount) {
     function renderMount(mount) {
         var result = buildSvg(getRenderableWidth(nav, mount));
         var width;
        var layout;
        var wrap;
        var canvas;
        var ctx;
        var dpr;
        var hitLayer;


         if (nav.__categoryNavRenderKey === result.key && nav.querySelector('svg')) return;
         if (!mount) return false;
        width = readWidth(mount);
        if (!width) {
            if (!mount.__categoryNavRetry) {
                mount.__categoryNavRetry = window.setTimeout(function () {
                    mount.__categoryNavRetry = 0;
                    renderMount(mount);
                }, 60);
            }
            return false;
        }


         nav.__categoryNavRenderKey = result.key;
         layout = calculateLayout(width);
        clearNode(nav);
        nav.appendChild(result.svg);
    }


    function scheduleRender(nav, mount) {
        wrap = document.createElement('div');
         if (!nav || nav.__categoryNavFrame) return;
         wrap.className = 'portal-category-canvas-nav';
        wrap.style.position = 'relative';
        wrap.style.width = width + 'px';
        wrap.style.height = ROW_HEIGHT + 'px';
        applyCounterScale(wrap);


         nav.__categoryNavFrame = window.requestAnimationFrame(function () {
         canvas = document.createElement('canvas');
            nav.__categoryNavFrame = null;
        canvas.className = 'portal-category-canvas';
            renderSvgIntoNav(nav, mount);
        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';


    function renderCategoryNavMount(mount) {
        ctx = canvas.getContext('2d');
         if (!mount) return;
        ctx.imageSmoothingEnabled = false;
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
         draw(canvas, layout);


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


         clearNode(mount);
         while (mount.firstChild) mount.removeChild(mount.firstChild);
        wrap.appendChild(canvas);
        wrap.appendChild(hitLayer);
        mount.appendChild(wrap);
        mount.setAttribute('data-category-nav-width', String(width));
        mount.setAttribute('data-category-nav-rendered', 'canvas-full');


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


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


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


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


     window.CLBI = window.CLBI || {};
     function schedule(root) {
    window.CLBI.categoryNav = {
        window.clearTimeout(renderTimer);
         init: renderAllCategoryNavs,
        renderTimer = window.setTimeout(function () {
        renderMount: renderCategoryNavMount
            renderTimer = 0;
     };
            renderAll(root || document);
         }, 0);
     }


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


     if (window.mw && mw.hook) {
     if (window.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;
             schedule($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));

2026년 7월 20일 (월) 20:03 판

/* =========================================
COASTLINE: BLACK ICE - CategoryNav
전체 재구현: 상단 5버튼 + 프로젝트 기둥 (canvas, 경계 공유 + 정수 계단)
버전: 20260720-canvas-full-001
========================================= */

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

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

    /* ---- 고정 규격 (HANDOVER §B-5) ---- */
    var CUT = 10;
    var GAP = 2;
    var BTN_H = 24;
    var FRAME_H = 11;
    var WELL = 2;
    var ROW_HEIGHT = FRAME_H + WELL + BTN_H + WELL + FRAME_H; /* 50 */
    var BTN_TOP = FRAME_H + WELL; /* 13 */

    var BORDER = '#050505';
    var HI = '#555555';
    var SH = '#101010';
    var SURFACE = '#1d1d1d';
    var WELL_BG = '#080808';

    var renderTimer = 0;

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

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

        for (i = 0; i <= count; i++) {
            x = Math.round((width * i) / count);
            if (i === 0 || i === count || i === half) {
                top.push(x);
                bottom.push(x);
            } else if (i < half) {
                top.push(x - cut);
                bottom.push(x);
            } else {
                top.push(x);
                bottom.push(x - cut);
            }
        }
        return { top:top, bottom:bottom, width:width };
    }

    /* §B-2 — 정수 계단. clamp 없이 이어지는 버전(하단 프레임까지 연장 가능하도록) */
    function slantEdge(topX, bottomX, row, unitH) {
        var total = bottomX - topX;
        var stepSize;
        var k;

        if (total === 0) return topX;
        stepSize = unitH / Math.abs(total);
        k = Math.floor(row / stepSize);
        return topX + (total > 0 ? k : -k);
    }

    /* §B-3 */
    function buttonEdges(i, count, bounds, row, btnH, gap) {
        var hg = gap / 2;
        var lo = (i === 0) ? 0 : hg;
        var ro = (i === count - 1) ? 0 : hg;
        var lx = slantEdge(bounds.top[i], bounds.bottom[i], row, btnH) + lo;
        var rx = slantEdge(bounds.top[i + 1], bounds.bottom[i + 1], row, btnH) - ro;
        return { lx:lx, rx:rx };
    }

    /* §B-4 */
    function paintButtonRow(ctx, i, count, bounds, row, yTop, btnH, gap) {
        var e = buttonEdges(i, count, bounds, row, btnH, gap);
        var x;
        var c;

        for (x = e.lx; x < e.rx; x++) {
            if (row === 0 || row === btnH - 1) c = BORDER;
            else if (x === e.lx || x === e.rx - 1) c = BORDER;
            else if (row === 1) c = HI;
            else if (row === btnH - 2) c = SH;
            else if (x === e.lx + 1) c = SH;
            else if (x === e.rx - 2) c = HI;
            else c = SURFACE;
            ctx.fillStyle = c;
            ctx.fillRect(x, yTop + row, 1, 1);
        }
    }

    function paintFrameBandFull(ctx, width, yTop, height) {
        var row;
        var c;

        for (row = 0; row < height; row++) {
            if (row === 0 || row === height - 1) c = BORDER;
            else if (row === 1) c = HI;
            else if (row === height - 2) c = SH;
            else c = SURFACE;
            ctx.fillStyle = c;
            ctx.fillRect(0, yTop + row, width, 1);
        }
    }

    function calculateLayout(width) {
        var count = ITEMS.length;
        var bounds = calculateBoundaries(width, count, CUT);
        return { width:width, count:count, bounds:bounds };
    }

    /* 프로젝트 — 경계(2,3)의 사선을, 버튼 높이(24px) 기준이 아니라 이 행 전체 높이(50px)
       기준으로 다시 계산해 처음부터 끝까지 하나의 매끄러운 사선으로 통일한다.
       (버튼 높이 구간만 사선이고 위아래 프레임 띠에서 뚝 끊겨 수직이 되는 방식은 "허리가 잘록한"
       부자연스러운 모양이 되어 폐기함.) */
    function paintProjectFused(ctx, bounds, rowHeight) {
        var row;
        var localRow;
        var lx;
        var rx;
        var c;

        for (row = 0; row < rowHeight; row++) {
            localRow = row - BTN_TOP;
            lx = slantEdge(bounds.top[2], bounds.bottom[2], localRow, BTN_H);
            rx = slantEdge(bounds.top[3], bounds.bottom[3], localRow, BTN_H);
            c = (row === 0 || row === rowHeight - 1) ? BORDER : SURFACE;
            ctx.fillStyle = c;
            ctx.fillRect(lx, row, rx - lx, 1);
        }
    }

    function draw(canvas, layout) {
        var ctx = canvas.getContext('2d');
        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);
        ctx.fillStyle = WELL_BG;
        ctx.fillRect(0, 0, width, ROW_HEIGHT);

        paintFrameBandFull(ctx, width, 0, FRAME_H);

        for (row = 0; row < BTN_H; row++) {
            for (i = 0; i < layout.count; i++) {
                if (ITEMS[i].key === 'project') continue;
                paintButtonRow(ctx, i, layout.count, bounds, row, BTN_TOP, BTN_H, GAP);
            }
        }

        paintFrameBandFull(ctx, width, BTN_TOP + BTN_H + WELL, FRAME_H);
        paintProjectFused(ctx, bounds, ROW_HEIGHT);

        ctx.font = '700 11px sans-serif';
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        ctx.lineJoin = 'round';
        for (idx = 0; idx < layout.count; idx++) {
            if (ITEMS[idx].key === 'project') {
                lx = slantEdge(bounds.top[2], bounds.bottom[2], Math.floor(ROW_HEIGHT / 2) - BTN_TOP, BTN_H);
                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;
            ctx.strokeStyle = BORDER;
            ctx.strokeText(ITEMS[idx].label, cx, cy);
            ctx.fillStyle = '#e2e2e2';
            ctx.fillText(ITEMS[idx].label, cx, cy);
        }
    }

    function buildHitLayer(layout) {
        var wrap = document.createElement('div');
        var bounds = layout.bounds;
        var i;
        var item;
        var t;
        var b;
        var leftMin;
        var rightMax;
        var a;

        wrap.className = 'portal-frame-hit-layer';
        wrap.style.position = 'absolute';
        wrap.style.left = '0';
        wrap.style.top = '0';
        wrap.style.width = layout.width + 'px';
        wrap.style.height = ROW_HEIGHT + 'px';

        for (i = 0; i < layout.count; i++) {
            item = ITEMS[i];
            if (item.key === 'project') continue; /* 비조작 */
            t = slantEdge(bounds.top[i], bounds.bottom[i], 0, BTN_H);
            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');
            a.href = buildPath(item.title);
            a.setAttribute('aria-label', item.label);
            a.setAttribute('data-category-key', item.key);
            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;
    }

    function readWidth(mount) {
        var rect = mount.getBoundingClientRect();
        var width = Math.floor(rect.width || 0);
        if (!width && mount.clientWidth) width = Math.floor(mount.clientWidth);
        return width || 0;
    }

    function applyCounterScale(el) {
        var scale = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--clbi-shell-scale')) || 1;
        el.style.transformOrigin = 'top left';
        el.style.transform = scale && scale !== 1 ? 'scale(' + (1 / scale) + ')' : '';
    }

    function renderMount(mount) {
        var width;
        var layout;
        var wrap;
        var canvas;
        var ctx;
        var dpr;
        var hitLayer;

        if (!mount) return false;
        width = readWidth(mount);
        if (!width) {
            if (!mount.__categoryNavRetry) {
                mount.__categoryNavRetry = window.setTimeout(function () {
                    mount.__categoryNavRetry = 0;
                    renderMount(mount);
                }, 60);
            }
            return false;
        }

        layout = calculateLayout(width);

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

        canvas = document.createElement('canvas');
        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');
        ctx.imageSmoothingEnabled = false;
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        draw(canvas, layout);

        hitLayer = buildHitLayer(layout);

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

        /* 하단 2단이 읽어갈 공유 레이아웃 (DOM 실측 대신 변수 전달, §8-2) */
        window.CLBI = window.CLBI || {};
        window.CLBI.categoryNav = window.CLBI.categoryNav || {};
        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) {
            mount.__categoryNavObserver = new window.ResizeObserver(function () {
                schedule(mount);
            });
            mount.__categoryNavObserver.observe(mount);
        }
        return true;
    }

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

        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(function (mount) {
            if (renderMount(mount)) rendered += 1;
        });
        return rendered;
    }

    function schedule(root) {
        window.clearTimeout(renderTimer);
        renderTimer = window.setTimeout(function () {
            renderTimer = 0;
            renderAll(root || document);
        }, 0);
    }

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

    if (window.mw && mw.hook) {
        mw.hook('wikipage.content').add(function ($content) {
            schedule($content && $content[0] ? $content[0] : document);
        });
    }
    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));