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

(Install package: clbiwiki-portal-frame-pixel-stack-20260717 / js/CategoryNav.js)
(Install package: clbiwiki-portal-frame-canvas-pixel-20260717 / js/CategoryNav.js)
1번째 줄: 1번째 줄:
/* =========================================
/* =========================================
COASTLINE: BLACK ICE - CategoryNav
COASTLINE: BLACK ICE - CategoryNav
대문 카테고리 SVG 네비 생성기
대문 카테고리 canvas 픽셀 네비 생성기
========================================= */
========================================= */


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


    var XLINK_NS = 'http://www.w3.org/1999/xlink';
     var ITEMS = [
     var ITEMS = [
         { key:'era', label:'시대', title:'시대' },
         { key:'era', label:'시대', title:'시대', slant:'right' },
         { key:'settings', label:'설정', title:'설정' },
         { key:'settings', label:'설정', title:'설정', slant:'right' },
         { key:'project', label:'프로젝트', title:'프로젝트:소개' },
         { key:'project', label:'프로젝트', title:'프로젝트:소개', slant:'both', pillar:true },
         { key:'corporations', label:'기업 및 공동체', title:'기업_및_공동체' },
         { key:'corporations', label:'기업 및 공동체', title:'기업_및_공동체', slant:'left' },
         { key:'military', label:'군, 정치집단', title:'군_정치집단' }
         { key:'military', label:'군, 정치집단', title:'군_정치집단', slant:'left' }
     ];
     ];
    var BOUNDARY_DIRS = [0, 1, 1, -1, -1, 0];
     var renderTimer = 0;
     var renderTimer = 0;


     function geometryApi() {
     function api() {
         return window.CLBI && window.CLBI.frame;
         return window.CLBI && window.CLBI.frame;
     }
     }
