2025년 8월 5일 화요일

함께 만드는 출동/순찰 poi(신정2)

<!DOCTYPE html>

<html lang="ko">

<head>

  <meta charset="utf-8" />

  <title>🚓 특정 poi 기반 출동/순찰 시스템</title>

  <!-- 🔽 자동 새로고침: 30초마다 새로고침 -->

  <meta http-equiv="refresh" content="30">

  <meta name="viewport" content="width=device-width, initial-scale=1" />

  <style>

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

    h3 { text-align: center; background: #007bff; color: white; margin: 0; padding: 10px; }

    #panel { padding: 10px; text-align: center; max-width: 400px; margin: 10px auto; }

    #panel input, #panel button {

      width: 90%; max-width: 350px; padding: 10px; margin: 5px auto;

      font-size: 16px; display: block; border: 1px solid #ccc; border-radius: 5px;

    }

    #map { height: 70vh; width: 100%; }

    #clickedCoords {

      width: 90%; max-width: 350px; padding: 8px; margin: 5px auto;

      border: 1px solid #007bff; background: #eef6ff; font-size: 14px;

      border-radius: 5px; text-align: center;

    }

    #copyBtn {

      width: 90%; max-width: 350px; background: #ff9800; color: white;

      border: none; padding: 8px; border-radius: 5px; cursor: pointer;

    }

    #completeBtn {

      background: #28a745; color: white; border: none;

      padding: 10px; border-radius: 5px; font-size: 16px;

      cursor: pointer; width: 90%; max-width: 350px; margin: 10px auto; display: block;

    }

    #navigateBtn {

      background:#007bff; color:white; border:none;

      padding:10px; border-radius:5px; font-size:16px;

      cursor:pointer; width:90%; max-width:350px; margin:10px auto; display:block;

    }

    #upcomingPanel {

      position: fixed; top: 10px; right: 10px; width: 250px; max-height: 60vh;

      overflow-y: auto; background: rgba(255,255,255,0.95);

      border: 1px solid #007bff; border-radius: 5px; padding: 10px;

      font-size: 14px; z-index: 9999; box-shadow: 2px 2px 5px rgba(0,0,0,0.2);

      transition: all 0.3s ease; cursor: pointer;

    }

    #upcomingPanel h4 { margin: 0 0 5px; font-size: 16px; color: #007bff; }

    #upcomingList { list-style: none; padding: 0; margin: 0; }

    #upcomingList li { padding: 5px 8px; border-bottom: 1px solid #ddd; cursor: pointer; border-radius: 3px; user-select: none; }

    #upcomingList li:hover { background: #eef6ff; }

    #upcomingPanel.hidden { width: 50px; height: 40px; padding: 5px; overflow: hidden; cursor: pointer; }

    #upcomingPanel.hidden h4, #upcomingPanel.hidden ul { display: none; }

    #upcomingPanel.hidden::after { content: "⏰"; position: absolute; top: 10px; left: 15px; font-size: 20px; color: #007bff; }

  </style>

  <script src="https://maps.googleapis.com/maps/api/js?key=AIzaSyA5qR9rGB6Qv43IYSMoHKhArm5gJdapXGk&libraries=places"></script>

</head>

<body>

<h3>🚓 함께 만드는 순찰/출동 poi(신정2)</h3>


<div id="panel">

  <input type="text" id="keyword" placeholder="키워드를 입력하세요 (예: L0, L003, 범자, 범 피, 전화번호 일부)" />

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

  <div id="clickedCoords">📍 지도에서 위치를 클릭하면 좌표가 여기에 표시됩니다.</div>

  <button id="copyBtn" onclick="copyCoords()">좌표 복사</button>

  <button id="completeBtn" onclick="completePatrol()">✅ 순찰 완료</button>

  <button id="navigateBtn" onclick="navigateToPatrol()">📍 Google Maps 길찾기</button>

</div>


<div id="upcomingPanel">

  <h4>⏰ 순찰 후 순찰완료 요망</h4>

  <ul id="upcomingList"></ul>

</div>


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


<script>

/* ------------------------ ✅ 데이터 정의 ------------------------ */

