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>

11-1

 <!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;

      nodes = {};

      edges = {};

      nodeNames = [];

      pathNodes = [];

      blinkNode = null;

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

      updateSelectOptions();

      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}' 노드를 삭제하시겠습니까?`)){

        // 1) nodes에서 삭제

        delete nodes[clickedNode];


        // 2) nodeNames에서 삭제

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


        // 3) edges에서 연결 제거

        delete edges[clickedNode];

        for(let key in edges){

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

        }


        // 4) 선택 박스 갱신

        updateSelectOptions();


        // 5) 경로 초기화

        pathNodes = [];


        draw();

      }

      return;

    }

  }


  openNodeDialog(x,y);

});


function findNodeAt(x,y){

  for(let name of nodeNames){

    const node = nodes[name];

    const dx=node.x-x;

    const dy=node.y-y;

    if(Math.sqrt(dx*dx+dy*dy)<=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 distance(n1,n2){

  const dx=n1.x-n2.x;

  const dy=n1.y-n2.y;

  return Math.sqrt(dx*dx+dy*dy);

}


// Dijkstra with allowed nodes

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

  const visited=new Set();

  const distances={};

  const prev={};

  const targetNodes=allowedNodes||nodeNames;

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

  distances[start]=0;


  while(visited.size<targetNodes.length){

    let minNode=null;

    for(let node of targetNodes){

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

        minNode=node;

      }

    }

    if(minNode===null) break;

    if(minNode===end) break;

    visited.add(minNode);

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

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

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

      if(alt<distances[neighbor]){

        distances[neighbor]=alt;

        prev[neighbor]=minNode;

      }

    }

  }


  const path=[];

  let curr=end;

  while(curr){

    path.unshift(curr);

    curr=prev[curr];

  }

  return path;

}


function getLabel(fullName){

  return fullName.split(' ').slice(1).join(' ').trim();

}


/* ====================== [UPDATED] 규칙 기반 층간 이동 지원 ======================= */

// 층 문자열을 파싱해서 {level, zone} 반환

// level: 지상 양수, 지하 음수. zone: '' | 'A' | 'B'

function parseFloor(floorStr){

  const s = floorStr.trim();

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

  const isGround   = s.includes('지상') || (!isBasement && s.includes('층'));

  // 숫자

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

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

  const level = isBasement ? -num : num;


  // 분기(zone): "지하2층 A", "지하3층 B" 등에서 A/B 인식

  let zone = '';

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

  if(zoneMatch){

    zone = zoneMatch[0].toUpperCase();

  }else{

    // "지하2층A" 같이 붙어있는 경우

    const compactZone = s.replace(/\s+/g,'').match(/지하[23]층([AB])/i);

    if(compactZone) zone = compactZone[1].toUpperCase();

  }

  return { level, zone };

}


// 같은 "구역" 판정: level 동일 && zone 동일 (단, zone이 없으면 동일 층으로 간주)

function sameArea(f1, f2){

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

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

  // 분기가 있는 층(-2, -3)에서는 zone까지 같아야 같은 층으로 간주

  const isSplit = (a.level === -2 || a.level === -3);

  if(isSplit) return a.zone === b.zone && a.zone !== '';

  return true;

}


// 규칙 테이블: (fromState) -> [ {toState, filters[]} ]

// filter: {type:'stairs'|'escalator'|'elevator', labelIncludes:'1번' 등}

const RULES = {

  // 지상1층 -> 지하1층 : 1번/4번/5번 계단

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

  // 지하1층 -> 지하2층 A : 1번 에스컬레이터

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

  // 지하2층 A -> 지하3층 A/B : 2번/3번 에스컬레이터

  '-2:A': [

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

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

  ],

  // 지상2층 -> 지하2층 B : 2번 계단

  '2:': [ { to:'-2:B', filters:[{type:'stairs',labelIncludes:'2번'}] } ],

  // 지상3층 -> 지하2층 B : 3번 계단

  '3:': [ { to:'-2:B', filters:[{type:'stairs',labelIncludes:'3번'}] } ],

  // 지하2층 B -> 지하3층 A/B : (A: 1번 엘리베이터/4번 에스컬레이터) (B: 2번 엘리베이터/5번 에스컬레이터)

  '-2:B': [

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

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

  ]

};


