본문으로 이동

미디어위키:Common.js

Conaonda Wiki
Admina7ec671ac513 (토론 | 기여)님의 2026년 8월 20일 (목) 13:19 판 (migrate: import legacy Wiki content to Vultr)
(차이) ← 이전 판 | 최신판 (차이) | 다음 판 → (차이)

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

  • 파이어폭스 / 사파리: Shift 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5 또는 Ctrl-R을 입력 (Mac에서는 ⌘-R)
  • 구글 크롬: Ctrl-Shift-R키를 입력 (Mac에서는 ⌘-Shift-R)
  • 엣지: Ctrl 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5를 입력.
/* Conaonda Wiki - Main Page Dynamic Content */
/*
 * 게시 위치: MediaWiki:Common.js (스킨 비의존).
 * 링크는 mw.util.getUrl()로 생성한다. $wgArticlePath의 '$1' 치환을 JS에서
 * 직접 하면 shell/ResourceLoader를 거치며 '$1'이 소실되는 문제가 있었으므로
 * 문자열 치환을 쓰지 않는다. MediaWiki:Vector-2022.js의 구버전은 깨진 링크를
 * 만들고 이 스크립트와 같은 DOM을 경쟁하므로 게시하지 않는다.
 */
mw.loader.using( 'mediawiki.util' ).then( function () {
  function init() {
    var title = mw.config.get( 'wgPageName' );
    if ( title !== '대문' ) return;

    var API = mw.config.get( 'wgScriptPath' ) + '/api.php';

    function timeAgo( d ) {
      var s = Math.floor( ( Date.now() - d.getTime() ) / 1000 );
      if ( s < 60 )   return '방금 전';
      if ( s < 3600 )  return Math.floor( s / 60 ) + '분 전';
      if ( s < 86400 ) return Math.floor( s / 3600 ) + '시간 전';
      if ( s < 604800 ) return Math.floor( s / 86400 ) + '일 전';
      return d.toLocaleDateString( 'ko-KR' );
    }

    function esc( s ) { var e = document.createElement( 'span' ); e.textContent = s; return e.innerHTML; }

    /* Feed 렌더러: 최근 활동/새 문서 공용. 대문 자기 자신은 제외한다. */
    function renderFeed( rootId, entries, limit ) {
      var root = document.getElementById( rootId );
      if ( !root ) return;
      var cs = entries.filter( function ( c ) { return c.title !== '대문'; } ).slice( 0, limit );
      if ( !cs.length ) { root.innerHTML = '<div class="cx-empty">아직 기록이 없습니다.</div>'; return; }
      var h = '<ul class="cx-feed">';
      cs.forEach( function ( c ) {
        var cls = c.type === 'new' ? 'cx-dot-new' : 'cx-dot-edit';
        var t = new Date( c.timestamp );
        var ago = timeAgo( t );
        var href = mw.util.getUrl( c.title );
        var diff = mw.util.getUrl( 'Special:Diff/' + c.revid );
        h += '<li><span class="cx-dot ' + cls + '"></span>'
          + '<span class="cx-feed-main">'
          + '<span class="cx-feed-title"><a href="' + href + '">' + esc( c.title ) + '</a></span>'
          + ( c.comment ? '<span class="cx-feed-comment">' + esc( c.comment ) + '</span>' : '' )
          + '</span>'
          + '<span class="cx-feed-meta">' + ago + ' — ' + esc( c.user )
          + ' · <a href="' + diff + '">차이</a></span></li>';
      } );
      root.innerHTML = h + '</ul>';
    }

    var rcRoot = document.getElementById( 'cx-recent-root' );
    if ( rcRoot ) {
      var p = ['action=query','list=recentchanges','rclimit=12','rcprop=title|timestamp|user|type|comment|ids','rcshow=!bot','rctype=edit|new','rcnamespace=0','format=json','formatversion=2'].join( '&' );
      fetch( API + '?' + p )
        .then( function ( r ) { return r.json(); } )
        .then( function ( d ) { renderFeed( 'cx-recent-root', ( d.query || {} ).recentchanges || [], 8 ); } )
        .catch( function () { rcRoot.innerHTML = '<div class="cx-empty">활동 정보를 불러올 수 없습니다.</div>'; } );
    }

    var newRoot = document.getElementById( 'cx-new-root' );
    if ( newRoot ) {
      var np = ['action=query','list=recentchanges','rclimit=8','rcprop=title|timestamp|user|type|comment|ids','rcshow=!bot','rctype=new','rcnamespace=0','format=json','formatversion=2'].join( '&' );
      fetch( API + '?' + np )
        .then( function ( r ) { return r.json(); } )
        .then( function ( d ) { renderFeed( 'cx-new-root', ( d.query || {} ).recentchanges || [], 5 ); } )
        .catch( function () { newRoot.innerHTML = '<div class="cx-empty">새 문서 정보를 불러올 수 없습니다.</div>'; } );
    }

    var stRoot = document.getElementById( 'cx-stats-root' );
    if ( stRoot ) {
      Promise.all( [
        fetch( API + '?action=query&meta=siteinfo&siprop=statistics&format=json&formatversion=2' ).then( function ( r ) { return r.json(); } ),
        fetch( API + '?action=query&list=allcategories&aclimit=500&format=json&formatversion=2' ).then( function ( r ) { return r.json(); } )
      ] )
        .then( function ( results ) {
          var s = ( results[0].query || {} ).statistics || {};
          var cats = ( ( results[1].query || {} ).allcategories || [] ).length;
          stRoot.innerHTML = '<div class="cx-stats">'
            + '<div class="cx-stat"><div class="cx-stat-num">' + ( s.articles || 0 ) + '</div><div class="cx-stat-label">문서</div></div>'
            + '<div class="cx-stat"><div class="cx-stat-num">' + ( s.edits || 0 ) + '</div><div class="cx-stat-label">편집</div></div>'
            + '<div class="cx-stat"><div class="cx-stat-num">' + cats + '</div><div class="cx-stat-label">카테고리</div></div>'
            + '<div class="cx-stat"><div class="cx-stat-num">' + ( s.files || 0 ) + '</div><div class="cx-stat-label">파일</div></div>'
            + '</div>';
        } )
        .catch( function () { stRoot.innerHTML = ''; } );
    }
  }

  if ( document.readyState === 'loading' ) {
    document.addEventListener( 'DOMContentLoaded', init );
  } else {
    init();
  }
} );