const lampData = {


  "L001": { 분류:"가로등", 고유이름:"L001", 전화번호:"02-2345-4444", 주소:"서울 양천구 합정동", 특이사항:"중요", 좌표:"37.385719,126.658645" },


  "L002": { 분류:"가로등", 고유이름:"L002", 전화번호:"02-2345-7744", 주소:"서울 양천구 합정동", 특이사항:"점검 필요", 좌표:"37.386500,126.660000" },


  "L003": { 분류:"가로등", 고유이름:"L003", 전화번호:"02-2345-4488", 주소:"", 특이사항:"중요", 좌표:"37.387000,126.661000" },


  "CV1(42호)": { 분류:"안전조치", 고유이름:"박00", 전화번호:"", 주소:"오목로37길 11(그린빌라)", 특이사항:"25.5.16~25.8.26. 22:00~23:00, 42호)", 좌표:"37.526140,126.856032" },


  "CV3(43호)": { 분류:"안전조치", 고유이름:"이00", 전화번호:"", 주소:"오목로34길7", 특이사항:"7.8~9.7(매일 22~23시)", 좌표:"37.524296,126.855753" },


  "CV4(41호)": { 분류:"안전조치", 고유이름:"박00", 전화번호:"", 주소:"신목로5 목동삼성아파트 105동", 특이사항:"5.22~8.21(매일 13~14, 22~23)", 좌표:"37.517046,126.876516" },


  "CV5(42호)": { 분류:"안전조치", 고유이름:"허00", 전화번호:"", 주소:"목동로23길35", 특이사항:"4.15~8.14(매일 12~13시)", 좌표:"37.527701,126.859364" },


  "CV6(43호)": { 분류:"안전조치", 고유이름:"서00", 전화번호:"", 주소:"은행정로42 신서고등학교", 특이사항:"6.8~9.7(월~금 8~9, 22~23시)", 좌표:"37.523921,126.860085" },


  "CV7(43호)": { 분류:"안전조치", 고유이름:"오00", 전화번호:"", 주소:"목동로11길 33", 특이사항:"5.23~8.22(매일 23~24시)", 좌표:"37.522949,126.861280" },


  "CV8(42호)": { 분류:"안전조치", 고유이름:"강00", 전화번호:"", 주소:"중앙로52길 50", 특이사항:"7.15~8.14(매일 9~10, 21~22)", 좌표:"37.525925,126.855922" },


  "CV10(42호)": { 분류:"안전조치", 고유이름:"김00", 전화번호:"", 주소:"목동로 193", 특이사항:"7.19~8.17(11~12, 21~22))", 좌표:"37.525567,126.858867" },


  "CV11(41호)": { 분류:"안전조치", 고유이름:"조00", 전화번호:"", 주소:"신목로5길 15", 특이사항:"7.19~8.18(13~14시)", 좌표:"37.517567,126.872267" },


  "CV11-1(41호)": { 분류:"안전조치", 고유이름:"조00", 전화번호:"", 주소:"쌍용아파트 102동", 특이사항:"7.19~8.18(13~14시)", 좌표:"37.518182,126.875558" },


  "CV12(41호)": { 분류:"안전조치", 고유이름:"채00", 전화번호:"", 주소:"오목로354 101동", 특이사항:"7.18~8.17(15~16, 22~23)", 좌표:"37.523919,126.876851" },


  "CV13(41호)": { 분류:"안전조치", 고유이름:"김000", 전화번호:"", 주소:"오목로300 204동", 특이사항:"7.31~8.30(05~06, 19~20)", 좌표:"37.524183,126.870829" },


  "PP1": { 분류:"공중전화", 고유이름:"PP1", 전화번호:"02-2696-2896", 주소:"목동로 189", 특이사항:"제일은행 앞", 좌표:"37.525392,126.864424" },


  "PP2": { 분류:"공중전화", 고유이름:"PP2", 전화번호:"02-2065-9837", 주소:"신정중앙로 81", 특이사항:"현대공업사, 점심밥상 앞", 좌표:"37.526729,126.860807" },


  "PP3": { 분류:"공중전화", 고유이름:"PP3", 전화번호:"02-2696-7731", 주소:"중앙로 326", 특이사항:"재정마트 앞 노상", 좌표:"37.52496,126.850586" },


  "PP4": { 분류:"공중전화", 고유이름:"PP4", 전화번호:"02-2694-9260", 주소:"신월로 337", 특이사항:"우리은행 신정동 금융센터 앞 노상", 좌표:"37.521859,126.858348" },


  "PP5": { 분류:"공중전화", 고유이름:"PP5", 전화번호:"02-2654-0174", 주소:"신목로 85", 특이사항:"오목교7번출구 240미터(골드치과 앞)", 좌표:"37.522277,126.874334" },


  "PP6": { 분류:"공중전화", 고유이름:"PP6", 전화번호:"02-2061-4217", 주소:"목동동로 152", 특이사항:"신정2치안센터 앞", 좌표:"37.519302,126.870335" },


  "PP7": { 분류:"공중전화", 고유이름:"PP7", 전화번호:"02-2654-0136/02-2644-8193", 주소:"신정동 129-85", 특이사항:"청구아파트 101동 앞", 좌표:"37.51979,126.875529" },


  "PP8": { 분류:"공중전화", 고유이름:"PP8", 전화번호:"02-2654-0262", 주소:"목동동로 12길 60", 특이사항:"현대아파트 입구", 좌표:"37.522168,126.877685" },


  "PP9": { 분류:"공중전화", 고유이름:"PP9", 전화번호:"02-2061-6579", 주소:"신목로 5", 특이사항:"신정삼성제2관리사무소 앞", 좌표:"37.517164,126.876795" },


  "PP10": { 분류:"공중전화", 고유이름:"PP10", 전화번호:"02-2654-4323/02-2654-0138", 주소:"신목로 10", 특이사항:"목동현대아파트 상가 앞", 좌표:"37.517926,126.876297" },


  "PP11": { 분류:"공중전화", 고유이름:"PP11", 전화번호:"02-2652-9138", 주소:"오목로 245", 특이사항:"목동역 지하 1층(화장실 앞)", 좌표:"37.526109,126.8643" },


  "PP12": { 분류:"공중전화", 고유이름:"PP12", 전화번호:"02-2061-0296", 주소:"목동로 225", 특이사항:"홍익병원 본관 2층", 좌표:"37.528466,126.863688" },


};


