(Install package: clbiwiki-mainpage-archive-graph-preview-20260711 / js/ArchiveGraph.js) |
(Install package: clbiwiki-mainpage-archive-graph-runtime-mount-fix-20260711 / js/ArchiveGraph.js) |
||
| 1,010번째 줄: | 1,010번째 줄: | ||
this.handlers = []; | this.handlers = []; | ||
}; | }; | ||
function graphConsoleMarkup() { | |||
return [ | |||
'<div class="titlebar"><span>ARCHIVE RELATION GRAPH</span><span>LIVE DOCUMENT INDEX</span></div>', | |||
'<div class="console-body">', | |||
'<div class="console-grid">', | |||
'<div class="main-screen graph-screen">', | |||
'<div class="archive-graph" data-archive-graph>', | |||
'<canvas class="archive-graph-canvas" aria-label="위키 문서 관계 그래프"></canvas>', | |||
'<div class="archive-graph-loading"><span>INDEXING ARCHIVE</span><small>DOCUMENT LINKS ARE BEING MAPPED</small></div>', | |||
'<div class="archive-graph-caption">', | |||
'<span class="archive-graph-caption-title">ARCHIVE RELATION GRAPH</span>', | |||
'<span class="archive-graph-caption-count">000 NODES / 000 LINKS</span>', | |||
'</div>', | |||
'</div>', | |||
'</div>', | |||
'</div>', | |||
'</div>' | |||
].join(''); | |||
} | |||
function graphDockMarkup() { | |||
return [ | |||
'<div class="portal-bottom-frame portal-status-frame">', | |||
'<div class="portal-side-title" aria-label="STATUS"><span class="portal-side-title-stack"><span>S</span><span>T</span><span>A</span><span>T</span><span>U</span><span>S</span></span></div>', | |||
'<div class="portal-dock-body portal-status-body">', | |||
'<div class="instrument-grid">', | |||
'<div class="gauge"><div class="gauge-well"><b data-graph-document-count>000</b><span>DOCUMENTS</span></div></div>', | |||
'<div class="gauge"><div class="gauge-well"><b data-graph-connection-count>000</b><span>CONNECTIONS</span></div></div>', | |||
'</div>', | |||
'<div class="instrument-lines graph-selection-shell">', | |||
'<div class="instrument-line graph-selection-line">', | |||
'<span class="instrument-label">SELECTED</span>', | |||
'<span class="instrument-value-well"><a class="instrument-value graph-selected-link" data-graph-selected-link href="#">SELECT A RECORD</a></span>', | |||
'</div>', | |||
'</div>', | |||
'</div>', | |||
'</div>', | |||
'<div class="portal-bottom-frame portal-information-frame">', | |||
'<div class="portal-side-title" aria-label="INFORMATION"><span class="portal-side-title-stack"><span>I</span><span>N</span><span>F</span><span>O</span><span>R</span><span>M</span><span>A</span><span>T</span><span>I</span><span>O</span><span>N</span></span></div>', | |||
'<div class="portal-dock-body portal-button-list portal-information-body">', | |||
'<div class="portal-dock-button graph-control" data-graph-filter="era">시대</div>', | |||
'<div class="portal-dock-button graph-control" data-graph-filter="setting">설정</div>', | |||
'<div class="portal-dock-button graph-control" data-graph-filter="company">컴퍼니 앤 커뮤니티</div>', | |||
'<div class="portal-dock-button graph-control" data-graph-filter="military">군, 정치집단</div>', | |||
'<div class="portal-dock-button graph-control" data-graph-filter="resource">리소스</div>', | |||
'</div>', | |||
'</div>', | |||
'<div class="portal-bottom-frame portal-project-frame">', | |||
'<div class="portal-side-title" aria-label="PROJECTS"><span class="portal-side-title-stack"><span>P</span><span>R</span><span>O</span><span>J</span><span>E</span><span>C</span><span>T</span><span>S</span></span></div>', | |||
'<div class="portal-dock-body portal-button-list portal-project-body">', | |||
'<div class="portal-dock-button graph-control is-active" data-graph-project="blackice">BLACK ICE</div>', | |||
'<div class="portal-dock-button graph-control is-active" data-graph-project="anecdote">ANECDOTE</div>', | |||
'</div>', | |||
'</div>' | |||
].join(''); | |||
} | |||
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'); | |||
if (!consoleNode) return; | |||
if (!consoleNode.querySelector(GRAPH_SELECTOR)) { | |||
consoleNode.innerHTML = graphConsoleMarkup(); | |||
} | |||
consoleNode.setAttribute('data-archive-graph-console', '1'); | |||
if (!bottomRow) { | |||
bottomRow = document.createElement('div'); | |||
bottomRow.className = 'portal-bottom-row'; | |||
if (consoleNode.nextSibling) portal.insertBefore(bottomRow, consoleNode.nextSibling); | |||
else portal.appendChild(bottomRow); | |||
} | |||
if (!bottomRow.querySelector('[data-graph-document-count]')) { | |||
bottomRow.innerHTML = graphDockMarkup(); | |||
} | |||
bottomRow.setAttribute('data-archive-graph-dock', '1'); | |||
}); | |||
} | |||
function initArchiveGraphs(root) { | function initArchiveGraphs(root) { | ||
| 1,024번째 줄: | 1,115번째 줄: | ||
function boot() { | function boot() { | ||
prepareMainPageGraph(document); | |||
initArchiveGraphs(document); | initArchiveGraphs(document); | ||
if (mw.hook) { | if (mw.hook) { | ||
mw.hook('wikipage.content').add(function (content) { | mw.hook('wikipage.content').add(function (content) { | ||
var root = content && content[0] ? content[0] : document; | |||
prepareMainPageGraph(root); | |||
initArchiveGraphs(root); | |||
}); | }); | ||
} | } | ||
2026년 7월 11일 (토) 10:30 판
/* =========================================
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:v1';
var CACHE_MAX_AGE = 6 * 60 * 60 * 1000;
var MAIN_NAMESPACE = 0;
var ANECDOTE_NAMESPACE = 3000;
var GRAPH_SELECTOR = '[data-archive-graph]';
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 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: [] };
});
function fetchBatch(batch) {
var continuation = {};
function step() {
var params = {
action: 'query',
formatversion: 2,
prop: 'links|categories',
pageids: batch.map(function (page) { return page.pageid; }).join('|'),
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();
}
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: [] };
var project = Number(page.ns) === ANECDOTE_NAMESPACE ? 'anecdote' : 'blackice';
nodes.push({
id: Number(page.pageid),
title: title,
namespace: Number(page.ns),
project: project,
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.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 point = function (event) {
var rect = self.canvas.getBoundingClientRect();
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
};
this.bind(this.canvas, 'pointerdown', function (event) {
var p = point(event);
var index = self.findNodeAt(p.x, p.y);
var world;
self.pointerDownX = p.x;
self.pointerDownY = p.y;
self.lastPointerX = p.x;
self.lastPointerY = p.y;
self.pointerMoved = false;
self.dragNode = index;
self.draggingCanvas = index < 0;
self.canvas.setPointerCapture(event.pointerId);
if (index >= 0 && self.worker) {
world = self.screenToWorld(p.x, p.y);
self.worker.postMessage({ type: 'pin', index: index, x: world.x, y: world.y });
}
event.preventDefault();
});
this.bind(this.canvas, 'pointermove', function (event) {
var p = point(event);
var dx = p.x - self.lastPointerX;
var dy = p.y - self.lastPointerY;
var world;
if (Math.abs(p.x - self.pointerDownX) + Math.abs(p.y - self.pointerDownY) > 4) self.pointerMoved = true;
if (self.dragNode >= 0) {
world = self.screenToWorld(p.x, p.y);
if (self.data && self.data.nodes[self.dragNode]) {
self.data.nodes[self.dragNode].x = world.x;
self.data.nodes[self.dragNode].y = world.y;
}
if (self.worker) self.worker.postMessage({ type: 'pin', index: self.dragNode, x: world.x, y: world.y });
self.scheduleRender();
} else if (self.draggingCanvas) {
self.panX += dx;
self.panY += dy;
self.scheduleRender();
} else {
self.hovered = self.findNodeAt(p.x, p.y);
self.canvas.style.cursor = self.hovered >= 0 ? 'pointer' : 'grab';
self.updateCaptionHover();
self.scheduleRender();
}
self.lastPointerX = p.x;
self.lastPointerY = p.y;
});
this.bind(this.canvas, 'pointerup', function (event) {
var p = point(event);
var clicked = self.dragNode >= 0 ? self.dragNode : self.findNodeAt(p.x, p.y);
if (self.dragNode >= 0 && self.worker) self.worker.postMessage({ type: 'unpin', index: self.dragNode });
if (!self.pointerMoved) {
if (clicked >= 0) self.selectNode(clicked);
else self.clearSelection();
}
self.dragNode = -1;
self.draggingCanvas = false;
self.canvas.releasePointerCapture(event.pointerId);
self.scheduleRender();
});
this.bind(this.canvas, 'pointerleave', function () {
if (self.dragNode < 0 && !self.draggingCanvas) {
self.hovered = -1;
self.updateCaptionHover();
self.scheduleRender();
}
});
this.bind(this.canvas, 'wheel', function (event) {
var p = point(event);
var before = self.screenToWorld(p.x, p.y);
var factor = Math.exp(-event.deltaY * 0.0012);
self.zoom = clamp(self.zoom * factor, 0.18, 4.5);
self.panX = p.x - self.width / 2 - before.x * self.zoom;
self.panY = p.y - self.height / 2 - before.y * self.zoom;
self.scheduleRender();
event.preventDefault();
}, { passive: false });
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);
} else {
self.fitView();
self.scheduleRender();
}
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 activeCount = Object.keys(self.activeProjects).filter(function (key) { return self.activeProjects[key]; }).length;
if (self.activeProjects[project] && activeCount === 1) return;
self.activeProjects[project] = !self.activeProjects[project];
control.classList.toggle('is-active', self.activeProjects[project]);
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;
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.connectionCount && this.selected < 0) this.connectionCount.textContent = padCount(visibleEdges);
if (this.captionCount) this.captionCount.textContent = padCount(visibleNodes) + ' NODES / ' + 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 graphConsoleMarkup() {
return [
'<div class="titlebar"><span>ARCHIVE RELATION GRAPH</span><span>LIVE DOCUMENT INDEX</span></div>',
'<div class="console-body">',
'<div class="console-grid">',
'<div class="main-screen graph-screen">',
'<div class="archive-graph" data-archive-graph>',
'<canvas class="archive-graph-canvas" aria-label="위키 문서 관계 그래프"></canvas>',
'<div class="archive-graph-loading"><span>INDEXING ARCHIVE</span><small>DOCUMENT LINKS ARE BEING MAPPED</small></div>',
'<div class="archive-graph-caption">',
'<span class="archive-graph-caption-title">ARCHIVE RELATION GRAPH</span>',
'<span class="archive-graph-caption-count">000 NODES / 000 LINKS</span>',
'</div>',
'</div>',
'</div>',
'</div>',
'</div>'
].join('');
}
function graphDockMarkup() {
return [
'<div class="portal-bottom-frame portal-status-frame">',
'<div class="portal-side-title" aria-label="STATUS"><span class="portal-side-title-stack"><span>S</span><span>T</span><span>A</span><span>T</span><span>U</span><span>S</span></span></div>',
'<div class="portal-dock-body portal-status-body">',
'<div class="instrument-grid">',
'<div class="gauge"><div class="gauge-well"><b data-graph-document-count>000</b><span>DOCUMENTS</span></div></div>',
'<div class="gauge"><div class="gauge-well"><b data-graph-connection-count>000</b><span>CONNECTIONS</span></div></div>',
'</div>',
'<div class="instrument-lines graph-selection-shell">',
'<div class="instrument-line graph-selection-line">',
'<span class="instrument-label">SELECTED</span>',
'<span class="instrument-value-well"><a class="instrument-value graph-selected-link" data-graph-selected-link href="#">SELECT A RECORD</a></span>',
'</div>',
'</div>',
'</div>',
'</div>',
'<div class="portal-bottom-frame portal-information-frame">',
'<div class="portal-side-title" aria-label="INFORMATION"><span class="portal-side-title-stack"><span>I</span><span>N</span><span>F</span><span>O</span><span>R</span><span>M</span><span>A</span><span>T</span><span>I</span><span>O</span><span>N</span></span></div>',
'<div class="portal-dock-body portal-button-list portal-information-body">',
'<div class="portal-dock-button graph-control" data-graph-filter="era">시대</div>',
'<div class="portal-dock-button graph-control" data-graph-filter="setting">설정</div>',
'<div class="portal-dock-button graph-control" data-graph-filter="company">컴퍼니 앤 커뮤니티</div>',
'<div class="portal-dock-button graph-control" data-graph-filter="military">군, 정치집단</div>',
'<div class="portal-dock-button graph-control" data-graph-filter="resource">리소스</div>',
'</div>',
'</div>',
'<div class="portal-bottom-frame portal-project-frame">',
'<div class="portal-side-title" aria-label="PROJECTS"><span class="portal-side-title-stack"><span>P</span><span>R</span><span>O</span><span>J</span><span>E</span><span>C</span><span>T</span><span>S</span></span></div>',
'<div class="portal-dock-body portal-button-list portal-project-body">',
'<div class="portal-dock-button graph-control is-active" data-graph-project="blackice">BLACK ICE</div>',
'<div class="portal-dock-button graph-control is-active" data-graph-project="anecdote">ANECDOTE</div>',
'</div>',
'</div>'
].join('');
}
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');
if (!consoleNode) return;
if (!consoleNode.querySelector(GRAPH_SELECTOR)) {
consoleNode.innerHTML = graphConsoleMarkup();
}
consoleNode.setAttribute('data-archive-graph-console', '1');
if (!bottomRow) {
bottomRow = document.createElement('div');
bottomRow.className = 'portal-bottom-row';
if (consoleNode.nextSibling) portal.insertBefore(bottomRow, consoleNode.nextSibling);
else portal.appendChild(bottomRow);
}
if (!bottomRow.querySelector('[data-graph-document-count]')) {
bottomRow.innerHTML = graphDockMarkup();
}
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);