2025년 8월 29일 금요일

11-2

 <!DOCTYPE html>

<html>

<head>

<meta charset="utf-8">

<title>🏬 다층 백화점 길안내 (층간 이동 지원)</title>

<style>

body { margin:0; overflow:hidden; font-family:sans-serif; }

canvas { border:1px solid #ccc; display:block; cursor:crosshair; }

#controls {

  position: fixed; top:10px; left:10px;

  background: rgba(255,255,255,0.95); padding:10px; border-radius:6px; z-index:10;

}

select, button, input { margin:2px; }

#infoBox {

  position: fixed; bottom:10px; left:10px;

  background: rgba(0,0,0,0.7); color:#fff; padding:5px 10px; border-radius:6px; font-weight:bold;

  display:none;

}

#nodeDialog {

  display:none; position:fixed; top:50%; left:50%;

  transform:translate(-50%,-50%);

  background:#fff; border:1px solid #ccc; padding:15px;

  border-radius:8px; z-index:1000; box-shadow:0 4px 12px rgba(0,0,0,0.2);

}

#nodeDialog h3 { margin:0 0 8px 0; font-size:16px; }

#nodeDialog .typeBtn { margin:2px; padding:3px 6px; cursor:pointer; }

</style>

</head>

<body>


<div id="controls">

  <div>

    Floor plan 업로드: <input type="file" id="fileInput" accept="image/*">

  </div>

  <div>

    출발지: <select id="start"></select>

    목적지: <select id="end"></select>

    <button onclick="findPath()">길찾기</button>

    <button onclick="resetAll()">초기화</button>

  </div>

  <div>

    위치 검색: <input type="text" id="searchInput">

    <button onclick="highlightNode()">검색</button>

  </div>

</div>


<div id="infoBox"></div>

<canvas id="map"></canvas>


<div id="nodeDialog">

  <h3>새 노드 추가</h3>

  <div>

    <label><input type="radio" name="floorType" value="지상" checked> 지상</label>

    <label><input type="radio" name="floorType" value="지하"> 지하</label>

    <input type="number" id="floorNumber" min="1" value="1" style="width:60px"> 층

  </div>

  <div style="margin-top:8px;">

    <label>노드 이름: <input type="text" id="nodeName" placeholder="예: 1번 계단 / 4번 에스컬레이터"></label>

  </div>

  <div style="margin-top:8px;">

    <span>노드 타입:</span><br>

    <button type="button" class="typeBtn" data-type="normal">normal</button>

    <button type="button" class="typeBtn" data-type="elevator">elevator</button>

    <button type="button" class="typeBtn" data-type="stairs">stairs</button>

    <button type="button" class="typeBtn" data-type="escalator">escalator</button>

  </div>

  <div style="margin-top:8px;">

    <label>참고사항: <input type="text" id="nodeNote"></label>

  </div>

  <div style="margin-top:10px; text-align:right;">

    <button onclick="closeNodeDialog()">취소</button>

    <button onclick="saveNode()">저장</button>

  </div>

</div>


<script>

const canvas = document.getElementById('map');

const ctx = canvas.getContext('2d');

const fileInput = document.getElementById('fileInput');

const searchInput = document.getElementById('searchInput');

const infoBox = document.getElementById('infoBox');


let floorImg = null;

let nodes = {};

let edges = {};

let nodeNames = [];

let blinkNode = null;

let blinkInterval = null;

let pathNodes = [];


function resizeCanvas(){

  canvas.width = window.innerWidth;

  canvas.height = window.innerHeight;

  draw();

}

window.addEventListener('resize', resizeCanvas);


fileInput.addEventListener('change',(e)=>{

  const file = e.target.files[0];

  if(!file) return;

  const reader = new FileReader();

  reader.onload = function(evt){

    const img = new Image();

    img.onload = ()=>{ 

      floorImg = img; 

      resetAll(true); // 이미지 업로드 시 초기화

      resizeCanvas();

    };

    img.src = evt.target.result;

  };

  reader.readAsDataURL(file);

});


// ----------------- 노드 클릭 / 삭제 / 이름 변경 -----------------