// 상태 키 생성: "level:zone" (zone 없으면 빈 문자열)

function stateKeyFromFloorStr(floorStr){

  const {level, zone} = parseFloor(floorStr);

  return `${level}:${zone||''}`;

}


// 상태 키에서 사람이 읽는 설명(디버그용)

function prettyState(key){

  const [levelStr, zone] = key.split(':');

  const level = parseInt(levelStr,10);

  const isB = level < 0;

  const abs = Math.abs(level);

  if(isB){

    return `지하${abs}층${zone?` ${zone}`:''}`;

  }else{

    return `지상${abs}층`;

  }

}


// RULES 기반 인접 상태 목록

function neighbors(state){

  return (RULES[state]||[]).map(r=>r.to);

}


// BFS로 층 상태 경로 계산

function bfsStatePath(startState, endState){

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

  const q=[startState];

  const prev={};

  const 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){

        const 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} = parseStateKey(stateKey);

  const candidates = nodeNames.filter(n=>{

    const f = parseFloor(nodes[n].floor);

    if(f.level!==level) return false;

    // 분기층에서는 zone 일치 필요

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

    // 타입/라벨 필터 중 하나라도 만족하면 후보

    const label = getLabel(n);

    return filters.some(fi=>{

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

    });

  });

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

  // 출발점과 가장 가까운 커넥터 선택

  const currNode = nodes[currNodeName];

  return candidates.reduce((best,now)=>

    distance(currNode, nodes[now]) < distance(currNode, nodes[best]) ? now : best

  );

}


// 반대편 층에서 "라벨 동일 + 타입 동일" 노드 찾기

function matchConnectorOnNext(closestCurrName, nextStateKey){

  const {level, zone} = parseStateKey(nextStateKey);

  const t = nodes[closestCurrName].type;

  const label = getLabel(closestCurrName);

  const candidates = nodeNames.filter(n=>{

    const f = parseFloor(nodes[n].floor);

    if(f.level!==level) return false;

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

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

  });

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

  // 현재 커넥터와 가장 가까운 동일 라벨/타입 노드 선택

  const curr = nodes[closestCurrName];

  return candidates.reduce((best,now)=>

    distance(curr, nodes[now]) < distance(curr, nodes[best]) ? now : best

  );

}


function parseStateKey(key){

  const [lv, zone] = key.split(':');

  return { level: parseInt(lv,10), zone: zone||'' };

}


// ---------- 최종 경로 계산 ----------

function multiFloorPath(startName,endName){

  const startFloorStr = nodes[startName].floor;

  const endFloorStr   = nodes[endName].floor;


  const startState = stateKeyFromFloorStr(startFloorStr);

  const endState   = stateKeyFromFloorStr(endFloorStr);


  // 같은 구역(같은 층 + 같은 분기)일 때만 같은 층 최단거리 허용

  if(sameArea(startFloorStr, endFloorStr)){

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

    return dijkstra(startName, endName, allowed);

  }


  // 상태 그래프에서 층간 경로 찾기

  const statePath = bfsStatePath(startState, endState);

  if(!statePath){

    alert(`층간 경로가 없습니다. (규칙으로 연결되지 않음)\n${prettyState(startState)} → ${prettyState(endState)}`);

    return null;

  }


  let currNode = startName;

  const full = [];


  // 각 상태 전이마다: (currState -> nextState)

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

    const currState = statePath[i];

    const nextState = statePath[i+1];


    // 사용 가능한 필터(커넥터 종류/번호)

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

    if(!rule){

      alert(`내부 규칙 오류: ${prettyState(currState)} → ${prettyState(nextState)} 전이를 찾을 수 없습니다.`);

      return null;

    }


    // 1) 현재 상태에서 가장 가까운 "허용 커넥터"

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

    if(!closestCurr){

      alert(`${prettyState(currState)} 에서 사용할 수 있는 커넥터 노드가 없습니다.\n(필요: ${rule.filters.map(f=>`${f.labelIncludes} ${f.type}`).join(' 또는 ')})`);

      return null;

    }


    // 2) 다음 상태에서 같은 라벨/타입의 대응 노드

    const closestNext = matchConnectorOnNext(closestCurr, nextState);

    if(!closestNext){

      alert(`${prettyState(nextState)} 에서 '${getLabel(closestCurr)}' (${nodes[closestCurr].type})에 대응하는 노드를 찾을 수 없습니다.`);

      return null;

    }


    // 3) 현재 상태 내부 최단거리(타입 무시)로 커넥터까지 이동

    const allowedCurr = nodeNames.filter(n=> {

      const f = nodes[n].floor;

      return sameArea(f, 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));


    // 4) 층 이동(커넥터 대응 노드로 ‘점프’)

    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;

}

/* ====================== [/UPDATED] ======================= */


function findPath(){

  const startName=document.getElementById('start').value;

  const endName=document.getElementById('end').value;

  pathNodes=multiFloorPath(startName,endName)||[];

  draw();

}


// 검색 입력 시 label 기준으로 노드 찾기

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(()=>{ draw(visible); visible=!visible; },500);

}