const patrolSchedule = [


  { id:"CV1(42호)", startDate:"2025-05-16", endDate:"2025-08-26", daysOfWeek:[0,1,2,3,4,5,6], timeRanges:[{ start:"22:00", end:"23:00" }] },


  { id:"CV3(43호)", startDate:"2025-07-08", endDate:"2025-09-07", daysOfWeek:[0,1,2,3,4,5,6], timeRanges:[{ start:"22:00", end:"23:00" }] },


  { id:"CV4(41호)", startDate:"2025-05-22", endDate:"2025-08-21", daysOfWeek:[0,1,2,3,4,5,6], timeRanges:[{ start:"13:00", end:"14:00" },{ start:"22:00", end:"23:00" }] },


  { id:"CV5(42호)", startDate:"2025-04-15", endDate:"2025-08-14", daysOfWeek:[0,1,2,3,4,5,6], timeRanges:[{ start:"12:00", end:"13:00" }] },


  { id:"CV6(43호)", startDate:"2025-06-08", endDate:"2025-09-07", daysOfWeek:[1,2,3,4,5], timeRanges:[{ start:"08:00", end:"09:00" },{ start:"22:00", end:"23:00" }] },


  { id:"CV7(43호)", startDate:"2025-05-23", endDate:"2025-08-22", daysOfWeek:[0,1,2,3,4,5,6], timeRanges:[{ start:"23:00", end:"00:00" }] },


  { id:"CV8(42호)", startDate:"2025-07-15", endDate:"2025-08-14", daysOfWeek:[0,1,2,3,4,5,6], timeRanges:[{ start:"09:00", end:"10:00" },{ start:"21:00", end:"22:00" }] },


  { id:"CV10(42호)", startDate:"2025-07-19", endDate:"2025-08-17", daysOfWeek:[0,1,2,3,4,5,6], timeRanges:[{ start:"11:00", end:"12:00" },{ start:"21:00", end:"22:00" }] },


  { id:"CV11(41호)", startDate:"2025-07-19", endDate:"2025-08-18", daysOfWeek:[0,1,2,3,4,5,6], timeRanges:[{ start:"13:00", end:"14:00" }] },


  { id:"CV11-1(41호)", startDate:"2025-07-19", endDate:"2025-08-18", daysOfWeek:[0,1,2,3,4,5,6], timeRanges:[{ start:"17:00", end:"18:00" },{ start:"21:00", end:"22:00" }] },


  { id:"CV12(41호)", startDate:"2025-07-18", endDate:"2025-08-17", daysOfWeek:[0,1,2,3,4,5,6], timeRanges:[{ start:"15:00", end:"16:00" },{ start:"22:00", end:"23:00" }] },


  { id:"CV13(41호)", startDate:"2025-07-31", endDate:"2025-08-30", daysOfWeek:[0,1,2,3,4,5,6], timeRanges:[{ start:"05:00", end:"06:00" },{ start:"19:00", end:"20:00" }] },


];

