MediaWiki:MindMap.js

From CoraTO Wiki - Official Wiki
Jump to navigation Jump to search

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
(function(){
      function initMap(container){
        var stage=container.querySelector('.mindmap-stage'); if(!stage)return;
        var links=container.querySelector('.mm-links'); var detail=container.querySelector('.mm-detail');
        var scale=1, tx=0, ty=0, panning=false, px=0, py=0;
        var rafDraw=0;
        function clamp(n,min,max){ return Math.min(max,Math.max(min,n)); }
        function scheduleDraw(){
          if(rafDraw) return;
          rafDraw=window.requestAnimationFrame(function(){ rafDraw=0; drawLines(); });
        }
        function applyTransform(){ stage.style.transform='translate('+tx+'px,'+ty+'px) scale('+scale+')'; scheduleDraw(); }
        function layoutBranches(){
          if(container.clientWidth===0 || container.clientHeight===0) return;
          var pad=24;
          var gapX=140;
          var gapY=34;
          var center=stage.querySelector('.mm-center');
          if(center){
            center.style.left=pad+'px';
            center.style.top=(container.clientHeight/2)+'px';
            center.style.transform='translate(0, -50%)';
          }

          var cw=center ? center.offsetWidth : 220;
          var cx=pad + cw + gapX;
          var y=pad;

          var branches=stage.querySelectorAll('.mm-branch');
          branches.forEach(function(b){
            b.style.left=cx+'px';
            b.style.top=y+'px';
            y+=b.offsetHeight+gapY;
          });
          scheduleDraw();
        }
        function zoomAt(clientX, clientY, nextScale){
          var rect=container.getBoundingClientRect();
          var mx=clientX-rect.left;
          var my=clientY-rect.top;
          var prev=scale;
          var ns=clamp(nextScale,0.6,2.2);
          if(ns===prev) return;
          var wx=(mx-tx)/prev;
          var wy=(my-ty)/prev;
          scale=ns;
          tx=mx-wx*scale;
          ty=my-wy*scale;
          applyTransform();
        }
        function centerOnEl(node){
          if(!node)return;
          var nr=node.getBoundingClientRect();
          var cr=container.getBoundingClientRect();
          var dx=(cr.left+cr.width/2)-(nr.left+nr.width/2);
          var dy=(cr.top+cr.height/2)-(nr.top+nr.height/2);
          tx+=dx;
          ty+=dy;
          applyTransform();
        }
        function showDetailForNode(node){
          if(!detail || !node) return;
          var d=node.getAttribute('data-detail');
          if(!d){
            var branch=node.parentElement && node.parentElement.classList && node.parentElement.classList.contains('mm-branch') ? node.parentElement : null;
            if(branch){
              var kids=branch.querySelectorAll(':scope > .mm-children > .mm-node, :scope > .mm-children > .mm-item > .mm-node');
              if(kids && kids.length){
                var labels=[];
                kids.forEach(function(k){ labels.push((k.textContent||'').trim()); });
                d=((node.textContent||'').trim())+' — '+labels.join(', ');
              }
            }
          }
          if(d){ detail.textContent=d; detail.classList.add('active'); } else { detail.classList.remove('active'); }
        }
        function drawLines(){
          var svgW=container.clientWidth, svgH=container.clientHeight;
          if(svgW===0 || svgH===0) return;
          links.setAttribute('width',svgW); links.setAttribute('height',svgH);
          links.innerHTML='';
          function addCurve(a,b){
            var ar=a.getBoundingClientRect(), br=b.getBoundingClientRect(), cr=container.getBoundingClientRect();
            var x1=ar.right-cr.left;
            var y1=(ar.top-cr.top)+ar.height/2;
            var x2=br.left-cr.left;
            var y2=(br.top-cr.top)+br.height/2;
            var dist=Math.abs(x2-x1);
            var dx=Math.max(40, Math.min(180, dist*0.45));
            var c1x=x1+dx;
            var c2x=x2-dx;
            var path=document.createElementNS('http://www.w3.org/2000/svg','path');
            path.setAttribute('d','M '+x1+' '+y1+' C '+c1x+' '+y1+', '+c2x+' '+y2+', '+x2+' '+y2);
            path.setAttribute('fill','none');
            path.setAttribute('stroke','rgba(255,107,157,.35)');
            path.setAttribute('stroke-width','2');
            links.appendChild(path);
          }
          var center=stage.querySelector('.mm-center');
          if(!center) return;
          var branches=stage.querySelectorAll('.mm-branch');
          branches.forEach(function(b){
            var head=b.querySelector(':scope > .mm-node');
            if(!head) return;
            addCurve(center,head);
            if(b.classList.contains('open')){
              var directChildren=b.querySelectorAll(':scope > .mm-children > .mm-node, :scope > .mm-children > .mm-item > .mm-node');
              directChildren.forEach(function(c){ addCurve(head,c); });
              var openItems=b.querySelectorAll(':scope > .mm-children > .mm-item.open');
              openItems.forEach(function(item){
                var itemNode=item.querySelector(':scope > .mm-node');
                if(!itemNode) return;
                var subs=item.querySelectorAll(':scope > .mm-subchildren > .mm-node');
                subs.forEach(function(s){ addCurve(itemNode,s); });
              });
            }
          });
        }

        var down=false, dragging=false, downId=null, startX=0, startY=0;
        container.addEventListener('pointerdown',function(e){
          if(e.button!==0) return;
          if(e.target.closest('.mm-controls')) return;
          down=true;
          dragging=false;
          downId=e.pointerId;
          startX=e.clientX;
          startY=e.clientY;
          px=e.clientX;
          py=e.clientY;
        });
        container.addEventListener('pointermove',function(e){
          if(!down || downId!==e.pointerId) return;
          var dx=e.clientX-px, dy=e.clientY-py;
          px=e.clientX;
          py=e.clientY;
          if(!dragging){
            var mx=e.clientX-startX, my=e.clientY-startY;
            if(Math.abs(mx)+Math.abs(my) < 6) return;
            dragging=true;
            container.classList.add('mm-dragging');
            try{ container.setPointerCapture(e.pointerId); }catch(err){}
          }
          tx+=dx;
          ty+=dy;
          applyTransform();
        });
        function endPointer(e){
          if(downId!==e.pointerId) return;
          down=false;
          downId=null;
          if(dragging){
            dragging=false;
            container.classList.remove('mm-dragging');
            try{ container.releasePointerCapture(e.pointerId); }catch(err){}
          }
        }
        container.addEventListener('pointerup',endPointer);
        container.addEventListener('pointercancel',endPointer);

        container.addEventListener('wheel',function(e){
          e.preventDefault();
          var next=scale+(e.deltaY<0?0.12:-0.12);
          zoomAt(e.clientX,e.clientY,next);
        },{passive:false});

        var btnIn=container.querySelector('[data-mm-zoom="in"]');
        var btnOut=container.querySelector('[data-mm-zoom="out"]');
        var btnReset=container.querySelector('[data-mm-reset]');

        function zoomAtCenter(next){
          var cr=container.getBoundingClientRect();
          zoomAt(cr.left+cr.width/2, cr.top+cr.height/2, next);
        }

        if(btnIn)btnIn.addEventListener('click',function(){ zoomAtCenter(scale+0.15); });
        if(btnOut)btnOut.addEventListener('click',function(){ zoomAtCenter(scale-0.15); });
        if(btnReset)btnReset.addEventListener('click',function(){ scale=1; tx=0; ty=0; applyTransform(); });

        stage.querySelectorAll('.mm-node').forEach(function(n){
          n.addEventListener('click',function(){
            showDetailForNode(n);
          });
        });
        stage.querySelectorAll('.mm-branch > .mm-node').forEach(function(head){
          head.addEventListener('click',function(){
            var branch=head.parentElement;
            if(branch.classList.contains('open')){ branch.classList.remove('open'); } else { branch.classList.add('open'); }
            layoutBranches();
            scheduleDraw();
          });
        });
        stage.addEventListener('click',function(e){
          var node=e.target.closest('.mm-item > .mm-node');
          if(!node) return;
          var item=node.parentElement;
          if(!item) return;
          if(item.classList.contains('open')){ item.classList.remove('open'); } else { item.classList.add('open'); }
          layoutBranches();
          scheduleDraw();
        });
        window.addEventListener('resize', function(){ layoutBranches(); drawLines(); });
        layoutBranches();
        applyTransform();

        container.__mmApi={
          layout: layoutBranches,
          draw: drawLines,
          showDetail: showDetailForNode
        };
      }

      function decorateLinkNodes(root){
        (root||document).querySelectorAll('.mm-node[data-href]').forEach(function(node){
          if(node.querySelector('a')) return;
          var href=node.getAttribute('data-href');
          if(!href) return;
          var label=(node.textContent||'').trim();
          node.textContent='';
          var a=document.createElement('a');
          a.href=href;
          a.target='_blank';
          a.rel='noopener';
          a.textContent=label;
          node.appendChild(a);
        });

        document.addEventListener('click', function(e){
          var a=e.target.closest('.mm-node a');
          if(!a) return;
          e.stopPropagation();
        }, true);
      }

      function renderLocationsTables(){
        var root=document.getElementById('locations-table-root');
        var stage=document.getElementById('mm-stage-locations');
        if(!root || !stage) return;

        var rows=[];
        stage.querySelectorAll('.mm-branch').forEach(function(branch){
          var townNode=branch.querySelector(':scope > .mm-node');
          var town=((townNode && townNode.textContent)||'').trim();
          if(!town) return;
          branch.querySelectorAll(':scope > .mm-children > .mm-node').forEach(function(loc){
            var name=((loc.textContent)||'').trim();
            var detail=(loc.getAttribute('data-detail')||'').trim();
            function detectCategory(t){
              var s=(t||'').toLowerCase();
              if(/gate/.test(s)) return 'Gate';
              if(/field/.test(s)) return 'Field';
              if(/mine/.test(s)) return 'Mine';
              if(/shop/.test(s)) return 'Shop';
              if(/dungeon/.test(s)) return 'Dungeon';
              if(/maze/.test(s)) return 'Maze';
              if(/lobby/.test(s)) return 'Lobby';
              if(/theme\s*spa/.test(s)) return 'Theme Spa';
              return 'Other';
            }
            rows.push({
              town: town,
              name: name,
              category: detectCategory(detail),
              detail: detail
            });
          });
        });

        root.innerHTML='';

        var controls=document.createElement('div');
        controls.className='mm-table-controls';
        controls.innerHTML='\
          <div class="mm-table-control">\
            <label class="mm-table-label" for="locations-table-search">Search</label>\
            <input id="locations-table-search" class="mm-table-input" type="search" placeholder="Type to filter…" autocomplete="off">\
          </div>\
          <div class="mm-table-control">\
            <label class="mm-table-label" for="locations-table-category">Category</label>\
            <select id="locations-table-category" class="mm-table-select">\
              <option value="">All</option>\
            </select>\
          </div>\
          <div class="mm-table-control mm-table-meta" id="locations-table-meta"></div>';
        root.appendChild(controls);

        var tableWrap=document.createElement('div');
        tableWrap.className='mm-table-wrap';
        root.appendChild(tableWrap);

        var table=document.createElement('table');
        table.className='mm-table mm-table--enhanced';
        table.innerHTML='\
          <thead>\
            <tr>\
              <th scope="col" data-key="town">Town</th>\
              <th scope="col" data-key="name">Location</th>\
              <th scope="col" data-key="category">Category</th>\
            </tr>\
          </thead>\
          <tbody></tbody>';
        tableWrap.appendChild(table);

        var tbody=table.querySelector('tbody');
        var searchEl=root.querySelector('#locations-table-search');
        var categoryEl=root.querySelector('#locations-table-category');
        var metaEl=root.querySelector('#locations-table-meta');
        var sortKey='town';
        var sortDir='asc';

        function normalize(s){ return (s||'').toLowerCase(); }
        function compare(a,b){
          var av=normalize(a), bv=normalize(b);
          if(av<bv) return -1;
          if(av>bv) return 1;
          return 0;
        }

        function apply(){
          var q=normalize(searchEl && searchEl.value);
          var cat=(categoryEl && categoryEl.value) || '';
          var filtered=rows.filter(function(r){
            if(cat && r.category!==cat) return false;
            if(!q) return true;
            var blob=(r.town+' '+r.name+' '+r.category).toLowerCase();
            return blob.indexOf(q)!==-1;
          });

          filtered.sort(function(a,b){
            var ka=a[sortKey] || '', kb=b[sortKey] || '';
            var c=compare(ka,kb);
            return sortDir==='asc' ? c : -c;
          });

          tbody.textContent='';
          filtered.forEach(function(r){
            var tr=document.createElement('tr');

            var tdTown=document.createElement('td');
            tdTown.className='mm-td-town';
            tdTown.textContent=r.town;
            tr.appendChild(tdTown);

            var tdName=document.createElement('td');
            tdName.className='mm-td-name';
            tdName.textContent=r.name;
            tr.appendChild(tdName);

            var tdCat=document.createElement('td');
            tdCat.className='mm-td-category';
            tdCat.textContent=r.category;
            tr.appendChild(tdCat);

            tbody.appendChild(tr);
          });

          if(metaEl) metaEl.textContent=filtered.length+' / '+rows.length;
        }

        var categories={};
        rows.forEach(function(r){ categories[r.category]=true; });
        Object.keys(categories).sort().forEach(function(c){
          var opt=document.createElement('option');
          opt.value=c;
          opt.textContent=c;
          categoryEl.appendChild(opt);
        });

        table.querySelectorAll('th[data-key]').forEach(function(th){
          th.tabIndex=0;
          th.addEventListener('click', function(){
            var k=th.getAttribute('data-key');
            if(!k) return;
            if(sortKey===k){ sortDir = sortDir==='asc' ? 'desc' : 'asc'; }
            else { sortKey=k; sortDir='asc'; }
            apply();
          });
          th.addEventListener('keydown', function(e){
            if(e.key==='Enter' || e.key===' '){ e.preventDefault(); th.click(); }
          });
        });

        if(searchEl) searchEl.addEventListener('input', apply);
        if(categoryEl) categoryEl.addEventListener('change', apply);
        apply();
      }

      function parseDetailTriplet(detail){
        var raw=(detail||'').trim();
        if(!raw) return { left:'', mid:'', right:'' };
        var parts=raw.split(/\s*—\s*/g).map(function(s){ return s.trim(); }).filter(Boolean);
        return {
          left: parts[0] || '',
          mid: parts[1] || '',
          right: parts.slice(2).join(' — ')
        };
      }

      function initServicesTable(){
        var root=document.getElementById('services-table-root');
        var stage=document.getElementById('mm-stage-services');
        if(!root || !stage) return;

        var rows=[];
        stage.querySelectorAll('.mm-branch').forEach(function(branch){
          var head=branch.querySelector(':scope > .mm-node');
          var category=((head && head.textContent)||'').trim();
          var children=branch.querySelectorAll(':scope > .mm-children > .mm-node');
          children.forEach(function(n){
            var name=((n.textContent)||'').trim();
            var href=n.getAttribute('data-href') || '';
            var d=parseDetailTriplet(n.getAttribute('data-detail')||'');
            rows.push({
              category: category,
              name: name,
              npc: d.mid,
              purpose: d.right,
              href: href,
              detail: (n.getAttribute('data-detail')||'').trim()
            });
          });
        });

        root.innerHTML='';

        var controls=document.createElement('div');
        controls.className='mm-table-controls';
        controls.innerHTML='\
          <div class="mm-table-control">\
            <label class="mm-table-label" for="services-table-search">Search</label>\
            <input id="services-table-search" class="mm-table-input" type="search" placeholder="Type to filter…" autocomplete="off">\
          </div>\
          <div class="mm-table-control">\
            <label class="mm-table-label" for="services-table-category">Category</label>\
            <select id="services-table-category" class="mm-table-select">\
              <option value="">All</option>\
            </select>\
          </div>\
          <div class="mm-table-control mm-table-meta" id="services-table-meta"></div>';
        root.appendChild(controls);

        var tableWrap=document.createElement('div');
        tableWrap.className='mm-table-wrap';
        root.appendChild(tableWrap);

        var table=document.createElement('table');
        table.className='mm-table mm-table--enhanced';
        table.innerHTML='\
          <thead>\
            <tr>\
              <th scope="col" data-key="category">Category</th>\
              <th scope="col" data-key="name">Function</th>\
              <th scope="col" data-key="npc">Replaced NPC</th>\
              <th scope="col" data-key="purpose">What\'s It For</th>\
              <th scope="col" data-key="link">Link</th>\
            </tr>\
          </thead>\
          <tbody></tbody>';
        tableWrap.appendChild(table);

        var tbody=table.querySelector('tbody');
        var searchEl=root.querySelector('#services-table-search');
        var categoryEl=root.querySelector('#services-table-category');
        var metaEl=root.querySelector('#services-table-meta');
        var sortKey='category';
        var sortDir='asc';

        function normalize(s){ return (s||'').toLowerCase(); }
        function compare(a,b){
          var av=normalize(a), bv=normalize(b);
          if(av<bv) return -1;
          if(av>bv) return 1;
          return 0;
        }

        function apply(){
          var q=normalize(searchEl && searchEl.value);
          var cat=(categoryEl && categoryEl.value) || '';
          var filtered=rows.filter(function(r){
            if(cat && r.category!==cat) return false;
            if(!q) return true;
            var blob=(r.category+' '+r.name+' '+r.npc+' '+r.purpose+' '+r.detail).toLowerCase();
            return blob.indexOf(q)!==-1;
          });

          filtered.sort(function(a,b){
            var ka=(sortKey==='link'? (a.href? a.name : '') : a[sortKey]) || '';
            var kb=(sortKey==='link'? (b.href? b.name : '') : b[sortKey]) || '';
            var c=compare(ka,kb);
            return sortDir==='asc' ? c : -c;
          });

          tbody.textContent='';
          filtered.forEach(function(r){
            var tr=document.createElement('tr');

            var tdCat=document.createElement('td');
            tdCat.className='mm-td-category';
            tdCat.textContent=r.category;
            tr.appendChild(tdCat);

            var tdName=document.createElement('td');
            tdName.className='mm-td-name';
            tdName.textContent=r.name;
            if(r.detail) tdName.title=r.detail;
            tr.appendChild(tdName);

            var tdNpc=document.createElement('td');
            tdNpc.textContent=r.npc || '—';
            tr.appendChild(tdNpc);

            var tdPurpose=document.createElement('td');
            tdPurpose.textContent=r.purpose || '—';
            if(r.detail) tdPurpose.title=r.detail;
            tr.appendChild(tdPurpose);

            var tdLink=document.createElement('td');
            if(r.href){
              var a=document.createElement('a');
              a.href=r.href;
              a.target='_blank';
              a.rel='noopener';
              a.textContent='Open';
              tdLink.appendChild(a);
            }else{
              tdLink.textContent='—';
            }
            tr.appendChild(tdLink);

            tbody.appendChild(tr);
          });

          if(metaEl) metaEl.textContent=filtered.length+' / '+rows.length;
        }

        var categories={};
        rows.forEach(function(r){ categories[r.category]=true; });
        Object.keys(categories).sort().forEach(function(c){
          var opt=document.createElement('option');
          opt.value=c;
          opt.textContent=c;
          categoryEl.appendChild(opt);
        });

        table.querySelectorAll('th[data-key]').forEach(function(th){
          th.tabIndex=0;
          th.addEventListener('click', function(){
            var k=th.getAttribute('data-key');
            if(!k) return;
            if(sortKey===k){ sortDir = sortDir==='asc' ? 'desc' : 'asc'; }
            else { sortKey=k; sortDir='asc'; }
            apply();
          });
          th.addEventListener('keydown', function(e){
            if(e.key==='Enter' || e.key===' '){ e.preventDefault(); th.click(); }
          });
        });

        if(searchEl) searchEl.addEventListener('input', apply);
        if(categoryEl) categoryEl.addEventListener('change', apply);
        apply();
      }
      function refreshMapsIn(root){
        (root||document).querySelectorAll('.mindmap').forEach(function(m){
          if(m.__mmApi){
            if(m.offsetParent===null) return;
            m.__mmApi.layout();
            m.__mmApi.draw();
          }
        });
      }

      function initAll(){
        decorateLinkNodes(document);
        document.querySelectorAll('.mindmap').forEach(function(m){ initMap(m); });
        renderLocationsTables();
        initServicesTable();
        refreshMapsIn(document);
      }

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

      document.addEventListener('click',function(e){
        var tabBtn=e.target.closest('.nav-tab');
        if(!tabBtn) return;
        var tabId=tabBtn.getAttribute('data-tab');
        setTimeout(function(){
          if(tabId){
            var tab=document.getElementById(tabId);
            if(tab) refreshMapsIn(tab);
          }else{
            refreshMapsIn(document);
          }
        },250);
      });

      document.addEventListener('click',function(e){
        var nested=e.target.closest('.nested-tab');
        if(!nested) return;
        var id=nested.getAttribute('data-tab');
        setTimeout(function(){
          if(id){
            var el=document.getElementById(id);
            if(el) refreshMapsIn(el);
          }else{
            refreshMapsIn(document);
          }
        },250);
      });
      document.querySelectorAll('.destaque-card').forEach(function(card){
        var target=card.getAttribute('data-node-target'); var tabBtn=card.getAttribute('data-tab-button'); var tabId=card.getAttribute('data-tab-trigger');
        if(target){ card.addEventListener('click',function(){
          var btn=document.getElementById(tabBtn); if(btn)btn.click();
          setTimeout(function(){
            var targetEl=document.getElementById(target);
            if(!targetEl) return;
            var node=targetEl;
            if(targetEl.classList.contains('mm-branch')){
              node=targetEl.querySelector(':scope > .mm-node');
              if(node && !targetEl.classList.contains('open')) targetEl.classList.add('open');
            }
            if(!node) return;
            var mindmap=node.closest('.mindmap');
            var api=mindmap && mindmap.__mmApi ? mindmap.__mmApi : null;
            var branch=node.closest('.mm-branch');
            var isHead=branch && branch.querySelector(':scope > .mm-node')===node;
            var inChildren=node.parentElement && node.parentElement.classList && node.parentElement.classList.contains('mm-children');
            if(branch && (inChildren || isHead) && !branch.classList.contains('open')) branch.classList.add('open');
            if(api){ api.layout(); api.draw(); api.showDetail(node); }
          },200);
        }); }
      });
    })();