(새 문서: →========================================= COASTLINE: BLACK ICE - CategoryNav 대문 카테고리 네비 DOM 생성기 =========================================: /* 설계 목적 ----------------------------------------- 이 파일은 대문 카테고리 네비를 MediaWiki 본문 위키문법이 아니라 JavaScript로 생성한다. 왜 JS를 쓰는가 ----------------------------------------- 테스트 단계에서는 다음 방식들을 시도했다. 1. 본문에 <span...) |
편집 요약 없음 |
||
| 1번째 줄: | 1번째 줄: | ||
/* ========================================= | /* ========================================= | ||
COASTLINE: BLACK ICE - CategoryNav | COASTLINE: BLACK ICE - CategoryNav | ||
대문 카테고리 네비 | 대문 카테고리 네비 SVG 생성기 | ||
========================================= */ | ========================================= */ | ||
| 8번째 줄: | 8번째 줄: | ||
----------------------------------------- | ----------------------------------------- | ||
이 파일은 대문 카테고리 네비를 MediaWiki 본문 위키문법이 아니라 | 이 파일은 대문 카테고리 네비를 MediaWiki 본문 위키문법이 아니라 | ||
JavaScript + SVG로 생성한다. | |||
왜 | 왜 SVG를 쓰는가 | ||
----------------------------------------- | ----------------------------------------- | ||
CSS 박스 기반 구현에서 다음 문제가 반복되었다. | |||
1. | 1. span 안에 [[문서|라벨]]을 넣는 방식 | ||
- MediaWiki가 내부에 <a>를 자동 생성한다. | - MediaWiki가 내부에 <a>를 자동 생성한다. | ||
- span은 | - span은 모양, a는 클릭을 담당하게 된다. | ||
- | - 실제 클릭 영역, 호버 영역, 시각 형태가 서로 분리된다. | ||
2. | 2. <a class="...">를 본문에 직접 넣는 방식 | ||
- | - MediaWiki 본문에서 안정적으로 먹히지 않았다. | ||
- | - 위키 문법 처리와 충돌해 코드가 무효화되는 문제가 있었다. | ||
3. | 3. clip-path + ::after 방식 | ||
- | - clip-path가 검은 획 pseudo-element까지 잘라먹었다. | ||
- 왼쪽 항목의 획이 사라지거나 일부만 보였다. | |||
- | |||
4. | 4. 버튼을 음수 margin으로 겹치는 방식 | ||
- | - 사선 빈 영역은 메웠지만, 호버가 옆 칸으로 넘어갔다. | ||
5. hover overlay / divider overlay 방식 | |||
- CSS grid, absolute 좌표, 프로젝트 가변폭이 서로 다른 기준을 가져서 | |||
호버 면과 검은 획이 계속 어긋났다. | |||
SVG 방식의 장점 | |||
----------------------------------------- | ----------------------------------------- | ||
SVG에서는 각 항목을 polygon으로 만든다. | |||
따라서 다음 요소들이 모두 같은 좌표계를 사용한다. | |||
- | - 보이는 버튼 면 | ||
- 실제 | - 실제 hover 면 | ||
- 실제 | - 실제 click 면 | ||
- | - 검은 구분선 | ||
즉, 이전처럼 사각형 박스를 사선처럼 보이게 흉내 내는 방식이 아니라, | |||
처음부터 사선 polygon을 생성한다. | |||
JS의 | JS의 책임 | ||
----------------------------------------- | ----------------------------------------- | ||
JS는 | JS는 구조와 좌표만 만든다. | ||
색상, 호버 색, 글자 스타일은 MainPage.css가 담당한다. | |||
좌표는 현재 nav 폭을 읽어서 계산한다. | |||
프로젝트 버튼은 좌우 카테고리 3개씩을 제외한 남은 중앙 폭을 차지한다. | |||
*/ | */ | ||
(function () { | (function () { | ||
'use strict'; | 'use strict'; | ||
var SVG_NS = 'http://www.w3.org/2000/svg'; | |||
var XLINK_NS = 'http://www.w3.org/1999/xlink'; | |||
/* | /* | ||
기본 | 기본 카테고리 구성 | ||
----------------------------------------- | ----------------------------------------- | ||
현재는 한국어 대문 기준이다. | |||
다국어 확장 시에는 여기의 label/title을 직접 바꾸기보다 | |||
getLabel(), getTitle()에서 window.LANG, window.CAT_LINKS 또는 | |||
별도 MainPageConfig.js를 읽게 하면 된다. | |||
*/ | */ | ||
| 75번째 줄: | 77번째 줄: | ||
label: '역사적 사건', | label: '역사적 사건', | ||
title: '역사적_사건', | title: '역사적_사건', | ||
type: 'left-start' | |||
}, | }, | ||
{ | { | ||
| 81번째 줄: | 83번째 줄: | ||
label: '설정', | label: '설정', | ||
title: '설정', | title: '설정', | ||
type: 'left-mid' | |||
}, | }, | ||
{ | { | ||
| 87번째 줄: | 89번째 줄: | ||
label: '국가 및 조합', | label: '국가 및 조합', | ||
title: '국가_및_조합', | title: '국가_및_조합', | ||
type: 'left-end' | |||
}, | }, | ||
{ | { | ||
| 93번째 줄: | 95번째 줄: | ||
label: '프로젝트', | label: '프로젝트', | ||
title: '프로젝트:소개', | title: '프로젝트:소개', | ||
type: 'project' | |||
}, | }, | ||
{ | { | ||
| 99번째 줄: | 101번째 줄: | ||
label: '기업 및 공동체', | label: '기업 및 공동체', | ||
title: '기업_및_공동체', | title: '기업_및_공동체', | ||
type: 'right-start' | |||
}, | }, | ||
{ | { | ||
| 105번째 줄: | 107번째 줄: | ||
label: '군, 정치집단', | label: '군, 정치집단', | ||
title: '군_정치집단', | title: '군_정치집단', | ||
type: 'right-mid' | |||
}, | }, | ||
{ | { | ||
| 111번째 줄: | 113번째 줄: | ||
label: '인물', | label: '인물', | ||
title: '인물', | title: '인물', | ||
type: 'right-end' | |||
} | } | ||
]; | ]; | ||
| 119번째 줄: | 121번째 줄: | ||
----------------------------------------- | ----------------------------------------- | ||
기존 Common.js에 getCurrentLang() 함수가 있을 수 있다. | 기존 Common.js에 getCurrentLang() 함수가 있을 수 있다. | ||
이 파일은 단독으로도 깨지지 않아야 하므로 fallback을 둔다. | |||
*/ | */ | ||
| 138번째 줄: | 139번째 줄: | ||
/* | /* | ||
경로 생성 | |||
----------------------------------------- | ----------------------------------------- | ||
기존 Common.js에 buildWikiPath(title)가 있으면 그것을 사용한다. | |||
없으면 /index.php/문서명 형식으로 fallback한다. | |||
*/ | */ | ||
| 160번째 줄: | 156번째 줄: | ||
라벨 선택 | 라벨 선택 | ||
----------------------------------------- | ----------------------------------------- | ||
현재는 한국어를 기준으로 둔다. | |||
기존 LANG.js와 CAT_LINKS에는 모든 새 항목이 정리되어 있지 않을 수 있다. | |||
예를 들어 '설정', '프로젝트'는 기존 세계관 카테고리 세트와 다르다. | |||
부분 번역을 섞으면 언어별 네비 폭과 의미가 흔들릴 수 있으므로, | |||
다국어 구조가 확정되기 전까지는 fallback label을 유지한다. | |||
*/ | */ | ||
| 173번째 줄: | 169번째 줄: | ||
return item.label; | return item.label; | ||
} | } | ||
return item.label; | |||
} | |||
/* | |||
좌표 계산 | |||
----------------------------------------- | |||
이상적인 카테고리 폭은 163px이다. | |||
이것은 이전 테스트에서 '프로젝트를 제외한 버튼 폭 10% 증가'를 반영한 값이다. | |||
다만 실제 본문 폭이 좁으면 163px 6개 + 프로젝트 최소폭이 들어가지 않는다. | |||
그럴 때는 cellW를 줄여서 전체가 깨지지 않게 한다. | |||
계산 원칙: | |||
- 좌측 3개와 우측 3개는 같은 폭이다. | |||
- 프로젝트는 남은 중앙 폭을 차지한다. | |||
- 사선 cut 값은 cellW에 따라 너무 커지거나 작아지지 않게 제한한다. | |||
*/ | |||
function calculateGeometry(width) { | |||
var h = 30; | |||
var idealCell = 163; | |||
var minCell = 96; | |||
var minProject = 180; | |||
var usableWidth = Math.max(320, width); | |||
var cellW = idealCell; | |||
if ((cellW * 6) + minProject > usableWidth) { | |||
cellW = Math.floor((usableWidth - minProject) / 6); | |||
cellW = Math.max(minCell, cellW); | |||
} | |||
var projectW = usableWidth - (cellW * 6); | |||
if (projectW < 100) { | |||
projectW = 100; | |||
} | |||
/* | |||
projectW를 보정했을 경우 전체 폭이 넘칠 수 있다. | |||
그 상황에서는 viewBox를 전체 필요 폭으로 잡고, | |||
SVG가 가로로 스케일되게 둔다. | |||
일반 대문 폭에서는 이 분기가 거의 발생하지 않는다. | |||
*/ | |||
var totalW = (cellW * 6) + projectW; | |||
var cut = Math.round(cellW * 0.061); | |||
cut = Math.max(6, Math.min(10, cut)); | |||
var x0 = 0; | |||
var x1 = cellW; | |||
var x2 = cellW * 2; | |||
var x3 = cellW * 3; | |||
var x4 = x3 + projectW; | |||
var x5 = x4 + cellW; | |||
var x6 = x4 + (cellW * 2); | |||
var x7 = x4 + (cellW * 3); | |||
return { | |||
width: totalW, | |||
height: h, | |||
cellW: cellW, | |||
projectW: projectW, | |||
cut: cut, | |||
x: [x0, x1, x2, x3, x4, x5, x6, x7] | |||
}; | |||
} | |||
/* | |||
polygon 좌표 | |||
----------------------------------------- | |||
요구 형태: | |||
왼쪽군: \ \ \ | |||
프로젝트: \ PROJECT / | |||
오른쪽군: / / / | |||
좌표 설명: | |||
- \ 경계는 위쪽 x가 왼쪽, 아래쪽 x가 오른쪽이다. | |||
- / 경계는 위쪽 x가 오른쪽, 아래쪽 x가 왼쪽이다. | |||
예시: | |||
왼쪽 첫 경계 \ : | |||
top x1 - cut | |||
bottom x1 | |||
오른쪽 첫 경계 / : | |||
top x4 | |||
bottom x4 - cut | |||
*/ | |||
function getSegmentPoints(index, g) { | |||
var x = g.x; | |||
var c = g.cut; | |||
var h = g.height; | |||
if (index === 0) { | |||
return [ | |||
[x[0], 0], | |||
[x[1] - c, 0], | |||
[x[1], h], | |||
[x[0], h] | |||
]; | |||
} | |||
if (index === 1) { | |||
return [ | |||
[x[1] - c, 0], | |||
[x[2] - c, 0], | |||
[x[2], h], | |||
[x[1], h] | |||
]; | |||
} | |||
if (index === 2) { | |||
return [ | |||
[x[2] - c, 0], | |||
[x[3] - c, 0], | |||
[x[3], h], | |||
[x[2], h] | |||
]; | |||
} | |||
if (index === 3) { | |||
return [ | |||
[x[3] - c, 0], | |||
[x[4], 0], | |||
[x[4] - c, h], | |||
[x[3], h] | |||
]; | |||
} | |||
if (index === 4) { | |||
return [ | |||
[x[4], 0], | |||
[x[5], 0], | |||
[x[5] - c, h], | |||
[x[4] - c, h] | |||
]; | |||
} | |||
if (index === 5) { | |||
return [ | |||
[x[5], 0], | |||
[x[6], 0], | |||
[x[6] - c, h], | |||
[x[5] - c, h] | |||
]; | |||
} | |||
return [ | |||
[x[6], 0], | |||
[x[7], 0], | |||
[x[7], h], | |||
[x[6] - c, h] | |||
]; | |||
} | |||
function pointsToString(points) { | |||
return points.map(function (point) { | |||
return point[0] + ',' + point[1]; | |||
}).join(' '); | |||
} | |||
function getTextX(index, g) { | |||
var x = g.x; | |||
if (index === 0) return (x[0] + x[1]) / 2; | |||
if (index === 1) return (x[1] + x[2]) / 2; | |||
if (index === 2) return (x[2] + x[3]) / 2; | |||
if (index === 3) return (x[3] + x[4]) / 2; | |||
if (index === 4) return (x[4] + x[5]) / 2; | |||
if (index === 5) return (x[5] + x[6]) / 2; | |||
return (x[6] + x[7]) / 2; | |||
} | |||
function createSvgElement(name) { | |||
return document.createElementNS(SVG_NS, name); | |||
} | |||
/* | |||
SVG 링크 생성 | |||
----------------------------------------- | |||
SVG <a> 안에 polygon과 text를 함께 넣는다. | |||
polygon이 hover/click 면이고, text는 pointer-events:none으로 둔다. | |||
*/ | |||
function createSegment(item, index, g, lang) { | |||
var anchor = createSvgElement('a'); | |||
var polygon = createSvgElement('polygon'); | |||
var text = createSvgElement('text'); | |||
var href = buildPath(item.title); | |||
var label = getLabel(item, lang); | |||
anchor.setAttribute('class', 'portal-cat-anchor'); | |||
anchor.setAttribute('href', href); | |||
anchor.setAttributeNS(XLINK_NS, 'xlink:href', href); | |||
anchor.setAttribute('aria-label', label); | |||
anchor.setAttribute('data-category-key', item.key); | |||
polygon.setAttribute('class', 'portal-cat-shape'); | |||
polygon.setAttribute('points', pointsToString(getSegmentPoints(index, g))); | |||
text.setAttribute('class', item.key === 'project' | |||
? 'portal-cat-label portal-cat-project-label' | |||
: 'portal-cat-label' | |||
); | |||
text.setAttribute('x', getTextX(index, g)); | |||
text.setAttribute('y', g.height / 2 + 1); | |||
text.textContent = label; | |||
anchor.appendChild(polygon); | |||
anchor.appendChild(text); | |||
return anchor; | |||
} | |||
/* | |||
구분선 생성 | |||
----------------------------------------- | |||
polygon 경계와 같은 x 값을 사용해 line을 만든다. | |||
그러므로 호버 면과 검은 획이 다른 좌표계를 쓰지 않는다. | |||
*/ | |||
function createDivider(x1, y1, x2, y2, side) { | |||
var line = createSvgElement('line'); | |||
line.setAttribute('class', 'portal-cat-divider portal-cat-divider-' + side); | |||
line.setAttribute('x1', x1); | |||
line.setAttribute('y1', y1); | |||
line.setAttribute('x2', x2); | |||
line.setAttribute('y2', y2); | |||
return line; | |||
} | |||
function appendDividers(svg, g) { | |||
var x = g.x; | |||
var c = g.cut; | |||
var h = g.height; | |||
/* | /* | ||
왼쪽 3개 구분선: \ 방향 | |||
*/ | |||
svg.appendChild(createDivider(x[1] - c, 0, x[1], h, 'left')); | |||
svg.appendChild(createDivider(x[2] - c, 0, x[2], h, 'left')); | |||
svg.appendChild(createDivider(x[3] - c, 0, x[3], h, 'left')); | |||
/* | |||
오른쪽 3개 구분선: / 방향 | |||
*/ | */ | ||
svg.appendChild(createDivider(x[4], 0, x[4] - c, h, 'right')); | |||
svg.appendChild(createDivider(x[5], 0, x[5] - c, h, 'right')); | |||
svg.appendChild(createDivider(x[6], 0, x[6] - c, h, 'right')); | |||
} | |||
function clearNode(node) { | |||
while (node.firstChild) { | |||
node.removeChild(node.firstChild); | |||
} | |||
} | } | ||
/* | /* | ||
실제 SVG 렌더링 | |||
----------------------------------------- | ----------------------------------------- | ||
nav의 현재 폭을 읽고, 그 폭에 맞춰 viewBox와 polygon을 재생성한다. | |||
ResizeObserver가 이 함수를 다시 호출하면 폭 변화에도 대응한다. | |||
*/ | |||
function renderSvgIntoNav(nav) { | |||
var rect = nav.getBoundingClientRect(); | |||
var measuredWidth = Math.floor(rect.width || nav.clientWidth || 0); | |||
if (!measuredWidth) { | |||
if (! | return; | ||
} | |||
var lang = getCurrentLanguageSafe(); | var lang = getCurrentLanguageSafe(); | ||
var | var g = calculateGeometry(measuredWidth); | ||
var svg = createSvgElement('svg'); | |||
svg.setAttribute('class', 'portal-category-svg'); | |||
svg.setAttribute('viewBox', '0 0 ' + g.width + ' ' + g.height); | |||
svg.setAttribute('width', g.width); | |||
svg.setAttribute('height', g.height); | |||
svg.setAttribute('preserveAspectRatio', 'none'); | |||
svg.setAttribute('aria-hidden', 'false'); | |||
CATEGORY_NAV_ITEMS.forEach(function (item, index) { | |||
svg.appendChild(createSegment(item, index, g, lang)); | |||
}); | |||
appendDividers(svg, g); | |||
clearNode(nav); | |||
nav.appendChild(svg); | |||
} | |||
function scheduleRender(nav) { | |||
if (!nav || nav.__categoryNavFrame) { | |||
return; | |||
} | |||
nav. | nav.__categoryNavFrame = window.requestAnimationFrame(function () { | ||
nav.__categoryNavFrame = null; | |||
renderSvgIntoNav(nav); | |||
}); | }); | ||
} | } | ||
/* | /* | ||
mount 렌더링 | |||
----------------------------------------- | ----------------------------------------- | ||
본문에는 다음 자리만 둔다. | |||
<div data-component="category-nav"></div> | <div data-component="category-nav"></div> | ||
JS는 그 안에 nav.portal-category-nav를 만들고, | |||
SVG를 렌더링한다. | |||
*/ | */ | ||
function renderCategoryNavMount(mount) { | |||
if (!mount) return; | |||
if (mount.__categoryNavObserver) { | |||
mount.__categoryNavObserver.disconnect(); | |||
mount.__categoryNavObserver = null; | |||
} | |||
clearNode(mount); | |||
var nav = document.createElement('nav'); | |||
nav.className = 'portal-category-nav'; | |||
nav.setAttribute('aria-label', 'Main categories'); | |||
mount.appendChild(nav); | |||
mount.setAttribute('data-category-nav-rendered', '1'); | |||
scheduleRender(nav); | |||
/* | |||
nav 폭이 바뀌면 SVG 좌표를 다시 계산한다. | |||
이것이 CSS absolute overlay 방식과 다른 점이다. | |||
폭 변화가 생겨도 JS가 polygon 좌표를 다시 맞춘다. | |||
*/ | |||
if (window.ResizeObserver) { | |||
var observer = new ResizeObserver(function () { | |||
scheduleRender(nav); | |||
}); | |||
observer.observe(nav); | |||
mount.__categoryNavObserver = observer; | |||
} else { | |||
window.addEventListener('resize', function () { | |||
scheduleRender(nav); | |||
}); | |||
} | |||
} | |||
function renderAllCategoryNavs(root) { | function renderAllCategoryNavs(root) { | ||
| 248번째 줄: | 539번째 줄: | ||
초기화 | 초기화 | ||
----------------------------------------- | ----------------------------------------- | ||
1. 일반 페이지 로드 시 | 1. 일반 페이지 로드 시 실행 | ||
2. MediaWiki SPA/동적 본문 | 2. MediaWiki SPA/동적 본문 갱신 시 실행 | ||
3. | 3. 필요할 때 window.CLBI.categoryNav.init()으로 수동 재실행 가능 | ||
*/ | */ | ||
2026년 5월 19일 (화) 07:46 판
/* =========================================
COASTLINE: BLACK ICE - CategoryNav
대문 카테고리 네비 SVG 생성기
========================================= */
/*
설계 목적
-----------------------------------------
이 파일은 대문 카테고리 네비를 MediaWiki 본문 위키문법이 아니라
JavaScript + SVG로 생성한다.
왜 SVG를 쓰는가
-----------------------------------------
CSS 박스 기반 구현에서 다음 문제가 반복되었다.
1. span 안에 [[문서|라벨]]을 넣는 방식
- MediaWiki가 내부에 <a>를 자동 생성한다.
- span은 모양, a는 클릭을 담당하게 된다.
- 실제 클릭 영역, 호버 영역, 시각 형태가 서로 분리된다.
2. <a class="...">를 본문에 직접 넣는 방식
- MediaWiki 본문에서 안정적으로 먹히지 않았다.
- 위키 문법 처리와 충돌해 코드가 무효화되는 문제가 있었다.
3. clip-path + ::after 방식
- clip-path가 검은 획 pseudo-element까지 잘라먹었다.
- 왼쪽 항목의 획이 사라지거나 일부만 보였다.
4. 버튼을 음수 margin으로 겹치는 방식
- 사선 빈 영역은 메웠지만, 호버가 옆 칸으로 넘어갔다.
5. hover overlay / divider overlay 방식
- CSS grid, absolute 좌표, 프로젝트 가변폭이 서로 다른 기준을 가져서
호버 면과 검은 획이 계속 어긋났다.
SVG 방식의 장점
-----------------------------------------
SVG에서는 각 항목을 polygon으로 만든다.
따라서 다음 요소들이 모두 같은 좌표계를 사용한다.
- 보이는 버튼 면
- 실제 hover 면
- 실제 click 면
- 검은 구분선
즉, 이전처럼 사각형 박스를 사선처럼 보이게 흉내 내는 방식이 아니라,
처음부터 사선 polygon을 생성한다.
JS의 책임
-----------------------------------------
JS는 구조와 좌표만 만든다.
색상, 호버 색, 글자 스타일은 MainPage.css가 담당한다.
좌표는 현재 nav 폭을 읽어서 계산한다.
프로젝트 버튼은 좌우 카테고리 3개씩을 제외한 남은 중앙 폭을 차지한다.
*/
(function () {
'use strict';
var SVG_NS = 'http://www.w3.org/2000/svg';
var XLINK_NS = 'http://www.w3.org/1999/xlink';
/*
기본 카테고리 구성
-----------------------------------------
현재는 한국어 대문 기준이다.
다국어 확장 시에는 여기의 label/title을 직접 바꾸기보다
getLabel(), getTitle()에서 window.LANG, window.CAT_LINKS 또는
별도 MainPageConfig.js를 읽게 하면 된다.
*/
var CATEGORY_NAV_ITEMS = [
{
key: 'history',
label: '역사적 사건',
title: '역사적_사건',
type: 'left-start'
},
{
key: 'settings',
label: '설정',
title: '설정',
type: 'left-mid'
},
{
key: 'nations',
label: '국가 및 조합',
title: '국가_및_조합',
type: 'left-end'
},
{
key: 'project',
label: '프로젝트',
title: '프로젝트:소개',
type: 'project'
},
{
key: 'corporations',
label: '기업 및 공동체',
title: '기업_및_공동체',
type: 'right-start'
},
{
key: 'military',
label: '군, 정치집단',
title: '군_정치집단',
type: 'right-mid'
},
{
key: 'personnel',
label: '인물',
title: '인물',
type: 'right-end'
}
];
/*
언어 감지
-----------------------------------------
기존 Common.js에 getCurrentLang() 함수가 있을 수 있다.
이 파일은 단독으로도 깨지지 않아야 하므로 fallback을 둔다.
*/
function getCurrentLanguageSafe() {
if (typeof window.getCurrentLang === 'function') {
return window.getCurrentLang();
}
var langData = document.getElementById('clbi-lang-data');
if (langData) {
return langData.getAttribute('data-lang') || 'ko';
}
return 'ko';
}
/*
경로 생성
-----------------------------------------
기존 Common.js에 buildWikiPath(title)가 있으면 그것을 사용한다.
없으면 /index.php/문서명 형식으로 fallback한다.
*/
function buildPath(title) {
if (typeof window.buildWikiPath === 'function') {
return window.buildWikiPath(title);
}
return '/index.php/' + encodeURI(String(title || '').replace(/ /g, '_'));
}
/*
라벨 선택
-----------------------------------------
현재는 한국어를 기준으로 둔다.
기존 LANG.js와 CAT_LINKS에는 모든 새 항목이 정리되어 있지 않을 수 있다.
예를 들어 '설정', '프로젝트'는 기존 세계관 카테고리 세트와 다르다.
부분 번역을 섞으면 언어별 네비 폭과 의미가 흔들릴 수 있으므로,
다국어 구조가 확정되기 전까지는 fallback label을 유지한다.
*/
function getLabel(item, lang) {
if (lang === 'ko') {
return item.label;
}
return item.label;
}
/*
좌표 계산
-----------------------------------------
이상적인 카테고리 폭은 163px이다.
이것은 이전 테스트에서 '프로젝트를 제외한 버튼 폭 10% 증가'를 반영한 값이다.
다만 실제 본문 폭이 좁으면 163px 6개 + 프로젝트 최소폭이 들어가지 않는다.
그럴 때는 cellW를 줄여서 전체가 깨지지 않게 한다.
계산 원칙:
- 좌측 3개와 우측 3개는 같은 폭이다.
- 프로젝트는 남은 중앙 폭을 차지한다.
- 사선 cut 값은 cellW에 따라 너무 커지거나 작아지지 않게 제한한다.
*/
function calculateGeometry(width) {
var h = 30;
var idealCell = 163;
var minCell = 96;
var minProject = 180;
var usableWidth = Math.max(320, width);
var cellW = idealCell;
if ((cellW * 6) + minProject > usableWidth) {
cellW = Math.floor((usableWidth - minProject) / 6);
cellW = Math.max(minCell, cellW);
}
var projectW = usableWidth - (cellW * 6);
if (projectW < 100) {
projectW = 100;
}
/*
projectW를 보정했을 경우 전체 폭이 넘칠 수 있다.
그 상황에서는 viewBox를 전체 필요 폭으로 잡고,
SVG가 가로로 스케일되게 둔다.
일반 대문 폭에서는 이 분기가 거의 발생하지 않는다.
*/
var totalW = (cellW * 6) + projectW;
var cut = Math.round(cellW * 0.061);
cut = Math.max(6, Math.min(10, cut));
var x0 = 0;
var x1 = cellW;
var x2 = cellW * 2;
var x3 = cellW * 3;
var x4 = x3 + projectW;
var x5 = x4 + cellW;
var x6 = x4 + (cellW * 2);
var x7 = x4 + (cellW * 3);
return {
width: totalW,
height: h,
cellW: cellW,
projectW: projectW,
cut: cut,
x: [x0, x1, x2, x3, x4, x5, x6, x7]
};
}
/*
polygon 좌표
-----------------------------------------
요구 형태:
왼쪽군: \ \ \
프로젝트: \ PROJECT /
오른쪽군: / / /
좌표 설명:
- \ 경계는 위쪽 x가 왼쪽, 아래쪽 x가 오른쪽이다.
- / 경계는 위쪽 x가 오른쪽, 아래쪽 x가 왼쪽이다.
예시:
왼쪽 첫 경계 \ :
top x1 - cut
bottom x1
오른쪽 첫 경계 / :
top x4
bottom x4 - cut
*/
function getSegmentPoints(index, g) {
var x = g.x;
var c = g.cut;
var h = g.height;
if (index === 0) {
return [
[x[0], 0],
[x[1] - c, 0],
[x[1], h],
[x[0], h]
];
}
if (index === 1) {
return [
[x[1] - c, 0],
[x[2] - c, 0],
[x[2], h],
[x[1], h]
];
}
if (index === 2) {
return [
[x[2] - c, 0],
[x[3] - c, 0],
[x[3], h],
[x[2], h]
];
}
if (index === 3) {
return [
[x[3] - c, 0],
[x[4], 0],
[x[4] - c, h],
[x[3], h]
];
}
if (index === 4) {
return [
[x[4], 0],
[x[5], 0],
[x[5] - c, h],
[x[4] - c, h]
];
}
if (index === 5) {
return [
[x[5], 0],
[x[6], 0],
[x[6] - c, h],
[x[5] - c, h]
];
}
return [
[x[6], 0],
[x[7], 0],
[x[7], h],
[x[6] - c, h]
];
}
function pointsToString(points) {
return points.map(function (point) {
return point[0] + ',' + point[1];
}).join(' ');
}
function getTextX(index, g) {
var x = g.x;
if (index === 0) return (x[0] + x[1]) / 2;
if (index === 1) return (x[1] + x[2]) / 2;
if (index === 2) return (x[2] + x[3]) / 2;
if (index === 3) return (x[3] + x[4]) / 2;
if (index === 4) return (x[4] + x[5]) / 2;
if (index === 5) return (x[5] + x[6]) / 2;
return (x[6] + x[7]) / 2;
}
function createSvgElement(name) {
return document.createElementNS(SVG_NS, name);
}
/*
SVG 링크 생성
-----------------------------------------
SVG <a> 안에 polygon과 text를 함께 넣는다.
polygon이 hover/click 면이고, text는 pointer-events:none으로 둔다.
*/
function createSegment(item, index, g, lang) {
var anchor = createSvgElement('a');
var polygon = createSvgElement('polygon');
var text = createSvgElement('text');
var href = buildPath(item.title);
var label = getLabel(item, lang);
anchor.setAttribute('class', 'portal-cat-anchor');
anchor.setAttribute('href', href);
anchor.setAttributeNS(XLINK_NS, 'xlink:href', href);
anchor.setAttribute('aria-label', label);
anchor.setAttribute('data-category-key', item.key);
polygon.setAttribute('class', 'portal-cat-shape');
polygon.setAttribute('points', pointsToString(getSegmentPoints(index, g)));
text.setAttribute('class', item.key === 'project'
? 'portal-cat-label portal-cat-project-label'
: 'portal-cat-label'
);
text.setAttribute('x', getTextX(index, g));
text.setAttribute('y', g.height / 2 + 1);
text.textContent = label;
anchor.appendChild(polygon);
anchor.appendChild(text);
return anchor;
}
/*
구분선 생성
-----------------------------------------
polygon 경계와 같은 x 값을 사용해 line을 만든다.
그러므로 호버 면과 검은 획이 다른 좌표계를 쓰지 않는다.
*/
function createDivider(x1, y1, x2, y2, side) {
var line = createSvgElement('line');
line.setAttribute('class', 'portal-cat-divider portal-cat-divider-' + side);
line.setAttribute('x1', x1);
line.setAttribute('y1', y1);
line.setAttribute('x2', x2);
line.setAttribute('y2', y2);
return line;
}
function appendDividers(svg, g) {
var x = g.x;
var c = g.cut;
var h = g.height;
/*
왼쪽 3개 구분선: \ 방향
*/
svg.appendChild(createDivider(x[1] - c, 0, x[1], h, 'left'));
svg.appendChild(createDivider(x[2] - c, 0, x[2], h, 'left'));
svg.appendChild(createDivider(x[3] - c, 0, x[3], h, 'left'));
/*
오른쪽 3개 구분선: / 방향
*/
svg.appendChild(createDivider(x[4], 0, x[4] - c, h, 'right'));
svg.appendChild(createDivider(x[5], 0, x[5] - c, h, 'right'));
svg.appendChild(createDivider(x[6], 0, x[6] - c, h, 'right'));
}
function clearNode(node) {
while (node.firstChild) {
node.removeChild(node.firstChild);
}
}
/*
실제 SVG 렌더링
-----------------------------------------
nav의 현재 폭을 읽고, 그 폭에 맞춰 viewBox와 polygon을 재생성한다.
ResizeObserver가 이 함수를 다시 호출하면 폭 변화에도 대응한다.
*/
function renderSvgIntoNav(nav) {
var rect = nav.getBoundingClientRect();
var measuredWidth = Math.floor(rect.width || nav.clientWidth || 0);
if (!measuredWidth) {
return;
}
var lang = getCurrentLanguageSafe();
var g = calculateGeometry(measuredWidth);
var svg = createSvgElement('svg');
svg.setAttribute('class', 'portal-category-svg');
svg.setAttribute('viewBox', '0 0 ' + g.width + ' ' + g.height);
svg.setAttribute('width', g.width);
svg.setAttribute('height', g.height);
svg.setAttribute('preserveAspectRatio', 'none');
svg.setAttribute('aria-hidden', 'false');
CATEGORY_NAV_ITEMS.forEach(function (item, index) {
svg.appendChild(createSegment(item, index, g, lang));
});
appendDividers(svg, g);
clearNode(nav);
nav.appendChild(svg);
}
function scheduleRender(nav) {
if (!nav || nav.__categoryNavFrame) {
return;
}
nav.__categoryNavFrame = window.requestAnimationFrame(function () {
nav.__categoryNavFrame = null;
renderSvgIntoNav(nav);
});
}
/*
mount 렌더링
-----------------------------------------
본문에는 다음 자리만 둔다.
<div data-component="category-nav"></div>
JS는 그 안에 nav.portal-category-nav를 만들고,
SVG를 렌더링한다.
*/
function renderCategoryNavMount(mount) {
if (!mount) return;
if (mount.__categoryNavObserver) {
mount.__categoryNavObserver.disconnect();
mount.__categoryNavObserver = null;
}
clearNode(mount);
var nav = document.createElement('nav');
nav.className = 'portal-category-nav';
nav.setAttribute('aria-label', 'Main categories');
mount.appendChild(nav);
mount.setAttribute('data-category-nav-rendered', '1');
scheduleRender(nav);
/*
nav 폭이 바뀌면 SVG 좌표를 다시 계산한다.
이것이 CSS absolute overlay 방식과 다른 점이다.
폭 변화가 생겨도 JS가 polygon 좌표를 다시 맞춘다.
*/
if (window.ResizeObserver) {
var observer = new ResizeObserver(function () {
scheduleRender(nav);
});
observer.observe(nav);
mount.__categoryNavObserver = observer;
} else {
window.addEventListener('resize', function () {
scheduleRender(nav);
});
}
}
function renderAllCategoryNavs(root) {
var scope = root || document;
var mounts = scope.querySelectorAll('[data-component="category-nav"]');
Array.prototype.forEach.call(mounts, function (mount) {
renderCategoryNavMount(mount);
});
}
/*
초기화
-----------------------------------------
1. 일반 페이지 로드 시 실행
2. MediaWiki SPA/동적 본문 갱신 시 실행
3. 필요할 때 window.CLBI.categoryNav.init()으로 수동 재실행 가능
*/
window.CLBI = window.CLBI || {};
window.CLBI.categoryNav = {
init: renderAllCategoryNavs,
renderMount: renderCategoryNavMount
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () {
renderAllCategoryNavs(document);
});
} else {
renderAllCategoryNavs(document);
}
if (window.mw && mw.hook) {
mw.hook('wikipage.content').add(function ($content) {
var root = $content && $content[0] ? $content[0] : document;
renderAllCategoryNavs(root);
});
}
}());