/* 자동 문서 생성 (AutoCreate.js) 로드: 텍스트 선택 → 초안 생성/승인 */
mw.loader.load( mw.config.get( 'wgScriptPath' ) + '/index.php?title=MediaWiki:AutoCreate.js&action=raw&ctype=text/javascript' );

/* ───────── Network Graph (interactive, vanilla JS) ───────── */
( function () {
  if ( !window.mw || !mw.config ) return;
  if ( mw.config.get( 'wgPageName' ) !== '대문' ) return;

  var GRAPH_ROOT = document.getElementById( 'cx-graph-root' );
  if ( !GRAPH_ROOT ) return;
  if ( GRAPH_ROOT.getAttribute( 'data-cx-graph-inited' ) ) return;
  GRAPH_ROOT.setAttribute( 'data-cx-graph-inited', '1' );

  var API = mw.config.get( 'wgScriptPath' ) + '/api.php';
  var pageLinks = {};   // title -> array of linked titles
  var allTitles = new Set();

  var prevContinueKeys = [];
  function fetchAll( url, collect ) {
    return fetch( url ).then( function ( r ) { return r.json(); } ).then( function ( data ) {
      if ( data && data.query ) collect( data.query );
      if ( data && data.continue ) {
        var params = new URLSearchParams( data.continue );
        var base = url.split( '?' )[0];
        var qs = new URLSearchParams( url.split( '?' )[1] || '' );
        qs.delete( 'continue' );
        prevContinueKeys.forEach( function ( k ) { qs.delete( k ); } );
        params.forEach( function ( v, k ) { qs.append( k, v ); } );
        prevContinueKeys = Array.from( params.keys() );
        return fetchAll( base + '?' + qs.toString(), collect );
      }
    } );
  }

  function collectQuery( query ) {
    ( query.pages || [] ).forEach( function ( page ) {
      if ( !page.title || page.title === '대문' ) return;
      allTitles.add( page.title );
      var links = [];
      ( page.links || [] ).forEach( function ( l ) {
        if ( l.title && l.ns === 0 && l.title !== '대문' ) links.push( l.title );
      } );
      pageLinks[ page.title ] = links;
    } );
  }

  function buildData() {
    var titles = Array.from( allTitles );
    var index = {};
    titles.forEach( function ( t, i ) { index[ t ] = i; } );

    var edges = [];
    var inCount = {};
    titles.forEach( function ( t ) { inCount[ t ] = 0; } );
    Object.keys( pageLinks ).forEach( function ( src ) {
      pageLinks[ src ].forEach( function ( dst ) {
        if ( index[ dst ] !== undefined ) {
          inCount[ dst ]++;
          edges.push( [ index[ src ], index[ dst ] ] );
        }
      } );
    } );

    var connected = new Set();
    edges.forEach( function ( e ) { connected.add( e[0] ); connected.add( e[1] ); } );
    var keptTitles = titles.filter( function ( t, i ) { return connected.has( i ); } );
    if ( keptTitles.length < 3 ) {
      GRAPH_ROOT.innerHTML = '<div class="cx-empty" style="padding:2em;text-align:center">아직 연결된 페이지가 충분하지 않습니다.</div>';
      return;
    }

    var keptIndex = {};
    keptTitles.forEach( function ( t, i ) { keptIndex[ t ] = i; } );
    var keptEdges = edges
      .filter( function ( e ) { return keptIndex[ titles[ e[0] ] ] !== undefined && keptIndex[ titles[ e[1] ] ] !== undefined; } )
      .map( function ( e ) { return [ keptIndex[ titles[ e[0] ] ], keptIndex[ titles[ e[1] ] ] ]; } );

    var maxIn = 1;
    keptTitles.forEach( function ( t ) { maxIn = Math.max( maxIn, inCount[ t ] ); } );
    initInteractiveGraph( keptTitles, keptEdges, inCount, maxIn );
  }

  function initInteractiveGraph( titles, edges, inCount, maxIn ) {
    var W = GRAPH_ROOT.clientWidth || 860;
    var H = 480;
    var N = titles.length;

    var pos = new Array( N );
    var vel = new Array( N );
    var rng = 1;
    function rand() { rng = ( rng * 16807 ) % 2147483647; return ( rng - 1 ) / 2147483646; }
    for ( var i = 0; i < N; i++ ) {
      pos[ i ] = { x: W / 2 + ( rand() - 0.5 ) * W * 0.6, y: H / 2 + ( rand() - 0.5 ) * H * 0.6 };
      vel[ i ] = { x: 0, y: 0 };
    }

    var REPULSION = 900, SPRING = 0.05, CENTER = 0.02, DAMPING = 0.85, REST_LEN = 90;
    function stepSim( iters ) {
      for ( var iter = 0; iter < iters; iter++ ) {
        for ( var a = 0; a < N; a++ ) {
          for ( var b = a + 1; b < N; b++ ) {
            var dx = pos[ a ].x - pos[ b ].x, dy = pos[ a ].y - pos[ b ].y;
            var dist = Math.sqrt( dx * dx + dy * dy ) || 1;
            var force = REPULSION / ( dist * dist );
            vel[ a ].x += ( dx / dist ) * force; vel[ a ].y += ( dy / dist ) * force;
            vel[ b ].x -= ( dx / dist ) * force; vel[ b ].y -= ( dy / dist ) * force;
          }
        }
        edges.forEach( function ( e ) {
          var sa = pos[ e[0] ], sb = pos[ e[1] ];
          var dx = sb.x - sa.x, dy = sb.y - sa.y;
          var dist = Math.sqrt( dx * dx + dy * dy ) || 1;
          var force = ( dist - REST_LEN ) * SPRING;
          vel[ e[0] ].x += ( dx / dist ) * force; vel[ e[0] ].y += ( dy / dist ) * force;
          vel[ e[1] ].x -= ( dx / dist ) * force; vel[ e[1] ].y -= ( dy / dist ) * force;
        } );
        for ( var c = 0; c < N; c++ ) {
          vel[ c ].x += ( W / 2 - pos[ c ].x ) * CENTER;
          vel[ c ].y += ( H / 2 - pos[ c ].y ) * CENTER;
        }
        for ( var m = 0; m < N; m++ ) {
          vel[ m ].x *= DAMPING; vel[ m ].y *= DAMPING;
          pos[ m ].x += vel[ m ].x; pos[ m ].y += vel[ m ].y;
          pos[ m ].x = Math.max( 15, Math.min( W - 15, pos[ m ].x ) );
          pos[ m ].y = Math.max( 15, Math.min( H - 15, pos[ m ].y ) );
        }
      }
    }
    stepSim( 220 );

    // Adjacency for highlighting
    var adj = [];
    for ( var i = 0; i < N; i++ ) adj.push( {} );
    var edgeNeighbors = new Array( edges.length );
    edges.forEach( function ( e, ei ) {
      adj[ e[0] ][ e[1] ] = true;
      adj[ e[1] ][ e[0] ] = true;
      edgeNeighbors[ ei ] = e;
    } );

    var nodeR = [], nodeColor = [];
    titles.forEach( function ( t, i ) {
      var ratio = ( inCount[ t ] || 0 ) / maxIn;
      nodeR[ i ] = 6 + 10 * ratio;
      nodeColor[ i ] = 'rgb(' + Math.round( 37 + ( 1 - ratio ) * 160 ) + ',' + Math.round( 99 + ( 1 - ratio ) * 80 ) + ',' + Math.round( 235 - ratio * 80 ) + ')';
    } );

    // Build DOM
    GRAPH_ROOT.innerHTML = '';

    var controls = document.createElement( 'div' );
    controls.className = 'cx-graph-controls';
    var search = document.createElement( 'input' );
    search.type = 'text';
    search.placeholder = '페이지 검색...';
    search.className = 'cx-graph-search';
    var legend = document.createElement( 'div' );
    legend.className = 'cx-graph-legend';
    legend.textContent = '크기/색 = 연결 수 · 드래그로 이동 · 휠 줌 · 배경 드래그로 이동';
    controls.appendChild( search );
    controls.appendChild( legend );
    GRAPH_ROOT.appendChild( controls );

    var svg = document.createElementNS( 'http://www.w3.org/2000/svg', 'svg' );
    svg.setAttribute( 'viewBox', '0 0 ' + W + ' ' + H );
    svg.setAttribute( 'preserveAspectRatio', 'xMidYMid meet' );
    svg.setAttribute( 'class', 'cx-graph-svg' );
    GRAPH_ROOT.appendChild( svg );

    var content = document.createElementNS( 'http://www.w3.org/2000/svg', 'g' );
    svg.appendChild( content );
    var edgeLayer = document.createElementNS( 'http://www.w3.org/2000/svg', 'g' );
    var nodeLayer = document.createElementNS( 'http://www.w3.org/2000/svg', 'g' );
    content.appendChild( edgeLayer );
    content.appendChild( nodeLayer );

    var tooltip = document.createElement( 'div' );
    tooltip.className = 'cx-graph-tooltip';
    tooltip.style.opacity = '0';
    GRAPH_ROOT.appendChild( tooltip );

    // View transform (pan/zoom)
    var view = { k: 1, tx: 0, ty: 0 };
    function applyView() {
      content.setAttribute( 'transform', 'translate(' + view.tx + ',' + view.ty + ') scale(' + view.k + ')' );
    }

    // Edge elements
    var lineEls = edges.map( function ( e ) {
      var line = document.createElementNS( 'http://www.w3.org/2000/svg', 'line' );
      line.setAttribute( 'class', 'cx-graph-link' );
      edgeLayer.appendChild( line );
      return { line: line, e: e };
    } );

    // Node + label elements
    var nodeEls = titles.map( function ( t, i ) {
      var g = document.createElementNS( 'http://www.w3.org/2000/svg', 'g' );
      g.setAttribute( 'class', 'cx-graph-node-group' );
      var circle = document.createElementNS( 'http://www.w3.org/2000/svg', 'circle' );
      circle.setAttribute( 'r', nodeR[ i ] );
      circle.setAttribute( 'fill', nodeColor[ i ] );
      circle.setAttribute( 'class', 'cx-graph-node' );
      circle.style.cursor = 'pointer';
      g.appendChild( circle );
      var text = document.createElementNS( 'http://www.w3.org/2000/svg', 'text' );
      text.setAttribute( 'text-anchor', 'middle' );
      text.setAttribute( 'class', 'cx-graph-label' );
      text.textContent = t.length > 18 ? t.slice( 0, 16 ) + '...' : t;
      g.appendChild( text );
      nodeLayer.appendChild( g );
      return { g: g, circle: circle, text: text, i: i, title: t };
    } );

    function draw() {
      lineEls.forEach( function ( o ) {
        var e = o.e;
        o.line.setAttribute( 'x1', pos[ e[0] ].x );
        o.line.setAttribute( 'y1', pos[ e[0] ].y );
        o.line.setAttribute( 'x2', pos[ e[1] ].x );
        o.line.setAttribute( 'y2', pos[ e[1] ].y );
      } );
      nodeEls.forEach( function ( o ) {
        o.g.setAttribute( 'transform', 'translate(' + pos[ o.i ].x + ',' + pos[ o.i ].y + ')' );
        o.text.setAttribute( 'dy', -( nodeR[ o.i ] + 5 ) );
      } );
    }
    draw();
    applyView();

    // Hover highlight
    function setHighlight( i ) {
      var active = i !== null && i >= 0;
      lineEls.forEach( function ( o ) {
        var e = o.e;
        var conn = active && ( e[0] === i || e[1] === i );
        o.line.setAttribute( 'class', 'cx-graph-link' + ( conn ? ' cx-graph-link-active' : '' ) );
      } );
      nodeEls.forEach( function ( o ) {
        if ( i === null ) {
          o.circle.setAttribute( 'class', 'cx-graph-node' );
          o.text.style.opacity = '1';
          return;
        }
        var conn = o.i === i || ( active && adj[ i ][ o.i ] );
        o.circle.setAttribute( 'class', 'cx-graph-node' + ( conn ? ' cx-graph-node-active' : ' cx-graph-node-dim' ) );
        o.text.style.opacity = conn ? '1' : '0.25';
      } );
    }
    function showTooltip( title, i, ev ) {
      tooltip.textContent = title + ' · 연결 ' + ( inCount[ title ] || 0 ) + '개';
      tooltip.style.opacity = '1';
      var rect = GRAPH_ROOT.getBoundingClientRect();
      tooltip.style.left = ( ev.clientX - rect.left + 12 ) + 'px';
      tooltip.style.top = ( ev.clientY - rect.top - 34 ) + 'px';
    }
    function hideTooltip() { tooltip.style.opacity = '0'; }

    // Node drag + click. Pointer capture retargets pointermove/pointerup
    // and the derived click event to the capture target, which would break
    // node dragging and node click navigation. Use window-level listeners.
    var dragging = null, dragMoved = false;
    function clientToSvg( clientX, clientY ) {
      var pt = svg.createSVGPoint ? svg.createSVGPoint() : null;
      if ( pt && svg.getScreenCTM ) {
        pt.x = clientX; pt.y = clientY;
        var ctm = svg.getScreenCTM();
        if ( ctm ) { var p = pt.matrixTransform( ctm.inverse() ); return { x: p.x, y: p.y }; }
      }
      var rect = svg.getBoundingClientRect();
      return { x: clientX - rect.left, y: clientY - rect.top };
    }
    function onDragMove( ev ) {
      if ( dragging === null ) return;
      var w = clientToSvg( ev.clientX, ev.clientY );
      var wx = ( w.x - view.tx ) / view.k;
      var wy = ( w.y - view.ty ) / view.k;
      var dx = wx - pos[ dragging ].x, dy = wy - pos[ dragging ].y;
      if ( Math.abs( dx ) + Math.abs( dy ) > 3 ) dragMoved = true;
      pos[ dragging ].x = Math.max( 15, Math.min( W - 15, wx ) );
      pos[ dragging ].y = Math.max( 15, Math.min( H - 15, wy ) );
      vel[ dragging ].x = 0; vel[ dragging ].y = 0;
      stepSim( 4 );
      draw();
    }
    function onDragEnd() {
      if ( dragging === null ) return;
      dragging = null;
      window.removeEventListener( 'pointermove', onDragMove );
      window.removeEventListener( 'pointerup', onDragEnd );
      window.removeEventListener( 'pointercancel', onDragEnd );
    }
    function onNodePointerDown( ev, i ) {
      if ( ev.button !== undefined && ev.button !== 0 ) return;
      ev.preventDefault();
      dragging = i;
      dragMoved = false;
      window.addEventListener( 'pointermove', onDragMove );
      window.addEventListener( 'pointerup', onDragEnd );
      window.addEventListener( 'pointercancel', onDragEnd );
    }

    nodeEls.forEach( function ( o ) {
      o.g.addEventListener( 'pointerdown', function ( ev ) { onNodePointerDown( ev, o.i ); } );
      o.g.addEventListener( 'pointermove', function ( ev ) {
        if ( dragging !== null ) return;
        showTooltip( o.title, o.i, ev );
        setHighlight( o.i );
      } );
      o.g.addEventListener( 'pointerleave', function () {
        if ( dragging === null ) { setHighlight( null ); hideTooltip(); }
      } );
      o.g.addEventListener( 'click', function () {
        if ( !dragMoved ) window.location.href = mw.util.getUrl( o.title );
      } );
    } );
    // Background pan
    var panning = false, panStart = null;
    svg.addEventListener( 'pointerdown', function ( ev ) {
      if ( dragging !== null ) return;
      if ( ev.button !== undefined && ev.button !== 0 ) return;
      panning = true;
      panStart = { x: ev.clientX, y: ev.clientY, tx: view.tx, ty: view.ty };
    } );
    svg.addEventListener( 'pointermove', function ( ev ) {
      if ( dragging !== null ) return; // node drag handled on window
      if ( panning && panStart ) {
        view.tx = panStart.tx + ( ev.clientX - panStart.x );
        view.ty = panStart.ty + ( ev.clientY - panStart.y );
        applyView();
      }
    } );
    svg.addEventListener( 'pointerup', function () { panning = false; panStart = null; } );
    svg.addEventListener( 'pointerleave', function () { panning = false; panStart = null; } );
    // Zoom (focal point precise even with viewBox letterboxing)
    svg.addEventListener( 'wheel', function ( ev ) {
      ev.preventDefault();
      var w = clientToSvg( ev.clientX, ev.clientY );
      var px = w.x, py = w.y;
      var factor = ev.deltaY < 0 ? 1.15 : 0.87;
      var newK = Math.max( 0.3, Math.min( 4, view.k * factor ) );
      var ratio = newK / view.k;
      view.tx = px - ( px - view.tx ) * ratio;
      view.ty = py - ( py - view.ty ) * ratio;
      view.k = newK;
      applyView();
    }, { passive: false } );
    // Search
    search.addEventListener( 'input', function () {
      var q = search.value.trim().toLowerCase();
      if ( !q ) { setHighlight( null ); return; }
      var found = -1;
      titles.forEach( function ( t, i ) {
        if ( t.toLowerCase().indexOf( q ) === 0 && found === -1 ) found = i;
      } );
      if ( found === -1 ) {
        titles.forEach( function ( t, i ) {
          if ( t.toLowerCase().indexOf( q ) > -1 && found === -1 ) found = i;
        } );
      }
      setHighlight( found );
      if ( found > -1 ) {
        view.k = Math.max( view.k, 1.4 );
        view.tx = W / 2 - pos[ found ].x * view.k;
        view.ty = H / 2 - pos[ found ].y * view.k;
        applyView();
      }
    } );
  }

  var base = API + '?action=query&generator=allpages&gaplimit=50&gapnamespace=0&prop=links&pllimit=100&format=json&formatversion=2';
  fetchAll( base, collectQuery )
    .then( buildData )
    .catch( function () {
      GRAPH_ROOT.innerHTML = '<div class="cx-empty" style="padding:2em;text-align:center">그래프를 불러오지 못했습니다.</div>';
    } );
} )();