canvas.addEventListener('click',(e)=>{

  if(!floorImg) return;

  const rect = canvas.getBoundingClientRect();

  const x = e.clientX - rect.left;

  const y = e.clientY - rect.top;

  const clickedNode = findNodeAt(x,y);


  if(clickedNode){

    if(e.shiftKey){

      const newName = prompt("새 노드 이름을 입력하세요:", clickedNode);

      if(!newName || nodes[newName]) { alert("이름이 비어있거나 이미 존재합니다."); return; }

      const note = prompt("노드 참고사항을 입력하세요:", nodes[clickedNode].note||"");

      nodes[newName] = {...nodes[clickedNode], note};

      const idx = nodeNames.indexOf(clickedNode);

      if(idx!==-1) nodeNames[idx]=newName;

      edges[newName]=edges[clickedNode]||[];

      delete edges[clickedNode];

      for(let key in edges) edges[key]=edges[key].map(n=>n===clickedNode?newName:n);

      delete nodes[clickedNode];

      updateSelectOptions();

      draw();

      return;

    } else {

      const n = nodes[clickedNode];

      infoBox.style.display='block';

      infoBox.textContent = `${clickedNode} (${n.type}, ${n.floor}) ${n.note||''}`;

      setTimeout(()=>{ infoBox.style.display='none'; }, 2000);


      if(confirm(`'${clickedNode}' 노드를 삭제하시겠습니까?`)){

        delete nodes[clickedNode];

        nodeNames = nodeNames.filter(n=>n!==clickedNode);

        delete edges[clickedNode];

        for(let key in edges) edges[key]=edges[key].filter(n=>n!==clickedNode);

        updateSelectOptions();

        pathNodes=[];

        draw();

      }

      return;

    }

  }


  openNodeDialog(x,y);

});


function findNodeAt(x,y){

  for(let name of nodeNames){

    const node = nodes[name];

    if(Math.hypot(node.x-x,node.y-y)<=8) return name;

  }

  return null;

}


// ----------------- 선택 박스 업데이트 -----------------

function updateSelectOptions(){

  const startSel=document.getElementById('start');

  const endSel=document.getElementById('end');

  startSel.innerHTML=''; endSel.innerHTML='';

  nodeNames.forEach(n=>{

    const opt1=document.createElement('option'); opt1.value=n; opt1.textContent=n; startSel.appendChild(opt1);

    const opt2=document.createElement('option'); opt2.value=n; opt2.textContent=n; endSel.appendChild(opt2);

  });

}


function getLabel(fullName){ return fullName.split(' ').slice(1).join(' ').trim(); }


// ----------------- 거리 계산 -----------------

function distance(n1,n2){ return Math.hypot(n1.x-n2.x,n1.y-n2.y); }


// ----------------- Dijkstra (노드 범위 제한 가능) -----------------

function dijkstra(start,end,allowedNodes=null){

  const visited=new Set(), distances={}, prev={};

  const targetNodes = allowedNodes||nodeNames;

  targetNodes.forEach(n=>distances[n]=Infinity);

  distances[start]=0;


  while(visited.size<targetNodes.length){

    let minNode=null;

    for(let n of targetNodes){

      if(!visited.has(n)&&(minNode===null||distances[n]<distances[minNode])) minNode=n;

    }

    if(minNode===null) break;

    if(minNode===end) break;

    visited.add(minNode);

    for(let nb of edges[minNode]||[]){

      if(!targetNodes.includes(nb)) continue;

      let alt = distances[minNode]+distance(nodes[minNode],nodes[nb]);

      if(alt<distances[nb]) { distances[nb]=alt; prev[nb]=minNode; }

    }

  }


  const path=[]; let curr=end;

  while(curr){ path.unshift(curr); curr=prev[curr]; }

  return path;

}


// ----------------- 층 파싱 및 RULES -----------------

function parseFloor(f){

  const s=f.trim();

  const isBasement = s.includes('지하');

  const numMatch = s.match(/(\d+)/);

  const num = numMatch?parseInt(numMatch[1],10):0;

  let zone='';

  const zMatch = s.match(/[AB]\b/i);

  if(zMatch) zone=zMatch[0].toUpperCase();

  return {level:isBasement?-num:num, zone};

}

function sameArea(f1,f2){

  const a=parseFloor(f1), b=parseFloor(f2);

  if(a.level!==b.level) return false;

  if(a.level===-2 || a.level===-3) return a.zone===b.zone && a.zone!=='';

  return true;

}

const RULES={

  '1:':[{to:'-1:', filters:[{type:'stairs',labelIncludes:'1번'},{type:'stairs',labelIncludes:'4번'},{type:'stairs',labelIncludes:'5번'}]}],

  '-1:':[ {to:'-2:', filters:[{type:'escalator',labelIncludes:'1번'}]} ],

  '-2:':[ {to:'-3:', filters:[{type:'escalator',labelIncludes:'1번'}]} ]

};