// 길찾기/검색 초기화

function resetAll(){

  if(!confirm("길찾기 경로와 검색 상태를 초기화합니다.")) return;

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

  blinkNode=null;

  pathNodes=[];

  draw();

}


// 그리기

function draw(blinkVisible=true){

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

  if(floorImg){

    const scale=Math.min(canvas.width/floorImg.width,canvas.height/floorImg.height);

    const imgW=floorImg.width*scale;

    const imgH=floorImg.height*scale;

    const x=(canvas.width-imgW)/2;

    const y=(canvas.height-imgH)/2;

    ctx.drawImage(floorImg,x,y,imgW,imgH);

  }

  nodeNames.forEach(n=>{

    ctx.beginPath();

    ctx.arc(nodes[n].x,nodes[n].y,6,0,Math.PI*2);

    ctx.fillStyle=(n===blinkNode && !blinkVisible)?'white':'gray';

    ctx.fill();

    ctx.strokeStyle='black';

    ctx.stroke();

  });


  if(pathNodes.length>=2){

    ctx.strokeStyle='red';

    ctx.lineWidth=4;

    ctx.beginPath();

    ctx.moveTo(nodes[pathNodes[0]].x,nodes[pathNodes[0]].y);

    for(let i=1;i<pathNodes.length;i++) ctx.lineTo(nodes[pathNodes[i]].x,nodes[pathNodes[i]].y);

    ctx.stroke();

  }


  if(pathNodes.length>=1){

    [pathNodes[0], pathNodes[pathNodes.length-1]].forEach((node,i)=>{

      ctx.beginPath();

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

      ctx.fillStyle=i===0?'green':'blue';

      ctx.fill();

      ctx.strokeStyle='black';

      ctx.stroke();

    });

  }

}


resizeCanvas();


/* ---------- 노드 추가 다이얼로그 ---------- */

let pendingX=0, pendingY=0;

let selectedType="normal";


document.querySelectorAll(".typeBtn").forEach(btn=>{

  btn.addEventListener("click",()=>{ 

    selectedType=btn.dataset.type;

    document.querySelectorAll(".typeBtn").forEach(b=>b.style.background="");

    btn.style.background="yellow";

  });

});


function openNodeDialog(x,y){

  pendingX=x; pendingY=y;

  selectedType="normal";

  document.querySelectorAll(".typeBtn").forEach(b=>b.style.background="");

  document.querySelector(".typeBtn[data-type='normal']").style.background="yellow";

  document.getElementById("nodeDialog").style.display="block";

}


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


