(Install package: clbiwiki-mainpage-attached-section-top-face-seven-20260717 / PortalSectionNav.js) |
(Install package: clbiwiki-mainpage-attached-section-separated-outline-effects-20260717 / PortalSectionNav.js) |
||
| 10번째 줄: | 10번째 줄: | ||
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- | var VERSION = '20260717-portal-section-layered-outline-044'; | ||
var BUTTON_HEIGHT = 22; | var BUTTON_HEIGHT = 22; | ||
var TOP_FRAME = 8; | var TOP_FRAME = 8; | ||
| 267번째 줄: | 267번째 줄: | ||
var surface = projectSidesToY(outer, 0, ROW_HEIGHT - 1, OUTER_BORDER); | var surface = projectSidesToY(outer, 0, ROW_HEIGHT - 1, OUTER_BORDER); | ||
var well = projectSidesToY(outer, TOP_FRAME, ROW_HEIGHT - BOTTOM_FRAME, TOP_FRAME); | var well = projectSidesToY(outer, TOP_FRAME, ROW_HEIGHT - BOTTOM_FRAME, TOP_FRAME); | ||
var outline = projectSidesToY(outer, 0.5, ROW_HEIGHT - 0.5, 0.5); | |||
var innerDirection = projectSidesToY(outer, 1.5, ROW_HEIGHT - 1.5, 1.5); | |||
var group = createSvgElement('g'); | var group = createSvgElement('g'); | ||
var joinDepth = TOP_FRAME; | var joinDepth = TOP_FRAME; | ||
| 272번째 줄: | 274번째 줄: | ||
var rightJoinCover; | var rightJoinCover; | ||
var leftDirection = [ | var leftDirection = [ | ||
[ | innerDirection[0], | ||
innerDirection[3] | |||
]; | ]; | ||
var rightDirection = [ | var rightDirection = [ | ||
innerDirection[1], | |||
innerDirection[2] | |||
]; | ]; | ||
| 285번째 줄: | 287번째 줄: | ||
group.appendChild(createPolygon('portal-section-frame-well', well)); | group.appendChild(createPolygon('portal-section-frame-well', well)); | ||
/* 수평 방향성은 좌우 접합 처리와 무관하므로 먼저 그린다. */ | /* 우물의 수평 방향성은 좌우 접합 처리와 무관하므로 먼저 그린다. */ | ||
group.appendChild(createLine('portal-section-well-shadow', well[0], well[1])); | group.appendChild(createLine('portal-section-well-shadow', well[0], well[1])); | ||
group.appendChild(createLine('portal-section-well-highlight', well[2], well[3])); | group.appendChild(createLine('portal-section-well-highlight', well[2], well[3])); | ||
| 310번째 줄: | 311번째 줄: | ||
group.appendChild(createPolygon('portal-section-join-side-cover', rightJoinCover)); | 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-shadow', leftDirection[1], leftDirection[0])); | ||
group.appendChild(createLine('portal-section-frame-highlight', rightDirection[0], rightDirection[1])); | group.appendChild(createLine('portal-section-frame-highlight', rightDirection[0], rightDirection[1])); | ||
2026년 7월 17일 (금) 08:46 판
/* =========================================
PortalSectionNav
대문 상단 카테고리 하단 부착형 섹션 버튼
========================================= */
(function (window, document, mw) {
'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 VERSION = '20260717-portal-section-layered-outline-044';
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 observer = null;
var ITEMS = [
{ key:'black-ice', label:'블랙 아이스', title:'대문' },
{
key:'anecdote',
label:'에넥도트',
href:'/index.php/특수:모든문서?namespace=3000'
}
];
function createSvgElement(name) {
return document.createElementNS(SVG_NS, name);
}
function buildWikiPath(title) {
if (typeof window.buildWikiPath === 'function') {
return window.buildWikiPath(title);
}
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) {
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 fallbackGeometry(width) {
var cut = Math.max(6, Math.min(10, Math.round((width / 5) * 0.045)));
var run = Math.round(cut * ROW_HEIGHT / 22);
var outer = [
[Math.round(width * 0.2), 0],
[Math.round(width * 0.8), 0],
[Math.round(width * 0.8) - run, ROW_HEIGHT],
[Math.round(width * 0.2) + run, ROW_HEIGHT]
];
return {
outer:outer,
leftDirection:projectSidesToY(outer, 0, ROW_HEIGHT - 1, INSET_OFFSET).map(function (point, index) {
return index === 0 || index === 3 ? point : null;
}).filter(Boolean),
rightDirection:projectSidesToY(outer, 0, ROW_HEIGHT - 1, INSET_OFFSET).slice(1, 3)
};
}
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 {
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) {
var surface = projectSidesToY(outer, 0, ROW_HEIGHT - 1, OUTER_BORDER);
var well = projectSidesToY(outer, TOP_FRAME, ROW_HEIGHT - BOTTOM_FRAME, TOP_FRAME);
var outline = projectSidesToY(outer, 0.5, ROW_HEIGHT - 0.5, 0.5);
var innerDirection = projectSidesToY(outer, 1.5, ROW_HEIGHT - 1.5, 1.5);
var group = createSvgElement('g');
var joinDepth = TOP_FRAME;
var leftJoinCover;
var rightJoinCover;
var leftDirection = [
innerDirection[0],
innerDirection[3]
];
var rightDirection = [
innerDirection[1],
innerDirection[2]
];
group.setAttribute('class', 'portal-section-frame');
group.appendChild(createPolygon('portal-section-frame-edge', outer));
group.appendChild(createPolygon('portal-section-frame-surface', surface));
group.appendChild(createPolygon('portal-section-frame-well', well));
/* 우물의 수평 방향성은 좌우 접합 처리와 무관하므로 먼저 그린다. */
group.appendChild(createLine('portal-section-well-shadow', well[0], well[1]));
group.appendChild(createLine('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);
}
function splitButtons(outer) {
var union = projectSidesToY(outer, BUTTON_TOP, BUTTON_BOTTOM, TOP_FRAME + GAP);
var topMid = (union[0][0] + union[1][0]) / 2;
var bottomMid = (union[3][0] + union[2][0]) / 2;
return [
[
union[0],
[topMid - GAP / 2, BUTTON_TOP],
[bottomMid - GAP / 2, BUTTON_BOTTOM],
union[3]
],
[
[topMid + GAP / 2, BUTTON_TOP],
union[1],
union[2],
[bottomMid + GAP / 2, BUTTON_BOTTOM]
]
];
}
function appendButton(svg, item, points) {
var anchor = createSvgElement('a');
var geometry = createSvgElement('g');
var surfacePoints = projectSidesToY(
points,
points[0][1] + OUTER_BORDER,
points[3][1] - OUTER_BORDER,
OUTER_BORDER
);
var insetPoints = projectSidesToY(
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');
anchor.setAttribute('data-category-key', item.key);
anchor.setAttribute('href', href);
anchor.setAttributeNS(XLINK_NS, 'xlink:href', href);
anchor.setAttribute('aria-label', item.label);
geometry.setAttribute('class', 'portal-section-button-geometry');
geometry.appendChild(createPolygon('portal-cat-edge', points));
geometry.appendChild(createPolygon('portal-cat-surface', surfacePoints));
geometry.appendChild(createLine('portal-cat-inset portal-cat-inset-top', insetPoints[0], insetPoints[1]));
geometry.appendChild(createLine('portal-cat-inset portal-cat-inset-right', insetPoints[1], insetPoints[2]));
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');
geometry.appendChild(hit);
text.setAttribute('class', 'portal-cat-label portal-section-label');
text.setAttribute('x', roundCoordinate(center[0]));
text.setAttribute('y', roundCoordinate((BUTTON_TOP + BUTTON_BOTTOM) / 2 + 1));
text.textContent = item.label;
anchor.appendChild(geometry);
anchor.appendChild(text);
svg.appendChild(anchor);
}
function renderPortal(portal) {
var topMount = portal.querySelector('[data-component="category-nav"]');
var mount;
var rect;
var width;
var geometry;
var outer;
var buttons;
var nav;
var svg;
if (!topMount || !topMount.querySelector('.portal-category-svg')) return false;
mount = ensureMount(portal, topMount);
rect = mount.getBoundingClientRect();
width = Math.floor(rect.width || topMount.getBoundingClientRect().width || 0);
if (!width) return false;
geometry = readFrameGeometry(topMount, mount, width);
outer = geometry.outer;
buttons = splitButtons(outer);
nav = document.createElement('nav');
svg = createSvgElement('svg');
nav.className = 'portal-section-nav';
nav.setAttribute('aria-label', '작품 영역');
svg.setAttribute('class', 'portal-section-svg');
svg.setAttribute('viewBox', '0 0 ' + width + ' ' + ROW_HEIGHT);
svg.setAttribute('width', '100%');
svg.setAttribute('height', '100%');
svg.setAttribute('preserveAspectRatio', 'none');
appendFrame(svg, outer, 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(width));
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) {
var rendered = 0;
collectPortals(root).forEach(function (portal) {
if (renderPortal(portal)) rendered += 1;
});
return rendered;
}
function scheduleRender(root) {
window.clearTimeout(renderTimer);
renderTimer = window.setTimeout(function () {
renderTimer = 0;
renderAll(root || document);
}, 0);
}
function observe() {
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) {
mw.hook('wikipage.content').add(function (content) {
scheduleRender(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 = {
version:VERSION,
init:init,
render:renderPortal,
renderAll:renderAll
};
}(window, document, window.mw));