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

(Install package: clbiwiki-portal-frame-display-fix-20260717 / js/PortalSectionNav.js)
(Install package: clbiwiki-portal-frame-fixed / js/PortalSectionNav.js)
 
(같은 사용자의 중간 판 하나는 보이지 않습니다)
1번째 줄: 1번째 줄:
/* =========================================
/* =========================================
PortalSectionNav
PortalSectionNav
대문 상단 카테고리 하단 부착형 섹션 버튼
대문 상단 카테고리 하단 부착형 canvas 픽셀 버튼
========================================= */
========================================= */


7번째 줄: 7번째 줄:
     'use strict';
     'use strict';


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


35번째 줄: 34번째 줄:
         }
         }
         return mount;
         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) {
     function outerGeometry(seams) {
         var frame = api();
         var frame = api();
        var d = frame.DIMENSIONS;
         var phase = seams.bottomStartAbsY % frame.FRAME.SLANT_RISE;
         var phase = seams.bottomStartAbsY % frame.FRAME.SLANT_RISE;
        var leftSeamX = frame.slantX(
            seams.settingsLeftTopX,
            seams.bottomStartAbsY,
            seams.settingsLeftDir,
            0
        );
        var rightSeamX = frame.slantX(
            seams.corpRightTopX,
            seams.bottomStartAbsY,
            seams.corpRightDir,
            0
        );
         return {
         return {
            width:seams.width,
            height:frame.DIMENSIONS.ROW_HEIGHT,
             phase:phase,
             phase:phase,
             leftSeamX:leftSeamX,
             leftSeamX:Math.max(0, Math.round(seams.settingsLeftSeamX)),
            rightSeamX:rightSeamX,
             rightSeamX:Math.min(seams.width - 1, Math.round(seams.corpRightSeamX))
            leftDir:seams.settingsLeftDir,
            rightDir:seams.corpRightDir,
             points:frame.segment(
                leftSeamX,
                rightSeamX,
                d.ROW_HEIGHT,
                seams.settingsLeftDir,
                seams.corpRightDir,
                phase
            )
         };
         };
     }
     }


     function appendFrame(svg, geometry) {
     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 frame = api();
         var f = frame.FRAME;
         var absoluteStep = Math.floor(
        var d = frame.DIMENSIONS;
             seams.bottomStartAbsY / frame.FRAME.SLANT_RISE
        var group = frame.create('g', 'portal-section-frame');
        var surface = frame.segmentAt(
            geometry.leftSeamX,
            geometry.rightSeamX,
            f.BORDER,
            d.ROW_HEIGHT - f.BORDER,
            geometry.leftDir,
            geometry.rightDir,
             geometry.phase,
            f.BORDER,
            f.BORDER
        );
        var well = frame.segmentAt(
            geometry.leftSeamX,
            geometry.rightSeamX,
            f.FRAME_FACE,
            d.ROW_HEIGHT - f.FRAME_FACE,
            geometry.leftDir,
            geometry.rightDir,
            geometry.phase,
            f.FRAME_FACE,
            f.FRAME_FACE
        );
        var joinCover = frame.segmentAt(
            geometry.leftSeamX,
            geometry.rightSeamX,
            0,
            d.OVERLAP,
            geometry.leftDir,
            geometry.rightDir,
            geometry.phase,
            0,
            0
        );
        var sideBevel = frame.segmentAt(
            geometry.leftSeamX,
            geometry.rightSeamX,
            d.OVERLAP,
            d.ROW_HEIGHT - f.BORDER,
            geometry.leftDir,
            geometry.rightDir,
            geometry.phase,
            f.BORDER + f.BEVEL,
            f.BORDER + f.BEVEL
         );
         );
        var leftSeam = seams.projectLeftOriginX + absoluteStep;
        var rightSeam = seams.projectRightOriginX - absoluteStep;
        var y;
        var x;


         group.appendChild(frame.polygon('portal-section-frame-edge', geometry.points));
         for (y = 0; y < geometry.height; y++) {
        group.appendChild(frame.polygon('portal-section-frame-surface', surface));
            var step = Math.floor((y + geometry.phase) / frame.FRAME.SLANT_RISE);
        group.appendChild(frame.polygon('portal-section-frame-well', well));
            var left = leftSeam + step;
        group.appendChild(frame.polygon('portal-section-join-side-cover', joinCover));
            var right = rightSeam - step;
        group.appendChild(frame.line('portal-section-frame-shadow', sideBevel[3], sideBevel[0]));
            for (x = left; x <= right; x++) {
        group.appendChild(frame.line('portal-section-frame-highlight', sideBevel[1], sideBevel[2]));
                frame.setPixel(image, geometry.width, geometry.height, x, y, 'S');
        group.appendChild(frame.line('portal-section-well-shadow', well[0], well[1]));
            }
        group.appendChild(frame.line('portal-section-well-shadow', well[1], well[2]));
            frame.setPixel(image, geometry.width, geometry.height, left, y, 'D');
        group.appendChild(frame.line('portal-section-well-highlight', well[3], well[0]));
            frame.setPixel(image, geometry.width, geometry.height, right, y, 'H');
        group.appendChild(frame.line('portal-section-well-highlight', well[2], well[3]));
         }
         svg.appendChild(group);
     }
     }


     function splitButtons(geometry) {
     function splitButtons(geometry) {
         var frame = api();
         var frame = api();
        var f = frame.FRAME;
         var d = frame.DIMENSIONS;
         var d = frame.DIMENSIONS;
         var leftBase = geometry.leftSeamX + f.FRAME_FACE + f.GAP;
         var left = sideAt(geometry, d.BUTTON_EDGE_TOP, 'left') + d.WELL_TOP;
         var rightBase = geometry.rightSeamX - f.FRAME_FACE - f.GAP;
         var right = sideAt(geometry, d.BUTTON_EDGE_TOP, 'right') - d.WELL_TOP;
         var leftTop = frame.slantX(leftBase, d.BUTTON_TOP, geometry.leftDir, geometry.phase);
         var available = Math.max(12, right - left + 1);
         var rightTop = frame.slantX(rightBase, d.BUTTON_TOP, geometry.rightDir, geometry.phase);
         var btnW = Math.max(4, Math.floor((available - frame.FRAME.GAP) / 2));
        var topMid = Math.round((leftTop + rightTop) / 2);
        var halfGap = f.GAP / 2;
 
         return [
         return [
             {
             {
                 leftTopX:leftBase,
                 x:left,
                 rightTopX:topMid - halfGap,
                 y:d.BUTTON_EDGE_TOP,
                 leftDir:geometry.leftDir,
                btnW:btnW,
                 rightDir:0,
                pixelWidth:btnW + 2,
                 pixelHeight:d.BUTTON_TOTAL,
                 slant:'left',
                 phase:geometry.phase
                 phase:geometry.phase
             },
             },
             {
             {
                 leftTopX:topMid + halfGap,
                 x:left + btnW + frame.FRAME.GAP,
                 rightTopX:rightBase,
                 y:d.BUTTON_EDGE_TOP,
                 leftDir:0,
                 btnW:btnW,
                 rightDir:geometry.rightDir,
                 pixelWidth:btnW + 2,
                pixelHeight:d.BUTTON_TOTAL,
                slant:'right',
                 phase:geometry.phase
                 phase:geometry.phase
             }
             }
163번째 줄: 164번째 줄:
     }
     }


     function appendButton(svg, item, button) {
     function labelCenter(button) {
         var frame = api();
         var frame = api();
         var f = frame.FRAME;
         var mask = frame.buttonMask(
        var d = frame.DIMENSIONS;
             button.btnW,
        var points = frame.segmentAt(
             Math.floor(frame.FRAME.BUTTON_FACE / 2),
             button.leftTopX,
             button.slant,
             button.rightTopX,
             button.phase
            d.BUTTON_TOP,
            d.BUTTON_BOTTOM,
             button.leftDir,
            button.rightDir,
             button.phase,
            0,
            0
         );
         );
         var surface = frame.segmentAt(
         return button.x + Math.round((mask.left + mask.right) / 2);
            button.leftTopX,
    }
            button.rightTopX,
 
            d.BUTTON_TOP + f.BORDER,
    function draw(canvas, geometry, buttons, seams, hoverIndex) {
            d.BUTTON_BOTTOM - f.BORDER,
        var frame = api();
            button.leftDir,
        var ctx = canvas.getContext('2d');
            button.rightDir,
        var image = frame.createImage(ctx, geometry.width, geometry.height, 'BG');
            button.phase,
 
            f.BORDER,
         paintOuterFrame(image, geometry);
            f.BORDER
         buttons.forEach(function (button) {
         );
            var grid = frame.buildButtonPixels(
         var bevel = frame.segmentAt(
                button.btnW,
            button.leftTopX,
                frame.FRAME.BUTTON_FACE,
            button.rightTopX,
                button.slant,
             d.BUTTON_TOP + f.BORDER + f.BEVEL,
                button.phase
            d.BUTTON_BOTTOM - f.BORDER - f.BEVEL,
            );
            button.leftDir,
             frame.paintGrid(
            button.rightDir,
                image,
            button.phase,
                geometry.width,
            f.BORDER + f.BEVEL,
                geometry.height,
             f.BORDER + f.BEVEL
                grid,
         );
                button.x,
         var anchor = frame.create('a', 'portal-cat-anchor portal-section-anchor');
                button.y,
         var group = frame.create('g', 'portal-section-button-geometry');
                true
         var hit = frame.polygon('portal-cat-hit', points);
             );
        var text = frame.create('text', 'portal-cat-label portal-section-label');
         });
        var href = item.href || buildWikiPath(item.title);
         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.setAttribute('data-category-key', item.key);
         anchor.setAttribute('href', href);
         anchor.style.left = button.x + 'px';
         anchor.setAttributeNS(XLINK_NS, 'xlink:href', href);
         anchor.style.top = button.y + 'px';
         anchor.setAttribute('aria-label', item.label);
         anchor.style.width = button.pixelWidth + 'px';
        group.appendChild(frame.polygon('portal-cat-edge', points));
         anchor.style.height = button.pixelHeight + 'px';
         group.appendChild(frame.polygon('portal-cat-surface', surface));
         label.className = 'portal-frame-sr-label';
         group.appendChild(frame.line('portal-cat-inset portal-cat-inset-top', bevel[0], bevel[1]));
         label.textContent = item.label;
         group.appendChild(frame.line('portal-cat-inset portal-cat-inset-right', bevel[1], bevel[2]));
         anchor.appendChild(label);
         group.appendChild(frame.line('portal-cat-inset portal-cat-inset-bottom', bevel[2], bevel[3]));
         anchor.addEventListener('mouseenter', function () {
         group.appendChild(frame.line('portal-cat-inset portal-cat-inset-left', bevel[3], bevel[0]));
            nav.__frameHoverIndex = index;
        hit.setAttribute('pointer-events', 'all');
            redraw();
        group.appendChild(hit);
         });
         anchor.appendChild(group);
         anchor.addEventListener('mouseleave', function () {
 
            if (nav.__frameHoverIndex === index) nav.__frameHoverIndex = -1;
         text.setAttribute('x', frame.centerX(surface));
            redraw();
        text.setAttribute('y', Math.round((d.BUTTON_TOP + d.BUTTON_BOTTOM) / 2) + 1);
        });
         text.setAttribute('font-size', '12');
         anchor.addEventListener('focus', function () {
        text.setAttribute('font-weight', '700');
            nav.__frameHoverIndex = index;
         text.setAttribute('text-anchor', 'middle');
            redraw();
         text.setAttribute('dominant-baseline', 'middle');
         });
        text.textContent = item.label;
         anchor.addEventListener('blur', function () {
         anchor.appendChild(text);
            if (nav.__frameHoverIndex === index) nav.__frameHoverIndex = -1;
         svg.appendChild(anchor);
            redraw();
         });
         return anchor;
     }
     }


237번째 줄: 252번째 줄:
         var mount;
         var mount;
         var nav;
         var nav;
         var svg;
         var inner;
        var canvas;
        var hitLayer;
         var geometry;
         var geometry;
         var buttons;
         var buttons;
        var redraw;


         if (!frame || !portal || !current || !current.width) return false;
         if (!frame || !portal || !current || !current.width) return false;
247번째 줄: 265번째 줄:
         geometry = outerGeometry(current);
         geometry = outerGeometry(current);
         buttons = splitButtons(geometry);
         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 = document.createElement('nav');
         nav.className = 'portal-section-nav';
         nav.className = 'portal-section-nav portal-frame-canvas-nav';
         nav.setAttribute('aria-label', '작품 영역');
         nav.setAttribute('aria-label', '작품 영역');
         svg = frame.create('svg', 'portal-section-svg');
         nav.__frameHoverIndex = -1;
         svg.setAttribute('viewBox', '0 0 ' + current.width + ' ' + frame.DIMENSIONS.ROW_HEIGHT);
 
         svg.setAttribute('width', '100%');
        inner = document.createElement('div');
         svg.setAttribute('height', frame.DIMENSIONS.ROW_HEIGHT);
        inner.className = 'portal-frame-counter-inner portal-section-counter-inner';
         svg.setAttribute('preserveAspectRatio', 'none');
         inner.style.width = geometry.width + 'px';
         svg.setAttribute('data-frame-width', current.width);
 
        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));
        });


         appendFrame(svg, geometry);
         inner.appendChild(canvas);
         ITEMS.forEach(function (item, index) { appendButton(svg, item, buttons[index]); });
         inner.appendChild(hitLayer);
         nav.appendChild(svg);
         nav.appendChild(inner);
         mount.replaceChildren(nav);
         mount.replaceChildren(nav);
         mount.setAttribute('data-portal-section-rendered', '1');
        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-width', String(current.width));
         mount.setAttribute('data-portal-section-phase', String(geometry.phase));
         mount.setAttribute('data-portal-section-phase', String(geometry.phase));
        mount.setAttribute('data-portal-section-scale', String(frame.getShellScale()));
         return true;
         return true;
     }
     }
293번째 줄: 342번째 줄:
     }
     }


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

2026년 7월 17일 (금) 17:49 기준 최신판

/* =========================================
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));