미디어위키:ArchiveGraph.js

Nxdsxn (토론 | 기여)님의 2026년 7월 11일 (토) 10:34 판 (Install package: clbiwiki-mainpage-archive-graph-dom-mount-fix-20260711 / js/ArchiveGraph.js)

참고: 설정을 저장한 후에 바뀐 점을 확인하기 위해서는 브라우저의 캐시를 새로 고쳐야 합니다.

  • 파이어폭스 / 사파리: 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 = '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 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 buildGraphConsole() {
        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 graph-screen');
        var graph = createElement('div', 'archive-graph');
        var canvas = createElement('canvas', 'archive-graph-canvas');
        var loading = createElement('div', 'archive-graph-loading');
        var caption = createElement('div', 'archive-graph-caption');

        titlebar.appendChild(createElement('span', '', 'ARCHIVE RELATION GRAPH'));
        titlebar.appendChild(createElement('span', '', 'LIVE DOCUMENT INDEX'));

        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 NODES / 000 LINKS'));

        graph.appendChild(canvas);
        graph.appendChild(loading);
        graph.appendChild(caption);
        screen.appendChild(graph);
        grid.appendChild(screen);
        body.appendChild(grid);
        fragment.appendChild(titlebar);
        fragment.appendChild(body);
        return fragment;
    }

    function buildGauge(label, attributeName) {
        var gauge = createElement('div', 'gauge');
        var well = createElement('div', 'gauge-well');
        var value = createElement('b', '', '000');
        value.setAttribute(attributeName, '');
        well.appendChild(value);
        well.appendChild(createElement('span', '', label));
        gauge.appendChild(well);
        return gauge;
    }

    function buildStatusFrame() {
        var frame = createElement('div', 'portal-bottom-frame portal-status-frame');
        var body = createElement('div', 'portal-dock-body portal-status-body');
        var grid = createElement('div', 'instrument-grid');
        var shell = createElement('div', 'instrument-lines graph-selection-shell');
        var line = createElement('div', 'instrument-line graph-selection-line');
        var well = createElement('span', 'instrument-value-well');
        var link = createElement('a', 'instrument-value graph-selected-link', 'SELECT A RECORD');

        link.setAttribute('data-graph-selected-link', '');
        link.href = '#';
        grid.appendChild(buildGauge('DOCUMENTS', 'data-graph-document-count'));
        grid.appendChild(buildGauge('CONNECTIONS', 'data-graph-connection-count'));
        line.appendChild(createElement('span', 'instrument-label', 'SELECTED'));
        well.appendChild(link);
        line.appendChild(well);
        shell.appendChild(line);
        body.appendChild(grid);
        body.appendChild(shell);
        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 is-active', item[1]);
            button.setAttribute('data-graph-project', item[0]);
            body.appendChild(button);
        });
        frame.appendChild(createVerticalTitle('PROJECTS'));
        frame.appendChild(body);
        return frame;
    }

    function rebuildGraphConsole(consoleNode) {
        while (consoleNode.firstChild) consoleNode.removeChild(consoleNode.firstChild);
        consoleNode.appendChild(buildGraphConsole());
    }

    function rebuildGraphDock(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 graphMount;
            var actualCanvas;
            var actualSelectedLink;

            if (!consoleNode) return;

            graphMount = consoleNode.querySelector(GRAPH_SELECTOR);
            actualCanvas = graphMount && graphMount.querySelector('canvas.archive-graph-canvas');
            if (!graphMount || !actualCanvas) rebuildGraphConsole(consoleNode);
            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);
            }

            actualSelectedLink = bottomRow.querySelector('a[data-graph-selected-link]');
            if (!bottomRow.querySelector('[data-graph-document-count]') || !actualSelectedLink) {
                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);