/* ✅ 날짜/시간 유틸 함수 */

function isWithinDateRange(dateStr,startDateStr,endDateStr){ const d=new Date(dateStr); return d>=new Date(startDateStr)&&d<=new Date(endDateStr); }

function isDayAllowed(date,days){return days.includes(date.getDay());}

function parseCoords(s){const[a,b]=s.split(",").map(Number);return{lat:a,lng:b};}

function pad(n){ return n.toString().padStart(2,"0"); }

function normalizeText(str){ return (str||"").toLowerCase().replace(/[\s\-\_\.,]/g,""); }

function isSequenceMatch(text, keyword){ let idx=0; for(const ch of keyword){ idx=text.indexOf(ch, idx); if(idx === -1) return false; idx++; } return true; }


/* ✅ 로컬스토리지 관리 */

function getTodayKey(){ return "patrol_" + new Date().toISOString().slice(0,10); }

function loadCompletedPatrols(){ const data=localStorage.getItem(getTodayKey()); return data?new Set(JSON.parse(data)):new Set(); }

function saveCompletedPatrols(){ localStorage.setItem(getTodayKey(), JSON.stringify([...completedPatrols])); }

let completedPatrols = loadCompletedPatrols();


/* ✅ 상태 변수 */

let activePatrol = null;

let lastClickedCoord = "";

let map, markers=[], infoWindow;


/* ✅ 순찰 리스트 표시 */

function showUpcomingPatrols(){

  const now=new Date(), nowM=now.getHours()*60+now.getMinutes();

  const list=document.getElementById("upcomingList");

  list.innerHTML="";

  const upcoming=[];


  patrolSchedule.forEach(task=>{

    if(!isWithinDateRange(now.toISOString().slice(0,10),task.startDate,task.endDate)) return;

    if(!isDayAllowed(now,task.daysOfWeek)) return;


    task.timeRanges.forEach(r=>{

      const [sH,sM]=r.start.split(":").map(Number);

      const [eH,eM]=r.end.split(":").map(Number);

      const startM=sH*60+sM-30;

      const endM=eH*60+eM+30;

      const isOngoing=nowM>=sH*60+sM && nowM<=eH*60+eM;

      const isInWindow=nowM>=startM && nowM<=endM;

      const patrolKey=`${task.id}|${r.start}~${r.end}`;


      if(isInWindow && !completedPatrols.has(patrolKey)){

        upcoming.push({task,hour:sH,minute:sM,endHour:eH,endMinute:eM,key:patrolKey,isInTime:isOngoing});

      }

    });

  });


  if(upcoming.length===0){ list.innerHTML="<li>✅ 예정 없음</li>"; return; }


  upcoming.sort((a,b)=>(a.hour*60+a.minute)-(b.hour*60+b.minute));

  upcoming.forEach(item=>{

    const li=document.createElement("li");

    li.style.color=item.isInTime?"blue":"black";

    li.textContent=`${item.task.id} - ${pad(item.hour)}:${pad(item.minute)}~${pad(item.endHour)}:${pad(item.endMinute)}`;

    li.onclick=()=>{

      activePatrol={task:item.task,hour:item.hour,minute:item.minute,key:item.key};

      const id = item.task.id;

      if(!lampData[id]){ alert("해당 ID의 좌표 정보가 없습니다."); return; }

      const pos=parseCoords(lampData[id].좌표);

      window.open(`https://www.google.com/maps/dir/?api=1&destination=${pos.lat},${pos.lng}`,"_blank");

    };

    list.appendChild(li);

  });

}


/* ------------------------ ✅ 지도 기능 ------------------------ */

function initMap(){

  map=new google.maps.Map(document.getElementById("map"),{center:{lat:37.3857,lng:126.6586},zoom:15});

  infoWindow=new google.maps.InfoWindow();

  map.addListener("click",e=>{

    lastClickedCoord=`${e.latLng.lat().toFixed(6)},${e.latLng.lng().toFixed(6)}`;

    document.getElementById("clickedCoords").innerText=`📍 클릭 좌표: ${lastClickedCoord}`;

  });

}

