미디어위키:PortalSectionNav.js

Nxdsxn (토론 | 기여)님의 2026년 7월 17일 (금) 17:49 판 (Install package: clbiwiki-portal-frame-fixed / js/PortalSectionNav.js)
(차이) ← 이전 판 | 최신판 (차이) | 다음 판 → (차이)

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

  • 파이어폭스 / 사파리: Shift 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5 또는 Ctrl-R을 입력 (Mac에서는 ⌘-R)
  • 구글 크롬: Ctrl-Shift-R키를 입력 (Mac에서는 ⌘-Shift-R)
  • 인터넷 익스플로러 / 엣지: Ctrl 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5를 입력.
  • 오페라: Ctrl-F5를 입력.
/* =========================================
PortalSectionNav
대문 상단 카테고리 하단 부착형 canvas 픽셀 버튼
========================================= */

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

    var COMPONENT = 'portal-section-nav';
    var VERSION = '20260717-canvas-pixel-001';
    var renderTimer = 0;
    var ITEMS = [
        { key:'black-ice', label:'블랙 아이스', title:'대문', slant:'left' },
        { key:'anecdote', label:'에넥도트', href:'/index.php/특수:모든문서?namespace=3000', slant:'right' }
    ];

    function api() {
        return window.CLBI && window.CLBI.frame;
    }

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

    function ensureMount(portal, topMount) {
        var mount = portal.querySelector('[data-component="' + COMPONENT + '"]');
        if (!mount) {
            mount = document.createElement('div');
            mount.setAttribute('data-component', COMPONENT);
        }
        if (mount.parentNode !== topMount.parentNode || topMount.nextSibling !== mount) {
            topMount.parentNode.insertBefore(mount, topMount.nextSibling);
        }
        return mount;
    }

    function sideAt(geometry, y, side) {
        var step = Math.floor((y + geometry.phase) / api().FRAME.SLANT_RISE);
        return side === 'left'
            ? geometry.leftSeamX + step
            : geometry.rightSeamX - step;
    }

    function outerGeometry(seams) {
        var frame = api();
        var phase = seams.bottomStartAbsY % frame.FRAME.SLANT_RISE;
        return {
            width:seams.width,
            height:frame.DIMENSIONS.ROW_HEIGHT,
            phase:phase,
            leftSeamX:Math.max(0, Math.round(seams.settingsLeftSeamX)),
            rightSeamX:Math.min(seams.width - 1, Math.round(seams.corpRightSeamX))
        };
    }

    function paintOuterFrame(image, geometry) {
        var frame = api();
        var width = geometry.width;
        var height = geometry.height;
        var body = [];
        var src;
        var y;
        var x;

        function inside(px, py) {
            if (py < 0 || py >= height) return false;
            return px >= sideAt(geometry, py, 'left') && px <= sideAt(geometry, py, 'right');
        }

        for (y = 0; y < height; y++) {
            body[y] = [];
            for (x = 0; x < width; x++) {
                if (!inside(x, y)) {
                    body[y][x] = 'BG';
                    continue;
                }
                body[y][x] = !(inside(x, y - 1) && inside(x, y + 1) && inside(x - 1, y) && inside(x + 1, y))
                    ? 'B'
                    : 'S';
            }
        }

        src = body.map(function (row) { return row.slice(); });
        for (y = 0; y < height; y++) {
            for (x = 0; x < width; x++) {
                if (src[y][x] !== 'S') continue;
                var tb = y > 0 && src[y - 1][x] === 'B';
                var rb = x < width - 1 && src[y][x + 1] === 'B';
                var bb = y < height - 1 && src[y + 1][x] === 'B';
                var lb = x > 0 && src[y][x - 1] === 'B';
                if (tb || rb) body[y][x] = 'H';
                else if (bb || lb) body[y][x] = 'D';
            }
        }
        frame.paintGrid(image, width, height, body, 0, 0, true);

        /* 상단 9px은 상단층과 같은 #1d1d1d 접합면으로 연다. */
        for (y = 0; y < frame.DIMENSIONS.OVERLAP; y++) {
            var left = sideAt(geometry, y, 'left');
            var right = sideAt(geometry, y, 'right');
            for (x = left + 1; x < right; x++) frame.setPixel(image, width, height, x, y, 'S');
            frame.setPixel(image, width, height, left, y, 'D');
            frame.setPixel(image, width, height, right, y, 'H');
        }

        /* 프레임 안쪽 우물. 위·아래 2px 간격은 순수 #080808로 남긴다. */
        for (y = frame.DIMENSIONS.WELL_TOP; y < frame.DIMENSIONS.WELL2_BOTTOM; y++) {
            var wellLeft = sideAt(geometry, y, 'left') + frame.DIMENSIONS.WELL_TOP;
            var wellRight = sideAt(geometry, y, 'right') - frame.DIMENSIONS.WELL_TOP;
            for (x = wellLeft; x <= wellRight; x++) frame.setPixel(image, width, height, x, y, 'BG');
        }
    }

    function paintProjectPillar(image, geometry, seams) {
        var frame = api();
        var absoluteStep = Math.floor(
            seams.bottomStartAbsY / frame.FRAME.SLANT_RISE
        );
        var leftSeam = seams.projectLeftOriginX + absoluteStep;
        var rightSeam = seams.projectRightOriginX - absoluteStep;
        var y;
        var x;

        for (y = 0; y < geometry.height; y++) {
            var step = Math.floor((y + geometry.phase) / frame.FRAME.SLANT_RISE);
            var left = leftSeam + step;
            var right = rightSeam - step;
            for (x = left; x <= right; x++) {
                frame.setPixel(image, geometry.width, geometry.height, x, y, 'S');
            }
            frame.setPixel(image, geometry.width, geometry.height, left, y, 'D');
            frame.setPixel(image, geometry.width, geometry.height, right, y, 'H');
        }
    }

    function splitButtons(geometry) {
        var frame = api();
        var d = frame.DIMENSIONS;
        var left = sideAt(geometry, d.BUTTON_EDGE_TOP, 'left') + d.WELL_TOP;
        var right = sideAt(geometry, d.BUTTON_EDGE_TOP, 'right') - d.WELL_TOP;
        var available = Math.max(12, right - left + 1);
        var btnW = Math.max(4, Math.floor((available - frame.FRAME.GAP) / 2));
        return [
            {
                x:left,
                y:d.BUTTON_EDGE_TOP,
                btnW:btnW,
                pixelWidth:btnW + 2,
                pixelHeight:d.BUTTON_TOTAL,
                slant:'left',
                phase:geometry.phase
            },
            {
                x:left + btnW + frame.FRAME.GAP,
                y:d.BUTTON_EDGE_TOP,
                btnW:btnW,
                pixelWidth:btnW + 2,
                pixelHeight:d.BUTTON_TOTAL,
                slant:'right',
                phase:geometry.phase
            }
        ];
    }

    function labelCenter(button) {
        var frame = api();
        var mask = frame.buttonMask(
            button.btnW,
            Math.floor(frame.FRAME.BUTTON_FACE / 2),
            button.slant,
            button.phase
        );
        return button.x + Math.round((mask.left + mask.right) / 2);
    }

    function draw(canvas, geometry, buttons, seams, hoverIndex) {
        var frame = api();
        var ctx = canvas.getContext('2d');
        var image = frame.createImage(ctx, geometry.width, geometry.height, 'BG');

        paintOuterFrame(image, geometry);
        buttons.forEach(function (button) {
            var grid = frame.buildButtonPixels(
                button.btnW,
                frame.FRAME.BUTTON_FACE,
                button.slant,
                button.phase
            );
            frame.paintGrid(
                image,
                geometry.width,
                geometry.height,
                grid,
                button.x,
                button.y,
                true
            );
        });
        paintProjectPillar(image, geometry, seams);
        ctx.putImageData(image, 0, 0);

        buttons.forEach(function (button, index) {
            frame.drawLabel(
                ctx,
                ITEMS[index].label,
                labelCenter(button),
                Math.round((frame.DIMENSIONS.BUTTON_SURFACE_TOP + frame.DIMENSIONS.BUTTON_SURFACE_BOTTOM) / 2) + 1,
                hoverIndex === index,
                geometry.width <= 820 ? 11 : 12
            );
        });
    }

    function createHit(item, index, button, nav, redraw) {
        var anchor = document.createElement('a');
        var label = document.createElement('span');
        anchor.className = 'portal-frame-hit portal-section-anchor';
        anchor.href = item.href || buildWikiPath(item.title);
        anchor.setAttribute('aria-label', item.label);
        anchor.setAttribute('data-category-key', item.key);
        anchor.style.left = button.x + 'px';
        anchor.style.top = button.y + 'px';
        anchor.style.width = button.pixelWidth + 'px';
        anchor.style.height = button.pixelHeight + 'px';
        label.className = 'portal-frame-sr-label';
        label.textContent = item.label;
        anchor.appendChild(label);
        anchor.addEventListener('mouseenter', function () {
            nav.__frameHoverIndex = index;
            redraw();
        });
        anchor.addEventListener('mouseleave', function () {
            if (nav.__frameHoverIndex === index) nav.__frameHoverIndex = -1;
            redraw();
        });
        anchor.addEventListener('focus', function () {
            nav.__frameHoverIndex = index;
            redraw();
        });
        anchor.addEventListener('blur', function () {
            if (nav.__frameHoverIndex === index) nav.__frameHoverIndex = -1;
            redraw();
        });
        return anchor;
    }

    function renderPortal(portal, seams) {
        var frame = api();
        var current = seams || (frame && frame.seams);
        var topMount;
        var mount;
        var nav;
        var inner;
        var canvas;
        var hitLayer;
        var geometry;
        var buttons;
        var redraw;

        if (!frame || !portal || !current || !current.width) return false;
        topMount = portal.querySelector('[data-component="category-nav"]');
        if (!topMount) return false;
        mount = ensureMount(portal, topMount);
        geometry = outerGeometry(current);
        buttons = splitButtons(geometry);
        if (
            mount.getAttribute('data-portal-section-width') === String(current.width) &&
            mount.getAttribute('data-portal-section-phase') === String(geometry.phase) &&
            mount.getAttribute('data-portal-section-scale') === String(frame.getShellScale()) &&
            mount.querySelector('.portal-section-canvas')
        ) {
            return true;
        }

        nav = document.createElement('nav');
        nav.className = 'portal-section-nav portal-frame-canvas-nav';
        nav.setAttribute('aria-label', '작품 영역');
        nav.__frameHoverIndex = -1;

        inner = document.createElement('div');
        inner.className = 'portal-frame-counter-inner portal-section-counter-inner';
        inner.style.width = geometry.width + 'px';

        canvas = frame.createCanvas('portal-frame-canvas portal-section-canvas', geometry.width, geometry.height);
        canvas.setAttribute('data-frame-width', String(geometry.width));
        canvas.setAttribute('data-frame-height', String(geometry.height));

        hitLayer = document.createElement('div');
        hitLayer.className = 'portal-frame-hit-layer';
        hitLayer.style.width = geometry.width + 'px';
        hitLayer.style.height = geometry.height + 'px';

        redraw = function () { draw(canvas, geometry, buttons, current, nav.__frameHoverIndex); };
        ITEMS.forEach(function (item, index) {
            hitLayer.appendChild(createHit(item, index, buttons[index], nav, redraw));
        });

        inner.appendChild(canvas);
        inner.appendChild(hitLayer);
        nav.appendChild(inner);
        mount.replaceChildren(nav);
        frame.applyCounterScale(
            [mount, nav],
            inner,
            geometry.height,
            frame.DIMENSIONS.OVERLAP
        );
        redraw();

        mount.setAttribute('data-portal-section-rendered', 'canvas');
        mount.setAttribute('data-portal-section-width', String(current.width));
        mount.setAttribute('data-portal-section-phase', String(geometry.phase));
        mount.setAttribute('data-portal-section-scale', String(frame.getShellScale()));
        return true;
    }

    function collectPortals(root) {
        var scope = root && root.querySelectorAll ? root : document;
        var portals = [];
        if (scope.matches && scope.matches('.main-portal')) portals.push(scope);
        Array.prototype.forEach.call(scope.querySelectorAll('.main-portal'), function (portal) {
            if (portals.indexOf(portal) === -1) portals.push(portal);
        });
        return portals;
    }

    function renderAll(root, seams) {
        var rendered = 0;
        collectPortals(root).forEach(function (portal) {
            if (renderPortal(portal, seams)) rendered += 1;
        });
        return rendered;
    }

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

    if (api()) {
        api().onSeams(function (seams) { schedule(document, seams); });
        api().onScale(function () { schedule(document); });
    }
    if (mw && mw.hook) {
        mw.hook('wikipage.content').add(function (content) {
            schedule(content && content[0] ? content[0] : document);
        });
    }

    window.PortalSectionNav = {
        version:VERSION,
        init:renderAll,
        render:renderPortal,
        renderAll:renderAll
    };
}(window, document, window.mw));