(Install package: clbiwiki-mainpage-archive-graph-dom-mount-fix-20260711 / js/ArchiveGraph.js) |
(Install package: clbiwiki-mainpage-graph-in-status-square-lang-20260711 / js/ArchiveGraph.js) |
||
| 12번째 줄: | 12번째 줄: | ||
if (!mw || !mw.Api || !mw.util) return; | if (!mw || !mw.Api || !mw.util) return; | ||
var CACHE_KEY = 'archiveGraph:data: | var CACHE_KEY = 'archiveGraph:data:v2-language'; | ||
var CACHE_MAX_AGE = 6 * 60 * 60 * 1000; | var CACHE_MAX_AGE = 6 * 60 * 60 * 1000; | ||
var MAIN_NAMESPACE = 0; | var MAIN_NAMESPACE = 0; | ||
var ANECDOTE_NAMESPACE = 3000; | var ANECDOTE_NAMESPACE = 3000; | ||
var GRAPH_SELECTOR = '[data-archive-graph]'; | var GRAPH_SELECTOR = '[data-archive-graph]'; | ||
var LANGUAGE_CODES = ['ko', 'en', 'zh', 'ja', 'ru', 'es']; | |||
var LANGUAGE_LABELS = { ko: 'KR', en: 'EN', zh: 'ZH', ja: 'JA', ru: 'RU', es: 'ES' }; | |||
function toArray(list) { | function toArray(list) { | ||
| 35번째 줄: | 37번째 줄: | ||
function normalizeTitle(value) { | function normalizeTitle(value) { | ||
return String(value || '').replace(/_/g, ' ').replace(/\s+/g, ' ').trim(); | return String(value || '').replace(/_/g, ' ').replace(/\s+/g, ' ').trim(); | ||
} | |||
function normalizeLanguage(value) { | |||
var code = String(value || '').trim().toLowerCase().split('-')[0]; | |||
if (code === 'kr') code = 'ko'; | |||
if (code === 'cn') code = 'zh'; | |||
if (code === 'jp') code = 'ja'; | |||
return LANGUAGE_CODES.indexOf(code) !== -1 ? code : 'ko'; | |||
} | |||
function detectPageLanguage(wikitext) { | |||
var text = String(wikitext || ''); | |||
var match = /\{\{\s*Langlink\b[\s\S]*?\|\s*lang\s*=\s*([a-zA-Z-]{2,12})/i.exec(text); | |||
return normalizeLanguage(match ? match[1] : 'ko'); | |||
} | |||
function getCurrentLanguage() { | |||
var langData = document.getElementById('clbi-lang-data'); | |||
if (typeof window.getCurrentLang === 'function') { | |||
try { return normalizeLanguage(window.getCurrentLang()); } catch (err) {} | |||
} | |||
return normalizeLanguage(langData ? langData.getAttribute('data-lang') : 'ko'); | |||
} | } | ||
| 122번째 줄: | 147번째 줄: | ||
pages.forEach(function (page) { | pages.forEach(function (page) { | ||
relationMap[String(page.pageid)] = { links: [], categories: [] }; | relationMap[String(page.pageid)] = { links: [], categories: [], lang: 'ko' }; | ||
}); | }); | ||
| 132번째 줄: | 157번째 줄: | ||
action: 'query', | action: 'query', | ||
formatversion: 2, | formatversion: 2, | ||
prop: 'links|categories', | prop: 'links|categories|revisions', | ||
pageids: batch.map(function (page) { return page.pageid; }).join('|'), | pageids: batch.map(function (page) { return page.pageid; }).join('|'), | ||
plnamespace: MAIN_NAMESPACE + '|' + ANECDOTE_NAMESPACE, | plnamespace: MAIN_NAMESPACE + '|' + ANECDOTE_NAMESPACE, | ||
pllimit: 'max', | pllimit: 'max', | ||
cllimit: 'max' | cllimit: 'max', | ||
rvprop: 'content', | |||
rvslots: 'main', | |||
rvlimit: 1 | |||
}; | }; | ||
Object.keys(continuation).forEach(function (key) { | Object.keys(continuation).forEach(function (key) { | ||
| 146번째 줄: | 174번째 줄: | ||
responsePages.forEach(function (page) { | responsePages.forEach(function (page) { | ||
var key = String(page.pageid); | var key = String(page.pageid); | ||
var target = relationMap[key] || (relationMap[key] = { links: [], categories: [] }); | var target = relationMap[key] || (relationMap[key] = { links: [], categories: [], lang: 'ko' }); | ||
var revision = page.revisions && page.revisions[0]; | |||
var slot = revision && revision.slots && revision.slots.main; | |||
var content = slot && (slot.content != null ? slot.content : slot['*']); | |||
if (content != null) target.lang = detectPageLanguage(content); | |||
(page.links || []).forEach(function (link) { | (page.links || []).forEach(function (link) { | ||
target.links.push(normalizeTitle(link.title)); | target.links.push(normalizeTitle(link.title)); | ||
| 184번째 줄: | 216번째 줄: | ||
pages.forEach(function (page, index) { | pages.forEach(function (page, index) { | ||
var title = normalizeTitle(page.title); | var title = normalizeTitle(page.title); | ||
var relation = relationMap[String(page.pageid)] || { links: [], categories: [] }; | var relation = relationMap[String(page.pageid)] || { links: [], categories: [], lang: 'ko' }; | ||
var project = Number(page.ns) === ANECDOTE_NAMESPACE ? 'anecdote' : 'blackice'; | var project = Number(page.ns) === ANECDOTE_NAMESPACE ? 'anecdote' : 'blackice'; | ||
nodes.push({ | nodes.push({ | ||
| 191번째 줄: | 223번째 줄: | ||
namespace: Number(page.ns), | namespace: Number(page.ns), | ||
project: project, | project: project, | ||
lang: normalizeLanguage(relation.lang), | |||
groups: classifyNode(title, relation.categories), | groups: classifyNode(title, relation.categories), | ||
categories: relation.categories.slice(0, 12), | categories: relation.categories.slice(0, 12), | ||
| 447번째 줄: | 480번째 줄: | ||
this.connectionCount = this.portal ? this.portal.querySelector('[data-graph-connection-count]') : null; | this.connectionCount = this.portal ? this.portal.querySelector('[data-graph-connection-count]') : null; | ||
this.selectedLink = this.portal ? this.portal.querySelector('[data-graph-selected-link]') : null; | this.selectedLink = this.portal ? this.portal.querySelector('[data-graph-selected-link]') : null; | ||
this.languageCountNodes = {}; | |||
this.currentLanguage = getCurrentLanguage(); | |||
if (this.portal) { | |||
toArray(this.portal.querySelectorAll('[data-graph-language-count]')).forEach(function (node) { | |||
this.languageCountNodes[normalizeLanguage(node.getAttribute('data-graph-language-count'))] = node; | |||
}, this); | |||
} | |||
this.filterControls = this.portal ? toArray(this.portal.querySelectorAll('[data-graph-filter]')) : []; | this.filterControls = this.portal ? toArray(this.portal.querySelectorAll('[data-graph-filter]')) : []; | ||
this.projectControls = this.portal ? toArray(this.portal.querySelectorAll('[data-graph-project]')) : []; | this.projectControls = this.portal ? toArray(this.portal.querySelectorAll('[data-graph-project]')) : []; | ||
| 652번째 줄: | 692번째 줄: | ||
ArchiveGraphController.prototype.installPointerControls = function () { | ArchiveGraphController.prototype.installPointerControls = function () { | ||
var self = this; | var self = this; | ||
var downIndex = -1; | |||
var downX = 0; | |||
var downY = 0; | |||
var point = function (event) { | var point = function (event) { | ||
var rect = self.canvas.getBoundingClientRect(); | var rect = self.canvas.getBoundingClientRect(); | ||
| 657번째 줄: | 700번째 줄: | ||
}; | }; | ||
this.bind(this.canvas, ' | this.bind(this.canvas, 'pointermove', function (event) { | ||
var p = point(event); | var p = point(event); | ||
self.hovered = self.findNodeAt(p.x, p.y); | |||
self.canvas.style.cursor = self.hovered >= 0 ? 'pointer' : 'default'; | |||
self. | self.updateCaptionHover(); | ||
self.scheduleRender(); | |||
self. | |||
}); | }); | ||
this.bind(this.canvas, ' | this.bind(this.canvas, 'pointerdown', function (event) { | ||
var p = point(event); | var p = point(event); | ||
downIndex = self.findNodeAt(p.x, p.y); | |||
downX = p.x; | |||
downY = p.y; | |||
}); | }); | ||
this.bind(this.canvas, 'pointerup', function (event) { | this.bind(this.canvas, 'pointerup', function (event) { | ||
var p = point(event); | var p = point(event); | ||
var | var moved = Math.abs(p.x - downX) + Math.abs(p.y - downY) > 4; | ||
if ( | var clicked = self.findNodeAt(p.x, p.y); | ||
if (! | if (!moved && clicked >= 0 && clicked === downIndex) self.selectNode(clicked); | ||
else if (!moved && clicked < 0) self.clearSelection(); | |||
downIndex = -1; | |||
self.scheduleRender(); | self.scheduleRender(); | ||
}); | }); | ||
this.bind(this.canvas, 'pointerleave', function () { | this.bind(this.canvas, 'pointerleave', function () { | ||
self.hovered = -1; | |||
downIndex = -1; | |||
self.updateCaptionHover(); | |||
self.scheduleRender(); | |||
}); | }); | ||
this.bind(this.canvas, 'dblclick', function (event) { | this.bind(this.canvas, 'dblclick', function (event) { | ||
var p = point(event); | var p = point(event); | ||
var index = self.findNodeAt(p.x, p.y); | var index = self.findNodeAt(p.x, p.y); | ||
if (index >= 0 && self.data) | if (index >= 0 && self.data) window.location.href = mw.util.getUrl(self.data.nodes[index].title); | ||
event.preventDefault(); | event.preventDefault(); | ||
}); | }); | ||
| 782번째 줄: | 771번째 줄: | ||
makeKeyboardClickable(control, function () { | makeKeyboardClickable(control, function () { | ||
var project = control.getAttribute('data-graph-project'); | var project = control.getAttribute('data-graph-project'); | ||
var | var alreadySolo = self.activeProjects[project] && Object.keys(self.activeProjects).every(function (key) { | ||
if (self.activeProjects | return key === project || !self.activeProjects[key]; | ||
}); | |||
if (alreadySolo) { | |||
self.activeProjects.blackice = true; | |||
self.activeProjects.anecdote = true; | |||
} else { | |||
self.activeProjects.blackice = project === 'blackice'; | |||
self.activeProjects.anecdote = project === 'anecdote'; | |||
} | |||
self.projectControls.forEach(function (item) { | |||
var key = item.getAttribute('data-graph-project'); | |||
var solo = self.activeProjects[key] && Object.keys(self.activeProjects).every(function (other) { | |||
return other === key || !self.activeProjects[other]; | |||
}); | |||
item.classList.toggle('is-active', solo); | |||
}); | |||
if (self.selected >= 0 && self.data && !self.isProjectVisible(self.data.nodes[self.selected])) self.clearSelection(); | if (self.selected >= 0 && self.data && !self.isProjectVisible(self.data.nodes[self.selected])) self.clearSelection(); | ||
self.updateVisibleStats(); | self.updateVisibleStats(); | ||
| 842번째 줄: | 847번째 줄: | ||
var visibleNodes = 0; | var visibleNodes = 0; | ||
var visibleEdges = 0; | var visibleEdges = 0; | ||
var languageCounts = { ko: 0, en: 0, zh: 0, ja: 0, ru: 0, es: 0 }; | |||
if (!this.data) return; | if (!this.data) return; | ||
this.data.nodes.forEach(function (node) { | this.data.nodes.forEach(function (node) { | ||
if (self.isProjectVisible(node) && self.matchesFilter(node)) visibleNodes += 1; | if (self.isProjectVisible(node) && self.matchesFilter(node)) { | ||
visibleNodes += 1; | |||
languageCounts[normalizeLanguage(node.lang)] += 1; | |||
} | |||
}); | }); | ||
this.data.edges.forEach(function (edge) { | this.data.edges.forEach(function (edge) { | ||
| 853번째 줄: | 862번째 줄: | ||
if (this.documentCount) this.documentCount.textContent = padCount(visibleNodes); | if (this.documentCount) this.documentCount.textContent = padCount(visibleNodes); | ||
if (this.connectionCount && this.selected < 0) this.connectionCount.textContent = padCount(visibleEdges); | if (this.connectionCount && this.selected < 0) this.connectionCount.textContent = padCount(visibleEdges); | ||
if (this.captionCount) this.captionCount.textContent = padCount( | LANGUAGE_CODES.forEach(function (code) { | ||
if (self.languageCountNodes[code]) self.languageCountNodes[code].textContent = padCount(languageCounts[code]); | |||
}); | |||
if (this.captionCount) this.captionCount.textContent = LANGUAGE_LABELS[this.currentLanguage] + ' ' + padCount(languageCounts[this.currentLanguage]) + ' / ' + padCount(visibleNodes); | |||
}; | }; | ||
| 1,031번째 줄: | 1,043번째 줄: | ||
} | } | ||
function | function buildGraphMount() { | ||
var | var graph = createElement('div', 'archive-graph archive-graph-status'); | ||
var canvas = createElement('canvas', 'archive-graph-canvas'); | var canvas = createElement('canvas', 'archive-graph-canvas'); | ||
var loading = createElement('div', 'archive-graph-loading'); | var loading = createElement('div', 'archive-graph-loading'); | ||
var caption = createElement('div', 'archive-graph-caption'); | var caption = createElement('div', 'archive-graph-caption'); | ||
graph.setAttribute('data-archive-graph', ''); | graph.setAttribute('data-archive-graph', ''); | ||
| 1,050번째 줄: | 1,054번째 줄: | ||
loading.appendChild(createElement('small', '', 'DOCUMENT LINKS ARE BEING MAPPED')); | loading.appendChild(createElement('small', '', 'DOCUMENT LINKS ARE BEING MAPPED')); | ||
caption.appendChild(createElement('span', 'archive-graph-caption-title', 'ARCHIVE RELATION GRAPH')); | caption.appendChild(createElement('span', 'archive-graph-caption-title', 'ARCHIVE RELATION GRAPH')); | ||
caption.appendChild(createElement('span', 'archive-graph-caption-count', '000 | caption.appendChild(createElement('span', 'archive-graph-caption-count', 'KR 000 / 000')); | ||
graph.appendChild(canvas); | graph.appendChild(canvas); | ||
graph.appendChild(loading); | graph.appendChild(loading); | ||
graph.appendChild(caption); | graph.appendChild(caption); | ||
screen.appendChild( | return graph; | ||
} | |||
function buildMainConsole() { | |||
var fragment = document.createDocumentFragment(); | |||
var titlebar = createElement('div', 'titlebar'); | |||
var body = createElement('div', 'console-body'); | |||
var grid = createElement('div', 'console-grid'); | |||
var screen = createElement('div', 'main-screen'); | |||
var feed = createElement('div', 'image-feed'); | |||
var caption = createElement('div', 'feed-caption'); | |||
var statement = createElement('div', 'statement-plate'); | |||
var heading = createElement('h2', '', '“몸부림”'); | |||
titlebar.appendChild(createElement('span', '', 'COASTLINE: BLACK ICE / OFFICIAL ARCHIVE')); | |||
titlebar.appendChild(createElement('span', '', 'MAIN RECORD ACCESS')); | |||
['feed-layer-1 feed-bg-001', 'feed-layer-2 feed-bg-002', 'feed-layer-3 feed-bg-003', 'feed-layer-4 feed-bg-004'].forEach(function (classes) { | |||
feed.appendChild(createElement('div', 'feed-layer ' + classes)); | |||
}); | |||
caption.appendChild(createElement('span', '', 'IMAGE FEED / SELECTED ATMOSPHERE RECORDS')); | |||
caption.appendChild(createElement('span', '', '4 FRAMES')); | |||
feed.appendChild(caption); | |||
statement.appendChild(heading); | |||
statement.appendChild(createElement('p', '', '쉼 없이 돌아가는 전쟁 기계들과 변방의 공단에서 뿜어져 나오는 검은 연기가 하늘을 뒤덮고, 매섭게 휘몰아치는 눈보라와 뼛속까지 스며드는 쓰라림이 마지막 생활권을 위협하고 있습니다.')); | |||
statement.appendChild(createElement('p', '', '땅이 굳고, 자원은 바닥을 드러냈습니다. 작은 것을 쟁취하기 위해 더 많은 노력이 요구되며, 갈등은 더욱 원시적인 방식으로 치닫고 있습니다.')); | |||
screen.appendChild(feed); | |||
screen.appendChild(statement); | |||
grid.appendChild(screen); | grid.appendChild(screen); | ||
body.appendChild(grid); | body.appendChild(grid); | ||
| 1,063번째 줄: | 1,095번째 줄: | ||
} | } | ||
function | function buildLanguageStats() { | ||
var | var stats = createElement('div', 'graph-language-stats'); | ||
var well = createElement('div', ' | LANGUAGE_CODES.forEach(function (code) { | ||
var panel = createElement('div', 'graph-language-stat'); | |||
var well = createElement('div', 'graph-language-stat-well'); | |||
var label = createElement('span', 'graph-language-stat-label', LANGUAGE_LABELS[code]); | |||
var value = createElement('b', 'graph-language-stat-value', '000'); | |||
value.setAttribute('data-graph-language-count', code); | |||
return | panel.setAttribute('data-graph-language', code); | ||
if (code === getCurrentLanguage()) panel.classList.add('is-current-language'); | |||
well.appendChild(label); | |||
well.appendChild(value); | |||
panel.appendChild(well); | |||
stats.appendChild(panel); | |||
}); | |||
return stats; | |||
} | } | ||
function buildStatusFrame() { | function buildStatusFrame() { | ||
var frame = createElement('div', 'portal-bottom-frame portal-status-frame'); | var frame = createElement('div', 'portal-bottom-frame portal-status-frame'); | ||
var body = createElement('div', 'portal-dock-body portal-status-body | var body = createElement('div', 'portal-dock-body portal-status-body portal-status-graph-body'); | ||
body.appendChild(buildGraphMount()); | |||
body.appendChild(buildLanguageStats()); | |||
frame.appendChild(createVerticalTitle('STATUS')); | frame.appendChild(createVerticalTitle('STATUS')); | ||
frame.appendChild(body); | frame.appendChild(body); | ||
| 1,124번째 줄: | 1,149번째 줄: | ||
['anecdote', 'ANECDOTE'] | ['anecdote', 'ANECDOTE'] | ||
].forEach(function (item) { | ].forEach(function (item) { | ||
var button = createElement('div', 'portal-dock-button graph-control | var button = createElement('div', 'portal-dock-button graph-control', item[1]); | ||
button.setAttribute('data-graph-project', item[0]); | button.setAttribute('data-graph-project', item[0]); | ||
body.appendChild(button); | body.appendChild(button); | ||
| 1,133번째 줄: | 1,158번째 줄: | ||
} | } | ||
function | function destroyGraphInside(node) { | ||
var mount = node && node.querySelector ? node.querySelector(GRAPH_SELECTOR) : null; | |||
if (mount && mount._archiveGraphController) { | |||
mount._archiveGraphController.destroy(); | |||
mount._archiveGraphController = null; | |||
} | |||
} | |||
function rebuildMainConsole(consoleNode) { | |||
destroyGraphInside(consoleNode); | |||
while (consoleNode.firstChild) consoleNode.removeChild(consoleNode.firstChild); | while (consoleNode.firstChild) consoleNode.removeChild(consoleNode.firstChild); | ||
consoleNode.appendChild( | consoleNode.appendChild(buildMainConsole()); | ||
consoleNode.removeAttribute('data-archive-graph-console'); | |||
} | } | ||
function rebuildGraphDock(bottomRow) { | function rebuildGraphDock(bottomRow) { | ||
destroyGraphInside(bottomRow); | |||
while (bottomRow.firstChild) bottomRow.removeChild(bottomRow.firstChild); | while (bottomRow.firstChild) bottomRow.removeChild(bottomRow.firstChild); | ||
bottomRow.appendChild(buildStatusFrame()); | bottomRow.appendChild(buildStatusFrame()); | ||
| 1,155번째 줄: | 1,191번째 줄: | ||
var consoleNode = portal.querySelector('.console'); | var consoleNode = portal.querySelector('.console'); | ||
var bottomRow = portal.querySelector('.portal-bottom-row'); | var bottomRow = portal.querySelector('.portal-bottom-row'); | ||
var | var statusGraph; | ||
if (!consoleNode) return; | if (!consoleNode) return; | ||
if (!consoleNode.querySelector('.image-feed') || consoleNode.hasAttribute('data-archive-graph-console')) rebuildMainConsole(consoleNode); | |||
if (!bottomRow) { | if (!bottomRow) { | ||
| 1,173번째 줄: | 1,203번째 줄: | ||
} | } | ||
statusGraph = bottomRow.querySelector('.portal-status-frame ' + GRAPH_SELECTOR + ' canvas.archive-graph-canvas'); | |||
if (!bottomRow. | if (!statusGraph || bottomRow.querySelectorAll('[data-graph-language-count]').length !== LANGUAGE_CODES.length) rebuildGraphDock(bottomRow); | ||
bottomRow.setAttribute('data-archive-graph-dock', '1'); | bottomRow.setAttribute('data-archive-graph-dock', '1'); | ||
}); | }); | ||
2026년 7월 11일 (토) 10:45 판
/* =========================================
ArchiveGraph
대문 문서 관계 그래프
=========================================
- 새 공유 기능의 공개 이름에는 CLBI/clbi 접두사를 사용하지 않는다.
- MediaWiki API에서 일반 문서와 Anecdote 문서의 내부 링크를 읽는다.
- Canvas는 그리기만 담당하고, 힘 기반 배치는 Web Worker에서 계산한다.
*/
(function (window, document, mw) {
'use strict';
if (!mw || !mw.Api || !mw.util) return;
var CACHE_KEY = 'archiveGraph:data:v2-language';
var CACHE_MAX_AGE = 6 * 60 * 60 * 1000;
var MAIN_NAMESPACE = 0;
var ANECDOTE_NAMESPACE = 3000;
var GRAPH_SELECTOR = '[data-archive-graph]';
var LANGUAGE_CODES = ['ko', 'en', 'zh', 'ja', 'ru', 'es'];
var LANGUAGE_LABELS = { ko: 'KR', en: 'EN', zh: 'ZH', ja: 'JA', ru: 'RU', es: 'ES' };
function toArray(list) {
return Array.prototype.slice.call(list || []);
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function padCount(value) {
var number = Math.max(0, Number(value) || 0);
if (number < 1000) return String(number).padStart(3, '0');
if (number < 10000) return String(number);
return Math.round(number / 1000) + 'K';
}
function normalizeTitle(value) {
return String(value || '').replace(/_/g, ' ').replace(/\s+/g, ' ').trim();
}
function normalizeLanguage(value) {
var code = String(value || '').trim().toLowerCase().split('-')[0];
if (code === 'kr') code = 'ko';
if (code === 'cn') code = 'zh';
if (code === 'jp') code = 'ja';
return LANGUAGE_CODES.indexOf(code) !== -1 ? code : 'ko';
}
function detectPageLanguage(wikitext) {
var text = String(wikitext || '');
var match = /\{\{\s*Langlink\b[\s\S]*?\|\s*lang\s*=\s*([a-zA-Z-]{2,12})/i.exec(text);
return normalizeLanguage(match ? match[1] : 'ko');
}
function getCurrentLanguage() {
var langData = document.getElementById('clbi-lang-data');
if (typeof window.getCurrentLang === 'function') {
try { return normalizeLanguage(window.getCurrentLang()); } catch (err) {}
}
return normalizeLanguage(langData ? langData.getAttribute('data-lang') : 'ko');
}
function hashString(value) {
var text = String(value || '');
var hash = 2166136261;
var i;
for (i = 0; i < text.length; i += 1) {
hash ^= text.charCodeAt(i);
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
}
return hash >>> 0;
}
function classifyNode(title, categories) {
var sample = (normalizeTitle(title) + ' ' + (categories || []).join(' ')).toLowerCase();
var groups = [];
if (/(?:시대|연대|연표|역사|전쟁|사건|혁명|내전|조약|19\d{2}|20\d{2}|21\d{2})/.test(sample)) groups.push('era');
if (/(?:설정|세계관|개념|기술|문화|종교|이념|경제|사회|지리|언어)/.test(sample)) groups.push('setting');
if (/(?:기업|회사|공동체|재단|협회|조직|코퍼레이션|컨소시엄|상회|산업)/.test(sample)) groups.push('company');
if (/(?:군|군사|정치|정당|정부|부대|사단|여단|연대|대대|전선|민병|경찰|정보기관|집단)/.test(sample)) groups.push('military');
if (/(?:리소스|자료|가이드|안내|참고|용어|목록|색인|도움말)/.test(sample)) groups.push('resource');
if (!groups.length) groups.push('other');
return groups;
}
function readCache() {
var raw;
var parsed;
try {
raw = window.localStorage.getItem(CACHE_KEY);
if (!raw) return null;
parsed = JSON.parse(raw);
if (!parsed || !parsed.savedAt || !parsed.data) return null;
if (Date.now() - parsed.savedAt > CACHE_MAX_AGE) return null;
return parsed.data;
} catch (err) {
return null;
}
}
function writeCache(data) {
try {
window.localStorage.setItem(CACHE_KEY, JSON.stringify({ savedAt: Date.now(), data: data }));
} catch (err) {}
}
function requestApi(api, params) {
return Promise.resolve(api.get(params));
}
function fetchAllPages(api, namespace, onProgress) {
var pages = [];
var continuation = {};
function step() {
var params = {
action: 'query',
formatversion: 2,
list: 'allpages',
apnamespace: namespace,
aplimit: 'max',
apfilterredir: 'nonredirects'
};
Object.keys(continuation).forEach(function (key) {
params[key] = continuation[key];
});
return requestApi(api, params).then(function (result) {
var batch = result && result.query && result.query.allpages ? result.query.allpages : [];
pages = pages.concat(batch);
if (typeof onProgress === 'function') onProgress(pages.length);
continuation = result && result.continue ? result.continue : null;
return continuation ? step() : pages;
});
}
return step();
}
function fetchPageRelations(api, pages, onProgress) {
var relationMap = {};
var cursor = 0;
var batchSize = 30;
pages.forEach(function (page) {
relationMap[String(page.pageid)] = { links: [], categories: [], lang: 'ko' };
});
function fetchBatch(batch) {
var continuation = {};
function step() {
var params = {
action: 'query',
formatversion: 2,
prop: 'links|categories|revisions',
pageids: batch.map(function (page) { return page.pageid; }).join('|'),
plnamespace: MAIN_NAMESPACE + '|' + ANECDOTE_NAMESPACE,
pllimit: 'max',
cllimit: 'max',
rvprop: 'content',
rvslots: 'main',
rvlimit: 1
};
Object.keys(continuation).forEach(function (key) {
params[key] = continuation[key];
});
return requestApi(api, params).then(function (result) {
var responsePages = result && result.query && result.query.pages ? result.query.pages : [];
responsePages.forEach(function (page) {
var key = String(page.pageid);
var target = relationMap[key] || (relationMap[key] = { links: [], categories: [], lang: 'ko' });
var revision = page.revisions && page.revisions[0];
var slot = revision && revision.slots && revision.slots.main;
var content = slot && (slot.content != null ? slot.content : slot['*']);
if (content != null) target.lang = detectPageLanguage(content);
(page.links || []).forEach(function (link) {
target.links.push(normalizeTitle(link.title));
});
(page.categories || []).forEach(function (category) {
target.categories.push(normalizeTitle(category.title).replace(/^분류:|^Category:/i, ''));
});
});
continuation = result && result.continue ? result.continue : null;
return continuation ? step() : null;
});
}
return step();
}
function next() {
var batch;
if (cursor >= pages.length) return Promise.resolve(relationMap);
batch = pages.slice(cursor, cursor + batchSize);
cursor += batch.length;
return fetchBatch(batch).then(function () {
if (typeof onProgress === 'function') onProgress(cursor, pages.length);
return next();
});
}
return next();
}
function buildGraphData(pages, relationMap) {
var nodes = [];
var titleToIndex = {};
var edgeKeys = {};
var edges = [];
pages.forEach(function (page, index) {
var title = normalizeTitle(page.title);
var relation = relationMap[String(page.pageid)] || { links: [], categories: [], lang: 'ko' };
var project = Number(page.ns) === ANECDOTE_NAMESPACE ? 'anecdote' : 'blackice';
nodes.push({
id: Number(page.pageid),
title: title,
namespace: Number(page.ns),
project: project,
lang: normalizeLanguage(relation.lang),
groups: classifyNode(title, relation.categories),
categories: relation.categories.slice(0, 12),
degree: 0,
x: 0,
y: 0
});
titleToIndex[title.toLowerCase()] = index;
});
pages.forEach(function (page, sourceIndex) {
var relation = relationMap[String(page.pageid)] || { links: [] };
relation.links.forEach(function (targetTitle) {
var targetIndex = titleToIndex[normalizeTitle(targetTitle).toLowerCase()];
var a;
var b;
var key;
if (typeof targetIndex !== 'number' || targetIndex === sourceIndex) return;
a = Math.min(sourceIndex, targetIndex);
b = Math.max(sourceIndex, targetIndex);
key = a + ':' + b;
if (edgeKeys[key]) return;
edgeKeys[key] = true;
edges.push([a, b]);
nodes[a].degree += 1;
nodes[b].degree += 1;
});
});
nodes.forEach(function (node, index) {
var hash = hashString(node.title);
var angle = ((hash % 3600) / 3600) * Math.PI * 2;
var radius = 80 + Math.sqrt(index + 1) * 17 + ((hash >>> 8) % 90);
var projectOffset = node.project === 'anecdote' ? 260 : -80;
node.x = Math.cos(angle) * radius + projectOffset;
node.y = Math.sin(angle) * radius * 0.78;
});
return { nodes: nodes, edges: edges, generatedAt: Date.now() };
}
function loadGraphData(setLoadingText) {
var cached = readCache();
var api;
var allPages;
if (cached && cached.nodes && cached.edges) {
setLoadingText('RESTORING GRAPH CACHE', cached.nodes.length + ' DOCUMENTS');
return Promise.resolve(cached);
}
api = new mw.Api();
setLoadingText('INDEXING ARCHIVE', 'READING DOCUMENT LIST');
return Promise.all([
fetchAllPages(api, MAIN_NAMESPACE, function (count) {
setLoadingText('INDEXING BLACK ICE', count + ' DOCUMENTS');
}),
fetchAllPages(api, ANECDOTE_NAMESPACE, function (count) {
setLoadingText('INDEXING ANECDOTE', count + ' DOCUMENTS');
}).catch(function () { return []; })
]).then(function (results) {
allPages = results[0].concat(results[1]);
if (!allPages.length) throw new Error('No graph documents were returned by the API.');
setLoadingText('MAPPING CONNECTIONS', '0 / ' + allPages.length);
return fetchPageRelations(api, allPages, function (done, total) {
setLoadingText('MAPPING CONNECTIONS', done + ' / ' + total);
});
}).then(function (relations) {
var data = buildGraphData(allPages, relations);
writeCache(data);
return data;
});
}
function makeWorker() {
var workerBody = function () {
'use strict';
var nodes = [];
var edges = [];
var timer = 0;
var tickCount = 0;
var alpha = 1;
function clampNumber(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function start() {
if (timer) clearInterval(timer);
timer = setInterval(tick, 33);
}
function tick() {
var count = nodes.length;
var cellSize = 90;
var grid = Object.create(null);
var i;
var j;
var node;
var key;
var gx;
var gy;
var nx;
var ny;
var bucket;
var other;
var dx;
var dy;
var distance2;
var distance;
var force;
var edge;
var source;
var target;
var desired;
var spring;
var positions;
if (!count) return;
alpha = Math.max(0.035, alpha * 0.987);
for (i = 0; i < count; i += 1) {
node = nodes[i];
if (node.fx == null) {
node.vx += (-node.x * 0.0009) * alpha;
node.vy += (-node.y * 0.0009) * alpha;
}
gx = Math.floor(node.x / cellSize);
gy = Math.floor(node.y / cellSize);
key = gx + ':' + gy;
if (!grid[key]) grid[key] = [];
grid[key].push(i);
}
for (i = 0; i < count; i += 1) {
node = nodes[i];
gx = Math.floor(node.x / cellSize);
gy = Math.floor(node.y / cellSize);
for (nx = gx - 1; nx <= gx + 1; nx += 1) {
for (ny = gy - 1; ny <= gy + 1; ny += 1) {
bucket = grid[nx + ':' + ny];
if (!bucket) continue;
for (j = 0; j < bucket.length; j += 1) {
if (bucket[j] <= i) continue;
other = nodes[bucket[j]];
dx = other.x - node.x;
dy = other.y - node.y;
distance2 = dx * dx + dy * dy + 0.01;
if (distance2 > 16900) continue;
distance = Math.sqrt(distance2);
force = (36 / distance2) * alpha;
node.vx -= (dx / distance) * force;
node.vy -= (dy / distance) * force;
other.vx += (dx / distance) * force;
other.vy += (dy / distance) * force;
}
}
}
}
for (i = 0; i < edges.length; i += 1) {
edge = edges[i];
source = nodes[edge[0]];
target = nodes[edge[1]];
if (!source || !target) continue;
dx = target.x - source.x;
dy = target.y - source.y;
distance2 = dx * dx + dy * dy + 0.01;
distance = Math.sqrt(distance2);
desired = 52 + Math.min(46, (source.degree + target.degree) * 0.7);
spring = (distance - desired) * 0.0018 * alpha;
source.vx += (dx / distance) * spring;
source.vy += (dy / distance) * spring;
target.vx -= (dx / distance) * spring;
target.vy -= (dy / distance) * spring;
}
for (i = 0; i < count; i += 1) {
node = nodes[i];
if (node.fx != null && node.fy != null) {
node.x = node.fx;
node.y = node.fy;
node.vx = 0;
node.vy = 0;
} else {
node.vx = clampNumber(node.vx * 0.84, -12, 12);
node.vy = clampNumber(node.vy * 0.84, -12, 12);
node.x += node.vx;
node.y += node.vy;
}
}
tickCount += 1;
if (tickCount % 2 !== 0) return;
positions = new Float32Array(count * 2);
for (i = 0; i < count; i += 1) {
positions[i * 2] = nodes[i].x;
positions[i * 2 + 1] = nodes[i].y;
}
self.postMessage({ type: 'positions', positions: positions }, [positions.buffer]);
}
self.onmessage = function (event) {
var message = event.data || {};
var index;
if (message.type === 'init') {
nodes = (message.nodes || []).map(function (node) {
return {
x: Number(node.x) || 0,
y: Number(node.y) || 0,
vx: 0,
vy: 0,
degree: Number(node.degree) || 0,
fx: null,
fy: null
};
});
edges = message.edges || [];
alpha = 1;
start();
} else if (message.type === 'pin') {
index = Number(message.index);
if (!nodes[index]) return;
nodes[index].fx = Number(message.x) || 0;
nodes[index].fy = Number(message.y) || 0;
alpha = Math.max(alpha, 0.35);
} else if (message.type === 'unpin') {
index = Number(message.index);
if (!nodes[index]) return;
nodes[index].fx = null;
nodes[index].fy = null;
alpha = Math.max(alpha, 0.25);
} else if (message.type === 'reheat') {
alpha = Math.max(alpha, 0.6);
} else if (message.type === 'stop') {
if (timer) clearInterval(timer);
timer = 0;
}
};
};
var source = '(' + workerBody.toString() + ')();';
var blob = new Blob([source], { type: 'application/javascript' });
return new Worker(URL.createObjectURL(blob));
}
function ArchiveGraphController(mount) {
this.mount = mount;
this.canvas = mount.querySelector('.archive-graph-canvas');
this.context = this.canvas ? this.canvas.getContext('2d') : null;
this.loading = mount.querySelector('.archive-graph-loading');
this.captionTitle = mount.querySelector('.archive-graph-caption-title');
this.captionCount = mount.querySelector('.archive-graph-caption-count');
this.portal = mount.closest('.main-portal');
this.documentCount = this.portal ? this.portal.querySelector('[data-graph-document-count]') : null;
this.connectionCount = this.portal ? this.portal.querySelector('[data-graph-connection-count]') : null;
this.selectedLink = this.portal ? this.portal.querySelector('[data-graph-selected-link]') : null;
this.languageCountNodes = {};
this.currentLanguage = getCurrentLanguage();
if (this.portal) {
toArray(this.portal.querySelectorAll('[data-graph-language-count]')).forEach(function (node) {
this.languageCountNodes[normalizeLanguage(node.getAttribute('data-graph-language-count'))] = node;
}, this);
}
this.filterControls = this.portal ? toArray(this.portal.querySelectorAll('[data-graph-filter]')) : [];
this.projectControls = this.portal ? toArray(this.portal.querySelectorAll('[data-graph-project]')) : [];
this.data = null;
this.worker = null;
this.destroyed = false;
this.framePending = false;
this.width = 0;
this.height = 0;
this.dpr = 1;
this.zoom = 1;
this.panX = 0;
this.panY = 0;
this.hovered = -1;
this.selected = -1;
this.dragNode = -1;
this.draggingCanvas = false;
this.pointerDownX = 0;
this.pointerDownY = 0;
this.lastPointerX = 0;
this.lastPointerY = 0;
this.pointerMoved = false;
this.activeFilter = null;
this.activeProjects = { blackice: true, anecdote: true };
this.neighbors = [];
this.resizeObserver = null;
this.handlers = [];
}
ArchiveGraphController.prototype.setLoadingText = function (title, detail) {
var titleNode;
var detailNode;
if (!this.loading) return;
titleNode = this.loading.querySelector('span');
detailNode = this.loading.querySelector('small');
if (titleNode) titleNode.textContent = title || '';
if (detailNode) detailNode.textContent = detail || '';
};
ArchiveGraphController.prototype.bind = function (target, type, handler, options) {
target.addEventListener(type, handler, options || false);
this.handlers.push([target, type, handler, options || false]);
};
ArchiveGraphController.prototype.start = function () {
var self = this;
if (!this.canvas || !this.context) return;
this.installControls();
this.installPointerControls();
this.installResize();
this.resize();
loadGraphData(function (title, detail) {
self.setLoadingText(title, detail);
}).then(function (data) {
if (self.destroyed) return;
self.data = data;
self.buildNeighbors();
self.updateVisibleStats();
self.fitView();
self.startWorker();
if (self.loading) self.loading.classList.add('is-hidden');
self.scheduleRender();
}).catch(function (error) {
if (self.destroyed) return;
self.setLoadingText('GRAPH DATA UNAVAILABLE', error && error.message ? error.message : 'MEDIAWIKI API ERROR');
if (self.loading) self.loading.classList.add('is-error');
});
};
ArchiveGraphController.prototype.buildNeighbors = function () {
var self = this;
this.neighbors = this.data.nodes.map(function () { return new Set(); });
this.data.edges.forEach(function (edge) {
self.neighbors[edge[0]].add(edge[1]);
self.neighbors[edge[1]].add(edge[0]);
});
};
ArchiveGraphController.prototype.startWorker = function () {
var self = this;
try {
this.worker = makeWorker();
this.worker.onmessage = function (event) {
var message = event.data || {};
var positions;
var i;
if (message.type !== 'positions' || !self.data) return;
positions = message.positions;
for (i = 0; i < self.data.nodes.length; i += 1) {
self.data.nodes[i].x = positions[i * 2];
self.data.nodes[i].y = positions[i * 2 + 1];
}
self.scheduleRender();
};
this.worker.postMessage({
type: 'init',
nodes: this.data.nodes.map(function (node) {
return { x: node.x, y: node.y, degree: node.degree };
}),
edges: this.data.edges
});
} catch (err) {
this.worker = null;
this.scheduleRender();
}
};
ArchiveGraphController.prototype.installResize = function () {
var self = this;
if (window.ResizeObserver) {
this.resizeObserver = new ResizeObserver(function () { self.resize(); });
this.resizeObserver.observe(this.mount);
} else {
this.bind(window, 'resize', function () { self.resize(); });
}
};
ArchiveGraphController.prototype.resize = function () {
var rect = this.mount.getBoundingClientRect();
var width = Math.max(1, Math.round(rect.width));
var height = Math.max(1, Math.round(rect.height));
var dpr = clamp(window.devicePixelRatio || 1, 1, 2);
if (width === this.width && height === this.height && dpr === this.dpr) return;
this.width = width;
this.height = height;
this.dpr = dpr;
this.canvas.width = Math.max(1, Math.round(width * dpr));
this.canvas.height = Math.max(1, Math.round(height * dpr));
this.canvas.style.width = width + 'px';
this.canvas.style.height = height + 'px';
this.scheduleRender();
};
ArchiveGraphController.prototype.fitView = function () {
var nodes;
var minX = Infinity;
var maxX = -Infinity;
var minY = Infinity;
var maxY = -Infinity;
var graphWidth;
var graphHeight;
if (!this.data || !this.data.nodes.length) return;
nodes = this.data.nodes;
nodes.forEach(function (node) {
minX = Math.min(minX, node.x);
maxX = Math.max(maxX, node.x);
minY = Math.min(minY, node.y);
maxY = Math.max(maxY, node.y);
});
graphWidth = Math.max(1, maxX - minX);
graphHeight = Math.max(1, maxY - minY);
this.zoom = clamp(Math.min((this.width - 80) / graphWidth, (this.height - 80) / graphHeight), 0.25, 1.4);
this.panX = -((minX + maxX) / 2) * this.zoom;
this.panY = -((minY + maxY) / 2) * this.zoom;
};
ArchiveGraphController.prototype.worldToScreen = function (x, y) {
return {
x: this.width / 2 + this.panX + x * this.zoom,
y: this.height / 2 + this.panY + y * this.zoom
};
};
ArchiveGraphController.prototype.screenToWorld = function (x, y) {
return {
x: (x - this.width / 2 - this.panX) / this.zoom,
y: (y - this.height / 2 - this.panY) / this.zoom
};
};
ArchiveGraphController.prototype.isProjectVisible = function (node) {
return !!this.activeProjects[node.project];
};
ArchiveGraphController.prototype.matchesFilter = function (node) {
return !this.activeFilter || node.groups.indexOf(this.activeFilter) !== -1;
};
ArchiveGraphController.prototype.findNodeAt = function (x, y) {
var best = -1;
var bestDistance = Infinity;
var i;
var node;
var screen;
var dx;
var dy;
var radius;
if (!this.data) return -1;
for (i = 0; i < this.data.nodes.length; i += 1) {
node = this.data.nodes[i];
if (!this.isProjectVisible(node)) continue;
screen = this.worldToScreen(node.x, node.y);
dx = screen.x - x;
dy = screen.y - y;
radius = Math.max(7, this.nodeRadius(node) * this.zoom + 4);
if (dx * dx + dy * dy <= radius * radius && dx * dx + dy * dy < bestDistance) {
best = i;
bestDistance = dx * dx + dy * dy;
}
}
return best;
};
ArchiveGraphController.prototype.installPointerControls = function () {
var self = this;
var downIndex = -1;
var downX = 0;
var downY = 0;
var point = function (event) {
var rect = self.canvas.getBoundingClientRect();
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
};
this.bind(this.canvas, 'pointermove', function (event) {
var p = point(event);
self.hovered = self.findNodeAt(p.x, p.y);
self.canvas.style.cursor = self.hovered >= 0 ? 'pointer' : 'default';
self.updateCaptionHover();
self.scheduleRender();
});
this.bind(this.canvas, 'pointerdown', function (event) {
var p = point(event);
downIndex = self.findNodeAt(p.x, p.y);
downX = p.x;
downY = p.y;
});
this.bind(this.canvas, 'pointerup', function (event) {
var p = point(event);
var moved = Math.abs(p.x - downX) + Math.abs(p.y - downY) > 4;
var clicked = self.findNodeAt(p.x, p.y);
if (!moved && clicked >= 0 && clicked === downIndex) self.selectNode(clicked);
else if (!moved && clicked < 0) self.clearSelection();
downIndex = -1;
self.scheduleRender();
});
this.bind(this.canvas, 'pointerleave', function () {
self.hovered = -1;
downIndex = -1;
self.updateCaptionHover();
self.scheduleRender();
});
this.bind(this.canvas, 'dblclick', function (event) {
var p = point(event);
var index = self.findNodeAt(p.x, p.y);
if (index >= 0 && self.data) window.location.href = mw.util.getUrl(self.data.nodes[index].title);
event.preventDefault();
});
};
ArchiveGraphController.prototype.installControls = function () {
var self = this;
function makeKeyboardClickable(control, handler) {
control.setAttribute('role', 'button');
control.setAttribute('tabindex', '0');
self.bind(control, 'click', handler);
self.bind(control, 'keydown', function (event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handler(event);
}
});
}
this.filterControls.forEach(function (control) {
makeKeyboardClickable(control, function () {
var filter = control.getAttribute('data-graph-filter');
self.activeFilter = self.activeFilter === filter ? null : filter;
self.filterControls.forEach(function (item) {
item.classList.toggle('is-active', item.getAttribute('data-graph-filter') === self.activeFilter);
});
self.clearSelection();
self.updateVisibleStats();
self.scheduleRender();
});
});
this.projectControls.forEach(function (control) {
makeKeyboardClickable(control, function () {
var project = control.getAttribute('data-graph-project');
var alreadySolo = self.activeProjects[project] && Object.keys(self.activeProjects).every(function (key) {
return key === project || !self.activeProjects[key];
});
if (alreadySolo) {
self.activeProjects.blackice = true;
self.activeProjects.anecdote = true;
} else {
self.activeProjects.blackice = project === 'blackice';
self.activeProjects.anecdote = project === 'anecdote';
}
self.projectControls.forEach(function (item) {
var key = item.getAttribute('data-graph-project');
var solo = self.activeProjects[key] && Object.keys(self.activeProjects).every(function (other) {
return other === key || !self.activeProjects[other];
});
item.classList.toggle('is-active', solo);
});
if (self.selected >= 0 && self.data && !self.isProjectVisible(self.data.nodes[self.selected])) self.clearSelection();
self.updateVisibleStats();
self.scheduleRender();
});
});
if (this.selectedLink) {
this.bind(this.selectedLink, 'click', function (event) {
if (self.selected < 0 || !self.data) event.preventDefault();
});
}
};
ArchiveGraphController.prototype.selectNode = function (index) {
var node;
if (!this.data || !this.data.nodes[index]) return;
this.selected = index;
node = this.data.nodes[index];
if (this.selectedLink) {
this.selectedLink.textContent = node.title;
this.selectedLink.href = mw.util.getUrl(node.title);
this.selectedLink.title = node.title;
}
if (this.connectionCount) this.connectionCount.textContent = padCount(node.degree);
if (this.captionTitle) this.captionTitle.textContent = node.title;
};
ArchiveGraphController.prototype.clearSelection = function () {
this.selected = -1;
if (this.selectedLink) {
this.selectedLink.textContent = 'SELECT A RECORD';
this.selectedLink.href = '#';
this.selectedLink.removeAttribute('title');
}
this.updateVisibleStats();
this.updateCaptionHover();
};
ArchiveGraphController.prototype.updateCaptionHover = function () {
var node;
if (!this.captionTitle) return;
if (this.hovered >= 0 && this.data) {
node = this.data.nodes[this.hovered];
this.captionTitle.textContent = node.title;
} else if (this.selected >= 0 && this.data) {
node = this.data.nodes[this.selected];
this.captionTitle.textContent = node.title;
} else {
this.captionTitle.textContent = 'ARCHIVE RELATION GRAPH';
}
};
ArchiveGraphController.prototype.updateVisibleStats = function () {
var self = this;
var visibleNodes = 0;
var visibleEdges = 0;
var languageCounts = { ko: 0, en: 0, zh: 0, ja: 0, ru: 0, es: 0 };
if (!this.data) return;
this.data.nodes.forEach(function (node) {
if (self.isProjectVisible(node) && self.matchesFilter(node)) {
visibleNodes += 1;
languageCounts[normalizeLanguage(node.lang)] += 1;
}
});
this.data.edges.forEach(function (edge) {
var a = self.data.nodes[edge[0]];
var b = self.data.nodes[edge[1]];
if (self.isProjectVisible(a) && self.isProjectVisible(b) && self.matchesFilter(a) && self.matchesFilter(b)) visibleEdges += 1;
});
if (this.documentCount) this.documentCount.textContent = padCount(visibleNodes);
if (this.connectionCount && this.selected < 0) this.connectionCount.textContent = padCount(visibleEdges);
LANGUAGE_CODES.forEach(function (code) {
if (self.languageCountNodes[code]) self.languageCountNodes[code].textContent = padCount(languageCounts[code]);
});
if (this.captionCount) this.captionCount.textContent = LANGUAGE_LABELS[this.currentLanguage] + ' ' + padCount(languageCounts[this.currentLanguage]) + ' / ' + padCount(visibleNodes);
};
ArchiveGraphController.prototype.nodeRadius = function (node) {
return 2.2 + Math.min(7.5, Math.sqrt(Math.max(0, node.degree)) * 0.72);
};
ArchiveGraphController.prototype.nodeColor = function (node) {
if (node.project === 'anecdote') return '#8f8f8f';
if (node.groups.indexOf('era') !== -1) return '#e2e2e2';
if (node.groups.indexOf('military') !== -1) return '#c8c8c8';
if (node.groups.indexOf('company') !== -1) return '#a8a8a8';
if (node.groups.indexOf('resource') !== -1) return '#858585';
if (node.groups.indexOf('setting') !== -1) return '#b8b8b8';
return '#747474';
};
ArchiveGraphController.prototype.scheduleRender = function () {
var self = this;
if (this.framePending || this.destroyed) return;
this.framePending = true;
window.requestAnimationFrame(function () {
self.framePending = false;
self.render();
});
};
ArchiveGraphController.prototype.render = function () {
var ctx = this.context;
var self = this;
var focus = this.hovered >= 0 ? this.hovered : this.selected;
var focusNeighbors = focus >= 0 && this.neighbors[focus] ? this.neighbors[focus] : null;
var dimEdges = [];
var brightEdges = [];
var labelCandidates = [];
var i;
var node;
var edge;
var a;
var b;
var sa;
var sb;
var activeA;
var activeB;
var matchA;
var matchB;
var alpha;
var screen;
var radius;
if (!ctx || !this.width || !this.height) return;
ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
ctx.clearRect(0, 0, this.width, this.height);
ctx.fillStyle = '#030303';
ctx.fillRect(0, 0, this.width, this.height);
if (!this.data) return;
for (i = 0; i < this.data.edges.length; i += 1) {
edge = this.data.edges[i];
a = this.data.nodes[edge[0]];
b = this.data.nodes[edge[1]];
activeA = this.isProjectVisible(a);
activeB = this.isProjectVisible(b);
if (!activeA || !activeB) continue;
matchA = this.matchesFilter(a);
matchB = this.matchesFilter(b);
if (focus >= 0 && (edge[0] === focus || edge[1] === focus)) brightEdges.push(edge);
else if (focus >= 0) dimEdges.push([edge, 0.035]);
else if (this.activeFilter && (!matchA || !matchB)) dimEdges.push([edge, 0.025]);
else dimEdges.push([edge, 0.13]);
}
dimEdges.forEach(function (entry) {
edge = entry[0];
a = self.data.nodes[edge[0]];
b = self.data.nodes[edge[1]];
sa = self.worldToScreen(a.x, a.y);
sb = self.worldToScreen(b.x, b.y);
ctx.beginPath();
ctx.moveTo(sa.x, sa.y);
ctx.lineTo(sb.x, sb.y);
ctx.strokeStyle = 'rgba(150,150,150,' + entry[1] + ')';
ctx.lineWidth = 1;
ctx.stroke();
});
brightEdges.forEach(function (entry) {
a = self.data.nodes[entry[0]];
b = self.data.nodes[entry[1]];
sa = self.worldToScreen(a.x, a.y);
sb = self.worldToScreen(b.x, b.y);
ctx.beginPath();
ctx.moveTo(sa.x, sa.y);
ctx.lineTo(sb.x, sb.y);
ctx.strokeStyle = 'rgba(226,226,226,0.58)';
ctx.lineWidth = 1.2;
ctx.stroke();
});
for (i = 0; i < this.data.nodes.length; i += 1) {
node = this.data.nodes[i];
if (!this.isProjectVisible(node)) continue;
screen = this.worldToScreen(node.x, node.y);
if (screen.x < -30 || screen.x > this.width + 30 || screen.y < -30 || screen.y > this.height + 30) continue;
alpha = this.matchesFilter(node) ? 1 : 0.09;
if (focus >= 0 && i !== focus && !(focusNeighbors && focusNeighbors.has(i))) alpha *= 0.18;
radius = Math.max(1.4, this.nodeRadius(node) * clamp(this.zoom, 0.55, 1.5));
if (i === focus || i === this.selected) radius += 2;
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.arc(screen.x, screen.y, radius, 0, Math.PI * 2);
ctx.fillStyle = this.nodeColor(node);
ctx.fill();
if (i === this.selected) {
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1;
ctx.stroke();
}
ctx.globalAlpha = 1;
if (i === focus || i === this.selected || (this.zoom > 1.1 && node.degree >= 8)) {
labelCandidates.push([i, screen, alpha]);
}
}
labelCandidates.sort(function (left, right) {
return self.data.nodes[right[0]].degree - self.data.nodes[left[0]].degree;
}).slice(0, 18).forEach(function (entry) {
node = self.data.nodes[entry[0]];
screen = entry[1];
ctx.globalAlpha = Math.max(0.28, entry[2]);
ctx.font = (entry[0] === focus || entry[0] === self.selected ? '700 ' : '400 ') + '11px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.lineWidth = 3;
ctx.strokeStyle = '#030303';
ctx.strokeText(node.title, screen.x + 9, screen.y);
ctx.fillStyle = '#e2e2e2';
ctx.fillText(node.title, screen.x + 9, screen.y);
ctx.globalAlpha = 1;
});
};
ArchiveGraphController.prototype.destroy = function () {
this.destroyed = true;
if (this.worker) {
try { this.worker.postMessage({ type: 'stop' }); } catch (err) {}
this.worker.terminate();
this.worker = null;
}
if (this.resizeObserver) this.resizeObserver.disconnect();
this.handlers.forEach(function (entry) {
entry[0].removeEventListener(entry[1], entry[2], entry[3]);
});
this.handlers = [];
};
function createElement(tagName, className, text) {
var node = document.createElement(tagName);
if (className) node.className = className;
if (text != null) node.textContent = text;
return node;
}
function createVerticalTitle(label) {
var title = createElement('div', 'portal-side-title');
var stack = createElement('span', 'portal-side-title-stack');
title.setAttribute('aria-label', label);
String(label || '').split('').forEach(function (character) {
stack.appendChild(createElement('span', '', character));
});
title.appendChild(stack);
return title;
}
function buildGraphMount() {
var graph = createElement('div', 'archive-graph archive-graph-status');
var canvas = createElement('canvas', 'archive-graph-canvas');
var loading = createElement('div', 'archive-graph-loading');
var caption = createElement('div', 'archive-graph-caption');
graph.setAttribute('data-archive-graph', '');
canvas.setAttribute('aria-label', '위키 문서 관계 그래프');
loading.appendChild(createElement('span', '', 'INDEXING ARCHIVE'));
loading.appendChild(createElement('small', '', 'DOCUMENT LINKS ARE BEING MAPPED'));
caption.appendChild(createElement('span', 'archive-graph-caption-title', 'ARCHIVE RELATION GRAPH'));
caption.appendChild(createElement('span', 'archive-graph-caption-count', 'KR 000 / 000'));
graph.appendChild(canvas);
graph.appendChild(loading);
graph.appendChild(caption);
return graph;
}
function buildMainConsole() {
var fragment = document.createDocumentFragment();
var titlebar = createElement('div', 'titlebar');
var body = createElement('div', 'console-body');
var grid = createElement('div', 'console-grid');
var screen = createElement('div', 'main-screen');
var feed = createElement('div', 'image-feed');
var caption = createElement('div', 'feed-caption');
var statement = createElement('div', 'statement-plate');
var heading = createElement('h2', '', '“몸부림”');
titlebar.appendChild(createElement('span', '', 'COASTLINE: BLACK ICE / OFFICIAL ARCHIVE'));
titlebar.appendChild(createElement('span', '', 'MAIN RECORD ACCESS'));
['feed-layer-1 feed-bg-001', 'feed-layer-2 feed-bg-002', 'feed-layer-3 feed-bg-003', 'feed-layer-4 feed-bg-004'].forEach(function (classes) {
feed.appendChild(createElement('div', 'feed-layer ' + classes));
});
caption.appendChild(createElement('span', '', 'IMAGE FEED / SELECTED ATMOSPHERE RECORDS'));
caption.appendChild(createElement('span', '', '4 FRAMES'));
feed.appendChild(caption);
statement.appendChild(heading);
statement.appendChild(createElement('p', '', '쉼 없이 돌아가는 전쟁 기계들과 변방의 공단에서 뿜어져 나오는 검은 연기가 하늘을 뒤덮고, 매섭게 휘몰아치는 눈보라와 뼛속까지 스며드는 쓰라림이 마지막 생활권을 위협하고 있습니다.'));
statement.appendChild(createElement('p', '', '땅이 굳고, 자원은 바닥을 드러냈습니다. 작은 것을 쟁취하기 위해 더 많은 노력이 요구되며, 갈등은 더욱 원시적인 방식으로 치닫고 있습니다.'));
screen.appendChild(feed);
screen.appendChild(statement);
grid.appendChild(screen);
body.appendChild(grid);
fragment.appendChild(titlebar);
fragment.appendChild(body);
return fragment;
}
function buildLanguageStats() {
var stats = createElement('div', 'graph-language-stats');
LANGUAGE_CODES.forEach(function (code) {
var panel = createElement('div', 'graph-language-stat');
var well = createElement('div', 'graph-language-stat-well');
var label = createElement('span', 'graph-language-stat-label', LANGUAGE_LABELS[code]);
var value = createElement('b', 'graph-language-stat-value', '000');
value.setAttribute('data-graph-language-count', code);
panel.setAttribute('data-graph-language', code);
if (code === getCurrentLanguage()) panel.classList.add('is-current-language');
well.appendChild(label);
well.appendChild(value);
panel.appendChild(well);
stats.appendChild(panel);
});
return stats;
}
function buildStatusFrame() {
var frame = createElement('div', 'portal-bottom-frame portal-status-frame');
var body = createElement('div', 'portal-dock-body portal-status-body portal-status-graph-body');
body.appendChild(buildGraphMount());
body.appendChild(buildLanguageStats());
frame.appendChild(createVerticalTitle('STATUS'));
frame.appendChild(body);
return frame;
}
function buildInformationFrame() {
var frame = createElement('div', 'portal-bottom-frame portal-information-frame');
var body = createElement('div', 'portal-dock-body portal-button-list portal-information-body');
[
['era', '시대'],
['setting', '설정'],
['company', '컴퍼니 앤 커뮤니티'],
['military', '군, 정치집단'],
['resource', '리소스']
].forEach(function (item) {
var button = createElement('div', 'portal-dock-button graph-control', item[1]);
button.setAttribute('data-graph-filter', item[0]);
body.appendChild(button);
});
frame.appendChild(createVerticalTitle('INFORMATION'));
frame.appendChild(body);
return frame;
}
function buildProjectFrame() {
var frame = createElement('div', 'portal-bottom-frame portal-project-frame');
var body = createElement('div', 'portal-dock-body portal-button-list portal-project-body');
[
['blackice', 'BLACK ICE'],
['anecdote', 'ANECDOTE']
].forEach(function (item) {
var button = createElement('div', 'portal-dock-button graph-control', item[1]);
button.setAttribute('data-graph-project', item[0]);
body.appendChild(button);
});
frame.appendChild(createVerticalTitle('PROJECTS'));
frame.appendChild(body);
return frame;
}
function destroyGraphInside(node) {
var mount = node && node.querySelector ? node.querySelector(GRAPH_SELECTOR) : null;
if (mount && mount._archiveGraphController) {
mount._archiveGraphController.destroy();
mount._archiveGraphController = null;
}
}
function rebuildMainConsole(consoleNode) {
destroyGraphInside(consoleNode);
while (consoleNode.firstChild) consoleNode.removeChild(consoleNode.firstChild);
consoleNode.appendChild(buildMainConsole());
consoleNode.removeAttribute('data-archive-graph-console');
}
function rebuildGraphDock(bottomRow) {
destroyGraphInside(bottomRow);
while (bottomRow.firstChild) bottomRow.removeChild(bottomRow.firstChild);
bottomRow.appendChild(buildStatusFrame());
bottomRow.appendChild(buildInformationFrame());
bottomRow.appendChild(buildProjectFrame());
}
function prepareMainPageGraph(root) {
var scope = root && root.querySelectorAll ? root : document;
var portals = [];
if (scope.matches && scope.matches('.main-portal')) portals.push(scope);
portals = portals.concat(toArray(scope.querySelectorAll('.main-portal')));
portals.forEach(function (portal) {
var consoleNode = portal.querySelector('.console');
var bottomRow = portal.querySelector('.portal-bottom-row');
var statusGraph;
if (!consoleNode) return;
if (!consoleNode.querySelector('.image-feed') || consoleNode.hasAttribute('data-archive-graph-console')) rebuildMainConsole(consoleNode);
if (!bottomRow) {
bottomRow = document.createElement('div');
bottomRow.className = 'portal-bottom-row';
if (consoleNode.nextSibling) portal.insertBefore(bottomRow, consoleNode.nextSibling);
else portal.appendChild(bottomRow);
}
statusGraph = bottomRow.querySelector('.portal-status-frame ' + GRAPH_SELECTOR + ' canvas.archive-graph-canvas');
if (!statusGraph || bottomRow.querySelectorAll('[data-graph-language-count]').length !== LANGUAGE_CODES.length) rebuildGraphDock(bottomRow);
bottomRow.setAttribute('data-archive-graph-dock', '1');
});
}
function initArchiveGraphs(root) {
var scope = root && root.querySelectorAll ? root : document;
var mounts = [];
if (scope.matches && scope.matches(GRAPH_SELECTOR)) mounts.push(scope);
mounts = mounts.concat(toArray(scope.querySelectorAll(GRAPH_SELECTOR)));
mounts.forEach(function (mount) {
if (mount._archiveGraphController) return;
mount._archiveGraphController = new ArchiveGraphController(mount);
mount._archiveGraphController.start();
});
}
function boot() {
prepareMainPageGraph(document);
initArchiveGraphs(document);
if (mw.hook) {
mw.hook('wikipage.content').add(function (content) {
var root = content && content[0] ? content[0] : document;
prepareMainPageGraph(root);
initArchiveGraphs(root);
});
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();
}
})(window, document, window.mediaWiki || window.mw);