(Install package: clbiwiki-mainpage-guide-body-screen-switch-20260714 / js/BottomGuideNav.js) |
(Install package: clbiwiki-mainpage-guide-persistent-screen-toggle-20260714 / js/BottomGuideNav.js) |
||
| 525번째 줄: | 525번째 줄: | ||
well: well | well: well | ||
}; | }; | ||
} | |||
function setBodyScreenHidden(screen, hidden) { | |||
if (!screen) return; | |||
screen.classList.toggle('is-active', !hidden); | |||
screen.hidden = hidden; | |||
screen.setAttribute('aria-hidden', hidden ? 'true' : 'false'); | |||
} | |||
function updateGuideButtonState(portal, active) { | |||
var guideActive = active === 'guide'; | |||
var label = guideActive ? '돌아가기' : '길라잡이'; | |||
var target = guideActive ? 'manifesto' : 'guide'; | |||
var href = guideActive ? '#main-manifesto-screen' : '#main-guide-screen'; | |||
Array.prototype.forEach.call(portal.querySelectorAll('.portal-guide-anchor'), function (anchor) { | |||
anchor.setAttribute('href', href); | |||
anchor.setAttributeNS(XLINK_NS, 'xlink:href', href); | |||
anchor.setAttribute('aria-label', label); | |||
anchor.setAttribute('aria-pressed', guideActive ? 'true' : 'false'); | |||
anchor.setAttribute('data-main-body-target', target); | |||
anchor.removeAttribute('aria-current'); | |||
}); | |||
Array.prototype.forEach.call(portal.querySelectorAll('.portal-guide-label'), function (text) { | |||
text.textContent = label; | |||
}); | |||
Array.prototype.forEach.call(portal.querySelectorAll('.portal-guide-nav'), function (nav) { | |||
nav.setAttribute('aria-label', label); | |||
}); | |||
} | } | ||
| 531번째 줄: | 563번째 줄: | ||
var manifesto = layout.well.querySelector('.main-manifesto'); | var manifesto = layout.well.querySelector('.main-manifesto'); | ||
var guide = layout.well.querySelector('[data-main-body-screen="guide"]'); | var guide = layout.well.querySelector('[data-main-body-screen="guide"]'); | ||
var active = portal.getAttribute('data-main-body-screen') || 'manifesto'; | var panels; | ||
var active = layout.well.getAttribute('data-active-main-body-screen') || | |||
portal.getAttribute('data-main-body-screen') || | |||
'manifesto'; | |||
if (manifesto) { | if (manifesto) { | ||
manifesto.classList.add('main-body-screen'); | |||
manifesto.setAttribute('data-main-body-screen', 'manifesto'); | manifesto.setAttribute('data-main-body-screen', 'manifesto'); | ||
if (!manifesto.id) manifesto.id = 'main-manifesto-screen'; | |||
} | } | ||
if (!guide) { | if (!guide) { | ||
guide = document.createElement('section'); | guide = document.createElement('section'); | ||
guide.className = 'main-guide-screen'; | guide.className = 'main-body-screen main-guide-screen'; | ||
guide.setAttribute('data-main-body-screen', 'guide'); | guide.setAttribute('data-main-body-screen', 'guide'); | ||
guide.setAttribute('data-main-guide-screen-generated', '1'); | guide.setAttribute('data-main-guide-screen-generated', '1'); | ||
| 546번째 줄: | 583번째 줄: | ||
guide.id = 'main-guide-screen'; | guide.id = 'main-guide-screen'; | ||
layout.well.appendChild(guide); | layout.well.appendChild(guide); | ||
} else if (!guide.id) | } else { | ||
guide.classList.add('main-body-screen'); | |||
guide.classList.add('main-guide-screen'); | |||
if (!guide.id) guide.id = 'main-guide-screen'; | |||
} | } | ||
panels = Array.prototype.slice.call( | |||
layout.well.querySelectorAll('.main-body-screen[data-main-body-screen]') | |||
); | |||
if (active !== 'guide') active = 'manifesto'; | if (active !== 'guide') active = 'manifesto'; | ||
if ( | if (!panels.some(function (panel) { | ||
return panel.getAttribute('data-main-body-screen') === active; | |||
})) { | |||
active = 'manifesto'; | |||
} | |||
panels.forEach(function (panel) { | |||
setBodyScreenHidden( | |||
panel, | |||
panel.getAttribute('data-main-body-screen') !== active | |||
); | |||
}); | |||
} | |||
layout.well.setAttribute('data-main-body-screen-controller-ready', '1'); | |||
layout.well.setAttribute('data-active-main-body-screen', active); | |||
portal.setAttribute('data-main-body-screen', active); | portal.setAttribute('data-main-body-screen', active); | ||
portal.classList.toggle('is-guide-screen-active', active === 'guide'); | portal.classList.toggle('is-guide-screen-active', active === 'guide'); | ||
updateGuideButtonState(portal, active); | |||
return { | return { | ||
well: layout.well, | |||
panels: panels, | |||
manifesto: manifesto, | manifesto: manifesto, | ||
guide: guide, | guide: guide, | ||
| 576번째 줄: | 626번째 줄: | ||
var previous; | var previous; | ||
var active; | var active; | ||
var event; | var event; | ||
| 583번째 줄: | 632번째 줄: | ||
screens = ensureBodyScreens(portal); | screens = ensureBodyScreens(portal); | ||
screenName = screenName === 'guide' ? 'guide' : 'manifesto'; | screenName = screenName === 'guide' ? 'guide' : 'manifesto'; | ||
if ( | if (!screens.panels.some(function (panel) { | ||
return panel.getAttribute('data-main-body-screen') === screenName; | |||
})) return false; | |||
previous = screens.active; | previous = screens.active; | ||
active = screenName; | active = screenName; | ||
screens.panels.forEach(function (panel) { | |||
setBodyScreenHidden( | |||
panel, | |||
panel.getAttribute('data-main-body-screen') !== active | |||
); | |||
}); | |||
} | |||
screens.well.setAttribute('data-active-main-body-screen', active); | |||
portal.setAttribute('data-main-body-screen', active); | portal.setAttribute('data-main-body-screen', active); | ||
portal.classList.toggle('is-guide-screen-active', active === 'guide'); | portal.classList.toggle('is-guide-screen-active', active === 'guide'); | ||
updateGuideButtonState(portal, active); | |||
if (previous !== active) { | if (previous !== active) { | ||
| 619번째 줄: | 663번째 줄: | ||
return true; | return true; | ||
} | } | ||
function toggleBodyScreen(portal) { | |||
var screens; | |||
var target; | |||
if (!portal) return false; | |||
screens = ensureBodyScreens(portal); | |||
target = screens.active === 'guide' ? 'manifesto' : 'guide'; | |||
return activateBodyScreen(portal, target); | |||
} | |||
function handleGuideScreenCapture(event) { | |||
var target; | |||
var anchor; | |||
var portal; | |||
if (!event || event.__mainGuideScreenHandled) return; | |||
if (event.button > 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; | |||
target = event.target; | |||
anchor = target && target.closest | |||
? target.closest('.portal-guide-anchor[data-category-key="guide"]') | |||
: null; | |||
if (!anchor) return; | |||
portal = anchor.closest ? anchor.closest('.main-portal') : null; | |||
if (!portal || !toggleBodyScreen(portal)) return; | |||
event.__mainGuideScreenHandled = true; | |||
event.preventDefault(); | |||
event.stopPropagation(); | |||
if (typeof event.stopImmediatePropagation === 'function') { | |||
event.stopImmediatePropagation(); | |||
} | |||
} | |||
/* | |||
이전 실행본의 ResizeObserver가 오래된 문서 링크를 다시 그려도 | |||
전역 SPA 링크 처리보다 먼저 화면 전환으로 고정한다. | |||
*/ | |||
if (window.BottomGuideNavScreenCaptureHandler) { | |||
document.removeEventListener('click', window.BottomGuideNavScreenCaptureHandler, true); | |||
} | |||
window.BottomGuideNavScreenCaptureHandler = handleGuideScreenCapture; | |||
document.addEventListener('click', handleGuideScreenCapture, true); | |||
function ensureMount(portal) { | function ensureMount(portal) { | ||
| 658번째 줄: | 747번째 줄: | ||
var svg = createSvgElement('svg'); | var svg = createSvgElement('svg'); | ||
var anchor = createSvgElement('a'); | var anchor = createSvgElement('a'); | ||
var | var guideActive = portal.getAttribute('data-main-body-screen') === 'guide'; | ||
var label = guideActive ? '돌아가기' : '길라잡이'; | |||
var targetScreen = guideActive ? 'manifesto' : 'guide'; | |||
var href = guideActive ? '#main-manifesto-screen' : '#main-guide-screen'; | |||
var text = createSvgElement('text'); | var text = createSvgElement('text'); | ||
var outer = createPolygon('portal-cat-edge', points); | var outer = createPolygon('portal-cat-edge', points); | ||
| 665번째 줄: | 757번째 줄: | ||
nav.className = 'portal-category-nav portal-guide-nav is-continuous-weld'; | nav.className = 'portal-category-nav portal-guide-nav is-continuous-weld'; | ||
nav.setAttribute('aria-label', | nav.setAttribute('aria-label', label); | ||
svg.setAttribute('class', 'portal-category-svg portal-guide-svg'); | svg.setAttribute('class', 'portal-category-svg portal-guide-svg'); | ||
| 678번째 줄: | 770번째 줄: | ||
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', ' | anchor.setAttribute('aria-label', label); | ||
anchor.setAttribute('role', 'button'); | |||
anchor.setAttribute('aria-pressed', guideActive ? 'true' : 'false'); | |||
anchor.setAttribute('data-category-key', 'guide'); | anchor.setAttribute('data-category-key', 'guide'); | ||
anchor.setAttribute(' | anchor.setAttribute('data-main-body-target', targetScreen); | ||
anchor.setAttribute('aria-controls', 'main-manifesto-screen main-guide-screen'); | |||
anchor.addEventListener('click', function (event) { | anchor.addEventListener('click', function (event) { | ||
| 693번째 줄: | 785번째 줄: | ||
} | } | ||
if ( | if (toggleBodyScreen(portal)) { | ||
if (event) event.preventDefault(); | if (event) event.preventDefault(); | ||
} | } | ||
| 710번째 줄: | 802번째 줄: | ||
text.setAttribute('dominant-baseline', 'middle'); | text.setAttribute('dominant-baseline', 'middle'); | ||
text.setAttribute('pointer-events', 'none'); | text.setAttribute('pointer-events', 'none'); | ||
text.textContent = | text.textContent = label; | ||
anchor.appendChild(outer); | anchor.appendChild(outer); | ||
| 816번째 줄: | 908번째 줄: | ||
window.BottomGuideNav = { | window.BottomGuideNav = { | ||
version: '20260714-guide- | version: '20260714-guide-persistent-screen-toggle-013', | ||
render: function () { | render: function () { | ||
var portal = document.querySelector('.main-portal'); | var portal = document.querySelector('.main-portal'); | ||
| 824번째 줄: | 916번째 줄: | ||
var portal = document.querySelector('.main-portal'); | var portal = document.querySelector('.main-portal'); | ||
return portal ? activateBodyScreen(portal, screenName) : false; | return portal ? activateBodyScreen(portal, screenName) : false; | ||
}, | |||
toggle: function () { | |||
var portal = document.querySelector('.main-portal'); | |||
return portal ? toggleBodyScreen(portal) : false; | |||
}, | }, | ||
status: function () { | status: function () { | ||
2026년 7월 14일 (화) 10:57 판
/* =========================================
BottomGuideNav
대문 하단 「길라잡이」 SVG 버튼
========================================= */
/*
새 기능과 공개 이름에는 프로젝트 접두사를 사용하지 않는다.
상단 CategoryNav와 분리해 로드하며, 상단 SVG의 실제 좌표를 읽어
설정–프로젝트–기업 및 공동체의 합산 경계와 정확히 맞춘다.
*/
(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 = 'category-guide-nav';
/* 42px 본체·하이라이트·섀도우 + 상하 1px #050505 최외각선 */
var HEIGHT = 44;
/* 최외각선·광원선을 제외한 #1d1d1d 상단 실색 영역이 7px이 되도록 9px 좌표를 쓴다. */
var TOP_FRAME = 9;
var BOTTOM_FRAME = 8;
var GAP = 2;
var ROW_HEIGHT = TOP_FRAME + GAP + HEIGHT + GAP + BOTTOM_FRAME;
var BUTTON_TOP = TOP_FRAME + GAP;
var BUTTON_BOTTOM = BUTTON_TOP + HEIGHT;
var SHARED_BOTTOM_TOP = BUTTON_BOTTOM + GAP;
var FRAME_JOIN_EXTENSION = 1;
var OUTER_BORDER = 1;
var INSET_OFFSET = 1.5;
var retryTimer = 0;
var resizeTimer = 0;
var observer = null;
var manifestoIntroPromise = null;
var MANIFESTO_LOAD_TIMEOUT_MS = 5000;
var MANIFESTO_INTRO_VERSION = '20260713-divider-equal-gap-005';
function revealManifesto(root) {
var scope = root && root.querySelector ? root : document;
var portal = scope.matches && scope.matches('.main-portal')
? scope
: scope.querySelector('.main-portal') || document.querySelector('.main-portal');
var title;
var copy;
/* 이전 캐시에서 남은 전역 잠금을 즉시 해제한다. */
document.documentElement.classList.remove('manifesto-intro-pending');
document.documentElement.classList.remove('manifesto-intro-active');
if (!portal) return;
portal.classList.remove('manifesto-intro-prepared');
portal.classList.remove('manifesto-intro-running');
title = portal.querySelector('.main-manifesto-title');
copy = portal.querySelector('.main-manifesto-copy');
if (title) {
title.style.opacity = '1';
title.style.borderBottom = '0';
}
if (copy) copy.style.visibility = 'visible';
Array.prototype.forEach.call(portal.querySelectorAll('.manifesto-line'), function (line) {
line.style.height = '26px';
line.style.maxHeight = '26px';
line.style.opacity = '1';
line.style.overflow = 'visible';
line.style.clipPath = 'inset(0 0 0 0)';
});
Array.prototype.forEach.call(portal.querySelectorAll('.manifesto-line-inner'), function (inner) {
inner.style.opacity = '1';
inner.style.transform = 'translateY(0)';
});
}
function loadManifestoIntro(root) {
if (
window.ManifestoIntro &&
window.ManifestoIntro.version === MANIFESTO_INTRO_VERSION &&
typeof window.ManifestoIntro.init === 'function'
) {
try {
return Promise.resolve(window.ManifestoIntro.init(root || document)).catch(function () {
revealManifesto(root);
return false;
});
} catch (error) {
revealManifesto(root);
return Promise.resolve(false);
}
}
if (manifestoIntroPromise) return manifestoIntroPromise;
manifestoIntroPromise = new Promise(function (resolve) {
var script = document.createElement('script');
var settled = false;
var timeout = 0;
function settle(ok) {
if (settled) return;
settled = true;
window.clearTimeout(timeout);
if (!ok) {
revealManifesto(root);
manifestoIntroPromise = null;
}
resolve(ok);
}
script.src = '/index.php?title=' + encodeURIComponent('MediaWiki:ManifestoIntro.js') +
'&action=raw&ctype=text/javascript&v=' + encodeURIComponent(MANIFESTO_INTRO_VERSION) +
'&_manifestoFresh=' + encodeURIComponent(String(Date.now()));
script.async = false;
script.setAttribute('data-manifesto-intro-script', '1');
script.onload = function () {
if (
window.ManifestoIntro &&
window.ManifestoIntro.version === MANIFESTO_INTRO_VERSION &&
typeof window.ManifestoIntro.init === 'function'
) {
Promise.resolve(window.ManifestoIntro.init(root || document))
.then(function () { settle(true); })
.catch(function () { settle(false); });
} else {
settle(false);
}
};
script.onerror = function () {
settle(false);
};
timeout = window.setTimeout(function () {
if (script.parentNode) script.parentNode.removeChild(script);
settle(false);
}, MANIFESTO_LOAD_TIMEOUT_MS);
(document.head || document.documentElement).appendChild(script);
});
return manifestoIntroPromise;
}
revealManifesto(document);
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];
if (Math.abs(cross) < 0.000001) {
return [p[0], p[1]];
}
var qp = [q[0] - p[0], q[1] - p[1]];
var 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 createPolygon(className, points) {
var polygon = createSvgElement('polygon');
polygon.setAttribute('class', className);
polygon.setAttribute('points', pointsToString(points));
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 createRect(className, x, y, width, height) {
var rect = createSvgElement('rect');
rect.setAttribute('class', className);
rect.setAttribute('x', roundCoordinate(x));
rect.setAttribute('y', roundCoordinate(y));
rect.setAttribute('width', roundCoordinate(width));
rect.setAttribute('height', roundCoordinate(height));
rect.setAttribute('pointer-events', 'none');
return rect;
}
function fallbackGeometry(width) {
var usableWidth = Math.max(320, Math.floor(width || 0));
var count = 5;
var cut = Math.round((usableWidth / count) * 0.045);
var top = [];
var bottom = [];
var half = count / 2;
var i;
cut = Math.max(6, Math.min(10, cut));
for (i = 0; i <= count; i++) {
var x = Math.round((usableWidth * i) / count);
if (i === 0 || i === count || i === half) {
top.push(x);
bottom.push(x);
} else if (i < half) {
top.push(x - cut);
bottom.push(x);
} else {
top.push(x);
bottom.push(x - cut);
}
}
return [
[bottom[1] + 1, BUTTON_TOP],
[bottom[4] - 1, BUTTON_TOP],
[top[4] - 1, BUTTON_BOTTOM],
[top[1] + 1, BUTTON_BOTTOM]
];
}
function readGuidePoints(portal, mountRect) {
var topNav = portal.querySelector('[data-component="category-nav"]');
var topSvg = topNav && topNav.querySelector('.portal-category-svg');
var settings = topNav && topNav.querySelector(
'.portal-cat-anchor[data-category-key="settings"] .portal-cat-edge'
);
var corporations = topNav && topNav.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 viewBox;
var scaleX;
function localX(svgX) {
return Math.round(
svgRect.left +
(svgX - viewBox.x) * scaleX -
mountRect.left
);
}
/*
상단 SVG의 실제 화면 좌표를 길라잡이 행의 로컬 좌표로 변환한다.
프레임·2px 우물 간격이 추가되어도 설정–프로젝트–기업 및 공동체
경계와 정확히 일치한다.
*/
if (
topSvg &&
settingsPoints.length === 4 &&
corporationPoints.length === 4
) {
svgRect = topSvg.getBoundingClientRect();
viewBox = topSvg.viewBox && topSvg.viewBox.baseVal;
if (viewBox && viewBox.width > 0 && svgRect.width > 0) {
scaleX = svgRect.width / viewBox.width;
return [
[localX(settingsPoints[3][0]), BUTTON_TOP],
[localX(corporationPoints[2][0]), BUTTON_TOP],
[localX(corporationPoints[1][0]), BUTTON_BOTTOM],
[localX(settingsPoints[0][0]), BUTTON_BOTTOM]
];
}
}
return fallbackGeometry(Math.floor(mountRect.width));
}
function forceFrameY(points, topY, bottomY) {
var output = points.map(function (point) {
return [point[0], point[1]];
});
output[0][1] = topY;
output[1][1] = topY;
output[2][1] = bottomY;
output[3][1] = bottomY;
return output;
}
function appendContinuousBottomFrame(svg, buttonPoints, width) {
var group = createSvgElement('g');
var baseTop = ROW_HEIGHT - BOTTOM_FRAME;
var outer = forceFrameY(
insetPolygon(buttonPoints, -(TOP_FRAME + GAP)),
0,
baseTop + FRAME_JOIN_EXTENSION
);
var inner = forceFrameY(
insetPolygon(buttonPoints, -GAP),
TOP_FRAME,
baseTop
);
var outerLeftBottom = outer[3];
var outerLeftTop = outer[0];
var outerRightTop = outer[1];
var outerRightBottom = outer[2];
var innerLeftBottom = inner[3];
var innerLeftTop = inner[0];
var innerRightTop = inner[1];
var innerRightBottom = inner[2];
function clampPoint(point) {
point[0] = Math.max(0, Math.min(width, Math.round(point[0])));
point[1] = Math.max(0, Math.min(ROW_HEIGHT, Math.round(point[1])));
return point;
}
outer.forEach(clampPoint);
inner.forEach(clampPoint);
group.setAttribute('class', 'portal-guide-arch-frame');
group.setAttribute('pointer-events', 'none');
/*
기존 main-body-panel의 하단 프레임은 CSS가 그대로 그린다.
여기서는 버튼과 좌·우·상단 아치 프레임 조각만 애드온으로 얹는다.
outer의 하단만 1px 연장해 원래 우물 하단 #555555가 중앙 접합을 가로막는
동일한 픽셀 행을 #1d1d1d 프레임 면으로 덮는다. 하단 프레임 자체는 재구성하지 않는다.
*/
group.appendChild(createPolygon('portal-guide-arch-outer', outer));
group.appendChild(createPolygon('portal-guide-arch-well', inner));
/*
버튼과 마주 보는 본문 하단 프레임의 상단 반사광.
기존 본문 프레임이나 우물 inset을 다시 그리지 않는다.
아치의 좌·우 접합부는 #1d1d1d로 그대로 이어 두고,
두 안쪽 접점 사이의 동일한 1px 행에만 #555555를 놓는다.
*/
if (innerRightBottom[0] > innerLeftBottom[0]) {
group.appendChild(createRect(
'portal-guide-facing-highlight',
innerLeftBottom[0],
baseTop,
innerRightBottom[0] - innerLeftBottom[0],
1
));
}
/* 최외곽: 상단 + 양쪽 사선. 하단선 없음. */
group.appendChild(createLine(
'portal-guide-arch-edge',
outerLeftBottom,
outerLeftTop
));
group.appendChild(createLine(
'portal-guide-arch-edge',
outerLeftTop,
outerRightTop
));
group.appendChild(createLine(
'portal-guide-arch-edge',
outerRightTop,
outerRightBottom
));
/* 바깥 돌출면 광원: 상단/우측 밝음, 좌측 어두움. */
group.appendChild(createLine(
'portal-guide-arch-highlight',
[outerLeftTop[0] + 1, outerLeftTop[1] + 1],
[outerRightTop[0] - 1, outerRightTop[1] + 1]
));
group.appendChild(createLine(
'portal-guide-arch-highlight',
[outerRightTop[0] - 1, outerRightTop[1] + 1],
[outerRightBottom[0] - 1, outerRightBottom[1] - 1]
));
group.appendChild(createLine(
'portal-guide-arch-shadow',
[outerLeftBottom[0] + 1, outerLeftBottom[1] - 1],
[outerLeftTop[0] + 1, outerLeftTop[1] + 1]
));
/*
프레임 안쪽 우물 경계.
상단/우측 #555555, 좌측 #101010이며 하단은 패널 프레임으로 열어 둔다.
*/
group.appendChild(createLine(
'portal-guide-arch-inner-highlight',
innerLeftTop,
innerRightTop
));
group.appendChild(createLine(
'portal-guide-arch-inner-highlight',
innerRightTop,
innerRightBottom
));
group.appendChild(createLine(
'portal-guide-arch-inner-shadow',
innerLeftBottom,
innerLeftTop
));
svg.appendChild(group);
}
function ensureBodyPanel(portal) {
var panel = portal.querySelector('.main-body-panel');
var well = portal.querySelector('.main-body-well');
var topMount = portal.querySelector('[data-component="category-nav"]');
var manifesto = portal.querySelector('.main-manifesto');
if (!panel) {
panel = document.createElement('div');
panel.className = 'main-body-panel';
panel.setAttribute('data-main-body-panel-generated', '1');
if (topMount && topMount.parentNode) {
if (topMount.nextSibling) {
topMount.parentNode.insertBefore(panel, topMount.nextSibling);
} else {
topMount.parentNode.appendChild(panel);
}
} else {
portal.appendChild(panel);
}
}
if (!well) {
well = document.createElement('div');
well.className = 'main-body-well';
well.setAttribute('data-main-body-well-generated', '1');
panel.insertBefore(well, panel.firstChild);
} else if (well.parentNode !== panel) {
panel.insertBefore(well, panel.firstChild);
}
if (manifesto && manifesto.parentNode !== well) {
well.insertBefore(manifesto, well.firstChild);
}
return {
panel: panel,
well: well
};
}
function setBodyScreenHidden(screen, hidden) {
if (!screen) return;
screen.classList.toggle('is-active', !hidden);
screen.hidden = hidden;
screen.setAttribute('aria-hidden', hidden ? 'true' : 'false');
}
function updateGuideButtonState(portal, active) {
var guideActive = active === 'guide';
var label = guideActive ? '돌아가기' : '길라잡이';
var target = guideActive ? 'manifesto' : 'guide';
var href = guideActive ? '#main-manifesto-screen' : '#main-guide-screen';
Array.prototype.forEach.call(portal.querySelectorAll('.portal-guide-anchor'), function (anchor) {
anchor.setAttribute('href', href);
anchor.setAttributeNS(XLINK_NS, 'xlink:href', href);
anchor.setAttribute('aria-label', label);
anchor.setAttribute('aria-pressed', guideActive ? 'true' : 'false');
anchor.setAttribute('data-main-body-target', target);
anchor.removeAttribute('aria-current');
});
Array.prototype.forEach.call(portal.querySelectorAll('.portal-guide-label'), function (text) {
text.textContent = label;
});
Array.prototype.forEach.call(portal.querySelectorAll('.portal-guide-nav'), function (nav) {
nav.setAttribute('aria-label', label);
});
}
function ensureBodyScreens(portal) {
var layout = ensureBodyPanel(portal);
var manifesto = layout.well.querySelector('.main-manifesto');
var guide = layout.well.querySelector('[data-main-body-screen="guide"]');
var panels;
var active = layout.well.getAttribute('data-active-main-body-screen') ||
portal.getAttribute('data-main-body-screen') ||
'manifesto';
if (manifesto) {
manifesto.classList.add('main-body-screen');
manifesto.setAttribute('data-main-body-screen', 'manifesto');
if (!manifesto.id) manifesto.id = 'main-manifesto-screen';
}
if (!guide) {
guide = document.createElement('section');
guide.className = 'main-body-screen main-guide-screen';
guide.setAttribute('data-main-body-screen', 'guide');
guide.setAttribute('data-main-guide-screen-generated', '1');
guide.setAttribute('aria-label', '길라잡이');
guide.setAttribute('role', 'region');
guide.id = 'main-guide-screen';
layout.well.appendChild(guide);
} else {
guide.classList.add('main-body-screen');
guide.classList.add('main-guide-screen');
if (!guide.id) guide.id = 'main-guide-screen';
}
panels = Array.prototype.slice.call(
layout.well.querySelectorAll('.main-body-screen[data-main-body-screen]')
);
if (active !== 'guide') active = 'manifesto';
if (!panels.some(function (panel) {
return panel.getAttribute('data-main-body-screen') === active;
})) {
active = 'manifesto';
}
panels.forEach(function (panel) {
setBodyScreenHidden(
panel,
panel.getAttribute('data-main-body-screen') !== active
);
});
layout.well.setAttribute('data-main-body-screen-controller-ready', '1');
layout.well.setAttribute('data-active-main-body-screen', active);
portal.setAttribute('data-main-body-screen', active);
portal.classList.toggle('is-guide-screen-active', active === 'guide');
updateGuideButtonState(portal, active);
return {
well: layout.well,
panels: panels,
manifesto: manifesto,
guide: guide,
active: active
};
}
function activateBodyScreen(portal, screenName) {
var screens;
var previous;
var active;
var event;
if (!portal) return false;
screens = ensureBodyScreens(portal);
screenName = screenName === 'guide' ? 'guide' : 'manifesto';
if (!screens.panels.some(function (panel) {
return panel.getAttribute('data-main-body-screen') === screenName;
})) return false;
previous = screens.active;
active = screenName;
screens.panels.forEach(function (panel) {
setBodyScreenHidden(
panel,
panel.getAttribute('data-main-body-screen') !== active
);
});
screens.well.setAttribute('data-active-main-body-screen', active);
portal.setAttribute('data-main-body-screen', active);
portal.classList.toggle('is-guide-screen-active', active === 'guide');
updateGuideButtonState(portal, active);
if (previous !== active) {
try {
event = new CustomEvent('mainbodyscreenchange', {
bubbles: true,
detail: { screen: active, previous: previous }
});
portal.dispatchEvent(event);
} catch (error) {}
}
return true;
}
function toggleBodyScreen(portal) {
var screens;
var target;
if (!portal) return false;
screens = ensureBodyScreens(portal);
target = screens.active === 'guide' ? 'manifesto' : 'guide';
return activateBodyScreen(portal, target);
}
function handleGuideScreenCapture(event) {
var target;
var anchor;
var portal;
if (!event || event.__mainGuideScreenHandled) return;
if (event.button > 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
target = event.target;
anchor = target && target.closest
? target.closest('.portal-guide-anchor[data-category-key="guide"]')
: null;
if (!anchor) return;
portal = anchor.closest ? anchor.closest('.main-portal') : null;
if (!portal || !toggleBodyScreen(portal)) return;
event.__mainGuideScreenHandled = true;
event.preventDefault();
event.stopPropagation();
if (typeof event.stopImmediatePropagation === 'function') {
event.stopImmediatePropagation();
}
}
/*
이전 실행본의 ResizeObserver가 오래된 문서 링크를 다시 그려도
전역 SPA 링크 처리보다 먼저 화면 전환으로 고정한다.
*/
if (window.BottomGuideNavScreenCaptureHandler) {
document.removeEventListener('click', window.BottomGuideNavScreenCaptureHandler, true);
}
window.BottomGuideNavScreenCaptureHandler = handleGuideScreenCapture;
document.addEventListener('click', handleGuideScreenCapture, true);
function ensureMount(portal) {
var layout = ensureBodyPanel(portal);
var mount = portal.querySelector('[data-component="' + COMPONENT + '"]');
ensureBodyScreens(portal);
if (!mount) {
mount = document.createElement('div');
mount.setAttribute('data-component', COMPONENT);
mount.setAttribute('data-bottom-guide-generated', '1');
}
/*
길라잡이는 우물 안의 독립 버튼이 아니라
main-body-panel 하단 프레임에 융합된 돌출부다.
*/
if (mount.parentNode !== layout.panel) {
layout.panel.appendChild(mount);
}
return mount;
}
function render(portal) {
var mount = ensureMount(portal);
var topMount = portal.querySelector('[data-component="category-nav"]');
var mountRect = mount.getBoundingClientRect();
var width = Math.floor(mountRect.width);
if (!width) return false;
var points = readGuidePoints(portal, mountRect);
var surfacePoints = insetPolygon(points, OUTER_BORDER);
var insetPoints = insetPolygon(points, INSET_OFFSET);
var center = getCentroid(surfacePoints);
var nav = document.createElement('nav');
var svg = createSvgElement('svg');
var anchor = createSvgElement('a');
var guideActive = portal.getAttribute('data-main-body-screen') === 'guide';
var label = guideActive ? '돌아가기' : '길라잡이';
var targetScreen = guideActive ? 'manifesto' : 'guide';
var href = guideActive ? '#main-manifesto-screen' : '#main-guide-screen';
var text = createSvgElement('text');
var outer = createPolygon('portal-cat-edge', points);
var surface = createPolygon('portal-cat-surface', surfacePoints);
var hit = createPolygon('portal-cat-hit', points);
nav.className = 'portal-category-nav portal-guide-nav is-continuous-weld';
nav.setAttribute('aria-label', label);
svg.setAttribute('class', 'portal-category-svg portal-guide-svg');
svg.setAttribute('viewBox', '0 0 ' + width + ' ' + ROW_HEIGHT);
svg.setAttribute('width', '100%');
svg.setAttribute('height', '100%');
svg.setAttribute('preserveAspectRatio', 'none');
appendContinuousBottomFrame(svg, points, width);
anchor.setAttribute('class', 'portal-cat-anchor portal-guide-anchor');
anchor.setAttribute('href', href);
anchor.setAttributeNS(XLINK_NS, 'xlink:href', href);
anchor.setAttribute('aria-label', label);
anchor.setAttribute('role', 'button');
anchor.setAttribute('aria-pressed', guideActive ? 'true' : 'false');
anchor.setAttribute('data-category-key', 'guide');
anchor.setAttribute('data-main-body-target', targetScreen);
anchor.setAttribute('aria-controls', 'main-manifesto-screen main-guide-screen');
anchor.addEventListener('click', function (event) {
if (
event &&
(event.button > 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
) {
return;
}
if (toggleBodyScreen(portal)) {
if (event) event.preventDefault();
}
});
outer.setAttribute('pointer-events', 'none');
surface.setAttribute('pointer-events', 'none');
hit.setAttribute('pointer-events', 'all');
text.setAttribute('class', 'portal-cat-label portal-guide-label');
text.setAttribute('x', Math.round(center[0]));
text.setAttribute('y', Math.round((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.setAttribute('pointer-events', 'none');
text.textContent = label;
anchor.appendChild(outer);
anchor.appendChild(surface);
anchor.appendChild(createLine(
'portal-cat-inset portal-cat-inset-top',
insetPoints[0],
insetPoints[1]
));
anchor.appendChild(createLine(
'portal-cat-inset portal-cat-inset-right',
insetPoints[1],
insetPoints[2]
));
anchor.appendChild(createLine(
'portal-cat-inset portal-cat-inset-bottom',
insetPoints[2],
insetPoints[3]
));
anchor.appendChild(createLine(
'portal-cat-inset portal-cat-inset-left',
insetPoints[3],
insetPoints[0]
));
anchor.appendChild(hit);
anchor.appendChild(text);
svg.appendChild(anchor);
nav.appendChild(svg);
mount.replaceChildren(nav);
mount.setAttribute('data-bottom-guide-rendered', '1');
mount.setAttribute('data-bottom-guide-width', String(width));
return true;
}
function findPortal(root) {
var scope = root && root.querySelector ? root : document;
return scope.matches && scope.matches('.main-portal')
? scope
: scope.querySelector('.main-portal');
}
function schedule(root, attempt) {
window.clearTimeout(retryTimer);
retryTimer = window.setTimeout(function () {
var portal = findPortal(root);
if (!portal) return;
if (!render(portal) && attempt < 20) {
schedule(root, attempt + 1);
return;
}
if (observer) observer.disconnect();
if (window.ResizeObserver) {
observer = new ResizeObserver(function () {
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(function () {
render(portal);
}, 60);
});
observer.observe(portal);
var topMount = portal.querySelector('[data-component="category-nav"]');
var guideMount = portal.querySelector('[data-component="' + COMPONENT + '"]');
if (topMount) observer.observe(topMount);
if (guideMount) observer.observe(guideMount);
}
}, attempt ? 60 : 0);
}
function boot(root) {
var scope = root || document;
schedule(scope, 0);
loadManifestoIntro(scope);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () {
boot(document);
}, { once: true });
} else {
boot(document);
}
/* 일반 링크 진입과 새로고침의 최종 로드 시점도 명시적으로 보장한다. */
window.addEventListener('load', function () {
boot(document);
}, { once: true });
/* bfcache 복원과 브라우저 뒤로/앞으로 이동에서도 새 DOM만 초기화한다. */
window.addEventListener('pageshow', function () {
boot(document);
});
if (mw && mw.hook) {
mw.hook('wikipage.content').add(function ($content) {
boot($content && $content[0] ? $content[0] : document);
});
}
window.BottomGuideNav = {
version: '20260714-guide-persistent-screen-toggle-013',
render: function () {
var portal = document.querySelector('.main-portal');
return portal ? render(portal) : false;
},
activate: function (screenName) {
var portal = document.querySelector('.main-portal');
return portal ? activateBodyScreen(portal, screenName) : false;
},
toggle: function () {
var portal = document.querySelector('.main-portal');
return portal ? toggleBodyScreen(portal) : false;
},
status: function () {
var portal = document.querySelector('.main-portal');
var mount = portal && portal.querySelector('[data-component="' + COMPONENT + '"]');
return {
loaded: true,
portal: !!portal,
mount: !!mount,
rendered: !!(mount && mount.querySelector('.portal-guide-svg')),
generated: !!(mount && mount.getAttribute('data-bottom-guide-generated')),
panel: !!(portal && portal.querySelector('.main-body-panel')),
well: !!(portal && portal.querySelector('.main-body-well')),
insidePanel: !!(mount && mount.parentNode && mount.parentNode.classList.contains('main-body-panel')),
welded: !!(mount && mount.querySelector('.portal-guide-nav.is-continuous-weld')),
activeScreen: portal ? portal.getAttribute('data-main-body-screen') || 'manifesto' : '',
guideScreen: !!(portal && portal.querySelector('[data-main-body-screen="guide"]')),
width: mount ? mount.getAttribute('data-bottom-guide-width') || '' : ''
};
}
};
}(window, document, window.mw));