미디어위키:CategoryNav.js

Nxdsxn (토론 | 기여)님의 2026년 7월 20일 (월) 20:03 판 (Install package: clbi-categorynav-portalsection / js/CategoryNav.js)

참고: 설정을 저장한 후에 바뀐 점을 확인하기 위해서는 브라우저의 캐시를 새로 고쳐야 합니다.

  • 파이어폭스 / 사파리: Shift 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5 또는 Ctrl-R을 입력 (Mac에서는 ⌘-R)
  • 구글 크롬: Ctrl-Shift-R키를 입력 (Mac에서는 ⌘-Shift-R)
  • 인터넷 익스플로러 / 엣지: Ctrl 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5를 입력.
  • 오페라: Ctrl-F5를 입력.
/* =========================================
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));