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

  • 파이어폭스 / 사파리: 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);