function saveNode(){

  const floorType=document.querySelector("input[name=floorType]:checked").value;

  const floorNum=document.getElementById("floorNumber").value;

  if(!floorNum){ alert("층 번호를 입력하세요"); return; }

  let floor=`${floorType}${floorNum}층`;


  // 사용자가 직접 A/B 구역을 이름에 넣고 싶다면, 노드 이름에 'A' 또는 'B'를 포함하세요.

  // 예: "지하2층 A"로 만들려면 층 입력 뒤, 노드 이름에 A/B를 붙여 저장하거나

  // 아래처럼 floor 변수 후처리를 수정해도 됩니다.


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

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

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

  if(nodes[fullName]){ alert("같은 이름의 노드가 이미 존재합니다."); return; }

  const note=document.getElementById("nodeNote").value.trim();

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

  nodeNames.push(fullName);

  if(nodeNames.length>1){

    const prev=nodeNames[nodeNames.length-2];

    if(!edges[prev]) edges[prev]=[];

    if(!edges[fullName]) edges[fullName]=[];

    edges[prev].push(fullName);

    edges[fullName].push(prev);

  }

  updateSelectOptions();

  closeNodeDialog();

  draw();

}

</script>

</body>

</html>

INDOOR LOCATION 11

 <!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;

      nodes = {};

      edges = {};

      nodeNames = [];

      pathNodes = [];

      blinkNode = null;

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

      updateSelectOptions();

      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);

      return;

    }

  }


  openNodeDialog(x,y);

});


function findNodeAt(x,y){

  for(let name of nodeNames){

    const node = nodes[name];

    const dx=node.x-x;

    const dy=node.y-y;

    if(Math.sqrt(dx*dx+dy*dy)<=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 distance(n1,n2){

  const dx=n1.x-n2.x;

  const dy=n1.y-n2.y;

  return Math.sqrt(dx*dx+dy*dy);

}


// Dijkstra with allowed nodes

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

  const visited=new Set();

  const distances={};

  const prev={};

  const targetNodes=allowedNodes||nodeNames;

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

  distances[start]=0;


  while(visited.size<targetNodes.length){

    let minNode=null;

    for(let node of targetNodes){

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

        minNode=node;

      }

    }

    if(minNode===null) break;

    if(minNode===end) break;

    visited.add(minNode);

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

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

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

      if(alt<distances[neighbor]){

        distances[neighbor]=alt;

        prev[neighbor]=minNode;

      }

    }

  }


  const path=[];

  let curr=end;

  while(curr){

    path.unshift(curr);

    curr=prev[curr];

  }

  return path;

}


function getLabel(fullName){

  return fullName.split(' ').slice(1).join(' ').trim();

}


/* ====================== [UPDATED] 규칙 기반 층간 이동 지원 ======================= */

// 층 문자열을 파싱해서 {level, zone} 반환

// level: 지상 양수, 지하 음수. zone: '' | 'A' | 'B'

function parseFloor(floorStr){

  const s = floorStr.trim();

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

  const isGround   = s.includes('지상') || (!isBasement && s.includes('층'));

  // 숫자

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

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

  const level = isBasement ? -num : num;


  // 분기(zone): "지하2층 A", "지하3층 B" 등에서 A/B 인식

  let zone = '';

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

  if(zoneMatch){

    zone = zoneMatch[0].toUpperCase();

  }else{

    // "지하2층A" 같이 붙어있는 경우

    const compactZone = s.replace(/\s+/g,'').match(/지하[23]층([AB])/i);

    if(compactZone) zone = compactZone[1].toUpperCase();

  }

  return { level, zone };

}


// 같은 "구역" 판정: level 동일 && zone 동일 (단, zone이 없으면 동일 층으로 간주)

function sameArea(f1, f2){

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

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

  // 분기가 있는 층(-2, -3)에서는 zone까지 같아야 같은 층으로 간주

  const isSplit = (a.level === -2 || a.level === -3);

  if(isSplit) return a.zone === b.zone && a.zone !== '';

  return true;

}


// 규칙 테이블: (fromState) -> [ {toState, filters[]} ]

// filter: {type:'stairs'|'escalator'|'elevator', labelIncludes:'1번' 등}