function stateKeyFromFloorStr(floorStr){ const {level,zone}=parseFloor(floorStr); return `${level}:${zone||''}`; }

function prettyState(key){ const [lv,zone]=key.split(':'); const level=parseInt(lv,10); return level<0?`지하${-level}층${zone?` ${zone}`:''}`:`지상${level}층`; }

function neighbors(state){ return (RULES[state]||[]).map(r=>r.to); }

function bfsStatePath(startState,endState){

  if(startState===endState) return [startState];

  const q=[startState], prev={}, seen=new Set([startState]);

  while(q.length){

    const cur=q.shift();

    for(const nb of neighbors(cur)){

      if(seen.has(nb)) continue;

      seen.add(nb); prev[nb]=cur;

      if(nb===endState){ let path=[nb]; let p=cur; while(p){ path.unshift(p); p=prev[p]; } return path; }

      q.push(nb);

    }

  }

  return null;

}


// ----------------- 커넥터 선택 -----------------

function pickConnectorOnState(currNodeName,stateKey,filters){

  const {level,zone}=parseFloor(stateKey.replace(':','층'));

  const candidates=nodeNames.filter(n=>{

    const f=parseFloor(nodes[n].floor); if(f.level!==level) return false;

    if(level===-2||level===-3) if((zone||'')!==(f.zone||'')) return false;

    const label=getLabel(n);

    return filters.some(fi=>nodes[n].type===fi.type && label.includes(fi.labelIncludes));

  });

  if(candidates.length===0) return null;

  const curr=nodes[currNodeName];

  return candidates.reduce((best,now)=>distance(curr,nodes[now])<distance(curr,nodes[best])?now:best);

}

function matchConnectorOnNext(currName,nextStateKey){

  const {level,zone}=parseFloor(nextStateKey.replace(':','층'));

  const t=nodes[currName].type;

  const label=getLabel(currName);

  const candidates=nodeNames.filter(n=>{

    const f=parseFloor(nodes[n].floor); if(f.level!==level) return false;

    if(level===-2||level===-3) if((zone||'')!==(f.zone||'')) return false;

    return nodes[n].type===t && getLabel(n)===label;

  });

  if(candidates.length===0) return null;

  const curr=nodes[currName];

  return candidates.reduce((best,now)=>distance(curr,nodes[now])<distance(curr,nodes[best])?now:best);

}


// ----------------- 최종 경로 -----------------

function multiFloorPath(startName,endName){

  const startFloorStr=nodes[startName].floor;

  const endFloorStr=nodes[endName].floor;

  if(sameArea(startFloorStr,endFloorStr)){

    const allowed=nodeNames.filter(n=>sameArea(nodes[n].floor,startFloorStr));

    return dijkstra(startName,endName,allowed);

  }


  const startState=stateKeyFromFloorStr(startFloorStr);

  const endState=stateKeyFromFloorStr(endFloorStr);

  const statePath=bfsStatePath(startState,endState);

  if(!statePath){ alert(`층간 경로가 없습니다.\n${prettyState(startState)} → ${prettyState(endState)}`); return null; }


  let currNode=startName, full=[];

  for(let i=0;i<statePath.length-1;i++){

    const currState=statePath[i], nextState=statePath[i+1];

    const rule=(RULES[currState]||[]).find(r=>r.to===nextState);

    if(!rule){ alert(`규칙 오류: ${prettyState(currState)} → ${prettyState(nextState)}`); return null; }

    const closestCurr=pickConnectorOnState(currNode,currState,rule.filters);

    if(!closestCurr){ alert(`${prettyState(currState)}에 사용할 커넥터 없음`); return null; }

    const closestNext=matchConnectorOnNext(closestCurr,nextState);

    if(!closestNext){ alert(`${prettyState(nextState)}에 대응 커넥터 없음`); return null; }

    const allowedCurr=nodeNames.filter(n=>sameArea(nodes[n].floor,nodes[closestCurr].floor));

    const seg=dijkstra(currNode,closestCurr,allowedCurr);

    if(seg.length===0){ alert(`${currNode} → ${closestCurr} 경로 없음`); return null; }

    full.push(...(full.length?seg.slice(1):seg));

    if(full[full.length-1]!==closestNext) full.push(closestNext);

    currNode=closestNext;

  }

  const allowedEnd=nodeNames.filter(n=>sameArea(nodes[n].floor,endFloorStr));

  const endSeg=dijkstra(currNode,endName,allowedEnd);

  if(endSeg.length===0){ alert(`${currNode} → ${endName} 경로 없음`); return null; }

  full.push(...endSeg.slice(1));

  return full;

}


