2025년 8월 29일 금요일

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>

댓글 없음:

댓글 쓰기