const RULES = {

  // 지상1층 -> 지하1층 : 1번/4번/5번 계단

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

  // 지하1층 -> 지하2층 A : 1번 에스컬레이터

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

  // 지하2층 A -> 지하3층 A/B : 2번/3번 에스컬레이터

  '-2:A': [

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

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

  ],

  // 지상2층 -> 지하2층 B : 2번 계단

  '2:': [ { to:'-2:B', filters:[{type:'stairs',labelIncludes:'2번'}] } ],

  // 지상3층 -> 지하2층 B : 3번 계단

  '3:': [ { to:'-2:B', filters:[{type:'stairs',labelIncludes:'3번'}] } ],

  // 지하2층 B -> 지하3층 A/B : (A: 1번 엘리베이터/4번 에스컬레이터) (B: 2번 엘리베이터/5번 에스컬레이터)

  '-2:B': [

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

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

  ]

};


// 상태 키 생성: "level:zone" (zone 없으면 빈 문자열)

function stateKeyFromFloorStr(floorStr){

  const {level, zone} = parseFloor(floorStr);

  return `${level}:${zone||''}`;

}


// 상태 키에서 사람이 읽는 설명(디버그용)

function prettyState(key){

  const [levelStr, zone] = key.split(':');

  const level = parseInt(levelStr,10);

  const isB = level < 0;

  const abs = Math.abs(level);

  if(isB){

    return `지하${abs}층${zone?` ${zone}`:''}`;

  }else{

    return `지상${abs}층`;

  }

}


// RULES 기반 인접 상태 목록

function neighbors(state){

  return (RULES[state]||[]).map(r=>r.to);

}


// BFS로 층 상태 경로 계산

function bfsStatePath(startState, endState){

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

  const q=[startState];

  const prev={};

  const 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){

        const 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} = parseStateKey(stateKey);

  const candidates = nodeNames.filter(n=>{

    const f = parseFloor(nodes[n].floor);

    if(f.level!==level) return false;

    // 분기층에서는 zone 일치 필요

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

    // 타입/라벨 필터 중 하나라도 만족하면 후보

    const label = getLabel(n);

    return filters.some(fi=>{

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

    });

  });

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

  // 출발점과 가장 가까운 커넥터 선택

  const currNode = nodes[currNodeName];

  return candidates.reduce((best,now)=>

    distance(currNode, nodes[now]) < distance(currNode, nodes[best]) ? now : best

  );

}


// 반대편 층에서 "라벨 동일 + 타입 동일" 노드 찾기

function matchConnectorOnNext(closestCurrName, nextStateKey){

  const {level, zone} = parseStateKey(nextStateKey);

  const t = nodes[closestCurrName].type;

  const label = getLabel(closestCurrName);

  const candidates = nodeNames.filter(n=>{

    const f = parseFloor(nodes[n].floor);

    if(f.level!==level) return false;

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

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

  });

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

  // 현재 커넥터와 가장 가까운 동일 라벨/타입 노드 선택

  const curr = nodes[closestCurrName];

  return candidates.reduce((best,now)=>

    distance(curr, nodes[now]) < distance(curr, nodes[best]) ? now : best

  );

}


function parseStateKey(key){

  const [lv, zone] = key.split(':');

  return { level: parseInt(lv,10), zone: zone||'' };

}


// ---------- 최종 경로 계산 ----------