31번째 줄: 29번째 줄:
     }
     }


     function calculateGeometry(width) {
     function calculateLayout(width) {
         var api = geometryApi();
         var frame = api();
         var frame = api.FRAME;
         var f = frame.FRAME;
         var dimensions = api.DIMENSIONS;
         var d = frame.DIMENSIONS;
         var contentLeft = dimensions.WELL_TOP;
         var contentX = d.WELL_TOP;
         var contentWidth = Math.max(ITEMS.length, width - dimensions.WELL_TOP * 2);
         var contentWidth = Math.max(ITEMS.length * 8, width - contentX * 2);
         var boundaries = [];
         var btnW = Math.max(4, Math.floor((contentWidth - f.GAP * 4) / ITEMS.length));
         var segments = [];
         var segments = [];
        var xoff = contentX;
         var i;
         var i;
        for (i = 0; i <= ITEMS.length; i++) {
            boundaries.push(contentLeft + Math.round(contentWidth * i / ITEMS.length));
        }


         for (i = 0; i < ITEMS.length; i++) {
         for (i = 0; i < ITEMS.length; i++) {
            var leftTopX = boundaries[i] + (i === 0 ? 0 : frame.GAP / 2);
            var rightTopX = boundaries[i + 1] - (i === ITEMS.length - 1 ? 0 : frame.GAP / 2);
             segments.push({
             segments.push({
                 leftTopX:leftTopX,
                 index:i,
                 rightTopX:rightTopX,
                 key:ITEMS[i].key,
                 leftDir:BOUNDARY_DIRS[i],
                 label:ITEMS[i].label,
                 rightDir:BOUNDARY_DIRS[i + 1],
                 slant:ITEMS[i].slant,
                 points:api.segmentAt(
                 x:xoff,
                    leftTopX,
                y:d.BUTTON_EDGE_TOP,
                    rightTopX,
                btnW:btnW,
                    dimensions.BUTTON_TOP,
                pixelWidth:btnW + 2,
                    dimensions.BUTTON_BOTTOM,
                 pixelHeight:d.BUTTON_TOTAL
                    BOUNDARY_DIRS[i],
                    BOUNDARY_DIRS[i + 1],
                    0,
                    0,
                    0
                 )
             });
             });
            xoff += btnW + f.GAP;
         }
         }


         return {
         return {
             width:width,
             width:width,
             boundaries:boundaries,
             height:d.ROW_HEIGHT,
             segments:segments,
             contentX:contentX,
             dimensions:dimensions
            contentWidth:contentWidth,
            buttonWidth:btnW,
            usedRight:xoff - f.GAP,
             segments:segments
         };
         };
     }
     }


     function appendFrame(svg, geometry) {
     function paintProjectPillar(image, width, height, segment) {
         var api = geometryApi();
         var frame = api();
        var frame = api.FRAME;
         var d = frame.DIMENSIONS;
         var d = geometry.dimensions;
         var topStep = Math.floor(d.BUTTON_EDGE_TOP / frame.FRAME.SLANT_RISE);
         var width = geometry.width;
         var leftTop = segment.x - topStep;
        var group = api.create('g', 'portal-category-frame');
         var rightTop = segment.x + segment.btnW + topStep;
         var surfaceLastX = width - d.FRAME_SURFACE_TOP - 1;
         var y;
         var surfaceLastY = d.ROW_HEIGHT - d.FRAME_SURFACE_TOP - 1;
         var x;
         var wellLastX = width - d.WELL_TOP - 1;
         var wellLastY = d.WELL2_BOTTOM - 1;


         group.appendChild(api.rect('portal-category-frame-edge', 0, 0, width, d.ROW_HEIGHT));
         for (y = 0; y < height; y++) {
        group.appendChild(api.rect(
            var step = Math.floor(y / frame.FRAME.SLANT_RISE);
            'portal-category-frame-surface',
             var left = leftTop + step;
            d.FRAME_SURFACE_TOP,
             var right = rightTop - step;
            d.FRAME_SURFACE_TOP,
            for (x = left; x <= right; x++) frame.setPixel(image, width, height, x, y, 'S');
             width - d.FRAME_SURFACE_TOP * 2,
            frame.setPixel(image, width, height, left, y, 'D');
             d.ROW_HEIGHT - d.FRAME_SURFACE_TOP * 2
            frame.setPixel(image, width, height, right, y, 'H');
        ));
         }
        group.appendChild(api.rect(
         return {
            'portal-category-frame-well',
             leftTop:leftTop,
            d.WELL_TOP,
             rightTop:rightTop,
            d.WELL_TOP,
             leftAtButton:segment.x,
            width - d.WELL_TOP * 2,
            rightAtButton:segment.x + segment.btnW
            d.WELL2_BOTTOM - d.WELL_TOP
         };
        ));
    }
 
        group.appendChild(api.line(
            'portal-category-frame-highlight',
            [d.FRAME_SURFACE_TOP, d.FRAME_SURFACE_TOP],
            [surfaceLastX, d.FRAME_SURFACE_TOP]
        ));
        group.appendChild(api.line(
            'portal-category-frame-highlight',
            [surfaceLastX, d.FRAME_SURFACE_TOP],
            [surfaceLastX, surfaceLastY]
         ));
         group.appendChild(api.line(
             'portal-category-frame-shadow',
             [d.FRAME_SURFACE_TOP, surfaceLastY],
             [d.FRAME_SURFACE_TOP, d.FRAME_SURFACE_TOP]
        ));
        group.appendChild(api.line(
            'portal-category-frame-shadow',
            [surfaceLastX, surfaceLastY],
            [d.FRAME_SURFACE_TOP, surfaceLastY]
         ));


         group.appendChild(api.line(
    function labelCenter(segment) {
            'portal-category-well-shadow',
         var frame = api();
            [d.WELL_TOP, d.WELL_TOP],
         var ys = Math.floor(frame.FRAME.BUTTON_FACE / 2);
            [wellLastX, d.WELL_TOP]
         var mask = frame.buttonMask(segment.btnW, ys, segment.slant, 0);
        ));
         return segment.x + Math.round((mask.left + mask.right) / 2);
         group.appendChild(api.line(
            'portal-category-well-shadow',
            [wellLastX, d.WELL_TOP],
            [wellLastX, wellLastY]
        ));
         group.appendChild(api.line(
            'portal-category-well-highlight',
            [d.WELL_TOP, wellLastY],
            [d.WELL_TOP, d.WELL_TOP]
        ));
         group.appendChild(api.line(
            'portal-category-well-highlight',
            [wellLastX, wellLastY],
            [d.WELL_TOP, wellLastY]
        ));
        svg.appendChild(group);
     }
     }


     function createSegment(item, index, geometry) {
     function draw(canvas, layout, hoverIndex) {
        var api = geometryApi();
         var frame = api();
         var frame = api.FRAME;
         var ctx = canvas.getContext('2d');
        var data = geometry.segments[index];
         var image = frame.createImage(ctx, layout.width, layout.height, 'BG');
        var d = geometry.dimensions;
         var projectGeometry = null;
        var anchor = api.create('a', 'portal-cat-anchor');
         var outer = api.polygon('portal-cat-edge', data.points);
        var surfacePoints = api.segmentAt(
            data.leftTopX,
            data.rightTopX,
            d.BUTTON_SURFACE_TOP,
            d.BUTTON_SURFACE_BOTTOM,
            data.leftDir,
            data.rightDir,
            0,
            frame.BORDER,
            frame.BORDER
        );
         var topInset = api.rowAt(
            data.leftTopX,
            data.rightTopX,
            d.BUTTON_SURFACE_TOP,
            data.leftDir,
            data.rightDir,
            0,
            frame.BORDER,
            frame.BORDER
        );
        var bottomInset = api.rowAt(
            data.leftTopX,
            data.rightTopX,
            d.BUTTON_SURFACE_BOTTOM - frame.BEVEL,
            data.leftDir,
            data.rightDir,
            0,
            frame.BORDER,
            frame.BORDER
        );
        var hit = api.polygon('portal-cat-hit', data.points);
        var text = api.create('text', 'portal-cat-label');
         var href = buildPath(item.title);


         anchor.setAttribute('href', href);
         frame.paintStandardFrame(image, layout.width, layout.height);
         anchor.setAttributeNS(XLINK_NS, 'xlink:href', href);
         layout.segments.forEach(function (segment) {
        anchor.setAttribute('aria-label', item.label);
            var grid = frame.buildButtonPixels(
         anchor.setAttribute('data-category-key', item.key);
                segment.btnW,
                frame.FRAME.BUTTON_FACE,
                segment.slant,
                0
            );
            frame.paintGrid(
                image,
                layout.width,
                layout.height,
                grid,
                segment.x,
                segment.y,
                true
            );
         });


         anchor.appendChild(outer);
         projectGeometry = paintProjectPillar(
        anchor.appendChild(api.polygon('portal-cat-surface', surfacePoints));
            image,
        anchor.appendChild(api.line('portal-cat-inset portal-cat-inset-top', topInset[0], topInset[1]));
            layout.width,
        anchor.appendChild(api.line('portal-cat-inset portal-cat-inset-right', topInset[1], bottomInset[1]));
            layout.height,
         anchor.appendChild(api.line('portal-cat-inset portal-cat-inset-bottom', bottomInset[1], bottomInset[0]));
            layout.segments[2]
         anchor.appendChild(api.line('portal-cat-inset portal-cat-inset-left', bottomInset[0], topInset[0]));
         );
        hit.setAttribute('pointer-events', 'all');
         ctx.putImageData(image, 0, 0);
        anchor.appendChild(hit);


         text.setAttribute('x', api.centerX(surfacePoints));
         layout.segments.forEach(function (segment, index) {
        text.setAttribute('y', Math.round((d.BUTTON_SURFACE_TOP + d.BUTTON_SURFACE_BOTTOM) / 2) + 1);
            var center = index === 2
        text.setAttribute('font-size', '12');
                ? Math.round((projectGeometry.leftAtButton + projectGeometry.rightAtButton) / 2)
        text.setAttribute('font-weight', '700');
                : labelCenter(segment);
        text.setAttribute('text-anchor', 'middle');
            frame.drawLabel(
        text.setAttribute('dominant-baseline', 'middle');
                ctx,
         text.setAttribute('pointer-events', 'none');
                segment.label,
         text.textContent = item.label;
                center,
        anchor.appendChild(text);
                Math.round((frame.DIMENSIONS.BUTTON_SURFACE_TOP + frame.DIMENSIONS.BUTTON_SURFACE_BOTTOM) / 2) + 1,
        return anchor;
                hoverIndex === index && index !== 2,
                layout.width <= 820 ? 11 : 12
            );
         });
         canvas.__projectGeometry = projectGeometry;
     }
     }


     function publishSeams(geometry) {
     function publishSeams(layout) {
         var api = geometryApi();
         var frame = api();
         var segments = geometry.segments;
         var d = frame.DIMENSIONS;
         var d = geometry.dimensions;
        var settings = layout.segments[1];
        var project = layout.segments[2];
         var corporations = layout.segments[3];
        var topStep = Math.floor(d.BUTTON_EDGE_TOP / frame.FRAME.SLANT_RISE);
        var settingsLeftOriginX = settings.x - topStep;
        var corpRightOriginX = corporations.x + corporations.btnW + topStep;
        var projectLeftOriginX = project.x - topStep;
        var projectRightOriginX = project.x + project.btnW + topStep;
        var seamStep = Math.floor(d.BOTTOM_START_ABS_Y / frame.FRAME.SLANT_RISE);


         api.setSeams({
         frame.setSeams({
             width:geometry.width,
             width:layout.width,
             rowHeight:d.ROW_HEIGHT,
             rowHeight:d.ROW_HEIGHT,
             buttonTop:d.BUTTON_TOP,
             buttonTop:d.BUTTON_EDGE_TOP,
             buttonBottom:d.BUTTON_BOTTOM,
             buttonBottom:d.BUTTON_EDGE_BOTTOM,
             overlap:d.OVERLAP,
             overlap:d.OVERLAP,
             bottomStartAbsY:d.BOTTOM_START_ABS_Y,
             bottomStartAbsY:d.BOTTOM_START_ABS_Y,
             settingsLeftTopX:segments[1].leftTopX,
            phase:d.BOTTOM_START_ABS_Y % frame.FRAME.SLANT_RISE,
             settingsLeftDir:segments[1].leftDir,
            settingsLeftOriginX:settingsLeftOriginX,
             corpRightTopX:segments[3].rightTopX,
             settingsLeftTopX:settings.x,
             corpRightDir:segments[3].rightDir,
            settingsLeftSeamX:settingsLeftOriginX + seamStep,
             projectLeftTopX:segments[2].leftTopX,
             settingsLeftDir:1,
             projectLeftDir:segments[2].leftDir,
            corpRightOriginX:corpRightOriginX,
             projectRightTopX:segments[2].rightTopX,
             corpRightTopX:corporations.x + corporations.btnW,
             projectRightDir:segments[2].rightDir
            corpRightSeamX:corpRightOriginX - seamStep,
             corpRightDir:-1,
            projectLeftOriginX:projectLeftOriginX,
            projectRightOriginX:projectRightOriginX,
            projectLeftTopX:project.x,
             projectRightTopX:project.x + project.btnW,
             projectLeftDir:1,
            projectRightDir:-1,
            buttonWidth:layout.buttonWidth,
             segments:layout.segments.map(function (segment) {
                return {
                    key:segment.key,
                    x:segment.x,
                    y:segment.y,
                    btnW:segment.btnW,
                    pixelWidth:segment.pixelWidth,
                    slant:segment.slant
                };
             }),
            shellScale:frame.getShellScale()
         });
         });
     }
     }


     function buildSvg(width) {
     function createHit(item, index, segment, nav, redraw) {
         var api = geometryApi();
         var hit;
         var geometry = calculateGeometry(width);
         if (item.pillar) {
        var svg = api.create('svg', 'portal-category-svg');
            hit = document.createElement('span');
 
            hit.setAttribute('aria-label', item.label);
        svg.setAttribute('viewBox', '0 0 ' + geometry.width + ' ' + geometry.dimensions.ROW_HEIGHT);
            hit.setAttribute('role', 'presentation');
        svg.setAttribute('width', '100%');
            hit.className = 'portal-frame-hit portal-category-pillar-hit';
         svg.setAttribute('height', geometry.dimensions.ROW_HEIGHT);
         } else {
        svg.setAttribute('preserveAspectRatio', 'none');
            hit = document.createElement('a');
        svg.setAttribute('aria-hidden', 'false');
            hit.href = buildPath(item.title);
        svg.setAttribute('data-frame-width', geometry.width);
            hit.setAttribute('aria-label', item.label);
 
            hit.className = 'portal-frame-hit portal-cat-anchor';
        appendFrame(svg, geometry);
            hit.setAttribute('data-category-key', item.key);
        ITEMS.forEach(function (item, index) {
            hit.addEventListener('mouseenter', function () {
             svg.appendChild(createSegment(item, index, geometry));
                nav.__frameHoverIndex = index;
         });
                redraw();
 
            });
         return { svg:svg, geometry:geometry };
            hit.addEventListener('mouseleave', function () {
                if (nav.__frameHoverIndex === index) nav.__frameHoverIndex = -1;
                redraw();
            });
            hit.addEventListener('focus', function () {
                nav.__frameHoverIndex = index;
                redraw();
            });
            hit.addEventListener('blur', function () {
                if (nav.__frameHoverIndex === index) nav.__frameHoverIndex = -1;
                redraw();
             });
        }
        hit.style.left = segment.x + 'px';
        hit.style.top = segment.y + 'px';
        hit.style.width = segment.pixelWidth + 'px';
        hit.style.height = segment.pixelHeight + 'px';
        hit.appendChild(document.createElement('span'));
         hit.lastChild.className = 'portal-frame-sr-label';
        hit.lastChild.textContent = item.label;
         return hit;
     }
     }


     function renderMount(mount) {
     function renderMount(mount) {
         var api = geometryApi();
         var frame = api();
         var width;
         var width;
        var layout;
         var nav;
         var nav;
         var result;
         var inner;
        var canvas;
        var hitLayer;
        var redraw;


         if (!mount || !api) return false;
         if (!mount || !frame) return false;
         width = api.readWidth(mount);
        frame.setShellScale(frame.getShellScale());
         width = frame.readRenderWidth(mount);
         if (!width) {
         if (!width) {
             if (!mount.__categoryNavRetry) {
             if (!mount.__categoryNavRetry) {
275번째 줄: 260번째 줄:
         }
         }


         if (mount.__categoryNavRetry) {
         layout = calculateLayout(width);
            window.clearTimeout(mount.__categoryNavRetry);
        if (
             mount.__categoryNavRetry = 0;
             mount.getAttribute('data-category-nav-width') === String(width) &&
        }
             mount.getAttribute('data-category-nav-scale') === String(frame.getShellScale()) &&
        nav = mount.querySelector('.portal-category-nav');
             mount.querySelector('.portal-category-canvas')
        if (!nav) {
        ) {
             clearNode(mount);
             publishSeams(layout);
            nav = document.createElement('nav');
             return true;
             nav.className = 'portal-category-nav';
             nav.setAttribute('aria-label', 'Main categories');
             mount.appendChild(nav);
         }
         }
        nav = document.createElement('nav');
        nav.className = 'portal-category-nav portal-frame-canvas-nav';
        nav.setAttribute('aria-label', 'Main categories');
        nav.__frameHoverIndex = -1;
        inner = document.createElement('div');
        inner.className = 'portal-frame-counter-inner portal-category-counter-inner';
        inner.style.width = width + 'px';
        canvas = frame.createCanvas('portal-frame-canvas portal-category-canvas', width, layout.height);
        canvas.setAttribute('data-frame-width', String(width));
        canvas.setAttribute('data-frame-height', String(layout.height));
        hitLayer = document.createElement('div');
        hitLayer.className = 'portal-frame-hit-layer';
        hitLayer.style.width = width + 'px';
        hitLayer.style.height = layout.height + 'px';
        redraw = function () {
            draw(canvas, layout, nav.__frameHoverIndex);
        };
        ITEMS.forEach(function (item, index) {
            hitLayer.appendChild(createHit(item, index, layout.segments[index], nav, redraw));
        });


         if (nav.__frameWidth === width && nav.querySelector('.portal-category-svg')) {
         inner.appendChild(canvas);
            publishSeams(calculateGeometry(width));
        inner.appendChild(hitLayer);
            return true;
        nav.appendChild(inner);
         }
        clearNode(mount);
        mount.appendChild(nav);
        frame.applyCounterScale([mount, nav], inner, layout.height);
        redraw();
         publishSeams(layout);


         result = buildSvg(width);
         mount.setAttribute('data-category-nav-rendered', 'canvas');
         nav.__frameWidth = width;
         mount.setAttribute('data-category-nav-width', String(width));
        clearNode(nav);
         mount.setAttribute('data-category-nav-scale', String(frame.getShellScale()));
        nav.appendChild(result.svg);
         mount.setAttribute('data-category-nav-rendered', '1');
        publishSeams(result.geometry);


         if (window.ResizeObserver && !mount.__categoryNavObserver) {
         if (window.ResizeObserver && !mount.__categoryNavObserver) {
313번째 줄: 320번째 줄:
         var mounts = [];
         var mounts = [];
         var rendered = 0;
         var rendered = 0;
         if (scope.matches && scope.matches('[data-component="category-nav"]')) mounts.push(scope);
         if (scope.matches && scope.matches('[data-component="category-nav"]')) mounts.push(scope);
         Array.prototype.forEach.call(scope.querySelectorAll('[data-component="category-nav"]'), function (mount) {
         Array.prototype.forEach.call(scope.querySelectorAll('[data-component="category-nav"]'), function (mount) {
338번째 줄: 344번째 줄:
     }
     }


    if (api()) 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) {
343번째 줄: 350번째 줄:
         });
         });
     }
     }
     window.addEventListener('resize', function () { schedule(document); });
     window.addEventListener('resize', function () { schedule(document); });


     window.CategoryNav = {
     window.CategoryNav = {
        version:'20260717-canvas-pixel-001',
         init:renderAll,
         init:renderAll,
         renderAll:renderAll,
         renderAll:renderAll,

2026년 7월 17일 (금) 16:18 판

/* =========================================
COASTLINE: BLACK ICE - CategoryNav
대문 카테고리 canvas 픽셀 네비 생성기
========================================= */

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

    var ITEMS = [
        { key:'era', label:'시대', title:'시대', slant:'right' },
        { key:'settings', label:'설정', title:'설정', slant:'right' },
        { key:'project', label:'프로젝트', title:'프로젝트:소개', slant:'both', pillar:true },
        { key:'corporations', label:'기업 및 공동체', title:'기업_및_공동체', slant:'left' },
        { key:'military', label:'군, 정치집단', title:'군_정치집단', slant:'left' }
    ];
    var renderTimer = 0;

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

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

    function clearNode(node) {
        while (node && node.firstChild) node.removeChild(node.firstChild);
    }

    function calculateLayout(width) {
        var frame = api();
        var f = frame.FRAME;
        var d = frame.DIMENSIONS;
        var contentX = d.WELL_TOP;
        var contentWidth = Math.max(ITEMS.length * 8, width - contentX * 2);
        var btnW = Math.max(4, Math.floor((contentWidth - f.GAP * 4) / ITEMS.length));
        var segments = [];
        var xoff = contentX;
        var i;

        for (i = 0; i < ITEMS.length; i++) {
            segments.push({
                index:i,
                key:ITEMS[i].key,
                label:ITEMS[i].label,
                slant:ITEMS[i].slant,
                x:xoff,
                y:d.BUTTON_EDGE_TOP,
                btnW:btnW,
                pixelWidth:btnW + 2,
                pixelHeight:d.BUTTON_TOTAL
            });
            xoff += btnW + f.GAP;
        }

        return {
            width:width,
            height:d.ROW_HEIGHT,
            contentX:contentX,
            contentWidth:contentWidth,
            buttonWidth:btnW,
            usedRight:xoff - f.GAP,
            segments:segments
        };
    }

    function paintProjectPillar(image, width, height, segment) {
        var frame = api();
        var d = frame.DIMENSIONS;
        var topStep = Math.floor(d.BUTTON_EDGE_TOP / frame.FRAME.SLANT_RISE);
        var leftTop = segment.x - topStep;
        var rightTop = segment.x + segment.btnW + topStep;
        var y;
        var x;

        for (y = 0; y < height; y++) {
            var step = Math.floor(y / frame.FRAME.SLANT_RISE);
            var left = leftTop + step;
            var right = rightTop - step;
            for (x = left; 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');
        }
        return {
            leftTop:leftTop,
            rightTop:rightTop,
            leftAtButton:segment.x,
            rightAtButton:segment.x + segment.btnW
        };
    }

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

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

        frame.paintStandardFrame(image, layout.width, layout.height);
        layout.segments.forEach(function (segment) {
            var grid = frame.buildButtonPixels(
                segment.btnW,
                frame.FRAME.BUTTON_FACE,
                segment.slant,
                0
            );
            frame.paintGrid(
                image,
                layout.width,
                layout.height,
                grid,
                segment.x,
                segment.y,
                true
            );
        });

        projectGeometry = paintProjectPillar(
            image,
            layout.width,
            layout.height,
            layout.segments[2]
        );
        ctx.putImageData(image, 0, 0);

        layout.segments.forEach(function (segment, index) {
            var center = index === 2
                ? Math.round((projectGeometry.leftAtButton + projectGeometry.rightAtButton) / 2)
                : labelCenter(segment);
            frame.drawLabel(
                ctx,
                segment.label,
                center,
                Math.round((frame.DIMENSIONS.BUTTON_SURFACE_TOP + frame.DIMENSIONS.BUTTON_SURFACE_BOTTOM) / 2) + 1,
                hoverIndex === index && index !== 2,
                layout.width <= 820 ? 11 : 12
            );
        });
        canvas.__projectGeometry = projectGeometry;
    }

    function publishSeams(layout) {
        var frame = api();
        var d = frame.DIMENSIONS;
        var settings = layout.segments[1];
        var project = layout.segments[2];
        var corporations = layout.segments[3];
        var topStep = Math.floor(d.BUTTON_EDGE_TOP / frame.FRAME.SLANT_RISE);
        var settingsLeftOriginX = settings.x - topStep;
        var corpRightOriginX = corporations.x + corporations.btnW + topStep;
        var projectLeftOriginX = project.x - topStep;
        var projectRightOriginX = project.x + project.btnW + topStep;
        var seamStep = Math.floor(d.BOTTOM_START_ABS_Y / frame.FRAME.SLANT_RISE);

        frame.setSeams({
            width:layout.width,
            rowHeight:d.ROW_HEIGHT,
            buttonTop:d.BUTTON_EDGE_TOP,
            buttonBottom:d.BUTTON_EDGE_BOTTOM,
            overlap:d.OVERLAP,
            bottomStartAbsY:d.BOTTOM_START_ABS_Y,
            phase:d.BOTTOM_START_ABS_Y % frame.FRAME.SLANT_RISE,
            settingsLeftOriginX:settingsLeftOriginX,
            settingsLeftTopX:settings.x,
            settingsLeftSeamX:settingsLeftOriginX + seamStep,
            settingsLeftDir:1,
            corpRightOriginX:corpRightOriginX,
            corpRightTopX:corporations.x + corporations.btnW,
            corpRightSeamX:corpRightOriginX - seamStep,
            corpRightDir:-1,
            projectLeftOriginX:projectLeftOriginX,
            projectRightOriginX:projectRightOriginX,
            projectLeftTopX:project.x,
            projectRightTopX:project.x + project.btnW,
            projectLeftDir:1,
            projectRightDir:-1,
            buttonWidth:layout.buttonWidth,
            segments:layout.segments.map(function (segment) {
                return {
                    key:segment.key,
                    x:segment.x,
                    y:segment.y,
                    btnW:segment.btnW,
                    pixelWidth:segment.pixelWidth,
                    slant:segment.slant
                };
            }),
            shellScale:frame.getShellScale()
        });
    }

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

    function renderMount(mount) {
        var frame = api();
        var width;
        var layout;
        var nav;
        var inner;
        var canvas;
        var hitLayer;
        var redraw;

        if (!mount || !frame) return false;
        frame.setShellScale(frame.getShellScale());
        width = frame.readRenderWidth(mount);
        if (!width) {
            if (!mount.__categoryNavRetry) {
                mount.__categoryNavRetry = window.setTimeout(function () {
                    mount.__categoryNavRetry = 0;
                    renderMount(mount);
                }, 60);
            }
            return false;
        }

        layout = calculateLayout(width);
        if (
            mount.getAttribute('data-category-nav-width') === String(width) &&
            mount.getAttribute('data-category-nav-scale') === String(frame.getShellScale()) &&
            mount.querySelector('.portal-category-canvas')
        ) {
            publishSeams(layout);
            return true;
        }
        nav = document.createElement('nav');
        nav.className = 'portal-category-nav portal-frame-canvas-nav';
        nav.setAttribute('aria-label', 'Main categories');
        nav.__frameHoverIndex = -1;

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

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

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

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

        inner.appendChild(canvas);
        inner.appendChild(hitLayer);
        nav.appendChild(inner);
        clearNode(mount);
        mount.appendChild(nav);
        frame.applyCounterScale([mount, nav], inner, layout.height);
        redraw();
        publishSeams(layout);

        mount.setAttribute('data-category-nav-rendered', 'canvas');
        mount.setAttribute('data-category-nav-width', String(width));
        mount.setAttribute('data-category-nav-scale', String(frame.getShellScale()));

        if (window.ResizeObserver && !mount.__categoryNavObserver) {
            mount.__categoryNavObserver = new window.ResizeObserver(function () {
                schedule(mount);
            });
            mount.__categoryNavObserver.observe(mount);
        }
        return true;
    }

    function renderAll(root) {
        var scope = root && root.querySelectorAll ? root : document;
        var mounts = [];
        var rendered = 0;
        if (scope.matches && scope.matches('[data-component="category-nav"]')) mounts.push(scope);
        Array.prototype.forEach.call(scope.querySelectorAll('[data-component="category-nav"]'), function (mount) {
            if (mounts.indexOf(mount) === -1) mounts.push(mount);
        });
        mounts.forEach(function (mount) {
            if (renderMount(mount)) rendered += 1;
        });
        return rendered;
    }

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

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', function () { renderAll(document); });
    } else {
        renderAll(document);
    }

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

    window.CategoryNav = {
        version:'20260717-canvas-pixel-001',
        init:renderAll,
        renderAll:renderAll,
        renderMount:renderMount
    };
    window.CLBI = window.CLBI || {};
    window.CLBI.categoryNav = {
        init:renderAll,
        renderMount:renderMount
    };
}(window, document, window.mw));