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

(Install package: clbiwiki-mainpage-attached-section-bottom-visible-gap-2px-20260717 / PortalSectionNav.js)
(Install package: clbiwiki-portal-frame-display-fix-20260717 / js/PortalSectionNav.js)
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 XLINK_NS = 'http://www.w3.org/1999/xlink';
     var COMPONENT = 'portal-section-nav';
     var COMPONENT = 'portal-section-nav';
     var VERSION = '20260717-portal-section-bottom-gap-two-045';
     var VERSION = '20260717-shared-frame-phase-001';
    var BUTTON_HEIGHT = 22;
    var TOP_FRAME = 8;
    var BOTTOM_FRAME = 8;
    var GAP = 2;
    var BOTTOM_GAP = 3;
    var ROW_HEIGHT = TOP_FRAME + GAP + BUTTON_HEIGHT + BOTTOM_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:'대문' },
         {
         { key:'anecdote', label:'에넥도트', href:'/index.php/특수:모든문서?namespace=3000' }
            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);
139번째 줄: 37번째 줄:
     }
     }


     function fallbackGeometry(width) {
     function outerGeometry(seams) {
         var cut = Math.max(6, Math.min(10, Math.round((width / 5) * 0.045)));
         var frame = api();
        var run = Math.round(cut * ROW_HEIGHT / 22);
         var d = frame.DIMENSIONS;
        var outer = [
         var phase = seams.bottomStartAbsY % frame.FRAME.SLANT_RISE;
            [Math.round(width * 0.2), 0],
         var leftSeamX = frame.slantX(
            [Math.round(width * 0.8), 0],
             seams.settingsLeftTopX,
            [Math.round(width * 0.8) - run, ROW_HEIGHT],
             seams.bottomStartAbsY,
            [Math.round(width * 0.2) + run, ROW_HEIGHT]
             seams.settingsLeftDir,
        ];
             0
        return {
         );
            outer:outer,
         var rightSeamX = frame.slantX(
            leftDirection:projectSidesToY(outer, 0, ROW_HEIGHT - 1, INSET_OFFSET).map(function (point, index) {
             seams.corpRightTopX,
                return index === 0 || index === 3 ? point : null;
            seams.bottomStartAbsY,
            }).filter(Boolean),
             seams.corpRightDir,
            rightDirection:projectSidesToY(outer, 0, ROW_HEIGHT - 1, INSET_OFFSET).slice(1, 3)
             0
        };
         );
    }
 
    function readFrameGeometry(topMount, mount, width) {
        var topSvg = topMount.querySelector('.portal-category-svg');
        var settingsAnchor = topMount.querySelector('.portal-cat-anchor[data-category-key="settings"]');
         var corporationAnchor = topMount.querySelector('.portal-cat-anchor[data-category-key="corporations"]');
         var settings = settingsAnchor && settingsAnchor.querySelector('.portal-cat-edge');
        var corporations = corporationAnchor && corporationAnchor.querySelector('.portal-cat-edge');
         var settingsInset = settingsAnchor && settingsAnchor.querySelector('.portal-cat-inset-left');
        var corporationInset = corporationAnchor && corporationAnchor.querySelector('.portal-cat-inset-right');
        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) {
             return fallbackGeometry(width);
        }
 
        svgRect = topSvg.getBoundingClientRect();
        mountRect = mount.getBoundingClientRect();
        viewBox = getViewBox(topSvg);
        if (
            !viewBox ||
            !(svgRect.width > 0) ||
            !(svgRect.height > 0) ||
            !(mountRect.width > 0)
         ) {
            return fallbackGeometry(width);
         }
 
        scaleX = svgRect.width / viewBox.width;
        scaleY = svgRect.height / viewBox.height;
 
        function screenPoint(point) {
             return [
                svgRect.left + (point[0] - viewBox.x) * scaleX,
                svgRect.top + (point[1] - viewBox.y) * scaleY
            ];
        }
 
        function localLineXAt(pointA, pointB, localY) {
            var a = screenPoint(pointA);
            var b = screenPoint(pointB);
            var targetY = mountRect.top + localY;
            var dy = b[1] - a[1];
             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 {
         return {
             outer:outer,
             phase:phase,
             leftDirection:settingsInsetPoints.length === 2 && settingsInsetPoints.every(function (point) {
             leftSeamX:leftSeamX,
                return Number.isFinite(point[0]) && Number.isFinite(point[1]);
             rightSeamX:rightSeamX,
             }) ? [
            leftDir:seams.settingsLeftDir,
                [localLineXAt(settingsInsetPoints[0], settingsInsetPoints[1], 0), 0],
             rightDir:seams.corpRightDir,
                [localLineXAt(settingsInsetPoints[0], settingsInsetPoints[1], ROW_HEIGHT - 1), ROW_HEIGHT - 1]
             points:frame.segment(
             ] : fallback.leftDirection,
                 leftSeamX,
             rightDirection:corporationInsetPoints.length === 2 && corporationInsetPoints.every(function (point) {
                rightSeamX,
                 return Number.isFinite(point[0]) && Number.isFinite(point[1]);
                 d.ROW_HEIGHT,
            }) ? [
                seams.settingsLeftDir,
                 [localLineXAt(corporationInsetPoints[0], corporationInsetPoints[1], 0), 0],
                seams.corpRightDir,
                 [localLineXAt(corporationInsetPoints[0], corporationInsetPoints[1], ROW_HEIGHT - 1), ROW_HEIGHT - 1]
                 phase
             ] : fallback.rightDirection
             )
         };
         };
     }
     }


     function appendFrame(svg, outer, directions) {
     function appendFrame(svg, geometry) {
         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 f = frame.FRAME;
         var outline = projectSidesToY(outer, 0.5, ROW_HEIGHT - 0.5, 0.5);
        var d = frame.DIMENSIONS;
         var innerDirection = projectSidesToY(outer, 1.5, ROW_HEIGHT - 1.5, 1.5);
        var group = frame.create('g', 'portal-section-frame');
         var group = createSvgElement('g');
         var surface = frame.segmentAt(
        var joinDepth = TOP_FRAME;
            geometry.leftSeamX,
        var leftJoinCover;
            geometry.rightSeamX,
        var rightJoinCover;
            f.BORDER,
        var leftDirection = [
            d.ROW_HEIGHT - f.BORDER,
             innerDirection[0],
            geometry.leftDir,
             innerDirection[3]
            geometry.rightDir,
         ];
            geometry.phase,
         var rightDirection = [
            f.BORDER,
             innerDirection[1],
            f.BORDER
             innerDirection[2]
        );
         ];
         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
         );


         group.setAttribute('class', 'portal-section-frame');
         group.appendChild(frame.polygon('portal-section-frame-edge', geometry.points));
        group.appendChild(createPolygon('portal-section-frame-edge', outer));
         group.appendChild(frame.polygon('portal-section-frame-surface', surface));
         group.appendChild(createPolygon('portal-section-frame-surface', surface));
         group.appendChild(frame.polygon('portal-section-frame-well', well));
         group.appendChild(createPolygon('portal-section-frame-well', well));
         group.appendChild(frame.polygon('portal-section-join-side-cover', joinCover));
 
         group.appendChild(frame.line('portal-section-frame-shadow', sideBevel[3], sideBevel[0]));
        /* 우물의 수평 방향성은 좌우 접합 처리와 무관하므로 먼저 그린다. */
         group.appendChild(frame.line('portal-section-frame-highlight', sideBevel[1], sideBevel[2]));
         group.appendChild(createLine('portal-section-well-shadow', well[0], well[1]));
         group.appendChild(frame.line('portal-section-well-shadow', well[0], well[1]));
         group.appendChild(createLine('portal-section-well-highlight', well[2], well[3]));
         group.appendChild(frame.line('portal-section-well-shadow', well[1], well[2]));
 
         group.appendChild(frame.line('portal-section-well-highlight', well[3], well[0]));
        /*
         group.appendChild(frame.line('portal-section-well-highlight', well[2], well[3]));
        * 기존 프레임과 포개지는 상단 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 외곽 1px과 그 안쪽 방향선 1px을 서로 다른 픽셀 열에 둔다. */
         group.appendChild(createPolygon('portal-section-frame-outline', outline));
        group.appendChild(createLine('portal-section-frame-shadow', rightDirection[1], leftDirection[1]));
         group.appendChild(createLine('portal-section-frame-shadow', leftDirection[1], leftDirection[0]));
         group.appendChild(createLine('portal-section-frame-highlight', rightDirection[0], rightDirection[1]));
 
         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);
         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 f = frame.FRAME;
         var bottomMid = (union[3][0] + union[2][0]) / 2;
        var d = frame.DIMENSIONS;
        var leftBase = geometry.leftSeamX + f.FRAME_FACE + f.GAP;
        var rightBase = geometry.rightSeamX - f.FRAME_FACE - f.GAP;
        var leftTop = frame.slantX(leftBase, d.BUTTON_TOP, geometry.leftDir, geometry.phase);
        var rightTop = frame.slantX(rightBase, d.BUTTON_TOP, geometry.rightDir, geometry.phase);
         var topMid = Math.round((leftTop + rightTop) / 2);
         var halfGap = f.GAP / 2;


         return [
         return [
             [
             {
                 union[0],
                 leftTopX:leftBase,
                 [topMid - GAP / 2, BUTTON_TOP],
                 rightTopX:topMid - halfGap,
                 [bottomMid - GAP / 2, BUTTON_BOTTOM],
                leftDir:geometry.leftDir,
                 union[3]
                 rightDir:0,
             ],
                 phase:geometry.phase
             [
             },
                 [topMid + GAP / 2, BUTTON_TOP],
             {
                 union[1],
                 leftTopX:topMid + halfGap,
                 union[2],
                rightTopX:rightBase,
                 [bottomMid + GAP / 2, BUTTON_BOTTOM]
                 leftDir:0,
             ]
                 rightDir:geometry.rightDir,
                 phase:geometry.phase
             }
         ];
         ];
     }
     }


     function appendButton(svg, item, points) {
     function appendButton(svg, item, button) {
         var anchor = createSvgElement('a');
         var frame = api();
         var geometry = createSvgElement('g');
         var f = frame.FRAME;
         var surfacePoints = projectSidesToY(
         var d = frame.DIMENSIONS;
             points,
        var points = frame.segmentAt(
             points[0][1] + OUTER_BORDER,
             button.leftTopX,
             points[3][1] - OUTER_BORDER,
            button.rightTopX,
             OUTER_BORDER
            d.BUTTON_TOP,
            d.BUTTON_BOTTOM,
            button.leftDir,
            button.rightDir,
             button.phase,
             0,
             0
         );
         );
         var insetPoints = projectSidesToY(
         var surface = frame.segmentAt(
             points,
             button.leftTopX,
             points[0][1] + INSET_OFFSET,
             button.rightTopX,
             points[3][1] - INSET_OFFSET,
            d.BUTTON_TOP + f.BORDER,
             INSET_OFFSET
             d.BUTTON_BOTTOM - f.BORDER,
            button.leftDir,
            button.rightDir,
            button.phase,
            f.BORDER,
             f.BORDER
         );
         );
         var center = getCentroid(surfacePoints);
         var bevel = frame.segmentAt(
         var text = createSvgElement('text');
            button.leftTopX,
         var hit = createPolygon('portal-cat-hit', points);
            button.rightTopX,
            d.BUTTON_TOP + f.BORDER + f.BEVEL,
            d.BUTTON_BOTTOM - f.BORDER - f.BEVEL,
            button.leftDir,
            button.rightDir,
            button.phase,
            f.BORDER + f.BEVEL,
            f.BORDER + f.BEVEL
        );
        var anchor = frame.create('a', 'portal-cat-anchor portal-section-anchor');
         var group = frame.create('g', 'portal-section-button-geometry');
         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);
         var href = item.href || buildWikiPath(item.title);


        anchor.setAttribute('class', 'portal-cat-anchor portal-section-anchor');
         anchor.setAttribute('data-category-key', item.key);
         anchor.setAttribute('data-category-key', item.key);
         anchor.setAttribute('href', href);
         anchor.setAttribute('href', href);
         anchor.setAttributeNS(XLINK_NS, 'xlink:href', href);
         anchor.setAttributeNS(XLINK_NS, 'xlink:href', href);
         anchor.setAttribute('aria-label', item.label);
         anchor.setAttribute('aria-label', item.label);
 
         group.appendChild(frame.polygon('portal-cat-edge', points));
         geometry.setAttribute('class', 'portal-section-button-geometry');
         group.appendChild(frame.polygon('portal-cat-surface', surface));
        geometry.appendChild(createPolygon('portal-cat-edge', points));
         group.appendChild(frame.line('portal-cat-inset portal-cat-inset-top', bevel[0], bevel[1]));
         geometry.appendChild(createPolygon('portal-cat-surface', surfacePoints));
         group.appendChild(frame.line('portal-cat-inset portal-cat-inset-right', bevel[1], bevel[2]));
         geometry.appendChild(createLine('portal-cat-inset portal-cat-inset-top', insetPoints[0], insetPoints[1]));
         group.appendChild(frame.line('portal-cat-inset portal-cat-inset-bottom', bevel[2], bevel[3]));
         geometry.appendChild(createLine('portal-cat-inset portal-cat-inset-right', insetPoints[1], insetPoints[2]));
         group.appendChild(frame.line('portal-cat-inset portal-cat-inset-left', bevel[3], bevel[0]));
         geometry.appendChild(createLine('portal-cat-inset portal-cat-inset-bottom', insetPoints[2], insetPoints[3]));
         geometry.appendChild(createLine('portal-cat-inset portal-cat-inset-left', insetPoints[3], insetPoints[0]));
         hit.setAttribute('pointer-events', 'all');
         hit.setAttribute('pointer-events', 'all');
         geometry.appendChild(hit);
         group.appendChild(hit);
        anchor.appendChild(group);


         text.setAttribute('class', 'portal-cat-label portal-section-label');
         text.setAttribute('x', frame.centerX(surface));
         text.setAttribute('x', roundCoordinate(center[0]));
         text.setAttribute('y', Math.round((d.BUTTON_TOP + d.BUTTON_BOTTOM) / 2) + 1);
         text.setAttribute('y', roundCoordinate((BUTTON_TOP + BUTTON_BOTTOM) / 2 + 1));
         text.setAttribute('font-size', '12');
        text.setAttribute('font-weight', '700');
        text.setAttribute('text-anchor', 'middle');
        text.setAttribute('dominant-baseline', 'middle');
         text.textContent = item.label;
         text.textContent = item.label;
        anchor.appendChild(geometry);
         anchor.appendChild(text);
         anchor.appendChild(text);
         svg.appendChild(anchor);
         svg.appendChild(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 svg;
         var geometry;
         var geometry;
        var outer;
         var buttons;
         var buttons;
        var nav;
        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;
 
         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';
         nav.className = 'portal-section-nav';
         nav.setAttribute('aria-label', '작품 영역');
         nav.setAttribute('aria-label', '작품 영역');
         svg.setAttribute('class', 'portal-section-svg');
         svg = frame.create('svg', 'portal-section-svg');
         svg.setAttribute('viewBox', '0 0 ' + width + ' ' + ROW_HEIGHT);
         svg.setAttribute('viewBox', '0 0 ' + current.width + ' ' + frame.DIMENSIONS.ROW_HEIGHT);
         svg.setAttribute('width', '100%');
         svg.setAttribute('width', '100%');
         svg.setAttribute('height', '100%');
         svg.setAttribute('height', frame.DIMENSIONS.ROW_HEIGHT);
         svg.setAttribute('preserveAspectRatio', 'none');
         svg.setAttribute('preserveAspectRatio', 'none');
        svg.setAttribute('data-frame-width', current.width);


         appendFrame(svg, outer, geometry);
         appendFrame(svg, geometry);
         ITEMS.forEach(function (item, index) {
         ITEMS.forEach(function (item, index) { appendButton(svg, item, buttons[index]); });
            appendButton(svg, item, buttons[index]);
        });
 
         nav.appendChild(svg);
         nav.appendChild(svg);
         mount.replaceChildren(nav);
         mount.replaceChildren(nav);
         mount.setAttribute('data-portal-section-rendered', '1');
         mount.setAttribute('data-portal-section-rendered', '1');
         mount.setAttribute('data-portal-section-width', String(width));
         mount.setAttribute('data-portal-section-width', String(current.width));
        mount.setAttribute('data-portal-section-phase', String(geometry.phase));
         return true;
         return true;
     }
     }