// ----------------- 길찾기, 검색, 초기화 -----------------

function findPath(){ pathNodes=multiFloorPath(document.getElementById('start').value,document.getElementById('end').value)||[]; draw(); }


function highlightNode(){

  const input=searchInput.value.trim();

  if(!input){ alert("검색어 입력"); return; }

  const found=nodeNames.find(n=>getLabel(n)===input);

  if(!found){ alert("노드 없음"); return; }

  if(blinkInterval) clearInterval(blinkInterval);

  blinkNode=found;

  let visible=true;

  blinkInterval=setInterval(()=>{ visible=!visible; draw(); },400);

  setTimeout(()=>{ clearInterval(blinkInterval); blinkInterval=null; blinkNode=null; draw(); },3000);

}


function resetAll(skipConfirm=false){

  if(!skipConfirm && !confirm("모든 노드와 경로를 초기화하시겠습니까?")) return;

  nodes={}; edges={}; nodeNames=[]; pathNodes=[]; updateSelectOptions();

  if(blinkInterval){ clearInterval(blinkInterval); blinkInterval=null; }

  blinkNode=null; draw();

}


// ----------------- 노드 다이얼로그 -----------------

let tempX=0,tempY=0,selectedType='normal';

function openNodeDialog(x,y){ tempX=x; tempY=y; selectedType='normal'; document.getElementById('nodeDialog').style.display='block'; }

function closeNodeDialog(){ document.getElementById('nodeDialog').style.display='none'; }

document.querySelectorAll('#nodeDialog .typeBtn').forEach(btn=>btn.addEventListener('click',()=>{ selectedType=btn.dataset.type; }));


function saveNode(){

  const name=document.getElementById('nodeName').value.trim();

  const floor=document.querySelector('input[name="floorType"]:checked').value+document.getElementById('floorNumber').value+'층';

  const note=document.getElementById('nodeNote').value;

  if(!name){ alert("노드 이름 입력"); return; }

  const fullName=`${floor} ${name}`;

  if(nodes[fullName]){ alert("이미 존재"); return; }

  nodes[fullName]={x:tempX,y:tempY,floor,type:selectedType,note};

  nodeNames.push(fullName);


  // 같은 층 일반 연결

  nodeNames.forEach(n=>{

    if(n!==fullName && nodes[n].floor===floor){

      edges[n]=edges[n]||[]; edges[n].push(fullName);

      edges[fullName]=edges[fullName]||[]; edges[fullName].push(n);

    }

  });


  // 층간 이동 커넥터 연결

  if(selectedType==='escalator'||selectedType==='stairs'||selectedType==='elevator'){

    for(let n of nodeNames){

      if(n===fullName) continue;

      if(nodes[n].type===selectedType && getLabel(n)===getLabel(fullName)){

        edges[n]=edges[n]||[]; edges[n].push(fullName);

        edges[fullName]=edges[fullName]||[]; edges[fullName].push(n);

      }

    }

  }


  updateSelectOptions(); closeNodeDialog(); draw();

}


// ----------------- 그림 -----------------

function draw(){

  ctx.clearRect(0,0,canvas.width,canvas.height);

  if(floorImg) ctx.drawImage(floorImg,0,0,canvas.width,canvas.height);


  // 경로 그리기

  if(pathNodes.length>1){

    ctx.strokeStyle='red'; ctx.lineWidth=4;

    ctx.beginPath();

    pathNodes.forEach((n,i)=>{

      const node=nodes[n];

      if(i===0) ctx.moveTo(node.x,node.y);

      else ctx.lineTo(node.x,node.y);

    });

    ctx.stroke();

  }


  // 노드 그리기

  nodeNames.forEach(n=>{

    const node=nodes[n];

    ctx.beginPath();

    ctx.arc(node.x,node.y,8,0,Math.PI*2);

    if(n===blinkNode) ctx.fillStyle='yellow';

    else if(pathNodes.includes(n)) ctx.fillStyle='red';

    else ctx.fillStyle=node.type==='normal'?'blue':(node.type==='stairs'?'orange':node.type==='elevator'?'green':'purple');

    ctx.fill();

    ctx.strokeStyle='#000'; ctx.stroke();

  });

}


resizeCanvas();

</script>

</body>

</html>

댓글 없음:

댓글 쓰기