참고: 설정을 저장한 후에 바뀐 점을 확인하기 위해서는 브라우저의 캐시를 새로 고쳐야 합니다.
- 파이어폭스 / 사파리: Shift 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5 또는 Ctrl-R을 입력 (Mac에서는 ⌘-R)
- 구글 크롬: Ctrl-Shift-R키를 입력 (Mac에서는 ⌘-Shift-R)
- 인터넷 익스플로러 / 엣지: Ctrl 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5를 입력.
- 오페라: Ctrl-F5를 입력.
/* =========================================
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_PREFIX = 'archiveGraph:data:v4-status-count-controls:';
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 cacheKey(language) {
return CACHE_KEY_PREFIX + normalizeLanguage(language);
}
function readCache(language) {
var raw;
var parsed;
try {
raw = window.localStorage.getItem(cacheKey(language));
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(language, data) {
try {
window.localStorage.setItem(cacheKey(language), JSON.stringify({ savedAt: Date.now(), data: data }));
} catch (err) {}
}
function makeApiError(code, payload) {
var message = '';
if (payload && payload.error && payload.error.info) message = payload.error.info;
else if (payload && payload.exception) message = payload.exception;
else if (typeof payload === 'string') message = payload;
else if (code) message = String(code);
else message = 'MEDIAWIKI API ERROR';
var error = new Error(message);
error.code = code || 'api-error';
error.payload = payload || null;
return error;
}
function requestApi(api, params) {
return new Promise(function (resolve, reject) {
api.get(params).then(resolve, function (code, payload) {
reject(makeApiError(code, payload));
});
});
}
function fetchAllPages(api, namespace, onProgress) {
var pages = [];
var continuation = {};
function step() {
var params = {
action: 'query',
format: 'json',
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 revisionContent(page) {
var revision = page && page.revisions && page.revisions[0];
var slot = revision && revision.slots && revision.slots.main;
if (slot) return slot.content != null ? slot.content : (slot['*'] || '');
if (revision) return revision.content != null ? revision.content : (revision['*'] || '');
return '';
}
function fetchPageLanguages(api, pages, onProgress) {
var languageMap = Object.create(null);
var cursor = 0;
var batchSize = 20;
function fetchBatch(batch) {
return requestApi(api, {
action: 'query',
format: 'json',
formatversion: 2,
prop: 'revisions',
pageids: batch.map(function (page) { return page.pageid; }),
rvprop: 'content',
rvslots: 'main',
rvlimit: 1
}).then(function (result) {
var responsePages = result && result.query && result.query.pages ? result.query.pages : [];
responsePages.forEach(function (page) {
languageMap[String(page.pageid)] = detectPageLanguage(revisionContent(page));
});
}).catch(function () {
if (batch.length > 1) {
var middle = Math.ceil(batch.length / 2);
return fetchBatch(batch.slice(0, middle)).then(function () {
return fetchBatch(batch.slice(middle));
});
}
/* Langlink가 없는 기존 문서는 한국어 문서로 취급한다. */
languageMap[String(batch[0].pageid)] = 'ko';
return null;
});
}
function next() {
var batch;
if (cursor >= pages.length) return Promise.resolve(languageMap);
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 fetchPageRelations(api, pages, onProgress) {
var relationMap = Object.create(null);
var cursor = 0;
var batchSize = 20;
pages.forEach(function (page) {
relationMap[String(page.pageid)] = { links: [], categories: [] };
});
function fetchBatch(batch) {
var continuation = {};
function step() {
var params = {
action: 'query',
format: 'json',
formatversion: 2,
prop: 'links|categories',
pageids: batch.map(function (page) { return page.pageid; }),
plnamespace: [MAIN_NAMESPACE, ANECDOTE_NAMESPACE],
pllimit: 'max',
cllimit: 'max'
};
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: [] });
(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().catch(function () {
if (batch.length > 1) {
var middle = Math.ceil(batch.length / 2);
return fetchBatch(batch.slice(0, middle)).then(function () {
return fetchBatch(batch.slice(middle));
});
}
return null;
});
}
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, currentLanguage) {
var nodes = [];
var titleToIndex = Object.create(null);
var edgeKeys = Object.create(null);
var edges = [];
pages.forEach(function (page, index) {
var title = normalizeTitle(page.title);
var relation = relationMap[String(page.pageid)] || { links: [], categories: [] };
var project = Number(page.ns) === ANECDOTE_NAMESPACE ? 'anecdote' : 'blackice';
nodes.push({
id: Number(page.pageid),
title: title,
namespace: Number(page.ns),
project: project,
lang: currentLanguage,
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 = 60 + Math.sqrt(index + 1) * 14 + ((hash >>> 8) % 70);
var projectOffset = node.project === 'anecdote' ? 150 : -35;
node.x = Math.cos(angle) * radius + projectOffset;
node.y = Math.sin(angle) * radius * 0.78;
});
return {
nodes: nodes,
edges: edges,
language: currentLanguage,
generatedAt: Date.now()
};
}
function loadGraphData(currentLanguage, setLoadingText) {
var language = normalizeLanguage(currentLanguage);
var cached = readCache(language);
var api;
var allPages;
var languageMap;
var languagePages;
if (cached && cached.nodes && cached.edges && cached.language === language) {
setLoadingText('RESTORING GRAPH CACHE', cached.nodes.length + ' ' + LANGUAGE_LABELS[language] + ' 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 DOCUMENTS RETURNED');
setLoadingText('CHECKING ' + LANGUAGE_LABELS[language] + ' DOCUMENTS', '0 / ' + allPages.length);
return fetchPageLanguages(api, allPages, function (done, total) {
setLoadingText('CHECKING ' + LANGUAGE_LABELS[language] + ' DOCUMENTS', done + ' / ' + total);
});
}).then(function (map) {
languageMap = map;
languagePages = allPages.filter(function (page) {
return normalizeLanguage(languageMap[String(page.pageid)] || 'ko') === language;
});
if (!languagePages.length) {
return { nodes: [], edges: [], language: language, generatedAt: Date.now() };
}
setLoadingText('MAPPING ' + LANGUAGE_LABELS[language] + ' CONNECTIONS', '0 / ' + languagePages.length);
return fetchPageRelations(api, languagePages, function (done, total) {
setLoadingText('MAPPING ' + LANGUAGE_LABELS[language] + ' CONNECTIONS', done + ' / ' + total);
}).then(function (relations) {
return buildGraphData(languagePages, relations, language);
});
}).then(function (data) {
writeCache(language, 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('.portal-status-document-value') : null;
this.currentLanguage = getCurrentLanguage();
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(this.currentLanguage, 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) {
if (control.tagName === 'BUTTON') {
control.type = 'button';
self.bind(control, 'click', handler);
return;
}
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) {
control.setAttribute('aria-pressed', 'false');
makeKeyboardClickable(control, function () {
var filter = control.getAttribute('data-graph-filter');
self.activeFilter = self.activeFilter === filter ? null : filter;
self.filterControls.forEach(function (item) {
var active = item.getAttribute('data-graph-filter') === self.activeFilter;
item.classList.toggle('is-active', active);
item.setAttribute('aria-pressed', active ? 'true' : 'false');
});
self.clearSelection();
self.updateVisibleStats();
self.scheduleRender();
});
});
this.projectControls.forEach(function (control) {
control.setAttribute('aria-pressed', 'false');
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);
item.setAttribute('aria-pressed', solo ? 'true' : 'false');
});
if (self.selected >= 0 && self.data && !self.isProjectVisible(self.data.nodes[self.selected])) self.clearSelection();
self.updateVisibleStats();
self.scheduleRender();
});
});
};
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.captionTitle) this.captionTitle.textContent = node.title;
if (this.captionCount) this.captionCount.textContent = padCount(node.degree) + ' LINKS';
};
ArchiveGraphController.prototype.clearSelection = function () {
this.selected = -1;
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;
if (!this.data) return;
this.data.nodes.forEach(function (node) {
if (self.isProjectVisible(node) && self.matchesFilter(node)) visibleNodes += 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.captionCount) this.captionCount.textContent = padCount(visibleEdges) + ' LINKS';
};
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', '000 LINKS'));
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 buildStatusDocumentCount() {
var panel = createElement('div', 'portal-status-document-count');
panel.appendChild(createElement('span', 'portal-status-document-label', 'DOCUMENTS'));
panel.appendChild(createElement('span', 'portal-status-document-value', '000'));
return panel;
}
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(buildStatusDocumentCount());
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('button', 'portal-dock-button graph-control', item[1]);
button.type = 'button';
button.setAttribute('data-graph-filter', item[0]);
button.setAttribute('aria-pressed', 'false');
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('button', 'portal-dock-button graph-control', item[1]);
button.type = 'button';
button.setAttribute('data-graph-project', item[0]);
button.setAttribute('aria-pressed', 'false');
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');
var statusCount = bottomRow.querySelector('.portal-status-document-count .portal-status-document-value');
var controls = toArray(bottomRow.querySelectorAll('.graph-control'));
var controlsAreButtons = controls.length === 7 && controls.every(function (control) {
return control.tagName === 'BUTTON';
});
if (!statusGraph || !statusCount || !controlsAreButtons || bottomRow.querySelectorAll('.portal-bottom-frame').length !== 3) {
rebuildGraphDock(bottomRow);
}
bottomRow.setAttribute('data-archive-graph-dock', '2');
});
}
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);