436번째 줄: 270번째 줄:
         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) {
444번째 줄: 277번째 줄:
     }
     }


     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()) api().onSeams(function (seams) { schedule(document, seams); });
        if (observer || !window.MutationObserver || !document.documentElement) return;
        observer = new window.MutationObserver(function (records) {
            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') {
     window.PortalSectionNav = {
        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 = {
         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일 (금) 10:35 판

/* =========================================
PortalSectionNav
대문 상단 카테고리 하단 부착형 섹션 버튼
========================================= */

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

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

    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 outerGeometry(seams) {
        var frame = api();
        var d = frame.DIMENSIONS;
        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 {
            phase:phase,
            leftSeamX:leftSeamX,
            rightSeamX:rightSeamX,
            leftDir:seams.settingsLeftDir,
            rightDir:seams.corpRightDir,
            points:frame.segment(
                leftSeamX,
                rightSeamX,
                d.ROW_HEIGHT,
                seams.settingsLeftDir,
                seams.corpRightDir,
                phase
            )
        };
    }

    function appendFrame(svg, geometry) {
        var frame = api();
        var f = frame.FRAME;
        var d = frame.DIMENSIONS;
        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
        );

        group.appendChild(frame.polygon('portal-section-frame-edge', geometry.points));
        group.appendChild(frame.polygon('portal-section-frame-surface', surface));
        group.appendChild(frame.polygon('portal-section-frame-well', well));
        group.appendChild(frame.polygon('portal-section-join-side-cover', joinCover));
        group.appendChild(frame.line('portal-section-frame-shadow', sideBevel[3], sideBevel[0]));
        group.appendChild(frame.line('portal-section-frame-highlight', sideBevel[1], sideBevel[2]));
        group.appendChild(frame.line('portal-section-well-shadow', well[0], well[1]));
        group.appendChild(frame.line('portal-section-well-shadow', well[1], well[2]));
        group.appendChild(frame.line('portal-section-well-highlight', well[3], well[0]));
        group.appendChild(frame.line('portal-section-well-highlight', well[2], well[3]));
        svg.appendChild(group);
    }

    function splitButtons(geometry) {
        var frame = api();
        var f = frame.FRAME;
        var d = frame.DIMENSIONS;
        var leftBase = geometry.leftSeamX + f.FRAME_FACE + f.GAP;
        var rightBase = geometry.rightSeamX - f.FRAME_FACE - f.GAP;
        var leftTop = frame.slantX(leftBase, d.BUTTON_TOP, geometry.leftDir, geometry.phase);
        var rightTop = frame.slantX(rightBase, d.BUTTON_TOP, geometry.rightDir, geometry.phase);
        var topMid = Math.round((leftTop + rightTop) / 2);
        var halfGap = f.GAP / 2;

        return [
            {
                leftTopX:leftBase,
                rightTopX:topMid - halfGap,
                leftDir:geometry.leftDir,
                rightDir:0,
                phase:geometry.phase
            },
            {
                leftTopX:topMid + halfGap,
                rightTopX:rightBase,
                leftDir:0,
                rightDir:geometry.rightDir,
                phase:geometry.phase
            }
        ];
    }

    function appendButton(svg, item, button) {
        var frame = api();
        var f = frame.FRAME;
        var d = frame.DIMENSIONS;
        var points = frame.segmentAt(
            button.leftTopX,
            button.rightTopX,
            d.BUTTON_TOP,
            d.BUTTON_BOTTOM,
            button.leftDir,
            button.rightDir,
            button.phase,
            0,
            0
        );
        var surface = frame.segmentAt(
            button.leftTopX,
            button.rightTopX,
            d.BUTTON_TOP + f.BORDER,
            d.BUTTON_BOTTOM - f.BORDER,
            button.leftDir,
            button.rightDir,
            button.phase,
            f.BORDER,
            f.BORDER
        );
        var bevel = frame.segmentAt(
            button.leftTopX,
            button.rightTopX,
            d.BUTTON_TOP + f.BORDER + f.BEVEL,
            d.BUTTON_BOTTOM - f.BORDER - f.BEVEL,
            button.leftDir,
            button.rightDir,
            button.phase,
            f.BORDER + f.BEVEL,
            f.BORDER + f.BEVEL
        );
        var anchor = frame.create('a', 'portal-cat-anchor portal-section-anchor');
        var group = frame.create('g', 'portal-section-button-geometry');
        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);

        anchor.setAttribute('data-category-key', item.key);
        anchor.setAttribute('href', href);
        anchor.setAttributeNS(XLINK_NS, 'xlink:href', href);
        anchor.setAttribute('aria-label', item.label);
        group.appendChild(frame.polygon('portal-cat-edge', points));
        group.appendChild(frame.polygon('portal-cat-surface', surface));
        group.appendChild(frame.line('portal-cat-inset portal-cat-inset-top', bevel[0], bevel[1]));
        group.appendChild(frame.line('portal-cat-inset portal-cat-inset-right', bevel[1], bevel[2]));
        group.appendChild(frame.line('portal-cat-inset portal-cat-inset-bottom', bevel[2], bevel[3]));
        group.appendChild(frame.line('portal-cat-inset portal-cat-inset-left', bevel[3], bevel[0]));
        hit.setAttribute('pointer-events', 'all');
        group.appendChild(hit);
        anchor.appendChild(group);

        text.setAttribute('x', frame.centerX(surface));
        text.setAttribute('y', Math.round((d.BUTTON_TOP + d.BUTTON_BOTTOM) / 2) + 1);
        text.setAttribute('font-size', '12');
        text.setAttribute('font-weight', '700');
        text.setAttribute('text-anchor', 'middle');
        text.setAttribute('dominant-baseline', 'middle');
        text.textContent = item.label;
        anchor.appendChild(text);
        svg.appendChild(anchor);
    }

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

        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);
        nav = document.createElement('nav');
        nav.className = 'portal-section-nav';
        nav.setAttribute('aria-label', '작품 영역');
        svg = frame.create('svg', 'portal-section-svg');
        svg.setAttribute('viewBox', '0 0 ' + current.width + ' ' + frame.DIMENSIONS.ROW_HEIGHT);
        svg.setAttribute('width', '100%');
        svg.setAttribute('height', frame.DIMENSIONS.ROW_HEIGHT);
        svg.setAttribute('preserveAspectRatio', 'none');
        svg.setAttribute('data-frame-width', current.width);

        appendFrame(svg, geometry);
        ITEMS.forEach(function (item, index) { appendButton(svg, item, buttons[index]); });
        nav.appendChild(svg);
        mount.replaceChildren(nav);
        mount.setAttribute('data-portal-section-rendered', '1');
        mount.setAttribute('data-portal-section-width', String(current.width));
        mount.setAttribute('data-portal-section-phase', String(geometry.phase));
        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); });
    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));