function multiFloorPath(startName,endName){

  const startFloorStr = nodes[startName].floor;

  const endFloorStr   = nodes[endName].floor;


  const startState = stateKeyFromFloorStr(startFloorStr);

  const endState   = stateKeyFromFloorStr(endFloorStr);


  // 같은 구역(같은 층 + 같은 분기)일 때만 같은 층 최단거리 허용

  if(sameArea(startFloorStr, endFloorStr)){

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

    return dijkstra(startName, endName, allowed);

  }


  // 상태 그래프에서 층간 경로 찾기

  const statePath = bfsStatePath(startState, endState);

  if(!statePath){

    alert(`층간 경로가 없습니다. (규칙으로 연결되지 않음)\n${prettyState(startState)} → ${prettyState(endState)}`);

    return null;

  }


  let currNode = startName;

  const full = [];


  // 각 상태 전이마다: (currState -> nextState)

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

    const currState = statePath[i];

    const nextState = statePath[i+1];


    // 사용 가능한 필터(커넥터 종류/번호)

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

    if(!rule){

      alert(`내부 규칙 오류: ${prettyState(currState)} → ${prettyState(nextState)} 전이를 찾을 수 없습니다.`);

      return null;

    }


    // 1) 현재 상태에서 가장 가까운 "허용 커넥터"

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

    if(!closestCurr){

      alert(`${prettyState(currState)} 에서 사용할 수 있는 커넥터 노드가 없습니다.\n(필요: ${rule.filters.map(f=>`${f.labelIncludes} ${f.type}`).join(' 또는 ')})`);

      return null;

    }


    // 2) 다음 상태에서 같은 라벨/타입의 대응 노드

    const closestNext = matchConnectorOnNext(closestCurr, nextState);

    if(!closestNext){

      alert(`${prettyState(nextState)} 에서 '${getLabel(closestCurr)}' (${nodes[closestCurr].type})에 대응하는 노드를 찾을 수 없습니다.`);

      return null;

    }


    // 3) 현재 상태 내부 최단거리(타입 무시)로 커넥터까지 이동

    const allowedCurr = nodeNames.filter(n=> {

      const f = nodes[n].floor;

      return sameArea(f, 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));


    // 4) 층 이동(커넥터 대응 노드로 ‘점프’)

    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;

}

/* ====================== [/UPDATED] ======================= */


function findPath(){

  const startName=document.getElementById('start').value;

  const endName=document.getElementById('end').value;

  pathNodes=multiFloorPath(startName,endName)||[];

  draw();

}


// 검색 입력 시 label 기준으로 노드 찾기

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(()=>{ draw(visible); visible=!visible; },500);

}


// 길찾기/검색 초기화

function resetAll(){

  if(!confirm("길찾기 경로와 검색 상태를 초기화합니다.")) return;

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

  blinkNode=null;

  pathNodes=[];

  draw();

}


// 그리기

function draw(blinkVisible=true){

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

  if(floorImg){

    const scale=Math.min(canvas.width/floorImg.width,canvas.height/floorImg.height);

    const imgW=floorImg.width*scale;

    const imgH=floorImg.height*scale;

    const x=(canvas.width-imgW)/2;

    const y=(canvas.height-imgH)/2;

    ctx.drawImage(floorImg,x,y,imgW,imgH);

  }

  nodeNames.forEach(n=>{

    ctx.beginPath();

    ctx.arc(nodes[n].x,nodes[n].y,6,0,Math.PI*2);

    ctx.fillStyle=(n===blinkNode && !blinkVisible)?'white':'gray';

    ctx.fill();

    ctx.strokeStyle='black';

    ctx.stroke();

  });


  if(pathNodes.length>=2){

    ctx.strokeStyle='red';

    ctx.lineWidth=4;

    ctx.beginPath();

    ctx.moveTo(nodes[pathNodes[0]].x,nodes[pathNodes[0]].y);

    for(let i=1;i<pathNodes.length;i++) ctx.lineTo(nodes[pathNodes[i]].x,nodes[pathNodes[i]].y);

    ctx.stroke();

  }


  if(pathNodes.length>=1){

    [pathNodes[0], pathNodes[pathNodes.length-1]].forEach((node,i)=>{

      ctx.beginPath();

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

      ctx.fillStyle=i===0?'green':'blue';

      ctx.fill();

      ctx.strokeStyle='black';

      ctx.stroke();

    });

  }

}


resizeCanvas();


/* ---------- 노드 추가 다이얼로그 ---------- */

let pendingX=0, pendingY=0;

let selectedType="normal";


document.querySelectorAll(".typeBtn").forEach(btn=>{

  btn.addEventListener("click",()=>{ 

    selectedType=btn.dataset.type;

    document.querySelectorAll(".typeBtn").forEach(b=>b.style.background="");

    btn.style.background="yellow";

  });

});


