(Install package: clbiwiki-mainpage-trim-v3-renewal-20260711 (1) / js/CategoryNav.js) |
(Install package: clbiwiki-portal-frame-runtime-stabilization-20260720-003 / js/CategoryNav.js) |
||
| (같은 사용자의 중간 판 17개는 보이지 않습니다) | |||
| 1번째 줄: | 1번째 줄: | ||
/* ========================================= | /* ========================================= | ||
COASTLINE: BLACK ICE - CategoryNav | COASTLINE: BLACK ICE - CategoryNav | ||
대문 카테고리 SVG 네비 | 대문 프레임 공통 기하 모델과 상단 카테고리 SVG 네비 | ||
========================================= */ | ========================================= */ | ||
| 7번째 줄: | 7번째 줄: | ||
새 공통 기능에는 프로젝트 접두사를 사용하지 않는다. | 새 공통 기능에는 프로젝트 접두사를 사용하지 않는다. | ||
window.CLBI.categoryNav는 기존 Common.js 호출과의 호환을 위해서만 유지한다. | window.CLBI.categoryNav는 기존 Common.js 호출과의 호환을 위해서만 유지한다. | ||
이 파일은 대문 프레임 기하의 단일 소유자다. | |||
CategoryPillar.js, PortalSectionNav.js, BottomGuideNav.js는 SVG DOM을 다시 파싱하지 않고 | |||
window.PortalFrame이 제공하는 동일한 경계와 좌표 변환만 사용한다. | |||
*/ | */ | ||
(function () { | (function (window, document, mw) { | ||
'use strict'; | 'use strict'; | ||
var SVG_NS = 'http://www.w3.org/2000/svg'; | var SVG_NS = 'http://www.w3.org/2000/svg'; | ||
var XLINK_NS = 'http://www.w3.org/1999/xlink'; | var XLINK_NS = 'http://www.w3.org/1999/xlink'; | ||
var FRAME_BUILD = '20260720-portal-frame-stable-003'; | |||
var ITEMS = [ | var ITEMS = [ | ||
{ key: 'era', label: '시대', title: '시대' }, | { key:'era', label:'시대', title:'시대' }, | ||
{ key: 'settings', label: '설정', title: '설정' }, | { key:'settings', label:'설정', title:'설정' }, | ||
{ key: 'project', label: '프로젝트', title: '프로젝트:소개' }, | { key:'project', label:'프로젝트', title:'프로젝트:소개' }, | ||
{ key: 'corporations', label: '기업 및 공동체', title: '기업_및_공동체' }, | { key:'corporations', label:'기업 및 공동체', title:'기업_및_공동체' }, | ||
{ key: 'military', label: '군, 정치집단', title: '군_정치집단 | { key:'military', label:'군, 정치집단', title:'군_정치집단' } | ||
]; | ]; | ||
/* | |||
대문 프레임의 공유 규격. | |||
cut은 폭에 따라 변하지 않는다. 1880px 디자인 폭과 축소 셸에서 같은 경사를 유지한다. | |||
*/ | |||
var SPEC = Object.freeze ? Object.freeze({ | |||
buttonHeight:22, | |||
gap:2, | |||
cut:10, | |||
outerBorder:1, | |||
insetLineOffset:1.5, | |||
count:ITEMS.length | |||
}) : { | |||
buttonHeight:22, | |||
gap:2, | |||
cut:10, | |||
outerBorder:1, | |||
insetLineOffset:1.5, | |||
count:ITEMS.length | |||
}; | |||
function buildPath(title) { | function buildPath(title) { | ||
| 28번째 줄: | 52번째 줄: | ||
return window.buildWikiPath(title); | return window.buildWikiPath(title); | ||
} | } | ||
return '/index.php/' + encodeURI(String(title || '').replace(/ /g, '_')); | return '/index.php/' + encodeURI(String(title || '').replace(/ /g, '_')); | ||
} | } | ||
function readWidth(element) { | function readWidth(element) { | ||
var rect; | |||
var width; | |||
if (!element) return 0; | if (!element) return 0; | ||
rect = element.getBoundingClientRect ? element.getBoundingClientRect() : null; | |||
width = rect ? Math.floor(rect.width || 0) : 0; | |||
if (!width && element.clientWidth) width = Math.floor(element.clientWidth); | |||
if (!width && element.clientWidth) | |||
return width || 0; | return width || 0; | ||
} | } | ||
| 50번째 줄: | 72번째 줄: | ||
mount, | mount, | ||
mount ? mount.parentElement : null, | mount ? mount.parentElement : null, | ||
mount ? mount.closest('.main-portal') : null, | mount && mount.closest ? mount.closest('.main-portal') : null, | ||
document.querySelector('.liberty-content-main .mw-parser-output'), | document.querySelector('.liberty-content-main .mw-parser-output'), | ||
document.querySelector('.liberty-content-main'), | document.querySelector('.liberty-content-main'), | ||
document.querySelector('.liberty-content') | document.querySelector('.liberty-content') | ||
]; | ]; | ||
var i; | |||
var width; | |||
for ( | for (i = 0; i < candidates.length; i++) { | ||
width = readWidth(candidates[i]); | |||
if (width > 0) return width; | if (width > 0) return width; | ||
} | } | ||
| 65번째 줄: | 88번째 줄: | ||
return Math.max(320, Math.floor(document.documentElement.clientWidth - 48)); | return Math.max(320, Math.floor(document.documentElement.clientWidth - 48)); | ||
} | } | ||
return 1200; | |||
} | |||
return | function itemIndex(keyOrIndex) { | ||
var key; | |||
var i; | |||
if (typeof keyOrIndex === 'number' && isFinite(keyOrIndex)) { | |||
i = Math.floor(keyOrIndex); | |||
return i >= 0 && i < ITEMS.length ? i : -1; | |||
} | |||
key = String(keyOrIndex || ''); | |||
for (i = 0; i < ITEMS.length; i++) { | |||
if (ITEMS[i].key === key) return i; | |||
} | |||
return -1; | |||
} | } | ||
| 72번째 줄: | 110번째 줄: | ||
var usableWidth = Math.max(320, Math.floor(width || 0)); | var usableWidth = Math.max(320, Math.floor(width || 0)); | ||
var count = ITEMS.length; | var count = ITEMS.length; | ||
var top = []; | var top = []; | ||
var bottom = []; | var bottom = []; | ||
var | var center = count / 2; | ||
var i; | var i; | ||
var x; | |||
/* | |||
사선은 버튼의 속성이 아니라 count + 1개의 공유 경계의 속성이다. | |||
버튼 i의 우변과 버튼 i+1의 좌변은 반드시 같은 경계를 사용한다. | |||
*/ | |||
for (i = 0; i <= count; i++) { | for (i = 0; i <= count; i++) { | ||
x = Math.round((usableWidth * i) / count); | |||
if (i === 0 || i === count | if (i === 0 || i === count) { | ||
top.push(x); | top.push(x); | ||
bottom.push(x); | bottom.push(x); | ||
} else if (i < | } else if (i < center) { | ||
top.push(x - cut); | top.push(x - SPEC.cut); | ||
bottom.push(x); | bottom.push(x); | ||
} else { | } else { | ||
top.push(x); | top.push(x); | ||
bottom.push(x - cut); | bottom.push(x - SPEC.cut); | ||
} | } | ||
} | } | ||
return { | return { | ||
width: usableWidth, | version:FRAME_BUILD, | ||
height: | width:usableWidth, | ||
count: count, | height:SPEC.buttonHeight, | ||
cut: cut, | count:count, | ||
top: top, | cut:SPEC.cut, | ||
bottom: bottom | gap:SPEC.gap, | ||
top:top, | |||
bottom:bottom | |||
}; | }; | ||
} | } | ||
function | function clonePoints(points) { | ||
return | return (points || []).map(function (point) { | ||
return [Number(point[0]), Number(point[1])]; | |||
}); | |||
} | |||
function getSegmentPoints(keyOrIndex, geometry) { | |||
var index = itemIndex(keyOrIndex); | |||
var halfGap; | |||
var leftOffset; | |||
var rightOffset; | |||
if (!geometry || index < 0 || index >= geometry.count) return null; | |||
halfGap = geometry.gap / 2; | |||
leftOffset = index === 0 ? 0 : halfGap; | |||
rightOffset = index === geometry.count - 1 ? 0 : halfGap; | |||
return [ | |||
[geometry.top[index] + leftOffset, 0], | |||
[geometry.top[index + 1] - rightOffset, 0], | |||
[geometry.bottom[index + 1] - rightOffset, geometry.height], | |||
[geometry.bottom[index] + leftOffset, geometry.height] | |||
]; | |||
} | |||
function getSegmentEdge(keyOrIndex, side, geometry) { | |||
var points = getSegmentPoints(keyOrIndex, geometry); | |||
if (!points) return null; | |||
if (side === 'left') return [points[0], points[3]]; | |||
if (side === 'right') return [points[1], points[2]]; | |||
return null; | |||
} | |||
function lineXAtY(pointA, pointB, y) { | |||
var dy; | |||
if (!pointA || !pointB) return NaN; | |||
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 getCentroid(points) { | |||
var totalX = 0; | |||
var totalY = 0; | |||
points.forEach(function (point) { | |||
totalX += point[0]; | |||
totalY += point[1]; | |||
}); | |||
return [totalX / points.length, totalY / 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 qMinusP; | |||
var t; | |||
if (Math.abs(cross) < 0.000001) return [p[0], p[1]]; | |||
qMinusP = [q[0] - p[0], q[1] - p[1]]; | |||
t = (qMinusP[0] * s[1] - qMinusP[1] * s[0]) / cross; | |||
return [p[0] + t * r[0], p[1] + t * r[1]]; | |||
} | |||
function insetPolygon(points, distance) { | |||
var center = getCentroid(points); | |||
var shiftedLines = []; | |||
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; | |||
} | |||
shiftedLines.push({ | |||
point:[ | |||
start[0] + normal[0] * distance, | |||
start[1] + normal[1] * distance | |||
], | |||
direction:[dx, dy] | |||
}); | |||
} | |||
return shiftedLines.map(function (line, index) { | |||
var previous = shiftedLines[(index + count - 1) % count]; | |||
return intersectLines(previous, line); | |||
}); | |||
} | |||
function roundCoordinate(value) { | |||
return Math.round(Number(value) * 100) / 100; | |||
} | } | ||
function pointsToString(points) { | function pointsToString(points) { | ||
return points.map(function (point) { | return points.map(function (point) { | ||
return point[0] + ',' + point[1]; | return roundCoordinate(point[0]) + ',' + roundCoordinate(point[1]); | ||
}).join(' '); | }).join(' '); | ||
} | } | ||
function | function createSvgElement(name) { | ||
return | return document.createElementNS(SVG_NS, name); | ||
} | |||
function createPolygon(className, points) { | |||
var polygon = createSvgElement('polygon'); | |||
polygon.setAttribute('class', className); | |||
polygon.setAttribute('points', pointsToString(points)); | |||
return polygon; | |||
} | } | ||
function | function createEdgeLine(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 createSegment(item, index, geometry) { | function createSegment(item, index, geometry) { | ||
var anchor = createSvgElement('a'); | var anchor = createSvgElement('a'); | ||
var | var outerPoints = getSegmentPoints(index, geometry); | ||
var surfacePoints = insetPolygon(outerPoints, SPEC.outerBorder); | |||
var insetPoints = insetPolygon(outerPoints, SPEC.insetLineOffset); | |||
var outer = createPolygon('portal-cat-edge', outerPoints); | |||
var surface = createPolygon('portal-cat-surface', surfacePoints); | |||
var topEdge = createEdgeLine('portal-cat-inset portal-cat-inset-top', insetPoints[0], insetPoints[1]); | |||
var rightEdge = createEdgeLine('portal-cat-inset portal-cat-inset-right', insetPoints[1], insetPoints[2]); | |||
var bottomEdge = createEdgeLine('portal-cat-inset portal-cat-inset-bottom', insetPoints[2], insetPoints[3]); | |||
var leftEdge = createEdgeLine('portal-cat-inset portal-cat-inset-left', insetPoints[3], insetPoints[0]); | |||
var hit = createPolygon('portal-cat-hit', outerPoints); | |||
var text = createSvgElement('text'); | var text = createSvgElement('text'); | ||
var href = buildPath(item.title); | var href = buildPath(item.title); | ||
var center = getCentroid(surfacePoints); | |||
anchor.setAttribute('class', 'portal-cat-anchor'); | anchor.setAttribute('class', 'portal-cat-anchor'); | ||
| 146번째 줄: | 310번째 줄: | ||
anchor.setAttribute('data-category-key', item.key); | anchor.setAttribute('data-category-key', item.key); | ||
outer.setAttribute('pointer-events', 'none'); | |||
surface.setAttribute('pointer-events', 'none'); | |||
hit.setAttribute('pointer-events', 'all'); | |||
text.setAttribute('class', 'portal-cat-label'); | text.setAttribute('class', 'portal-cat-label'); | ||
text.setAttribute('x', | text.setAttribute('x', Math.round(center[0])); | ||
text.setAttribute('y', Math.round(geometry.height / 2) + 1); | text.setAttribute('y', Math.round(geometry.height / 2) + 1); | ||
text.setAttribute('font-size', '12'); | text.setAttribute('font-size', '12'); | ||
text.setAttribute('font-weight', '700'); | text.setAttribute('font-weight', '700'); | ||
| 160번째 줄: | 322번째 줄: | ||
text.setAttribute('dominant-baseline', 'middle'); | text.setAttribute('dominant-baseline', 'middle'); | ||
text.setAttribute('pointer-events', 'none'); | text.setAttribute('pointer-events', 'none'); | ||
text.textContent = item.label; | text.textContent = item.label; | ||
anchor.appendChild( | anchor.appendChild(outer); | ||
anchor.appendChild(surface); | |||
anchor.appendChild(topEdge); | |||
anchor.appendChild(rightEdge); | |||
anchor.appendChild(bottomEdge); | |||
anchor.appendChild(leftEdge); | |||
anchor.appendChild(hit); | |||
anchor.appendChild(text); | anchor.appendChild(text); | ||
return anchor; | |||
} | |||
function clearNode(node) { | |||
while (node && node.firstChild) node.removeChild(node.firstChild); | |||
} | |||
function findTopSvg(node) { | |||
var current = node; | |||
var svg; | |||
if (!current) return null; | |||
if (current.matches && current.matches('.portal-category-svg:not(.portal-guide-svg)')) { | |||
return current; | |||
} | |||
if (current.querySelector) { | |||
svg = current.querySelector('[data-component="category-nav"] .portal-category-svg'); | |||
if (!svg) svg = current.querySelector('.portal-category-svg:not(.portal-guide-svg)'); | |||
if (svg) return svg; | |||
} | |||
while (current && current !== document) { | |||
if (current.__portalFrameSvg) return current.__portalFrameSvg; | |||
current = current.parentNode; | |||
} | |||
return null; | |||
} | |||
function getGeometry(node) { | |||
var current = node; | |||
var svg; | |||
while (current && current !== document) { | |||
if (current.__portalFrameGeometry && current.__portalFrameGeometry.version === FRAME_BUILD) { | |||
return current.__portalFrameGeometry; | |||
} | |||
current = current.parentNode; | |||
} | |||
svg = findTopSvg(node); | |||
return svg && svg.__portalFrameGeometry && svg.__portalFrameGeometry.version === FRAME_BUILD | |||
? svg.__portalFrameGeometry | |||
: null; | |||
} | |||
function attachGeometry(mount, nav, svg, geometry) { | |||
[mount, nav, svg].forEach(function (node) { | |||
if (!node) return; | |||
node.__portalFrameGeometry = geometry; | |||
node.__portalFrameSvg = svg; | |||
if ( | |||
node.setAttribute && | |||
node.getAttribute('data-portal-frame-build') !== FRAME_BUILD | |||
) { | |||
node.setAttribute('data-portal-frame-build', FRAME_BUILD); | |||
} | |||
}); | |||
} | |||
function getSvgPadding(svg) { | |||
var geometry = getGeometry(svg); | |||
var nav = svg && svg.closest ? svg.closest('.portal-category-nav') : null; | |||
var style; | |||
var rect; | |||
var renderedHeight; | |||
var scale; | |||
var top; | |||
var bottom; | |||
if (!geometry || !nav || !window.getComputedStyle) return { top:3, bottom:3 }; | |||
style = window.getComputedStyle(nav); | |||
rect = svg.getBoundingClientRect ? svg.getBoundingClientRect() : null; | |||
renderedHeight = rect && rect.height ? rect.height : Number(svg.clientHeight || 0); | |||
scale = renderedHeight > 0 ? geometry.height / renderedHeight : 1; | |||
top = parseFloat(style.paddingTop) || 0; | |||
bottom = parseFloat(style.paddingBottom) || 0; | |||
return { | |||
top:top > 0 ? top * scale : 3, | |||
bottom:bottom > 0 ? bottom * scale : 3 | |||
}; | |||
} | |||
function mapXToElement(topNode, targetElement, x) { | |||
var svg = findTopSvg(topNode); | |||
var geometry = getGeometry(svg); | |||
var sourceRect; | |||
var targetRect; | |||
var scaleX; | |||
if (!svg || !geometry || !targetElement || !targetElement.getBoundingClientRect) return NaN; | |||
return | sourceRect = svg.getBoundingClientRect(); | ||
targetRect = targetElement.getBoundingClientRect(); | |||
if (!(sourceRect.width > 0) || !(targetRect.width > 0)) return NaN; | |||
scaleX = sourceRect.width / geometry.width; | |||
return sourceRect.left + Number(x) * scaleX - targetRect.left; | |||
} | |||
function projectSegmentEdgeToElement(topNode, keyOrIndex, side, targetElement, startY, endY) { | |||
var svg = findTopSvg(topNode); | |||
var geometry = getGeometry(svg); | |||
var edge = getSegmentEdge(keyOrIndex, side, geometry); | |||
var sourceRect; | |||
var targetRect; | |||
var scaleX; | |||
var scaleY; | |||
var screenA; | |||
var screenB; | |||
if (!svg || !geometry || !edge || !targetElement || !targetElement.getBoundingClientRect) { | |||
return null; | |||
} | |||
sourceRect = svg.getBoundingClientRect(); | |||
targetRect = targetElement.getBoundingClientRect(); | |||
if (!(sourceRect.width > 0) || !(sourceRect.height > 0) || !(targetRect.width > 0)) { | |||
return null; | |||
} | |||
scaleX = sourceRect.width / geometry.width; | |||
scaleY = sourceRect.height / geometry.height; | |||
screenA = [sourceRect.left + edge[0][0] * scaleX, sourceRect.top + edge[0][1] * scaleY]; | |||
screenB = [sourceRect.left + edge[1][0] * scaleX, sourceRect.top + edge[1][1] * scaleY]; | |||
function localPoint(y) { | |||
var targetScreenY = targetRect.top + Number(y); | |||
var screenX = lineXAtY(screenA, screenB, targetScreenY); | |||
return [screenX - targetRect.left, Number(y)]; | |||
} | |||
return [localPoint(startY), localPoint(endY)]; | |||
} | } | ||
function | function createAttachedSectionModel(topNode, targetElement, options) { | ||
var | var config = options || {}; | ||
var height = Math.max(1, Math.floor(Number(config.height) || 43)); | |||
var buttonTop = Math.max(0, Math.floor(Number(config.buttonTop) || 10)); | |||
var buttonHeight = Math.max(1, Math.floor(Number(config.buttonHeight) || SPEC.buttonHeight)); | |||
var buttonBottom = Math.min(height, buttonTop + buttonHeight); | |||
var gap = Math.max(0, Math.floor(Number(config.gap) || SPEC.gap)); | |||
var leftKey = config.leftKey || 'settings'; | |||
var rightKey = config.rightKey || 'corporations'; | |||
var leftSide = config.leftSide || 'left'; | |||
var rightSide = config.rightSide || 'right'; | |||
var left = projectSegmentEdgeToElement( | |||
topNode, | |||
leftKey, | |||
leftSide, | |||
targetElement, | |||
0, | |||
height | |||
); | |||
var right = projectSegmentEdgeToElement( | |||
topNode, | |||
rightKey, | |||
rightSide, | |||
targetElement, | |||
0, | |||
height | |||
); | |||
var targetRect; | |||
var width; | |||
var outer; | |||
var leftAtTop; | |||
var leftAtBottom; | |||
var rightAtTop; | |||
var rightAtBottom; | |||
var centerTop; | |||
var centerBottom; | |||
var halfGap = gap / 2; | |||
var boundaries; | |||
var buttons; | |||
if (!left || !right || left.length !== 2 || right.length !== 2) return null; | |||
if (!left.concat(right).every(function (point) { | |||
return point && Number.isFinite(point[0]) && Number.isFinite(point[1]); | |||
})) return null; | |||
targetRect = targetElement && targetElement.getBoundingClientRect | |||
? targetElement.getBoundingClientRect() | |||
: null; | |||
width = targetRect && targetRect.width > 0 ? Math.floor(targetRect.width) : 0; | |||
if (!width) return null; | |||
outer = [ | |||
[left[0][0], 0], | |||
[right[0][0], 0], | |||
[right[1][0], height], | |||
[left[1][0], height] | |||
]; | |||
leftAtTop = lineXAtY(outer[0], outer[3], buttonTop); | |||
leftAtBottom = lineXAtY(outer[0], outer[3], buttonBottom); | |||
rightAtTop = lineXAtY(outer[1], outer[2], buttonTop); | |||
rightAtBottom = lineXAtY(outer[1], outer[2], buttonBottom); | |||
centerTop = (leftAtTop + rightAtTop) / 2; | |||
centerBottom = (leftAtBottom + rightAtBottom) / 2; | |||
/* | |||
세 경계가 두 버튼의 유일한 형상 근거다. | |||
중앙 경계는 두 버튼이 공유하므로 두 형상 사이에 삼각형 공백이 생길 수 없다. | |||
*/ | |||
boundaries = [ | |||
[[leftAtTop, buttonTop], [leftAtBottom, buttonBottom]], | |||
[[centerTop, buttonTop], [centerBottom, buttonBottom]], | |||
[[rightAtTop, buttonTop], [rightAtBottom, buttonBottom]] | |||
]; | |||
buttons = [ | |||
[ | |||
[boundaries[0][0][0], buttonTop], | |||
[boundaries[1][0][0] - halfGap, buttonTop], | |||
[boundaries[1][1][0] - halfGap, buttonBottom], | |||
[boundaries[0][1][0], buttonBottom] | |||
], | |||
[ | |||
[boundaries[1][0][0] + halfGap, buttonTop], | |||
[boundaries[2][0][0], buttonTop], | |||
[boundaries[2][1][0], buttonBottom], | |||
[boundaries[1][1][0] + halfGap, buttonBottom] | |||
] | |||
]; | |||
return | return { | ||
version:FRAME_BUILD, | |||
width:width, | |||
height:height, | |||
outer:clonePoints(outer), | |||
boundaries:boundaries.map(clonePoints), | |||
buttons:buttons.map(clonePoints), | |||
buttonTop:buttonTop, | |||
buttonBottom:buttonBottom, | |||
buttonHeight:buttonHeight, | |||
gap:gap | |||
}; | |||
} | } | ||
function | function dispatchFrameUpdate(mount, nav, svg, geometry) { | ||
var detail = { mount:mount, nav:nav, svg:svg, geometry:geometry, version:FRAME_BUILD }; | |||
var event; | |||
try { | |||
event = new window.CustomEvent('portalframe:updated', { detail:detail }); | |||
} catch (err) { | |||
event = document.createEvent('CustomEvent'); | |||
event.initCustomEvent('portalframe:updated', false, false, detail); | |||
} | |||
document.dispatchEvent(event); | |||
} | } | ||
| 195번째 줄: | 589번째 줄: | ||
var geometry = calculateGeometry(width); | var geometry = calculateGeometry(width); | ||
var svg = createSvgElement('svg'); | var svg = createSvgElement('svg'); | ||
svg.setAttribute('class', 'portal-category-svg'); | svg.setAttribute('class', 'portal-category-svg'); | ||
| 203번째 줄: | 596번째 줄: | ||
svg.setAttribute('preserveAspectRatio', 'none'); | svg.setAttribute('preserveAspectRatio', 'none'); | ||
svg.setAttribute('aria-hidden', 'false'); | svg.setAttribute('aria-hidden', 'false'); | ||
svg.setAttribute('data-portal-frame-build', FRAME_BUILD); | |||
ITEMS.forEach(function (item, index) { | ITEMS.forEach(function (item, index) { | ||
svg.appendChild(createSegment(item, index, geometry)); | svg.appendChild(createSegment(item, index, geometry)); | ||
}); | }); | ||
return { | return { | ||
svg: svg, | svg:svg, | ||
key: geometry.width | geometry:geometry, | ||
key:[geometry.width, geometry.height, geometry.cut, geometry.gap, ITEMS.length, FRAME_BUILD].join('|') | |||
}; | }; | ||
} | } | ||
| 220번째 줄: | 611번째 줄: | ||
function renderSvgIntoNav(nav, mount) { | function renderSvgIntoNav(nav, mount) { | ||
var result = buildSvg(getRenderableWidth(nav, mount)); | var result = buildSvg(getRenderableWidth(nav, mount)); | ||
var existing = nav.querySelector('svg.portal-category-svg'); | |||
if (nav.__categoryNavRenderKey === result.key && nav. | if (nav.__categoryNavRenderKey === result.key && existing) { | ||
attachGeometry(mount, nav, existing, nav.__portalFrameGeometry || result.geometry); | |||
return false; | |||
} | |||
nav.__categoryNavRenderKey = result.key; | nav.__categoryNavRenderKey = result.key; | ||
clearNode(nav); | clearNode(nav); | ||
nav.appendChild(result.svg); | nav.appendChild(result.svg); | ||
attachGeometry(mount, nav, result.svg, result.geometry); | |||
dispatchFrameUpdate(mount, nav, result.svg, result.geometry); | |||
return true; | |||
} | } | ||
| 238번째 줄: | 636번째 줄: | ||
function renderCategoryNavMount(mount) { | function renderCategoryNavMount(mount) { | ||
var nav; | |||
var observer; | |||
var existingBuild; | |||
if (mount | if (!mount) return false; | ||
nav = mount.querySelector(':scope > .portal-category-nav'); | |||
existingBuild = nav && nav.getAttribute('data-portal-frame-build'); | |||
/* | |||
같은 빌드의 mount는 다시 비우지 않는다. | |||
Common.js, wikipage.content, SPA 초기화가 겹쳐 호출되어도 기존 SVG와 이벤트 대상을 유지한다. | |||
이전 빌드가 남아 있을 때만 해당 mount 하나를 교체한다. | |||
*/ | |||
if (!nav || existingBuild !== FRAME_BUILD) { | |||
if (mount.__categoryNavObserver) { | |||
mount.__categoryNavObserver.disconnect(); | |||
mount.__categoryNavObserver = null; | |||
} | |||
clearNode(mount); | |||
nav = document.createElement('nav'); | |||
nav.className = 'portal-category-nav'; | |||
nav.setAttribute('aria-label', 'Main categories'); | |||
nav.setAttribute('data-portal-frame-build', FRAME_BUILD); | |||
mount.appendChild(nav); | |||
} | |||
if (mount.getAttribute('data-category-nav-rendered') !== '1') { | |||
mount.setAttribute('data-category-nav-rendered', '1'); | |||
} | |||
if (mount.getAttribute('data-portal-frame-build') !== FRAME_BUILD) { | |||
mount.setAttribute('data-portal-frame-build', FRAME_BUILD); | |||
} | |||
scheduleRender(nav, mount); | scheduleRender(nav, mount); | ||
if (window.ResizeObserver) { | /* | ||
크기 변화의 소유자는 mount 하나다. nav까지 동시에 관찰하면 SVG 교체 자체가 | |||
다시 ResizeObserver를 깨워 불필요한 재측정이 연쇄될 수 있다. | |||
*/ | |||
if (window.ResizeObserver && !mount.__categoryNavObserver) { | |||
observer = new window.ResizeObserver(function () { | |||
scheduleRender(nav, mount); | scheduleRender(nav, mount); | ||
}); | }); | ||
observer.observe(mount); | observer.observe(mount); | ||
mount.__categoryNavObserver = observer; | mount.__categoryNavObserver = observer; | ||
} else { | } else if (!window.ResizeObserver && !mount.__categoryNavResizeBound) { | ||
mount.__categoryNavResizeBound = true; | |||
window.addEventListener('resize', function () { | window.addEventListener('resize', function () { | ||
scheduleRender(nav, mount); | scheduleRender(nav, mount); | ||
}); | }); | ||
} | } | ||
return true; | |||
} | } | ||
function renderAllCategoryNavs(root) { | function renderAllCategoryNavs(root) { | ||
var scope = root | var scope = root && root.querySelectorAll ? root : document; | ||
var mounts = | var mounts = []; | ||
Array.prototype.forEach.call( | 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(renderCategoryNavMount); | |||
return mounts.length; | |||
} | } | ||
var frameApi = { | |||
version:FRAME_BUILD, | |||
spec:SPEC, | |||
items:ITEMS.slice(), | |||
calculateGeometry:calculateGeometry, | |||
getGeometry:getGeometry, | |||
getTopSvg:findTopSvg, | |||
getSegmentPoints:function (keyOrIndex, geometry) { | |||
return clonePoints(getSegmentPoints(keyOrIndex, geometry)); | |||
}, | |||
getSegmentEdge:function (keyOrIndex, side, geometry) { | |||
return clonePoints(getSegmentEdge(keyOrIndex, side, geometry)); | |||
}, | |||
lineXAtY:lineXAtY, | |||
insetPolygon:function (points, distance) { | |||
return clonePoints(insetPolygon(points, distance)); | |||
}, | |||
roundCoordinate:roundCoordinate, | |||
getSvgPadding:getSvgPadding, | |||
mapXToElement:mapXToElement, | |||
projectSegmentEdgeToElement:projectSegmentEdgeToElement, | |||
createAttachedSectionModel:createAttachedSectionModel | |||
}; | |||
var categoryApi = { | |||
version:FRAME_BUILD, | |||
frameVersion:FRAME_BUILD, | |||
init:renderAllCategoryNavs, | |||
renderMount:renderCategoryNavMount, | |||
renderAll:renderAllCategoryNavs, | |||
geometry:frameApi | |||
}; | |||
window.PortalFrame = frameApi; | |||
window.CategoryNav = categoryApi; | |||
window.CLBI = window.CLBI || {}; | window.CLBI = window.CLBI || {}; | ||
window.CLBI.categoryNav = | window.CLBI.categoryNav = categoryApi; | ||
if (document.readyState === 'loading') { | if (document.readyState === 'loading') { | ||
| 296번째 줄: | 753번째 줄: | ||
} | } | ||
if ( | if (mw && mw.hook) { | ||
mw.hook('wikipage.content').add(function ($content) { | mw.hook('wikipage.content').add(function ($content) { | ||
var root = $content && $content[0] ? $content[0] : document; | var root = $content && $content[0] ? $content[0] : document; | ||
| 302번째 줄: | 759번째 줄: | ||
}); | }); | ||
} | } | ||
}()); | |||
/* CSS transform 기반 셸 배율 변화는 ResizeObserver가 놓칠 수 있으므로 창 리사이즈만 한 번 보조한다. */ | |||
if (!window.__portalFrameCategoryResizeBound) { | |||
window.__portalFrameCategoryResizeBound = true; | |||
window.addEventListener('resize', function () { | |||
renderAllCategoryNavs(document); | |||
}); | |||
} | |||
}(window, document, window.mw)); | |||
2026년 7월 20일 (월) 22:38 기준 최신판
/* =========================================
COASTLINE: BLACK ICE - CategoryNav
대문 프레임 공통 기하 모델과 상단 카테고리 SVG 네비
========================================= */
/*
새 공통 기능에는 프로젝트 접두사를 사용하지 않는다.
window.CLBI.categoryNav는 기존 Common.js 호출과의 호환을 위해서만 유지한다.
이 파일은 대문 프레임 기하의 단일 소유자다.
CategoryPillar.js, PortalSectionNav.js, BottomGuideNav.js는 SVG DOM을 다시 파싱하지 않고
window.PortalFrame이 제공하는 동일한 경계와 좌표 변환만 사용한다.
*/
(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 FRAME_BUILD = '20260720-portal-frame-stable-003';
var ITEMS = [
{ key:'era', label:'시대', title:'시대' },
{ key:'settings', label:'설정', title:'설정' },
{ key:'project', label:'프로젝트', title:'프로젝트:소개' },
{ key:'corporations', label:'기업 및 공동체', title:'기업_및_공동체' },
{ key:'military', label:'군, 정치집단', title:'군_정치집단' }
];
/*
대문 프레임의 공유 규격.
cut은 폭에 따라 변하지 않는다. 1880px 디자인 폭과 축소 셸에서 같은 경사를 유지한다.
*/
var SPEC = Object.freeze ? Object.freeze({
buttonHeight:22,
gap:2,
cut:10,
outerBorder:1,
insetLineOffset:1.5,
count:ITEMS.length
}) : {
buttonHeight:22,
gap:2,
cut:10,
outerBorder:1,
insetLineOffset:1.5,
count:ITEMS.length
};
function buildPath(title) {
if (typeof window.buildWikiPath === 'function') {
return window.buildWikiPath(title);
}
return '/index.php/' + encodeURI(String(title || '').replace(/ /g, '_'));
}
function readWidth(element) {
var rect;
var width;
if (!element) return 0;
rect = element.getBoundingClientRect ? element.getBoundingClientRect() : null;
width = rect ? Math.floor(rect.width || 0) : 0;
if (!width && element.clientWidth) width = Math.floor(element.clientWidth);
return width || 0;
}
function getRenderableWidth(nav, mount) {
var candidates = [
nav,
mount,
mount ? mount.parentElement : null,
mount && mount.closest ? mount.closest('.main-portal') : null,
document.querySelector('.liberty-content-main .mw-parser-output'),
document.querySelector('.liberty-content-main'),
document.querySelector('.liberty-content')
];
var i;
var width;
for (i = 0; i < candidates.length; i++) {
width = readWidth(candidates[i]);
if (width > 0) return width;
}
if (document.documentElement && document.documentElement.clientWidth) {
return Math.max(320, Math.floor(document.documentElement.clientWidth - 48));
}
return 1200;
}
function itemIndex(keyOrIndex) {
var key;
var i;
if (typeof keyOrIndex === 'number' && isFinite(keyOrIndex)) {
i = Math.floor(keyOrIndex);
return i >= 0 && i < ITEMS.length ? i : -1;
}
key = String(keyOrIndex || '');
for (i = 0; i < ITEMS.length; i++) {
if (ITEMS[i].key === key) return i;
}
return -1;
}
function calculateGeometry(width) {
var usableWidth = Math.max(320, Math.floor(width || 0));
var count = ITEMS.length;
var top = [];
var bottom = [];
var center = count / 2;
var i;
var x;
/*
사선은 버튼의 속성이 아니라 count + 1개의 공유 경계의 속성이다.
버튼 i의 우변과 버튼 i+1의 좌변은 반드시 같은 경계를 사용한다.
*/
for (i = 0; i <= count; i++) {
x = Math.round((usableWidth * i) / count);
if (i === 0 || i === count) {
top.push(x);
bottom.push(x);
} else if (i < center) {
top.push(x - SPEC.cut);
bottom.push(x);
} else {
top.push(x);
bottom.push(x - SPEC.cut);
}
}
return {
version:FRAME_BUILD,
width:usableWidth,
height:SPEC.buttonHeight,
count:count,
cut:SPEC.cut,
gap:SPEC.gap,
top:top,
bottom:bottom
};
}
function clonePoints(points) {
return (points || []).map(function (point) {
return [Number(point[0]), Number(point[1])];
});
}
function getSegmentPoints(keyOrIndex, geometry) {
var index = itemIndex(keyOrIndex);
var halfGap;
var leftOffset;
var rightOffset;
if (!geometry || index < 0 || index >= geometry.count) return null;
halfGap = geometry.gap / 2;
leftOffset = index === 0 ? 0 : halfGap;
rightOffset = index === geometry.count - 1 ? 0 : halfGap;
return [
[geometry.top[index] + leftOffset, 0],
[geometry.top[index + 1] - rightOffset, 0],
[geometry.bottom[index + 1] - rightOffset, geometry.height],
[geometry.bottom[index] + leftOffset, geometry.height]
];
}
function getSegmentEdge(keyOrIndex, side, geometry) {
var points = getSegmentPoints(keyOrIndex, geometry);
if (!points) return null;
if (side === 'left') return [points[0], points[3]];
if (side === 'right') return [points[1], points[2]];
return null;
}
function lineXAtY(pointA, pointB, y) {
var dy;
if (!pointA || !pointB) return NaN;
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 getCentroid(points) {
var totalX = 0;
var totalY = 0;
points.forEach(function (point) {
totalX += point[0];
totalY += point[1];
});
return [totalX / points.length, totalY / 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 qMinusP;
var t;
if (Math.abs(cross) < 0.000001) return [p[0], p[1]];
qMinusP = [q[0] - p[0], q[1] - p[1]];
t = (qMinusP[0] * s[1] - qMinusP[1] * s[0]) / cross;
return [p[0] + t * r[0], p[1] + t * r[1]];
}
function insetPolygon(points, distance) {
var center = getCentroid(points);
var shiftedLines = [];
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;
}
shiftedLines.push({
point:[
start[0] + normal[0] * distance,
start[1] + normal[1] * distance
],
direction:[dx, dy]
});
}
return shiftedLines.map(function (line, index) {
var previous = shiftedLines[(index + count - 1) % count];
return intersectLines(previous, line);
});
}
function roundCoordinate(value) {
return Math.round(Number(value) * 100) / 100;
}
function pointsToString(points) {
return points.map(function (point) {
return roundCoordinate(point[0]) + ',' + roundCoordinate(point[1]);
}).join(' ');
}
function createSvgElement(name) {
return document.createElementNS(SVG_NS, name);
}
function createPolygon(className, points) {
var polygon = createSvgElement('polygon');
polygon.setAttribute('class', className);
polygon.setAttribute('points', pointsToString(points));
return polygon;
}
function createEdgeLine(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 createSegment(item, index, geometry) {
var anchor = createSvgElement('a');
var outerPoints = getSegmentPoints(index, geometry);
var surfacePoints = insetPolygon(outerPoints, SPEC.outerBorder);
var insetPoints = insetPolygon(outerPoints, SPEC.insetLineOffset);
var outer = createPolygon('portal-cat-edge', outerPoints);
var surface = createPolygon('portal-cat-surface', surfacePoints);
var topEdge = createEdgeLine('portal-cat-inset portal-cat-inset-top', insetPoints[0], insetPoints[1]);
var rightEdge = createEdgeLine('portal-cat-inset portal-cat-inset-right', insetPoints[1], insetPoints[2]);
var bottomEdge = createEdgeLine('portal-cat-inset portal-cat-inset-bottom', insetPoints[2], insetPoints[3]);
var leftEdge = createEdgeLine('portal-cat-inset portal-cat-inset-left', insetPoints[3], insetPoints[0]);
var hit = createPolygon('portal-cat-hit', outerPoints);
var text = createSvgElement('text');
var href = buildPath(item.title);
var center = getCentroid(surfacePoints);
anchor.setAttribute('class', 'portal-cat-anchor');
anchor.setAttribute('href', href);
anchor.setAttributeNS(XLINK_NS, 'xlink:href', href);
anchor.setAttribute('aria-label', item.label);
anchor.setAttribute('data-category-key', item.key);
outer.setAttribute('pointer-events', 'none');
surface.setAttribute('pointer-events', 'none');
hit.setAttribute('pointer-events', 'all');
text.setAttribute('class', 'portal-cat-label');
text.setAttribute('x', Math.round(center[0]));
text.setAttribute('y', Math.round(geometry.height / 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 = item.label;
anchor.appendChild(outer);
anchor.appendChild(surface);
anchor.appendChild(topEdge);
anchor.appendChild(rightEdge);
anchor.appendChild(bottomEdge);
anchor.appendChild(leftEdge);
anchor.appendChild(hit);
anchor.appendChild(text);
return anchor;
}
function clearNode(node) {
while (node && node.firstChild) node.removeChild(node.firstChild);
}
function findTopSvg(node) {
var current = node;
var svg;
if (!current) return null;
if (current.matches && current.matches('.portal-category-svg:not(.portal-guide-svg)')) {
return current;
}
if (current.querySelector) {
svg = current.querySelector('[data-component="category-nav"] .portal-category-svg');
if (!svg) svg = current.querySelector('.portal-category-svg:not(.portal-guide-svg)');
if (svg) return svg;
}
while (current && current !== document) {
if (current.__portalFrameSvg) return current.__portalFrameSvg;
current = current.parentNode;
}
return null;
}
function getGeometry(node) {
var current = node;
var svg;
while (current && current !== document) {
if (current.__portalFrameGeometry && current.__portalFrameGeometry.version === FRAME_BUILD) {
return current.__portalFrameGeometry;
}
current = current.parentNode;
}
svg = findTopSvg(node);
return svg && svg.__portalFrameGeometry && svg.__portalFrameGeometry.version === FRAME_BUILD
? svg.__portalFrameGeometry
: null;
}
function attachGeometry(mount, nav, svg, geometry) {
[mount, nav, svg].forEach(function (node) {
if (!node) return;
node.__portalFrameGeometry = geometry;
node.__portalFrameSvg = svg;
if (
node.setAttribute &&
node.getAttribute('data-portal-frame-build') !== FRAME_BUILD
) {
node.setAttribute('data-portal-frame-build', FRAME_BUILD);
}
});
}
function getSvgPadding(svg) {
var geometry = getGeometry(svg);
var nav = svg && svg.closest ? svg.closest('.portal-category-nav') : null;
var style;
var rect;
var renderedHeight;
var scale;
var top;
var bottom;
if (!geometry || !nav || !window.getComputedStyle) return { top:3, bottom:3 };
style = window.getComputedStyle(nav);
rect = svg.getBoundingClientRect ? svg.getBoundingClientRect() : null;
renderedHeight = rect && rect.height ? rect.height : Number(svg.clientHeight || 0);
scale = renderedHeight > 0 ? geometry.height / renderedHeight : 1;
top = parseFloat(style.paddingTop) || 0;
bottom = parseFloat(style.paddingBottom) || 0;
return {
top:top > 0 ? top * scale : 3,
bottom:bottom > 0 ? bottom * scale : 3
};
}
function mapXToElement(topNode, targetElement, x) {
var svg = findTopSvg(topNode);
var geometry = getGeometry(svg);
var sourceRect;
var targetRect;
var scaleX;
if (!svg || !geometry || !targetElement || !targetElement.getBoundingClientRect) return NaN;
sourceRect = svg.getBoundingClientRect();
targetRect = targetElement.getBoundingClientRect();
if (!(sourceRect.width > 0) || !(targetRect.width > 0)) return NaN;
scaleX = sourceRect.width / geometry.width;
return sourceRect.left + Number(x) * scaleX - targetRect.left;
}
function projectSegmentEdgeToElement(topNode, keyOrIndex, side, targetElement, startY, endY) {
var svg = findTopSvg(topNode);
var geometry = getGeometry(svg);
var edge = getSegmentEdge(keyOrIndex, side, geometry);
var sourceRect;
var targetRect;
var scaleX;
var scaleY;
var screenA;
var screenB;
if (!svg || !geometry || !edge || !targetElement || !targetElement.getBoundingClientRect) {
return null;
}
sourceRect = svg.getBoundingClientRect();
targetRect = targetElement.getBoundingClientRect();
if (!(sourceRect.width > 0) || !(sourceRect.height > 0) || !(targetRect.width > 0)) {
return null;
}
scaleX = sourceRect.width / geometry.width;
scaleY = sourceRect.height / geometry.height;
screenA = [sourceRect.left + edge[0][0] * scaleX, sourceRect.top + edge[0][1] * scaleY];
screenB = [sourceRect.left + edge[1][0] * scaleX, sourceRect.top + edge[1][1] * scaleY];
function localPoint(y) {
var targetScreenY = targetRect.top + Number(y);
var screenX = lineXAtY(screenA, screenB, targetScreenY);
return [screenX - targetRect.left, Number(y)];
}
return [localPoint(startY), localPoint(endY)];
}
function createAttachedSectionModel(topNode, targetElement, options) {
var config = options || {};
var height = Math.max(1, Math.floor(Number(config.height) || 43));
var buttonTop = Math.max(0, Math.floor(Number(config.buttonTop) || 10));
var buttonHeight = Math.max(1, Math.floor(Number(config.buttonHeight) || SPEC.buttonHeight));
var buttonBottom = Math.min(height, buttonTop + buttonHeight);
var gap = Math.max(0, Math.floor(Number(config.gap) || SPEC.gap));
var leftKey = config.leftKey || 'settings';
var rightKey = config.rightKey || 'corporations';
var leftSide = config.leftSide || 'left';
var rightSide = config.rightSide || 'right';
var left = projectSegmentEdgeToElement(
topNode,
leftKey,
leftSide,
targetElement,
0,
height
);
var right = projectSegmentEdgeToElement(
topNode,
rightKey,
rightSide,
targetElement,
0,
height
);
var targetRect;
var width;
var outer;
var leftAtTop;
var leftAtBottom;
var rightAtTop;
var rightAtBottom;
var centerTop;
var centerBottom;
var halfGap = gap / 2;
var boundaries;
var buttons;
if (!left || !right || left.length !== 2 || right.length !== 2) return null;
if (!left.concat(right).every(function (point) {
return point && Number.isFinite(point[0]) && Number.isFinite(point[1]);
})) return null;
targetRect = targetElement && targetElement.getBoundingClientRect
? targetElement.getBoundingClientRect()
: null;
width = targetRect && targetRect.width > 0 ? Math.floor(targetRect.width) : 0;
if (!width) return null;
outer = [
[left[0][0], 0],
[right[0][0], 0],
[right[1][0], height],
[left[1][0], height]
];
leftAtTop = lineXAtY(outer[0], outer[3], buttonTop);
leftAtBottom = lineXAtY(outer[0], outer[3], buttonBottom);
rightAtTop = lineXAtY(outer[1], outer[2], buttonTop);
rightAtBottom = lineXAtY(outer[1], outer[2], buttonBottom);
centerTop = (leftAtTop + rightAtTop) / 2;
centerBottom = (leftAtBottom + rightAtBottom) / 2;
/*
세 경계가 두 버튼의 유일한 형상 근거다.
중앙 경계는 두 버튼이 공유하므로 두 형상 사이에 삼각형 공백이 생길 수 없다.
*/
boundaries = [
[[leftAtTop, buttonTop], [leftAtBottom, buttonBottom]],
[[centerTop, buttonTop], [centerBottom, buttonBottom]],
[[rightAtTop, buttonTop], [rightAtBottom, buttonBottom]]
];
buttons = [
[
[boundaries[0][0][0], buttonTop],
[boundaries[1][0][0] - halfGap, buttonTop],
[boundaries[1][1][0] - halfGap, buttonBottom],
[boundaries[0][1][0], buttonBottom]
],
[
[boundaries[1][0][0] + halfGap, buttonTop],
[boundaries[2][0][0], buttonTop],
[boundaries[2][1][0], buttonBottom],
[boundaries[1][1][0] + halfGap, buttonBottom]
]
];
return {
version:FRAME_BUILD,
width:width,
height:height,
outer:clonePoints(outer),
boundaries:boundaries.map(clonePoints),
buttons:buttons.map(clonePoints),
buttonTop:buttonTop,
buttonBottom:buttonBottom,
buttonHeight:buttonHeight,
gap:gap
};
}
function dispatchFrameUpdate(mount, nav, svg, geometry) {
var detail = { mount:mount, nav:nav, svg:svg, geometry:geometry, version:FRAME_BUILD };
var event;
try {
event = new window.CustomEvent('portalframe:updated', { detail:detail });
} catch (err) {
event = document.createEvent('CustomEvent');
event.initCustomEvent('portalframe:updated', false, false, detail);
}
document.dispatchEvent(event);
}
function buildSvg(width) {
var geometry = calculateGeometry(width);
var svg = createSvgElement('svg');
svg.setAttribute('class', 'portal-category-svg');
svg.setAttribute('viewBox', '0 0 ' + geometry.width + ' ' + geometry.height);
svg.setAttribute('width', '100%');
svg.setAttribute('height', '100%');
svg.setAttribute('preserveAspectRatio', 'none');
svg.setAttribute('aria-hidden', 'false');
svg.setAttribute('data-portal-frame-build', FRAME_BUILD);
ITEMS.forEach(function (item, index) {
svg.appendChild(createSegment(item, index, geometry));
});
return {
svg:svg,
geometry:geometry,
key:[geometry.width, geometry.height, geometry.cut, geometry.gap, ITEMS.length, FRAME_BUILD].join('|')
};
}
function renderSvgIntoNav(nav, mount) {
var result = buildSvg(getRenderableWidth(nav, mount));
var existing = nav.querySelector('svg.portal-category-svg');
if (nav.__categoryNavRenderKey === result.key && existing) {
attachGeometry(mount, nav, existing, nav.__portalFrameGeometry || result.geometry);
return false;
}
nav.__categoryNavRenderKey = result.key;
clearNode(nav);
nav.appendChild(result.svg);
attachGeometry(mount, nav, result.svg, result.geometry);
dispatchFrameUpdate(mount, nav, result.svg, result.geometry);
return true;
}
function scheduleRender(nav, mount) {
if (!nav || nav.__categoryNavFrame) return;
nav.__categoryNavFrame = window.requestAnimationFrame(function () {
nav.__categoryNavFrame = null;
renderSvgIntoNav(nav, mount);
});
}
function renderCategoryNavMount(mount) {
var nav;
var observer;
var existingBuild;
if (!mount) return false;
nav = mount.querySelector(':scope > .portal-category-nav');
existingBuild = nav && nav.getAttribute('data-portal-frame-build');
/*
같은 빌드의 mount는 다시 비우지 않는다.
Common.js, wikipage.content, SPA 초기화가 겹쳐 호출되어도 기존 SVG와 이벤트 대상을 유지한다.
이전 빌드가 남아 있을 때만 해당 mount 하나를 교체한다.
*/
if (!nav || existingBuild !== FRAME_BUILD) {
if (mount.__categoryNavObserver) {
mount.__categoryNavObserver.disconnect();
mount.__categoryNavObserver = null;
}
clearNode(mount);
nav = document.createElement('nav');
nav.className = 'portal-category-nav';
nav.setAttribute('aria-label', 'Main categories');
nav.setAttribute('data-portal-frame-build', FRAME_BUILD);
mount.appendChild(nav);
}
if (mount.getAttribute('data-category-nav-rendered') !== '1') {
mount.setAttribute('data-category-nav-rendered', '1');
}
if (mount.getAttribute('data-portal-frame-build') !== FRAME_BUILD) {
mount.setAttribute('data-portal-frame-build', FRAME_BUILD);
}
scheduleRender(nav, mount);
/*
크기 변화의 소유자는 mount 하나다. nav까지 동시에 관찰하면 SVG 교체 자체가
다시 ResizeObserver를 깨워 불필요한 재측정이 연쇄될 수 있다.
*/
if (window.ResizeObserver && !mount.__categoryNavObserver) {
observer = new window.ResizeObserver(function () {
scheduleRender(nav, mount);
});
observer.observe(mount);
mount.__categoryNavObserver = observer;
} else if (!window.ResizeObserver && !mount.__categoryNavResizeBound) {
mount.__categoryNavResizeBound = true;
window.addEventListener('resize', function () {
scheduleRender(nav, mount);
});
}
return true;
}
function renderAllCategoryNavs(root) {
var scope = root && root.querySelectorAll ? root : document;
var mounts = [];
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(renderCategoryNavMount);
return mounts.length;
}
var frameApi = {
version:FRAME_BUILD,
spec:SPEC,
items:ITEMS.slice(),
calculateGeometry:calculateGeometry,
getGeometry:getGeometry,
getTopSvg:findTopSvg,
getSegmentPoints:function (keyOrIndex, geometry) {
return clonePoints(getSegmentPoints(keyOrIndex, geometry));
},
getSegmentEdge:function (keyOrIndex, side, geometry) {
return clonePoints(getSegmentEdge(keyOrIndex, side, geometry));
},
lineXAtY:lineXAtY,
insetPolygon:function (points, distance) {
return clonePoints(insetPolygon(points, distance));
},
roundCoordinate:roundCoordinate,
getSvgPadding:getSvgPadding,
mapXToElement:mapXToElement,
projectSegmentEdgeToElement:projectSegmentEdgeToElement,
createAttachedSectionModel:createAttachedSectionModel
};
var categoryApi = {
version:FRAME_BUILD,
frameVersion:FRAME_BUILD,
init:renderAllCategoryNavs,
renderMount:renderCategoryNavMount,
renderAll:renderAllCategoryNavs,
geometry:frameApi
};
window.PortalFrame = frameApi;
window.CategoryNav = categoryApi;
window.CLBI = window.CLBI || {};
window.CLBI.categoryNav = categoryApi;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () {
renderAllCategoryNavs(document);
});
} else {
renderAllCategoryNavs(document);
}
if (mw && mw.hook) {
mw.hook('wikipage.content').add(function ($content) {
var root = $content && $content[0] ? $content[0] : document;
renderAllCategoryNavs(root);
});
}
/* CSS transform 기반 셸 배율 변화는 ResizeObserver가 놓칠 수 있으므로 창 리사이즈만 한 번 보조한다. */
if (!window.__portalFrameCategoryResizeBound) {
window.__portalFrameCategoryResizeBound = true;
window.addEventListener('resize', function () {
renderAllCategoryNavs(document);
});
}
}(window, document, window.mw));