function copyCoords(){ if(!lastClickedCoord){ alert("⚠️ 먼저 지도를 클릭하세요."); return; } navigator.clipboard.writeText(lastClickedCoord).then(()=>alert("✅ 복사됨: "+lastClickedCoord)); }

function clearMarkers(){ markers.forEach(m=>m.setMap(null)); markers=[]; }

function addMarker(id,pos,color){

  const m=new google.maps.Marker({position:pos,map,label:id,icon:{url:`http://maps.google.com/mapfiles/ms/icons/${color}-dot.png`}});

  m.addListener("click", () => {

    const data = lampData[id];

    infoWindow.setContent(`

      <div style="min-width:150px;">

        <strong>${data.고유이름}</strong><br/>

        <b>분류:</b> ${data.분류}<br/>

        <b>전화번호:</b> ${data.전화번호}<br/>

        <b>주소:</b> ${data.주소}<br/>

        <b>특이사항:</b> ${data.특이사항}

      </div>

  `  );

    infoWindow.open(map, m);

  });

  m.addListener("dblclick",()=>{const c=lampData[id].좌표.split(",");window.open(`https://www.google.com/maps/dir/?api=1&destination=${c[0]},${c[1]}`,"_blank");});

  markers.push(m);

}


/* ✅ 검색 */

function searchAndShow(){

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

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

  clearMarkers();

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

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


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

    const d=lampData[id];

    const name=normalizeText(d.고유이름);

    if(name===kwNoSpace) return true;

    if(/^l\d+$/i.test(d.고유이름)&&d.고유이름.toLowerCase().endsWith(kwNoSpace)) return true;

    if(isSequenceMatch(name,kwNoSpace)) return true;

    if(normalizeText(d.전화번호).includes(kwNoSpace)) return true;

    if([d.주소,d.분류,d.특이사항].some(f=>isSequenceMatch(normalizeText(f),kwNoSpace))) return true;

    return false;

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

}


/* ✅ 길찾기 버튼 */

function navigateToPatrol(){

  if(!lastClickedCoord){ alert("⚠️ 먼저 지도를 클릭하여 목적지를 선택하세요."); return; }

  const [lat,lng]=lastClickedCoord.split(",");

  window.open(`https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`,"_blank");

}


/* ✅ 📌 순찰 완료 → 로그 파일에 append */

function completePatrol(){

  if(!activePatrol){ alert("🚫 진행 중인 순찰이 없습니다."); return; }

  completedPatrols.add(activePatrol.key);

  saveCompletedPatrols();


  const patrolId = activePatrol.task.id;  // (예: "CV11(41호)")

  const data = lampData[patrolId];

  const patrolTime = new Date().toLocaleString("ko-KR");

  const logLine = `아이디: ${patrolId}, 순찰시간: ${patrolTime}, 고유이름: ${data?.고유이름 || "이름 정보 없음"},  주소: ${data?.주소 || "주소 정보 없음"}\n`;



  // ✅ 기존 로그가 있으면 이어붙이기

  const existingLog = localStorage.getItem("patrol_text_log") || "";

  const newLog = existingLog + logLine;

  localStorage.setItem("patrol_text_log", newLog);


  // ✅ 하나의 파일로 다운로드

  const blob = new Blob([newLog], { type: "text/plain" });

  const url = URL.createObjectURL(blob);

  const a = document.createElement("a");

  a.href = url;

  a.download = "patrol_log.txt";

  document.body.appendChild(a);

  a.click();

  document.body.removeChild(a);

  URL.revokeObjectURL(url);


  alert(`✅ ${activePatrol.task.id} (${activePatrol.key.split("|")[1]}) 순찰 완료!`);

  activePatrol=null;

  showUpcomingPatrols();

}



/* ✅ 초기화 */

document.addEventListener("DOMContentLoaded",()=>{

  document.getElementById("upcomingPanel").addEventListener("click",e=>{

    if(e.target.tagName.toLowerCase()!=="li") e.currentTarget.classList.toggle("hidden");

  });

});

window.onload=()=>{initMap();showUpcomingPatrols();setInterval(showUpcomingPatrols,60000);};

</script>


</body>

</html>

댓글 없음:

댓글 쓰기