function openNodeDialog(x,y){

  pendingX=x; pendingY=y;

  selectedType="normal";

  document.querySelectorAll(".typeBtn").forEach(b=>b.style.background="");

  document.querySelector(".typeBtn[data-type='normal']").style.background="yellow";

  document.getElementById("nodeDialog").style.display="block";

}


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


function saveNode(){

  const floorType=document.querySelector("input[name=floorType]:checked").value;

  const floorNum=document.getElementById("floorNumber").value;

  if(!floorNum){ alert("층 번호를 입력하세요"); return; }

  let floor=`${floorType}${floorNum}층`;


  // 사용자가 직접 A/B 구역을 이름에 넣고 싶다면, 노드 이름에 'A' 또는 'B'를 포함하세요.

  // 예: "지하2층 A"로 만들려면 층 입력 뒤, 노드 이름에 A/B를 붙여 저장하거나

  // 아래처럼 floor 변수 후처리를 수정해도 됩니다.


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

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

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

  if(nodes[fullName]){ alert("같은 이름의 노드가 이미 존재합니다."); return; }

  const note=document.getElementById("nodeNote").value.trim();

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

  nodeNames.push(fullName);

  if(nodeNames.length>1){

    const prev=nodeNames[nodeNames.length-2];

    if(!edges[prev]) edges[prev]=[];

    if(!edges[fullName]) edges[fullName]=[];

    edges[prev].push(fullName);

    edges[fullName].push(prev);

  }

  updateSelectOptions();

  closeNodeDialog();

  draw();

}

</script>

</body>

</html>

2025년 8월 23일 토요일

좌표

 <!DOCTYPE html>

<html lang="ko">

<head>

<meta charset="utf-8">

<title>카카오맵 거리 이동 좌표 계산</title>

