참고: 설정을 저장한 후에 바뀐 점을 확인하기 위해서는 브라우저의 캐시를 새로 고쳐야 합니다.
- 파이어폭스 / 사파리: Shift 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5 또는 Ctrl-R을 입력 (Mac에서는 ⌘-R)
- 구글 크롬: Ctrl-Shift-R키를 입력 (Mac에서는 ⌘-Shift-R)
- 인터넷 익스플로러 / 엣지: Ctrl 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5를 입력.
- 오페라: Ctrl-F5를 입력.
/* =========================================
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 = '20260716-portal-section-attached-frame-023';
var BUTTON_HEIGHT = 24;
var TOP_FRAME = 9;
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 * 100) / 100;
}
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 intersectLines(lineA, lineB) {
var p = lineA.point;
var r = lineA.direction;
var q = lineB.point;
var s = lineB.direction;
var cross = r[0] * s[1] - r[1] * s[0];
var qp;
var t;
if (Math.abs(cross) < 0.000001) return [p[0], p[1]];
qp = [q[0] - p[0], q[1] - p[1]];
t = (qp[0] * s[1] - qp[1] * s[0]) / cross;
return [p[0] + t * r[0], p[1] + t * r[1]];
}
function insetPolygon(points, distance) {
var center = getCentroid(points);
var lines = [];
var count = points.length;
var i;
for (i = 0; i < count; i++) {
var start = points[i];
var end = points[(i + 1) % count];
var dx = end[0] - start[0];
var dy = end[1] - start[1];
var length = Math.sqrt(dx * dx + dy * dy) || 1;
var normal = [-dy / length, dx / length];
var midpoint = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2];
var towardCenter = [center[0] - midpoint[0], center[1] - midpoint[1]];
if (normal[0] * towardCenter[0] + normal[1] * towardCenter[1] < 0) {
normal[0] *= -1;
normal[1] *= -1;
}
lines.push({
point:[
start[0] + normal[0] * distance,
start[1] + normal[1] * distance
],
direction:[dx, dy]
});
}
return lines.map(function (line, index) {
return intersectLines(lines[(index + count - 1) % count], line);
});
}
function forceY(points, top, bottom) {
var output = points.map(function (point) { return [point[0], point[1]]; });
output[0][1] = top;
output[1][1] = top;
output[2][1] = bottom;
output[3][1] = bottom;
return output;
}
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 fallbackOuter(width) {
var cut = Math.max(6, Math.min(10, Math.round((width / 5) * 0.045)));
return [
[Math.round(width * 0.2), 0],
[Math.round(width * 0.8) - cut, 0],
[Math.round(width * 0.8), ROW_HEIGHT],
[Math.round(width * 0.2) - cut, ROW_HEIGHT]
];
}
function readOuterPoints(topMount, mount, width) {
var topSvg = topMount.querySelector('.portal-category-svg');
var settings = topMount.querySelector(
'.portal-cat-anchor[data-category-key="settings"] .portal-cat-edge'
);
var corporations = topMount.querySelector(
'.portal-cat-anchor[data-category-key="corporations"] .portal-cat-edge'
);
var settingsPoints = settings ? parsePoints(settings.getAttribute('points')) : [];
var corporationPoints = corporations ? parsePoints(corporations.getAttribute('points')) : [];
var svgRect;
var mountRect;
var viewBox;
var scaleX;
if (!topSvg || settingsPoints.length !== 4 || corporationPoints.length !== 4) {
return fallbackOuter(width);
}
svgRect = topSvg.getBoundingClientRect();
mountRect = mount.getBoundingClientRect();
viewBox = getViewBox(topSvg);
if (!viewBox || !(svgRect.width > 0) || !(mountRect.width > 0)) {
return fallbackOuter(width);
}
scaleX = svgRect.width / viewBox.width;
function localX(svgX) {
return Math.round(
svgRect.left + (svgX - viewBox.x) * scaleX - mountRect.left
);
}
/* 상단은 버튼 하단 경계, 하단은 버튼 상단 경계를 받아 같은 사선을 잇는다. */
return [
[localX(settingsPoints[3][0]), 0],
[localX(corporationPoints[2][0]), 0],
[localX(corporationPoints[1][0]), ROW_HEIGHT],
[localX(settingsPoints[0][0]), ROW_HEIGHT]
];
}
function appendFrame(svg, outer) {
var surface = forceY(insetPolygon(outer, OUTER_BORDER), 0, ROW_HEIGHT - 1);
var well = forceY(insetPolygon(outer, TOP_FRAME), TOP_FRAME, ROW_HEIGHT - BOTTOM_FRAME);
var group = createSvgElement('g');
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-frame-shadow', surface[3], surface[0]));
group.appendChild(createLine('portal-section-frame-highlight', surface[1], surface[2]));
group.appendChild(createLine('portal-section-frame-shadow', surface[2], surface[3]));
/* 오목한 우물: 상·우 어두움, 하·좌 밝음. */
group.appendChild(createLine('portal-section-well-shadow', well[0], well[1]));
group.appendChild(createLine('portal-section-well-shadow', well[1], well[2]));
group.appendChild(createLine('portal-section-well-highlight', well[2], well[3]));
group.appendChild(createLine('portal-section-well-highlight', well[3], well[0]));
svg.appendChild(group);
}
function splitButtons(outer) {
var union = forceY(
insetPolygon(outer, TOP_FRAME + GAP),
BUTTON_TOP,
BUTTON_BOTTOM
);
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 = insetPolygon(points, OUTER_BORDER);
var insetPoints = insetPolygon(points, 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 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;
outer = readOuterPoints(topMount, mount, width);
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);
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));