(Install package: clbiwiki-category-bottomguide-restore / js/CategoryNav.js) 태그: 수동 되돌리기 |
(Install package: clbi-categorynav-portalsection / js/CategoryNav.js) |
||
| 1번째 줄: | 1번째 줄: | ||
/* ========================================= | /* ========================================= | ||
COASTLINE: BLACK ICE - CategoryNav | COASTLINE: BLACK ICE - CategoryNav | ||
전체 재구현: 상단 5버튼 + 프로젝트 기둥 (canvas, 경계 공유 + 정수 계단) | |||
버전: 20260720-canvas-full-001 | |||
========================================= */ | ========================================= */ | ||
(function (window, document, mw) { | |||
(function () { | |||
'use strict'; | 'use strict'; | ||
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:'군_정치집단' } | ||
]; | ]; | ||
var | /* ---- 고정 규격 (HANDOVER §B-5) ---- */ | ||
var | var CUT = 10; | ||
var | var GAP = 2; | ||
var BTN_H = 24; | |||
var FRAME_H = 11; | |||
var WELL = 2; | |||
var ROW_HEIGHT = FRAME_H + WELL + BTN_H + WELL + FRAME_H; /* 50 */ | |||
var BTN_TOP = FRAME_H + WELL; /* 13 */ | |||
var BORDER = '#050505'; | |||
var HI = '#555555'; | |||
var SH = '#101010'; | |||
var SURFACE = '#1d1d1d'; | |||
var WELL_BG = '#080808'; | |||
var renderTimer = 0; | |||
function buildPath(title) { | function buildPath(title) { | ||
if (typeof window.buildWikiPath === 'function') | if (typeof window.buildWikiPath === 'function') return window.buildWikiPath(title); | ||
return '/index.php/' + encodeURI(String(title || '').replace(/ /g, '_')); | return '/index.php/' + encodeURI(String(title || '').replace(/ /g, '_')); | ||
} | } | ||
/* §B-1 — 경계 배열. 인접 항목이 같은 경계를 공유 = 구멍 원천 불가. | |||
half를 정수로 고정하지 않는다(원본 SVG와 동일하게 count/2). 그래야 내부 경계 어느 것도 | |||
강제로 수직이 되지 않고, 프로젝트도 다른 버튼과 완전히 동일하게 양쪽 사선을 갖는다. | |||
(이전 버전은 half를 정수로 만들어 프로젝트를 수직 기둥으로 강제했으나, 실제 참조 화면과 | |||
달라 오류로 확인되어 제거함.) */ | |||
function calculateBoundaries(width, count, cut) { | |||
function | |||
var top = []; | var top = []; | ||
var bottom = []; | var bottom = []; | ||
var half = count / 2; | var half = count / 2; | ||
var i; | var i; | ||
var x; | |||
for (i = 0; i <= count; i++) { | for (i = 0; i <= count; i++) { | ||
x = Math.round((width * i) / count); | |||
if (i === 0 || i === count || i === half) { | if (i === 0 || i === count || i === half) { | ||
top.push(x); | top.push(x); | ||
| 104번째 줄: | 63번째 줄: | ||
} | } | ||
} | } | ||
return { top:top, bottom:bottom, width:width }; | |||
return { | |||
} | } | ||
function | /* §B-2 — 정수 계단. clamp 없이 이어지는 버전(하단 프레임까지 연장 가능하도록) */ | ||
function slantEdge(topX, bottomX, row, unitH) { | |||
var total = bottomX - topX; | |||
var stepSize; | |||
var k; | |||
if (total === 0) return topX; | |||
stepSize = unitH / Math.abs(total); | |||
k = Math.floor(row / stepSize); | |||
return topX + (total > 0 ? k : -k); | |||
} | } | ||
function | /* §B-3 */ | ||
function buttonEdges(i, count, bounds, row, btnH, gap) { | |||
var hg = gap / 2; | |||
} | var lo = (i === 0) ? 0 : hg; | ||
var ro = (i === count - 1) ? 0 : hg; | |||
var lx = slantEdge(bounds.top[i], bounds.bottom[i], row, btnH) + lo; | |||
var rx = slantEdge(bounds.top[i + 1], bounds.bottom[i + 1], row, btnH) - ro; | |||
return { lx:lx, rx:rx }; | |||
} | } | ||
function | /* §B-4 */ | ||
var | function paintButtonRow(ctx, i, count, bounds, row, yTop, btnH, gap) { | ||
var | var e = buttonEdges(i, count, bounds, row, btnH, gap); | ||
var | var x; | ||
var c; | |||
for (x = e.lx; x < e.rx; x++) { | |||
if (row === 0 || row === btnH - 1) c = BORDER; | |||
else if (x === e.lx || x === e.rx - 1) c = BORDER; | |||
else if (row === 1) c = HI; | |||
else if (row === btnH - 2) c = SH; | |||
else if (x === e.lx + 1) c = SH; | |||
else if (x === e.rx - 2) c = HI; | |||
else c = SURFACE; | |||
ctx.fillStyle = c; | |||
ctx.fillRect(x, yTop + row, 1, 1); | |||
} | |||
} | } | ||
function | function paintFrameBandFull(ctx, width, yTop, height) { | ||
var | var row; | ||
var | var c; | ||
for (row = 0; row < height; row++) { | |||
if (row === 0 || row === height - 1) c = BORDER; | |||
else if (row === 1) c = HI; | |||
} | else if (row === height - 2) c = SH; | ||
else c = SURFACE; | |||
ctx.fillStyle = c; | |||
ctx.fillRect(0, yTop + row, width, 1); | |||
} | |||
} | |||
function calculateLayout(width) { | |||
var count = ITEMS.length; | |||
var bounds = calculateBoundaries(width, count, CUT); | |||
return { width:width, count:count, bounds:bounds }; | |||
} | } | ||
function | /* 프로젝트 — 경계(2,3)의 사선을, 버튼 높이(24px) 기준이 아니라 이 행 전체 높이(50px) | ||
var | 기준으로 다시 계산해 처음부터 끝까지 하나의 매끄러운 사선으로 통일한다. | ||
var | (버튼 높이 구간만 사선이고 위아래 프레임 띠에서 뚝 끊겨 수직이 되는 방식은 "허리가 잘록한" | ||
var | 부자연스러운 모양이 되어 폐기함.) */ | ||
var | function paintProjectFused(ctx, bounds, rowHeight) { | ||
var | var row; | ||
var localRow; | |||
var lx; | |||
var rx; | |||
var c; | |||
for (row = 0; row < rowHeight; row++) { | |||
localRow = row - BTN_TOP; | |||
lx = slantEdge(bounds.top[2], bounds.bottom[2], localRow, BTN_H); | |||
rx = slantEdge(bounds.top[3], bounds.bottom[3], localRow, BTN_H); | |||
c = (row === 0 || row === rowHeight - 1) ? BORDER : SURFACE; | |||
ctx.fillStyle = c; | |||
ctx.fillRect(lx, row, rx - lx, 1); | |||
} | } | ||
} | } | ||
function | function draw(canvas, layout) { | ||
var | var ctx = canvas.getContext('2d'); | ||
var | var width = layout.width; | ||
var | var bounds = layout.bounds; | ||
var row; | |||
var i; | var i; | ||
var idx; | |||
var lx; | |||
var rx; | |||
var cx; | |||
var cy; | |||
ctx.clearRect(0, 0, width, ROW_HEIGHT); | |||
ctx.fillStyle = WELL_BG; | |||
ctx.fillRect(0, 0, width, ROW_HEIGHT); | |||
paintFrameBandFull(ctx, width, 0, FRAME_H); | |||
for (row = 0; row < BTN_H; row++) { | |||
for (i = 0; i < layout.count; i++) { | |||
if (ITEMS[i].key === 'project') continue; | |||
paintButtonRow(ctx, i, layout.count, bounds, row, BTN_TOP, BTN_H, GAP); | |||
} | } | ||
} | } | ||
paintFrameBandFull(ctx, width, BTN_TOP + BTN_H + WELL, FRAME_H); | |||
paintProjectFused(ctx, bounds, ROW_HEIGHT); | |||
ctx.font = '700 11px sans-serif'; | |||
ctx.textAlign = 'center'; | |||
ctx.textBaseline = 'middle'; | |||
ctx.lineJoin = 'round'; | |||
for (idx = 0; idx < layout.count; idx++) { | |||
if (ITEMS[idx].key === 'project') { | |||
lx = slantEdge(bounds.top[2], bounds.bottom[2], Math.floor(ROW_HEIGHT / 2) - BTN_TOP, BTN_H); | |||
rx = slantEdge(bounds.top[3], bounds.bottom[3], Math.floor(ROW_HEIGHT / 2) - BTN_TOP, BTN_H); | |||
cx = Math.round((lx + rx) / 2); | |||
cy = Math.round(ROW_HEIGHT / 2); | |||
} else { | |||
lx = slantEdge(bounds.top[idx], bounds.bottom[idx], Math.floor(BTN_H / 2), BTN_H); | |||
rx = slantEdge(bounds.top[idx + 1], bounds.bottom[idx + 1], Math.floor(BTN_H / 2), BTN_H); | |||
cx = Math.round((lx + rx) / 2); | |||
cy = BTN_TOP + Math.round(BTN_H / 2) + 1; | |||
} | |||
ctx.lineWidth = 2; | |||
ctx.strokeStyle = BORDER; | |||
ctx.strokeText(ITEMS[idx].label, cx, cy); | |||
ctx.fillStyle = '#e2e2e2'; | |||
ctx.fillText(ITEMS[idx].label, cx, cy); | |||
} | |||
} | } | ||
function | function buildHitLayer(layout) { | ||
var | var wrap = document.createElement('div'); | ||
var | var bounds = layout.bounds; | ||
var | var i; | ||
var | var item; | ||
var | var t; | ||
var | var b; | ||
var | var leftMin; | ||
var | var rightMax; | ||
var | var a; | ||
wrap.className = 'portal-frame-hit-layer'; | |||
wrap.style.position = 'absolute'; | |||
wrap.style.left = '0'; | |||
wrap.style.top = '0'; | |||
wrap.style.width = layout.width + 'px'; | |||
wrap.style.height = ROW_HEIGHT + 'px'; | |||
for (i = 0; i < layout.count; i++) { | |||
item = ITEMS[i]; | |||
if (item.key === 'project') continue; /* 비조작 */ | |||
t = slantEdge(bounds.top[i], bounds.bottom[i], 0, BTN_H); | |||
b = slantEdge(bounds.top[i], bounds.bottom[i], BTN_H - 1, BTN_H); | |||
leftMin = Math.min(t, b); | |||
t = slantEdge(bounds.top[i + 1], bounds.bottom[i + 1], 0, BTN_H); | |||
b = slantEdge(bounds.top[i + 1], bounds.bottom[i + 1], BTN_H - 1, BTN_H); | |||
rightMax = Math.max(t, b); | |||
a = document.createElement('a'); | |||
a.href = buildPath(item.title); | |||
a.setAttribute('aria-label', item.label); | |||
a.setAttribute('data-category-key', item.key); | |||
a.style.position = 'absolute'; | |||
a.style.left = leftMin + 'px'; | |||
a.style.top = BTN_TOP + 'px'; | |||
a.style.width = (rightMax - leftMin) + 'px'; | |||
a.style.height = BTN_H + 'px'; | |||
a.style.display = 'block'; | |||
wrap.appendChild(a); | |||
} | |||
return wrap; | |||
return | |||
} | } | ||
function | function readWidth(mount) { | ||
var rect = mount.getBoundingClientRect(); | |||
var width = Math.floor(rect.width || 0); | |||
if (!width && mount.clientWidth) width = Math.floor(mount.clientWidth); | |||
return width || 0; | |||
} | } | ||
function | function applyCounterScale(el) { | ||
var | var scale = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--clbi-shell-scale')) || 1; | ||
el.style.transformOrigin = 'top left'; | |||
el.style.transform = scale && scale !== 1 ? 'scale(' + (1 / scale) + ')' : ''; | |||
} | } | ||
function | function renderMount(mount) { | ||
var | var width; | ||
var layout; | |||
var wrap; | |||
var canvas; | |||
var ctx; | |||
var dpr; | |||
var hitLayer; | |||
if ( | if (!mount) return false; | ||
width = readWidth(mount); | |||
if (!width) { | |||
if (!mount.__categoryNavRetry) { | |||
mount.__categoryNavRetry = window.setTimeout(function () { | |||
mount.__categoryNavRetry = 0; | |||
renderMount(mount); | |||
}, 60); | |||
} | |||
return false; | |||
} | |||
layout = calculateLayout(width); | |||
wrap = document.createElement('div'); | |||
wrap.className = 'portal-category-canvas-nav'; | |||
wrap.style.position = 'relative'; | |||
wrap.style.width = width + 'px'; | |||
wrap.style.height = ROW_HEIGHT + 'px'; | |||
applyCounterScale(wrap); | |||
canvas = document.createElement('canvas'); | |||
canvas.className = 'portal-category-canvas'; | |||
dpr = window.devicePixelRatio || 1; | |||
canvas.width = width * dpr; | |||
canvas.height = ROW_HEIGHT * dpr; | |||
canvas.style.width = width + 'px'; | |||
canvas.style.height = ROW_HEIGHT + 'px'; | |||
canvas.style.display = 'block'; | |||
canvas.style.imageRendering = 'pixelated'; | |||
ctx = canvas.getContext('2d'); | |||
ctx.imageSmoothingEnabled = false; | |||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | |||
draw(canvas, layout); | |||
hitLayer = buildHitLayer(layout); | |||
while (mount.firstChild) mount.removeChild(mount.firstChild); | |||
wrap.appendChild(canvas); | |||
wrap.appendChild(hitLayer); | |||
mount.appendChild(wrap); | |||
mount.setAttribute('data-category-nav-width', String(width)); | |||
mount.setAttribute('data-category-nav-rendered', 'canvas-full'); | |||
/* 하단 2단이 읽어갈 공유 레이아웃 (DOM 실측 대신 변수 전달, §8-2) */ | |||
window.CLBI = window.CLBI || {}; | |||
window.CLBI.categoryNav = window.CLBI.categoryNav || {}; | |||
window.CLBI.categoryNav.lastLayout = { | |||
width:width, | |||
bounds:layout.bounds, | |||
rowHeight:ROW_HEIGHT, | |||
frameH:FRAME_H, | |||
well:WELL, | |||
btnH:BTN_H, | |||
gap:GAP, | |||
if ( | btnTop:BTN_TOP | ||
}; | |||
if (mw && mw.hook) mw.hook('clbi.categoryNav.rendered').fire(mount); | |||
if (window.ResizeObserver && !mount.__categoryNavObserver) { | |||
mount.__categoryNavObserver = new window.ResizeObserver(function () { | |||
mount.__categoryNavObserver = | schedule(mount); | ||
}); | }); | ||
mount.__categoryNavObserver.observe(mount); | |||
} | } | ||
return true; | |||
} | } | ||
function | function renderAll(root) { | ||
var scope = root | var scope = root && root.querySelectorAll ? root : document; | ||
var mounts = | var mounts = []; | ||
var rendered = 0; | |||
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(function (mount) { | |||
if (renderMount(mount)) rendered += 1; | |||
}); | |||
return rendered; | |||
} | } | ||
window. | function schedule(root) { | ||
window.clearTimeout(renderTimer); | |||
renderTimer = window.setTimeout(function () { | |||
renderTimer = 0; | |||
} | renderAll(root || document); | ||
}, 0); | |||
} | |||
if (document.readyState === 'loading') { | if (document.readyState === 'loading') { | ||
document.addEventListener('DOMContentLoaded', function () { | document.addEventListener('DOMContentLoaded', function () { renderAll(document); }); | ||
} else { | } else { | ||
renderAll(document); | |||
} | } | ||
if (window.mw && mw.hook) { | if (window.mw && mw.hook) { | ||
mw.hook('wikipage.content').add(function ($content) { | mw.hook('wikipage.content').add(function ($content) { | ||
schedule($content && $content[0] ? $content[0] : document); | |||
}); | }); | ||
} | } | ||
}()); | window.addEventListener('resize', function () { schedule(document); }); | ||
window.CategoryNav = { | |||
version:'20260720-canvas-full-001', | |||
init:renderAll, | |||
renderAll:renderAll, | |||
renderMount:renderMount, | |||
ROW_HEIGHT:ROW_HEIGHT, | |||
FRAME_H:FRAME_H, | |||
WELL:WELL, | |||
BTN_H:BTN_H, | |||
GAP:GAP, | |||
CUT:CUT, | |||
slantEdge:slantEdge | |||
}; | |||
window.CLBI = window.CLBI || {}; | |||
window.CLBI.categoryNav = window.CLBI.categoryNav || {}; | |||
window.CLBI.categoryNav.init = renderAll; | |||
window.CLBI.categoryNav.renderMount = renderMount; | |||
}(window, document, window.mw)); | |||
2026년 7월 20일 (월) 20:03 판
/* =========================================
COASTLINE: BLACK ICE - CategoryNav
전체 재구현: 상단 5버튼 + 프로젝트 기둥 (canvas, 경계 공유 + 정수 계단)
버전: 20260720-canvas-full-001
========================================= */
(function (window, document, mw) {
'use strict';
var ITEMS = [
{ key:'era', label:'시대', title:'시대' },
{ key:'settings', label:'설정', title:'설정' },
{ key:'project', label:'프로젝트', title:'프로젝트:소개' },
{ key:'corporations', label:'기업 및 공동체', title:'기업_및_공동체' },
{ key:'military', label:'군, 정치집단', title:'군_정치집단' }
];
/* ---- 고정 규격 (HANDOVER §B-5) ---- */
var CUT = 10;
var GAP = 2;
var BTN_H = 24;
var FRAME_H = 11;
var WELL = 2;
var ROW_HEIGHT = FRAME_H + WELL + BTN_H + WELL + FRAME_H; /* 50 */
var BTN_TOP = FRAME_H + WELL; /* 13 */
var BORDER = '#050505';
var HI = '#555555';
var SH = '#101010';
var SURFACE = '#1d1d1d';
var WELL_BG = '#080808';
var renderTimer = 0;
function buildPath(title) {
if (typeof window.buildWikiPath === 'function') return window.buildWikiPath(title);
return '/index.php/' + encodeURI(String(title || '').replace(/ /g, '_'));
}
/* §B-1 — 경계 배열. 인접 항목이 같은 경계를 공유 = 구멍 원천 불가.
half를 정수로 고정하지 않는다(원본 SVG와 동일하게 count/2). 그래야 내부 경계 어느 것도
강제로 수직이 되지 않고, 프로젝트도 다른 버튼과 완전히 동일하게 양쪽 사선을 갖는다.
(이전 버전은 half를 정수로 만들어 프로젝트를 수직 기둥으로 강제했으나, 실제 참조 화면과
달라 오류로 확인되어 제거함.) */
function calculateBoundaries(width, count, cut) {
var top = [];
var bottom = [];
var half = count / 2;
var i;
var x;
for (i = 0; i <= count; i++) {
x = Math.round((width * 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 { top:top, bottom:bottom, width:width };
}
/* §B-2 — 정수 계단. clamp 없이 이어지는 버전(하단 프레임까지 연장 가능하도록) */
function slantEdge(topX, bottomX, row, unitH) {
var total = bottomX - topX;
var stepSize;
var k;
if (total === 0) return topX;
stepSize = unitH / Math.abs(total);
k = Math.floor(row / stepSize);
return topX + (total > 0 ? k : -k);
}
/* §B-3 */
function buttonEdges(i, count, bounds, row, btnH, gap) {
var hg = gap / 2;
var lo = (i === 0) ? 0 : hg;
var ro = (i === count - 1) ? 0 : hg;
var lx = slantEdge(bounds.top[i], bounds.bottom[i], row, btnH) + lo;
var rx = slantEdge(bounds.top[i + 1], bounds.bottom[i + 1], row, btnH) - ro;
return { lx:lx, rx:rx };
}
/* §B-4 */
function paintButtonRow(ctx, i, count, bounds, row, yTop, btnH, gap) {
var e = buttonEdges(i, count, bounds, row, btnH, gap);
var x;
var c;
for (x = e.lx; x < e.rx; x++) {
if (row === 0 || row === btnH - 1) c = BORDER;
else if (x === e.lx || x === e.rx - 1) c = BORDER;
else if (row === 1) c = HI;
else if (row === btnH - 2) c = SH;
else if (x === e.lx + 1) c = SH;
else if (x === e.rx - 2) c = HI;
else c = SURFACE;
ctx.fillStyle = c;
ctx.fillRect(x, yTop + row, 1, 1);
}
}
function paintFrameBandFull(ctx, width, yTop, height) {
var row;
var c;
for (row = 0; row < height; row++) {
if (row === 0 || row === height - 1) c = BORDER;
else if (row === 1) c = HI;
else if (row === height - 2) c = SH;
else c = SURFACE;
ctx.fillStyle = c;
ctx.fillRect(0, yTop + row, width, 1);
}
}
function calculateLayout(width) {
var count = ITEMS.length;
var bounds = calculateBoundaries(width, count, CUT);
return { width:width, count:count, bounds:bounds };
}
/* 프로젝트 — 경계(2,3)의 사선을, 버튼 높이(24px) 기준이 아니라 이 행 전체 높이(50px)
기준으로 다시 계산해 처음부터 끝까지 하나의 매끄러운 사선으로 통일한다.
(버튼 높이 구간만 사선이고 위아래 프레임 띠에서 뚝 끊겨 수직이 되는 방식은 "허리가 잘록한"
부자연스러운 모양이 되어 폐기함.) */
function paintProjectFused(ctx, bounds, rowHeight) {
var row;
var localRow;
var lx;
var rx;
var c;
for (row = 0; row < rowHeight; row++) {
localRow = row - BTN_TOP;
lx = slantEdge(bounds.top[2], bounds.bottom[2], localRow, BTN_H);
rx = slantEdge(bounds.top[3], bounds.bottom[3], localRow, BTN_H);
c = (row === 0 || row === rowHeight - 1) ? BORDER : SURFACE;
ctx.fillStyle = c;
ctx.fillRect(lx, row, rx - lx, 1);
}
}
function draw(canvas, layout) {
var ctx = canvas.getContext('2d');
var width = layout.width;
var bounds = layout.bounds;
var row;
var i;
var idx;
var lx;
var rx;
var cx;
var cy;
ctx.clearRect(0, 0, width, ROW_HEIGHT);
ctx.fillStyle = WELL_BG;
ctx.fillRect(0, 0, width, ROW_HEIGHT);
paintFrameBandFull(ctx, width, 0, FRAME_H);
for (row = 0; row < BTN_H; row++) {
for (i = 0; i < layout.count; i++) {
if (ITEMS[i].key === 'project') continue;
paintButtonRow(ctx, i, layout.count, bounds, row, BTN_TOP, BTN_H, GAP);
}
}
paintFrameBandFull(ctx, width, BTN_TOP + BTN_H + WELL, FRAME_H);
paintProjectFused(ctx, bounds, ROW_HEIGHT);
ctx.font = '700 11px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.lineJoin = 'round';
for (idx = 0; idx < layout.count; idx++) {
if (ITEMS[idx].key === 'project') {
lx = slantEdge(bounds.top[2], bounds.bottom[2], Math.floor(ROW_HEIGHT / 2) - BTN_TOP, BTN_H);
rx = slantEdge(bounds.top[3], bounds.bottom[3], Math.floor(ROW_HEIGHT / 2) - BTN_TOP, BTN_H);
cx = Math.round((lx + rx) / 2);
cy = Math.round(ROW_HEIGHT / 2);
} else {
lx = slantEdge(bounds.top[idx], bounds.bottom[idx], Math.floor(BTN_H / 2), BTN_H);
rx = slantEdge(bounds.top[idx + 1], bounds.bottom[idx + 1], Math.floor(BTN_H / 2), BTN_H);
cx = Math.round((lx + rx) / 2);
cy = BTN_TOP + Math.round(BTN_H / 2) + 1;
}
ctx.lineWidth = 2;
ctx.strokeStyle = BORDER;
ctx.strokeText(ITEMS[idx].label, cx, cy);
ctx.fillStyle = '#e2e2e2';
ctx.fillText(ITEMS[idx].label, cx, cy);
}
}
function buildHitLayer(layout) {
var wrap = document.createElement('div');
var bounds = layout.bounds;
var i;
var item;
var t;
var b;
var leftMin;
var rightMax;
var a;
wrap.className = 'portal-frame-hit-layer';
wrap.style.position = 'absolute';
wrap.style.left = '0';
wrap.style.top = '0';
wrap.style.width = layout.width + 'px';
wrap.style.height = ROW_HEIGHT + 'px';
for (i = 0; i < layout.count; i++) {
item = ITEMS[i];
if (item.key === 'project') continue; /* 비조작 */
t = slantEdge(bounds.top[i], bounds.bottom[i], 0, BTN_H);
b = slantEdge(bounds.top[i], bounds.bottom[i], BTN_H - 1, BTN_H);
leftMin = Math.min(t, b);
t = slantEdge(bounds.top[i + 1], bounds.bottom[i + 1], 0, BTN_H);
b = slantEdge(bounds.top[i + 1], bounds.bottom[i + 1], BTN_H - 1, BTN_H);
rightMax = Math.max(t, b);
a = document.createElement('a');
a.href = buildPath(item.title);
a.setAttribute('aria-label', item.label);
a.setAttribute('data-category-key', item.key);
a.style.position = 'absolute';
a.style.left = leftMin + 'px';
a.style.top = BTN_TOP + 'px';
a.style.width = (rightMax - leftMin) + 'px';
a.style.height = BTN_H + 'px';
a.style.display = 'block';
wrap.appendChild(a);
}
return wrap;
}
function readWidth(mount) {
var rect = mount.getBoundingClientRect();
var width = Math.floor(rect.width || 0);
if (!width && mount.clientWidth) width = Math.floor(mount.clientWidth);
return width || 0;
}
function applyCounterScale(el) {
var scale = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--clbi-shell-scale')) || 1;
el.style.transformOrigin = 'top left';
el.style.transform = scale && scale !== 1 ? 'scale(' + (1 / scale) + ')' : '';
}
function renderMount(mount) {
var width;
var layout;
var wrap;
var canvas;
var ctx;
var dpr;
var hitLayer;
if (!mount) return false;
width = readWidth(mount);
if (!width) {
if (!mount.__categoryNavRetry) {
mount.__categoryNavRetry = window.setTimeout(function () {
mount.__categoryNavRetry = 0;
renderMount(mount);
}, 60);
}
return false;
}
layout = calculateLayout(width);
wrap = document.createElement('div');
wrap.className = 'portal-category-canvas-nav';
wrap.style.position = 'relative';
wrap.style.width = width + 'px';
wrap.style.height = ROW_HEIGHT + 'px';
applyCounterScale(wrap);
canvas = document.createElement('canvas');
canvas.className = 'portal-category-canvas';
dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = ROW_HEIGHT * dpr;
canvas.style.width = width + 'px';
canvas.style.height = ROW_HEIGHT + 'px';
canvas.style.display = 'block';
canvas.style.imageRendering = 'pixelated';
ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
draw(canvas, layout);
hitLayer = buildHitLayer(layout);
while (mount.firstChild) mount.removeChild(mount.firstChild);
wrap.appendChild(canvas);
wrap.appendChild(hitLayer);
mount.appendChild(wrap);
mount.setAttribute('data-category-nav-width', String(width));
mount.setAttribute('data-category-nav-rendered', 'canvas-full');
/* 하단 2단이 읽어갈 공유 레이아웃 (DOM 실측 대신 변수 전달, §8-2) */
window.CLBI = window.CLBI || {};
window.CLBI.categoryNav = window.CLBI.categoryNav || {};
window.CLBI.categoryNav.lastLayout = {
width:width,
bounds:layout.bounds,
rowHeight:ROW_HEIGHT,
frameH:FRAME_H,
well:WELL,
btnH:BTN_H,
gap:GAP,
btnTop:BTN_TOP
};
if (mw && mw.hook) mw.hook('clbi.categoryNav.rendered').fire(mount);
if (window.ResizeObserver && !mount.__categoryNavObserver) {
mount.__categoryNavObserver = new window.ResizeObserver(function () {
schedule(mount);
});
mount.__categoryNavObserver.observe(mount);
}
return true;
}
function renderAll(root) {
var scope = root && root.querySelectorAll ? root : document;
var mounts = [];
var rendered = 0;
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(function (mount) {
if (renderMount(mount)) rendered += 1;
});
return rendered;
}
function schedule(root) {
window.clearTimeout(renderTimer);
renderTimer = window.setTimeout(function () {
renderTimer = 0;
renderAll(root || document);
}, 0);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { renderAll(document); });
} else {
renderAll(document);
}
if (window.mw && mw.hook) {
mw.hook('wikipage.content').add(function ($content) {
schedule($content && $content[0] ? $content[0] : document);
});
}
window.addEventListener('resize', function () { schedule(document); });
window.CategoryNav = {
version:'20260720-canvas-full-001',
init:renderAll,
renderAll:renderAll,
renderMount:renderMount,
ROW_HEIGHT:ROW_HEIGHT,
FRAME_H:FRAME_H,
WELL:WELL,
BTN_H:BTN_H,
GAP:GAP,
CUT:CUT,
slantEdge:slantEdge
};
window.CLBI = window.CLBI || {};
window.CLBI.categoryNav = window.CLBI.categoryNav || {};
window.CLBI.categoryNav.init = renderAll;
window.CLBI.categoryNav.renderMount = renderMount;
}(window, document, window.mw));