<style>

  body { font-family: Arial, sans-serif; margin: 0; padding: 0; }

  #map { width: 100%; height: 500px; }

  .controls { padding: 10px; background: #f9f9f9; border-bottom: 1px solid #ccc; }

  input { margin: 5px; padding: 5px; }

</style>

<script type="text/javascript" src="//dapi.kakao.com/v2/maps/sdk.js?appkey=YOUR_APP_KEY"></script>

</head>

<body>

<div class="controls">

  기준 좌표:

  <input type="number" id="lat" value="37.111111" step="0.000001"> (위도)

  <input type="number" id="lon" value="126.222222" step="0.000001"> (경도)<br>

  이동 거리:

  <input type="number" id="north" placeholder="북(+) 남(-) m"> m

  <input type="number" id="east" placeholder="동(+) 서(-) m"> m

  <button onclick="movePoint()">좌표 계산 및 마커 표시</button>

</div>

<div id="map"></div>


<script>

var mapContainer = document.getElementById('map');

var mapOption = {

    center: new kakao.maps.LatLng(37.111111, 126.222222),

    level: 5

};

var map = new kakao.maps.Map(mapContainer, mapOption);


// 기준 마커

var baseMarker = new kakao.maps.Marker({

    position: map.getCenter()

});

baseMarker.setMap(map);


// 새 마커

var newMarker = null;


// 위도/경도 → 미터 변환 기준값

function metersPerDegLat(lat){

    return 111132.954 - 559.822 * Math.cos(2 * lat * Math.PI / 180);

}

function metersPerDegLon(lat){

    return 111412.84 * Math.cos(lat * Math.PI / 180);

}


function movePoint(){

    var lat = parseFloat(document.getElementById('lat').value);

    var lon = parseFloat(document.getElementById('lon').value);

    var north = parseFloat(document.getElementById('north').value) || 0;

    var east = parseFloat(document.getElementById('east').value) || 0;


    var mPerLat = metersPerDegLat(lat);

    var mPerLon = metersPerDegLon(lat);


    var dLat = north / mPerLat;

    var dLon = east / mPerLon;


    var newLat = lat + dLat;

    var newLon = lon + dLon;


    alert("새 좌표: 위도 " + newLat.toFixed(9) + ", 경도 " + newLon.toFixed(9));


    if(newMarker) newMarker.setMap(null);

    newMarker = new kakao.maps.Marker({

        position: new kakao.maps.LatLng(newLat, newLon),

        map: map

    });


    // 지도 중심 이동

    map.setCenter(new kakao.maps.LatLng(newLat, newLon));

}

</script>

</body>

</html>

 function searchAndShow(){

  const kw=document.getElementById("keyword").value.trim();

  if(!kw){ alert("⚠️ 키워드 입력하세요."); return; }

  searchMode = true;

  clearMarkers();

  const kwLower = kw.toLowerCase().replace(/\s+/g,"");

  if(kwLower.length<2){ alert("⚠️ 최소 2글자 이상 입력"); return; }

  const normalizeSimple = str => (str||"").toLowerCase().replace(/[\s\-\_\.,\/\(\)\[\]#]/g,"");

  const kwNormalized = normalizeSimple(kwLower);

  const res = Object.keys(lampData).filter(id=>{

    const d=lampData[id];

    const fields=[d.고유이름,d.전화번호,d.주소,d.분류,d.특이사항];

    return fields.some(field=>{

      const text=(field||"").toLowerCase();

      const textNorm=normalizeSimple(text);

      return text.includes(kwLower) || textNorm.includes(kwNormalized);

    });

  }).map(id=>({id,pos:parseCoords(lampData[id].좌표)}));

  if(res.length===0){ alert("❌ 검색 결과 없음"); return; }

  const bounds=new google.maps.LatLngBounds();

  res.forEach(r=>{ addMarker(r.id,r.pos,"blue"); bounds.extend(r.pos); });

  map.fitBounds(bounds);

}


2025년 8월 13일 수요일

git hub

 폴더 만들기

1️⃣ GitHub 웹에서 data 폴더 만들기

  1. store-map 저장소 접속

  2. Add file → Create new file 클릭

  3. 파일 이름 입력란에 data/.gitkeep 입력

    • 여기서 .gitkeep은 빈 폴더를 만들기 위한 더미 파일입니다.

  4. Commit new file 클릭

    • 이제 data 폴더가 생성됨


2️⃣ data 폴더 안에 파일 업로드

  1. data 폴더 클릭

  2. Add file → Upload files 클릭

  3. 바탕화면에서 floorplan.pnglocations.json 선택

  4. Commit changes 클릭

    • 이제 두 파일이 data 폴더 안으로 업로드됨


3️⃣ 기존 루트 파일 삭제

  • 루트에 남아 있는 floorplan.pnglocations.json 클릭 → Delete this file → Commit changes


💡 Tip:

  • GitHub 웹에서는 폴더 생성 후 그 안에 업로드하는 방식만 지원

  • 파일을 “폴더 경로 포함해서” 바로 업로드하는 기능은 없음


원하시면 제가 이 상태에서 배포용 HTML이 data 폴더에서 파일을 읽도록 수정한 코드도 만들어 드릴 수 있습니다.
그거 해드릴까요?

나의 말:
+data 안의 .gitkeep 를 삭제해야 하나요 나중에

ChatGPT의 말:

네, .gitkeep빈 폴더를 GitHub에 남기기 위해 만든 더미 파일이므로, 나중에 폴더 안에 실제 파일(floorplan.png, locations.json)이 들어가면 삭제해도 됩니다.

  • 삭제해도 문제 없음

  • 실제 파일이 존재하면 Git은 폴더를 자동으로 관리하기 때문에 .gitkeep이 필요하지 않습니다.

즉, data 폴더 안에 파일이 모두 들어간 후에는 .gitkeep 삭제 가능하고, 삭제해도 HTML에서 경로(data/floorplan.png, data/locations.json)를 읽는 데 전혀 영향이 없습니다.

원하시면 제가 배포용 HTML에서 data 폴더 경로로 바로 읽도록 수정한 코드 만들어 드릴 수도 있습니다.