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

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


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


    var SVG_NS = 'http://www.w3.org/2000/svg';
    var XLINK_NS = 'http://www.w3.org/1999/xlink';
     var COMPONENT = 'portal-section-nav';
     var COMPONENT = 'portal-section-nav';
     var VERSION = '20260716-portal-section-minimal-join-line-036';
     var VERSION = '20260717-canvas-pixel-001';
    var BUTTON_HEIGHT = 22;
    var TOP_FRAME = 8;
    var BOTTOM_FRAME = 8;
    var GAP = 2;
    var ROW_HEIGHT = TOP_FRAME + GAP + BUTTON_HEIGHT + GAP + BOTTOM_FRAME;
    var BUTTON_TOP = TOP_FRAME + GAP;
    var BUTTON_BOTTOM = BUTTON_TOP + BUTTON_HEIGHT;
    var OUTER_BORDER = 1;
    var INSET_OFFSET = 1.5;
    var resizeTimer = 0;
     var renderTimer = 0;
     var renderTimer = 0;
    var observer = null;
     var ITEMS = [
     var ITEMS = [
         { key:'black-ice', label:'블랙 아이스', title:'대문' },
         { key:'black-ice', label:'블랙 아이스', title:'대문', slant:'left' },
         {
         { key:'anecdote', label:'에넥도트', href:'/index.php/특수:모든문서?namespace=3000', slant:'right' }
            key:'anecdote',
            label:'에넥도트',
            href:'/index.php/특수:모든문서?namespace=3000'
        }
     ];
     ];


     function createSvgElement(name) {
     function api() {
         return document.createElementNS(SVG_NS, name);
         return window.CLBI && window.CLBI.frame;
     }
     }


     function buildWikiPath(title) {
     function buildWikiPath(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 roundCoordinate(value) {
        return Math.round(value * 10000) / 10000;
    }
    function pointsToString(points) {
        return points.map(function (point) {
            return roundCoordinate(point[0]) + ',' + roundCoordinate(point[1]);
        }).join(' ');
    }
    function parsePoints(value) {
        return String(value || '').trim().split(/\s+/).map(function (pair) {
            var parts = pair.split(',');
            return [Number(parts[0]), Number(parts[1])];
        }).filter(function (point) {
            return Number.isFinite(point[0]) && Number.isFinite(point[1]);
        });
    }
    function getCentroid(points) {
        var x = 0;
        var y = 0;
        points.forEach(function (point) {
            x += point[0];
            y += point[1];
        });
        return [x / points.length, y / points.length];
    }
    function lineXAtY(pointA, pointB, y) {
        var dy = pointB[1] - pointA[1];
        if (Math.abs(dy) < 0.000001) return pointA[0];
        return pointA[0] + (y - pointA[1]) * (pointB[0] - pointA[0]) / dy;
    }
    function projectSidesToY(points, top, bottom, horizontalInset) {
        var inset = Number(horizontalInset) || 0;
        return [
            [lineXAtY(points[0], points[3], top) + inset, top],
            [lineXAtY(points[1], points[2], top) - inset, top],
            [lineXAtY(points[1], points[2], bottom) - inset, bottom],
            [lineXAtY(points[0], points[3], bottom) + inset, bottom]
        ];
    }
    function createPolygon(className, points) {
        var polygon = createSvgElement('polygon');
        polygon.setAttribute('class', className);
        polygon.setAttribute('points', pointsToString(points));
        polygon.setAttribute('pointer-events', 'none');
        return polygon;
    }
    function createLine(className, start, end) {
        var line = createSvgElement('line');
        line.setAttribute('class', className);
        line.setAttribute('x1', roundCoordinate(start[0]));
        line.setAttribute('y1', roundCoordinate(start[1]));
        line.setAttribute('x2', roundCoordinate(end[0]));
        line.setAttribute('y2', roundCoordinate(end[1]));
        line.setAttribute('vector-effect', 'non-scaling-stroke');
        line.setAttribute('pointer-events', 'none');
        return line;
    }
    function getViewBox(svg) {
        var base = svg && svg.viewBox && svg.viewBox.baseVal;
        var values;
        if (base && base.width > 0 && base.height > 0) {
            return { x:base.x, y:base.y, width:base.width, height:base.height };
        }
        values = String(svg.getAttribute('viewBox') || '').trim().split(/[\s,]+/).map(Number);
        if (values.length === 4 && values[2] > 0 && values[3] > 0) {
            return { x:values[0], y:values[1], width:values[2], height:values[3] };
        }
        return null;
     }
     }


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


     function fallbackGeometry(width) {
     function sideAt(geometry, y, side) {
         var cut = Math.max(6, Math.min(10, Math.round((width / 5) * 0.045)));
         var step = Math.floor((y + geometry.phase) / api().FRAME.SLANT_RISE);
         var run = Math.round(cut * ROW_HEIGHT / 22);
         return side === 'left'
        var outer = [
             ? geometry.leftSeamX + step
             [Math.round(width * 0.2), 0],
             : geometry.rightSeamX - step;
             [Math.round(width * 0.8), 0],
    }
            [Math.round(width * 0.8) - run, ROW_HEIGHT],
 
            [Math.round(width * 0.2) + run, ROW_HEIGHT]
    function outerGeometry(seams) {
        ];
        var frame = api();
        var phase = seams.bottomStartAbsY % frame.FRAME.SLANT_RISE;
         return {
         return {
             outer:outer,
             width:seams.width,
             leftDirection:projectSidesToY(outer, 0, ROW_HEIGHT - 1, INSET_OFFSET).map(function (point, index) {
             height:frame.DIMENSIONS.ROW_HEIGHT,
                return index === 0 || index === 3 ? point : null;
            phase:phase,
             }).filter(Boolean),
             leftSeamX:Math.max(0, Math.round(seams.settingsLeftSeamX)),
             rightDirection:projectSidesToY(outer, 0, ROW_HEIGHT - 1, INSET_OFFSET).slice(1, 3)
             rightSeamX:Math.min(seams.width - 1, Math.round(seams.corpRightSeamX))
         };
         };
     }
     }


     function readFrameGeometry(topMount, mount, width) {
     function paintOuterFrame(image, geometry) {
         var topSvg = topMount.querySelector('.portal-category-svg');
         var frame = api();
         var settingsAnchor = topMount.querySelector('.portal-cat-anchor[data-category-key="settings"]');
         var width = geometry.width;
        var corporationAnchor = topMount.querySelector('.portal-cat-anchor[data-category-key="corporations"]');
         var height = geometry.height;
        var settings = settingsAnchor && settingsAnchor.querySelector('.portal-cat-edge');
         var body = [];
        var corporations = corporationAnchor && corporationAnchor.querySelector('.portal-cat-edge');
         var src;
         var settingsInset = settingsAnchor && settingsAnchor.querySelector('.portal-cat-inset-left');
         var y;
        var corporationInset = corporationAnchor && corporationAnchor.querySelector('.portal-cat-inset-right');
         var x;
        var settingsPoints = settings ? parsePoints(settings.getAttribute('points')) : [];
         var corporationPoints = corporations ? parsePoints(corporations.getAttribute('points')) : [];
         var settingsInsetPoints = settingsInset ? [
            [Number(settingsInset.getAttribute('x1')), Number(settingsInset.getAttribute('y1'))],
            [Number(settingsInset.getAttribute('x2')), Number(settingsInset.getAttribute('y2'))]
        ] : [];
        var corporationInsetPoints = corporationInset ? [
            [Number(corporationInset.getAttribute('x1')), Number(corporationInset.getAttribute('y1'))],
            [Number(corporationInset.getAttribute('x2')), Number(corporationInset.getAttribute('y2'))]
        ] : [];
        var svgRect;
        var mountRect;
        var viewBox;
        var scaleX;
        var scaleY;
         var outer;
         var fallback;


         if (!topSvg || settingsPoints.length !== 4 || corporationPoints.length !== 4) {
         function inside(px, py) {
             return fallbackGeometry(width);
            if (py < 0 || py >= height) return false;
             return px >= sideAt(geometry, py, 'left') && px <= sideAt(geometry, py, 'right');
         }
         }


         svgRect = topSvg.getBoundingClientRect();
         for (y = 0; y < height; y++) {
        mountRect = mount.getBoundingClientRect();
            body[y] = [];
        viewBox = getViewBox(topSvg);
            for (x = 0; x < width; x++) {
        if (
                if (!inside(x, y)) {
            !viewBox ||
                    body[y][x] = 'BG';
            !(svgRect.width > 0) ||
                    continue;
            !(svgRect.height > 0) ||
                }
            !(mountRect.width > 0)
                body[y][x] = !(inside(x, y - 1) && inside(x, y + 1) && inside(x - 1, y) && inside(x + 1, y))
        ) {
                    ? 'B'
            return fallbackGeometry(width);
                    : 'S';
            }
         }
         }


         scaleX = svgRect.width / viewBox.width;
         src = body.map(function (row) { return row.slice(); });
        scaleY = svgRect.height / viewBox.height;
        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);


         function screenPoint(point) {
         /* 상단 9px은 상단층과 같은 #1d1d1d 접합면으로 연다. */
             return [
        for (y = 0; y < frame.DIMENSIONS.OVERLAP; y++) {
                svgRect.left + (point[0] - viewBox.x) * scaleX,
             var left = sideAt(geometry, y, 'left');
                svgRect.top + (point[1] - viewBox.y) * scaleY
            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');
         }
         }


         function localLineXAt(pointA, pointB, localY) {
         /* 프레임 안쪽 우물. 위·아래 2px 간격은 순수 #080808로 남긴다. */
             var a = screenPoint(pointA);
        for (y = frame.DIMENSIONS.WELL_TOP; y < frame.DIMENSIONS.WELL2_BOTTOM; y++) {
             var b = screenPoint(pointB);
             var wellLeft = sideAt(geometry, y, 'left') + frame.DIMENSIONS.WELL_TOP;
            var targetY = mountRect.top + localY;
             var wellRight = sideAt(geometry, y, 'right') - frame.DIMENSIONS.WELL_TOP;
             var dy = b[1] - a[1];
             for (x = wellLeft; x <= wellRight; x++) frame.setPixel(image, width, height, x, y, 'BG');
            var screenX;
 
            if (Math.abs(dy) < 0.000001) {
                return a[0] - mountRect.left;
            }
 
            screenX = a[0] + (targetY - a[1]) * (b[0] - a[0]) / dy;
            return screenX - mountRect.left;
         }
         }
        /*
        * 설정 버튼의 왼쪽 사선과 기업 버튼의 오른쪽 사선을 화면 좌표계의
        * 직선으로 읽고, 새 프레임의 상단·하단 높이까지 그대로 연장한다.
        * 단순히 기존 x 편차를 재사용하지 않으므로 행 높이가 달라도 경사각이
        * 바뀌지 않으며 두 프레임 사이의 9px 겹침 안에서도 한 선으로 이어진다.
        */
        outer = [
            [localLineXAt(settingsPoints[0], settingsPoints[3], 0), 0],
            [localLineXAt(corporationPoints[1], corporationPoints[2], 0), 0],
            [localLineXAt(corporationPoints[1], corporationPoints[2], ROW_HEIGHT), ROW_HEIGHT],
            [localLineXAt(settingsPoints[0], settingsPoints[3], ROW_HEIGHT), ROW_HEIGHT]
        ];
        fallback = {
            leftDirection:[
                [lineXAtY(outer[0], outer[3], 0) + INSET_OFFSET, 0],
                [lineXAtY(outer[0], outer[3], ROW_HEIGHT - 1) + INSET_OFFSET, ROW_HEIGHT - 1]
            ],
            rightDirection:[
                [lineXAtY(outer[1], outer[2], 0) - INSET_OFFSET, 0],
                [lineXAtY(outer[1], outer[2], ROW_HEIGHT - 1) - INSET_OFFSET, ROW_HEIGHT - 1]
            ]
        };
        return {
            outer:outer,
            leftDirection:settingsInsetPoints.length === 2 && settingsInsetPoints.every(function (point) {
                return Number.isFinite(point[0]) && Number.isFinite(point[1]);
            }) ? [
                [localLineXAt(settingsInsetPoints[0], settingsInsetPoints[1], 0), 0],
                [localLineXAt(settingsInsetPoints[0], settingsInsetPoints[1], ROW_HEIGHT - 1), ROW_HEIGHT - 1]
            ] : fallback.leftDirection,
            rightDirection:corporationInsetPoints.length === 2 && corporationInsetPoints.every(function (point) {
                return Number.isFinite(point[0]) && Number.isFinite(point[1]);
            }) ? [
                [localLineXAt(corporationInsetPoints[0], corporationInsetPoints[1], 0), 0],
                [localLineXAt(corporationInsetPoints[0], corporationInsetPoints[1], ROW_HEIGHT - 1), ROW_HEIGHT - 1]
            ] : fallback.rightDirection
        };
     }
     }


     function appendFrame(svg, outer, directions) {
     function paintProjectPillar(image, geometry, seams) {
         var surface = projectSidesToY(outer, 0, ROW_HEIGHT - 1, OUTER_BORDER);
         var frame = api();
         var well = projectSidesToY(outer, TOP_FRAME, ROW_HEIGHT - BOTTOM_FRAME, TOP_FRAME);
         var absoluteStep = Math.floor(
        var topDirectionY = TOP_FRAME - 1;
             seams.bottomStartAbsY / frame.FRAME.SLANT_RISE
        var topDirection = [
         );
             [lineXAtY(well[0], well[3], topDirectionY), topDirectionY],
         var leftSeam = seams.projectLeftOriginX + absoluteStep;
            [lineXAtY(well[1], well[2], topDirectionY), topDirectionY]
         var rightSeam = seams.projectRightOriginX - absoluteStep;
        ];
         var y;
         var group = createSvgElement('g');
         var x;
         var joinDepth = TOP_FRAME;
        var leftJoinCover;
        var rightJoinCover;
        var leftDirection = directions.leftDirection;
         var rightDirection = directions.rightDirection;
        var joinLeftTop;
        var joinLeftBottom;
         var joinRightTop;
         var joinRightBottom;


         group.setAttribute('class', 'portal-section-frame');
         for (y = 0; y < geometry.height; y++) {
        group.appendChild(createPolygon('portal-section-frame-edge', outer));
            var step = Math.floor((y + geometry.phase) / frame.FRAME.SLANT_RISE);
        group.appendChild(createPolygon('portal-section-frame-surface', surface));
            var left = leftSeam + step;
        group.appendChild(createPolygon('portal-section-frame-well', well));
            var right = rightSeam - step;
 
             for (x = left; x <= right; x++) {
        /* 수평 방향성은 좌우 접합 처리와 무관하므로 먼저 그린다. */
                frame.setPixel(image, geometry.width, geometry.height, x, y, 'S');
        group.appendChild(createLine('portal-section-frame-shadow', rightDirection[1], leftDirection[1]));
            }
        group.appendChild(createLine('portal-section-well-shadow', topDirection[0], topDirection[1]));
            frame.setPixel(image, geometry.width, geometry.height, left, y, 'D');
        group.appendChild(createLine('portal-section-well-highlight', well[2], well[3]));
            frame.setPixel(image, geometry.width, geometry.height, right, y, 'H');
 
         }
        /* 하단의 원래 방향선은 유지하고, 접합 구간만 뒤의 덮개가 지운다. */
        group.appendChild(createLine('portal-section-frame-shadow', leftDirection[1], leftDirection[0]));
        group.appendChild(createLine('portal-section-frame-highlight', rightDirection[0], rightDirection[1]));
 
        /*
        * 기존 프레임과 포개지는 상단 9px은 같은 재료의 면이다.
        * 덮개는 외곽에서 우물 경계까지 정확히 채우되 우물 안으로 1px
        * 침범하지 않는다. 좌우 방향선은 덮개 뒤 y=9부터 직접 시작한다.
        */
        leftJoinCover = [
             [lineXAtY(outer[0], outer[3], 0) - OUTER_BORDER, 0],
            [lineXAtY(well[0], well[3], 0), 0],
            [lineXAtY(well[0], well[3], joinDepth), joinDepth],
            [lineXAtY(outer[0], outer[3], joinDepth) - OUTER_BORDER, joinDepth]
        ];
        rightJoinCover = [
            [lineXAtY(well[1], well[2], 0), 0],
            [lineXAtY(outer[1], outer[2], 0) + OUTER_BORDER, 0],
            [lineXAtY(outer[1], outer[2], joinDepth) + OUTER_BORDER, joinDepth],
            [lineXAtY(well[1], well[2], joinDepth), joinDepth]
        ];
        group.appendChild(createPolygon('portal-section-join-side-cover', leftJoinCover));
        group.appendChild(createPolygon('portal-section-join-side-cover', rightJoinCover));
 
        /*
        * 접합 구간에서는 제거한 #050505의 픽셀 자리에 방향선을 옮긴다.
        * 기존 방향선 자리는 덮개가 #1d1d1d로 되돌린다.
        */
        joinLeftTop = [lineXAtY(outer[0], outer[3], 0) + 0.5, 0];
        joinLeftBottom = [lineXAtY(outer[0], outer[3], joinDepth) + 0.5, joinDepth];
        joinRightTop = [lineXAtY(outer[1], outer[2], 0) - 0.5, 0];
        joinRightBottom = [lineXAtY(outer[1], outer[2], joinDepth) - 0.5, joinDepth];
        group.appendChild(createLine('portal-section-frame-shadow portal-section-frame-join-line', joinLeftTop, joinLeftBottom));
        group.appendChild(createLine('portal-section-frame-highlight portal-section-frame-join-line', joinRightTop, joinRightBottom));
 
        group.appendChild(createLine('portal-section-well-shadow', well[1], well[2]));
        group.appendChild(createLine('portal-section-well-highlight', well[3], well[0]));
         svg.appendChild(group);
     }
     }


     function splitButtons(outer) {
     function splitButtons(geometry) {
         var union = projectSidesToY(outer, BUTTON_TOP, BUTTON_BOTTOM, TOP_FRAME + GAP);
         var frame = api();
         var topMid = (union[0][0] + union[1][0]) / 2;
        var d = frame.DIMENSIONS;
         var bottomMid = (union[3][0] + union[2][0]) / 2;
        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 [
         return [
             [
             {
                 union[0],
                 x:left,
                 [topMid - GAP / 2, BUTTON_TOP],
                 y:d.BUTTON_EDGE_TOP,
                 [bottomMid - GAP / 2, BUTTON_BOTTOM],
                btnW:btnW,
                 union[3]
                 pixelWidth:btnW + 2,
             ],
                pixelHeight:d.BUTTON_TOTAL,
             [
                slant:'left',
                 [topMid + GAP / 2, BUTTON_TOP],
                 phase:geometry.phase
                 union[1],
             },
                 union[2],
             {
                 [bottomMid + GAP / 2, BUTTON_BOTTOM]
                 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 appendButton(svg, item, points) {
     function labelCenter(button) {
         var anchor = createSvgElement('a');
         var frame = api();
         var geometry = createSvgElement('g');
         var mask = frame.buttonMask(
        var surfacePoints = projectSidesToY(
             button.btnW,
             points,
             Math.floor(frame.FRAME.BUTTON_FACE / 2),
             points[0][1] + OUTER_BORDER,
             button.slant,
             points[3][1] - OUTER_BORDER,
             button.phase
             OUTER_BORDER
         );
         );
         var insetPoints = projectSidesToY(
         return button.x + Math.round((mask.left + mask.right) / 2);
            points,
    }
            points[0][1] + INSET_OFFSET,
            points[3][1] - INSET_OFFSET,
            INSET_OFFSET
        );
        var center = getCentroid(surfacePoints);
        var text = createSvgElement('text');
        var hit = createPolygon('portal-cat-hit', points);
        var href = item.href || buildWikiPath(item.title);


        anchor.setAttribute('class', 'portal-cat-anchor portal-section-anchor');
    function draw(canvas, geometry, buttons, seams, hoverIndex) {
         anchor.setAttribute('data-category-key', item.key);
         var frame = api();
         anchor.setAttribute('href', href);
         var ctx = canvas.getContext('2d');
         anchor.setAttributeNS(XLINK_NS, 'xlink:href', href);
         var image = frame.createImage(ctx, geometry.width, geometry.height, 'BG');
        anchor.setAttribute('aria-label', item.label);


         geometry.setAttribute('class', 'portal-section-button-geometry');
         paintOuterFrame(image, geometry);
         geometry.appendChild(createPolygon('portal-cat-edge', points));
         buttons.forEach(function (button) {
        geometry.appendChild(createPolygon('portal-cat-surface', surfacePoints));
            var grid = frame.buildButtonPixels(
        geometry.appendChild(createLine('portal-cat-inset portal-cat-inset-top', insetPoints[0], insetPoints[1]));
                button.btnW,
        geometry.appendChild(createLine('portal-cat-inset portal-cat-inset-right', insetPoints[1], insetPoints[2]));
                frame.FRAME.BUTTON_FACE,
        geometry.appendChild(createLine('portal-cat-inset portal-cat-inset-bottom', insetPoints[2], insetPoints[3]));
                button.slant,
        geometry.appendChild(createLine('portal-cat-inset portal-cat-inset-left', insetPoints[3], insetPoints[0]));
                button.phase
         hit.setAttribute('pointer-events', 'all');
            );
         geometry.appendChild(hit);
            frame.paintGrid(
                image,
                geometry.width,
                geometry.height,
                grid,
                button.x,
                button.y,
                true
            );
        });
         paintProjectPillar(image, geometry, seams);
         ctx.putImageData(image, 0, 0);


         text.setAttribute('class', 'portal-cat-label portal-section-label');
         buttons.forEach(function (button, index) {
        text.setAttribute('x', roundCoordinate(center[0]));
            frame.drawLabel(
        text.setAttribute('y', roundCoordinate((BUTTON_TOP + BUTTON_BOTTOM) / 2 + 1));
                ctx,
         text.textContent = item.label;
                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
            );
         });
    }


         anchor.appendChild(geometry);
    function createHit(item, index, button, nav, redraw) {
         anchor.appendChild(text);
        var anchor = document.createElement('a');
         svg.appendChild(anchor);
        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) {
     function renderPortal(portal, seams) {
         var topMount = portal.querySelector('[data-component="category-nav"]');
         var frame = api();
        var current = seams || (frame && frame.seams);
        var topMount;
         var mount;
         var mount;
         var rect;
         var nav;
         var width;
         var inner;
        var canvas;
        var hitLayer;
         var geometry;
         var geometry;
        var outer;
         var buttons;
         var buttons;
         var nav;
         var redraw;
        var svg;


         if (!topMount || !topMount.querySelector('.portal-category-svg')) return false;
         if (!frame || !portal || !current || !current.width) return false;
        topMount = portal.querySelector('[data-component="category-nav"]');
        if (!topMount) return false;
         mount = ensureMount(portal, topMount);
         mount = ensureMount(portal, topMount);
         rect = mount.getBoundingClientRect();
         geometry = outerGeometry(current);
         width = Math.floor(rect.width || topMount.getBoundingClientRect().width || 0);
        buttons = splitButtons(geometry);
         if (!width) return false;
         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;
        }


        geometry = readFrameGeometry(topMount, mount, width);
        outer = geometry.outer;
        buttons = splitButtons(outer);
         nav = document.createElement('nav');
         nav = document.createElement('nav');
         svg = createSvgElement('svg');
         nav.className = 'portal-section-nav portal-frame-canvas-nav';
        nav.setAttribute('aria-label', '작품 영역');
        nav.__frameHoverIndex = -1;


         nav.className = 'portal-section-nav';
         inner = document.createElement('div');
         nav.setAttribute('aria-label', '작품 영역');
        inner.className = 'portal-frame-counter-inner portal-section-counter-inner';
         svg.setAttribute('class', 'portal-section-svg');
         inner.style.width = geometry.width + 'px';
         svg.setAttribute('viewBox', '0 0 ' + width + ' ' + ROW_HEIGHT);
 
         svg.setAttribute('width', '100%');
         canvas = frame.createCanvas('portal-frame-canvas portal-section-canvas', geometry.width, geometry.height);
         svg.setAttribute('height', '100%');
         canvas.setAttribute('data-frame-width', String(geometry.width));
         svg.setAttribute('preserveAspectRatio', 'none');
         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';


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


         nav.appendChild(svg);
        inner.appendChild(canvas);
        inner.appendChild(hitLayer);
         nav.appendChild(inner);
         mount.replaceChildren(nav);
         mount.replaceChildren(nav);
         mount.setAttribute('data-portal-section-rendered', '1');
        frame.applyCounterScale(
         mount.setAttribute('data-portal-section-width', String(width));
            [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;
         return true;
     }
     }
446번째 줄: 319번째 줄:
         var scope = root && root.querySelectorAll ? root : document;
         var scope = root && root.querySelectorAll ? root : document;
         var portals = [];
         var portals = [];
         if (scope.matches && scope.matches('.main-portal')) portals.push(scope);
         if (scope.matches && scope.matches('.main-portal')) portals.push(scope);
         Array.prototype.forEach.call(scope.querySelectorAll('.main-portal'), function (portal) {
         Array.prototype.forEach.call(scope.querySelectorAll('.main-portal'), function (portal) {
454번째 줄: 326번째 줄:
     }
     }


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


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


     function observe() {
     if (api()) {
         if (observer || !window.MutationObserver || !document.documentElement) return;
         api().onSeams(function (seams) { schedule(document, seams); });
        observer = new window.MutationObserver(function (records) {
        api().onScale(function () { schedule(document); });
            var relevant = records.some(function (record) {
                return Array.prototype.some.call(record.addedNodes || [], function (node) {
                    return node && node.nodeType === 1 && (
                        (node.matches && node.matches('.portal-category-svg')) ||
                        (node.querySelector && node.querySelector('.portal-category-svg'))
                    );
                });
            });
            if (relevant) scheduleRender(document);
        });
        observer.observe(document.documentElement, { childList:true, subtree:true });
     }
     }
    function init(root) {
        renderAll(root || document);
        observe();
    }
     if (mw && mw.hook) {
     if (mw && mw.hook) {
         mw.hook('wikipage.content').add(function (content) {
         mw.hook('wikipage.content').add(function (content) {
             scheduleRender(content && content[0] ? content[0] : document);
             schedule(content && content[0] ? content[0] : document);
         });
         });
     }
     }
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', function () { init(document); });
    } else {
        init(document);
    }
    window.addEventListener('resize', function () {
        window.clearTimeout(resizeTimer);
        resizeTimer = window.setTimeout(function () {
            resizeTimer = 0;
            renderAll(document);
        }, 80);
    });


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

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