앱 HTML들의 복붙 현황을 조사하다 발견. 같은 이름의 escHtml이 4가지 버전으로
갈라져 있었고, 그 차이가 실제 동작 차이였음.
결함 1 — 속성 인젝션:
6개 앱(detective/investor/lawyer/mind/traffic/weather)과 2개 앱(nvr/writer)의
버전이 & < > 만 이스케이프하고 따옴표는 건드리지 않았음. 그런데 이 함수를 HTML
속성값 안에서 호출하는 곳이 8군데 있었음:
writer-app src="${escHtml(b.cover)}" href="${escHtml(it.url)}" href="${escHtml(b.link)}"
nvr-app name="${escHtml(...)}" x2
lawyer-app id=/name="${escHtml(...)}"
writer-app의 b.cover는 routes-writer.ts가 Google Books/구텐베르크/알라딘 응답을
그대로 담아 보내는 값(vi.imageLinks?.thumbnail 등) — 우리가 통제하지 않는 외부
데이터가 따옴표 미이스케이프 상태로 속성에 들어가고 있었음. 실증:
입력 x" onerror=alert(1) y="
이전 <img src="x" onerror=alert(1) y=""> ← 속성 탈출
이후 <img src="x" onerror=alert(1)...">
결함 2 — 숫자 0과 false가 화면에서 사라짐:
6개 앱이 String(s||'')를 쓰고 있어 escHtml(0)이 '', escHtml(false)도 ''를
반환했음. 재생수 0, 조회수 0 같은 값이 빈칸으로 렌더링되는 경로.
nvr/writer만 String(s==null?'':s)로 올바르게 처리하고 있었음.
11개 앱 전부를 language-app이 이미 쓰고 있던 가장 완전한 형태로 통일:
String(s ?? '') + [&<>"'] 전체 이스케이프
따옴표/홑따옴표를 엔티티로 바꿔도 텍스트 문맥에서는 브라우저가 동일하게
렌더링하므로 기존 화면 변화 없음. 배포 후 7개 앱 HTTP 200 및 writer-app의 실제
렌더 경로(악의적 커버 URL)로 검증함.
남은 중복은 이번에 수정하지 않음 — 조사 결과 callChat(7종)/mobToggle(10종)/
initSessionId(7종)은 발산 버그가 아니라 앱별 정당한 차이였음(세션 접두사, 패널
구조, Windy 자동로드 같은 훅). 통합하려면 훅 설계가 필요하고 지금 고장난 곳은
없으므로 보류.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
459 lines
22 KiB
HTML
459 lines
22 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>🚦 교통 CCTV</title>
|
|
<link rel="icon" type="image/png" sizes="64x64" href="/homeclaw_logo.png">
|
|
<link rel="stylesheet" href="../css/styles.css">
|
|
<script>try{var _t=localStorage.getItem('homeclaw_theme')||'dark';document.documentElement.setAttribute('data-theme',_t);}catch(e){}</script>
|
|
<style>
|
|
:root{--brand:#f59e0b;}
|
|
html,body{height:100%;margin:0;padding:0;}
|
|
body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;display:flex;flex-direction:column;overflow:hidden;height:100dvh;}
|
|
|
|
.hdr{display:flex;align-items:center;gap:8px;padding:6px 12px;background:var(--panel);border-bottom:1px solid var(--line);flex-shrink:0;}
|
|
.hdr h1{font-size:13px;font-weight:700;margin:0;}
|
|
.hdr-sp{flex:1;}
|
|
.hdr-btn{background:none;border:1.5px solid var(--line);border-radius:6px;padding:3px 9px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;}
|
|
.hdr-btn:hover{border-color:var(--brand);color:var(--brand);}
|
|
|
|
/* ── 사이드바 ── */
|
|
.sidebar{width:280px;flex-shrink:0;display:flex;flex-direction:column;border-right:1px solid var(--line);background:var(--panel);min-height:0;}
|
|
.sb-top{padding:8px;border-bottom:1px solid var(--line);flex-shrink:0;}
|
|
.search-box{width:100%;background:var(--panel-2);border:1.5px solid var(--line);border-radius:7px;padding:5px 10px;font-size:12px;color:var(--text);font-family:inherit;outline:none;box-sizing:border-box;transition:.15s;}
|
|
.search-box:focus{border-color:var(--brand);}
|
|
.region-bar{display:flex;gap:4px;flex-wrap:wrap;padding:6px 8px;border-bottom:1px solid var(--line);flex-shrink:0;}
|
|
.r-btn{padding:3px 8px;border:1.5px solid var(--line);border-radius:12px;font-size:11px;font-weight:600;cursor:pointer;background:none;color:var(--muted);font-family:inherit;transition:.12s;white-space:nowrap;}
|
|
.r-btn:hover{border-color:var(--brand);color:var(--brand);}
|
|
.r-btn.active{border-color:var(--brand);background:rgba(245,158,11,.12);color:var(--brand);}
|
|
.list-wrap{flex:1;overflow-y:auto;}
|
|
.list-wrap::-webkit-scrollbar{width:3px;}
|
|
.list-wrap::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
|
|
.cctv-item{padding:8px 10px;cursor:pointer;border-bottom:1px solid var(--line);transition:.12s;}
|
|
.cctv-item:hover{background:rgba(245,158,11,.06);}
|
|
.cctv-item.active{background:rgba(245,158,11,.12);border-left:2px solid var(--brand);}
|
|
.ci-name{font-size:12px;font-weight:600;color:var(--text);margin-bottom:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
.ci-road{font-size:10px;color:var(--muted);}
|
|
.list-empty{padding:20px;text-align:center;color:var(--muted);font-size:12px;}
|
|
|
|
/* ── 메인 뷰어 ── */
|
|
.main{flex:1;display:flex;flex-direction:column;min-width:0;}
|
|
.viewer-hdr{padding:8px 12px;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;display:flex;align-items:center;gap:8px;}
|
|
.viewer-title{font-size:13px;font-weight:700;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
.status-dot{width:8px;height:8px;border-radius:50%;background:#888;flex-shrink:0;}
|
|
.status-dot.live{background:#22c55e;box-shadow:0 0 6px #22c55e;}
|
|
.btn-sm{padding:3px 10px;background:none;border:1.5px solid var(--line);border-radius:6px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;}
|
|
.btn-sm:hover{border-color:var(--brand);color:var(--brand);}
|
|
|
|
.video-wrap{flex:1;position:relative;background:#000;display:flex;align-items:center;justify-content:center;min-height:0;}
|
|
.video-wrap video{width:100%;height:100%;object-fit:contain;}
|
|
.placeholder{color:#555;font-size:13px;text-align:center;padding:40px;line-height:1.8;}
|
|
.loading-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.7);color:#fff;font-size:13px;display:none;}
|
|
.loading-overlay.show{display:flex;}
|
|
|
|
/* ── 그리드 뷰 ── */
|
|
.grid-wrap{flex:1;overflow-y:auto;padding:8px;display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:8px;}
|
|
.grid-wrap::-webkit-scrollbar{width:3px;}
|
|
.grid-wrap::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
|
|
.grid-item{background:#111;border-radius:8px;overflow:hidden;cursor:pointer;border:1.5px solid var(--line);transition:.15s;aspect-ratio:4/3;position:relative;}
|
|
.grid-item:hover{border-color:var(--brand);}
|
|
.grid-item video{width:100%;height:100%;object-fit:cover;}
|
|
.grid-label{position:absolute;bottom:0;left:0;right:0;padding:4px 8px;background:rgba(0,0,0,.7);font-size:10px;color:#fff;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
|
|
.view-toggle{display:flex;gap:4px;}
|
|
.vt-btn{padding:3px 10px;background:none;border:1.5px solid var(--line);border-radius:6px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;}
|
|
.vt-btn.active{border-color:var(--brand);color:var(--brand);background:rgba(245,158,11,.1);}
|
|
|
|
.count-badge{font-size:11px;color:var(--muted);padding:0 4px;}
|
|
.loc-btn{width:100%;margin-top:6px;padding:6px 10px;background:none;border:1.5px solid var(--line);border-radius:7px;font-size:12px;font-weight:600;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;display:flex;align-items:center;justify-content:center;gap:5px;}
|
|
.loc-btn:hover{border-color:var(--brand);color:var(--brand);}
|
|
.loc-btn:disabled{opacity:.5;cursor:not-allowed;}
|
|
.ci-dist{font-size:10px;color:var(--brand);float:right;margin-left:4px;}
|
|
|
|
/* ── 탭 ── */
|
|
.tab-bar{display:flex;gap:0;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;}
|
|
.tab-btn{padding:7px 16px;font-size:12px;font-weight:600;cursor:pointer;background:none;border:none;color:var(--muted);font-family:inherit;border-bottom:2px solid transparent;transition:.15s;margin-bottom:-1px;}
|
|
.tab-btn:hover{color:var(--text);}
|
|
.tab-btn.active{color:var(--brand);border-bottom-color:var(--brand);}
|
|
.tab-pane{display:none;flex:1;min-height:0;}
|
|
.tab-pane.active{display:flex;flex-direction:column;}
|
|
|
|
/* ── UTIC iframe ── */
|
|
.utic-frame{flex:1;border:none;width:100%;height:100%;}
|
|
|
|
/* ── Mobile ── */
|
|
@media(max-width:768px){
|
|
/* Sidebar becomes overlay */
|
|
.sidebar{
|
|
position:fixed;top:0;left:0;bottom:0;z-index:500;
|
|
width:82vw;max-width:300px;
|
|
transform:translateX(-100%);transition:transform .25s ease;
|
|
box-shadow:4px 0 24px rgba(0,0,0,.4);
|
|
}
|
|
.sidebar.mob-open{transform:translateX(0);}
|
|
/* Backdrop */
|
|
#mob-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:499;}
|
|
#mob-backdrop.show{display:block;}
|
|
/* Main fills full width */
|
|
#pane-its{flex-direction:column;}
|
|
.main{width:100%;}
|
|
/* Region buttons: horizontal scroll */
|
|
.region-bar{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;}
|
|
.region-bar::-webkit-scrollbar{display:none;}
|
|
.r-btn{flex-shrink:0;}
|
|
/* Grid: smaller cards */
|
|
.grid-wrap{grid-template-columns:repeat(auto-fill,minmax(150px,1fr));}
|
|
/* Viewer header wraps */
|
|
.viewer-hdr{flex-wrap:wrap;gap:4px;padding:6px 8px;}
|
|
.viewer-title{font-size:12px;}
|
|
/* iOS auto-zoom prevention */
|
|
.search-box,input{font-size:16px !important;}
|
|
/* Tab buttons smaller */
|
|
.tab-btn{padding:6px 10px;font-size:11px;}
|
|
/* Show mob list button */
|
|
#mob-list-btn{display:inline-flex !important;}
|
|
}
|
|
@media(min-width:769px){
|
|
#mob-list-btn{display:none !important;}
|
|
#mob-backdrop{display:none !important;}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header class="hdr">
|
|
<a href="/" style="text-decoration:none;font-size:13px;color:var(--muted)">← 홈</a>
|
|
<h1>🚦 교통 CCTV</h1>
|
|
<span class="count-badge" id="count-badge"></span>
|
|
<span class="hdr-sp"></span>
|
|
<button class="hdr-btn" onclick="toggleTheme()">🌙</button>
|
|
</header>
|
|
|
|
<div style="display:flex;flex-direction:column;flex:1;min-height:0;">
|
|
<!-- 탭 바 -->
|
|
<div class="tab-bar">
|
|
<button class="tab-btn active" id="tab-its" onclick="switchTab('its')">📡 ITS 고속도로 CCTV</button>
|
|
<button class="tab-btn" id="tab-utic" onclick="switchTab('utic')">🗺️ UTIC 교통지도</button>
|
|
</div>
|
|
|
|
<!-- ITS CCTV 탭 -->
|
|
<div id="mob-backdrop" onclick="mobCloseSidebar()"></div>
|
|
<div class="tab-pane active" id="pane-its" style="flex-direction:row;">
|
|
<!-- 사이드바: CCTV 목록 -->
|
|
<aside class="sidebar">
|
|
<div class="sb-top">
|
|
<input class="search-box" id="search" type="text" placeholder="🔍 도로·지역 검색..." oninput="filterList()">
|
|
<button class="loc-btn" id="loc-btn" onclick="findNearMe()">📍 내 위치 근처 검색</button>
|
|
</div>
|
|
<div class="region-bar" id="region-bar">
|
|
<button class="r-btn active" data-minX="124.0" data-maxX="132.0" data-minY="33.0" data-maxY="39.0" onclick="loadRegion(this)">전국</button>
|
|
<button class="r-btn" data-minX="126.7" data-maxX="127.2" data-minY="37.4" data-maxY="37.7" onclick="loadRegion(this)">서울</button>
|
|
<button class="r-btn" data-minX="126.6" data-maxX="127.5" data-minY="37.1" data-maxY="37.8" onclick="loadRegion(this)">경기</button>
|
|
<button class="r-btn" data-minX="128.8" data-maxX="129.3" data-minY="35.0" data-maxY="35.4" onclick="loadRegion(this)">부산</button>
|
|
<button class="r-btn" data-minX="128.5" data-maxX="129.0" data-minY="35.7" data-maxY="36.1" onclick="loadRegion(this)">대구</button>
|
|
<button class="r-btn" data-minX="126.6" data-maxX="127.0" data-minY="35.0" data-maxY="35.3" onclick="loadRegion(this)">광주</button>
|
|
<button class="r-btn" data-minX="127.3" data-maxX="127.6" data-minY="36.2" data-maxY="36.5" onclick="loadRegion(this)">대전</button>
|
|
<button class="r-btn" data-minX="128.5" data-maxX="129.3" data-minY="35.4" data-maxY="35.9" onclick="loadRegion(this)">울산</button>
|
|
<button class="r-btn" data-minX="126.3" data-maxX="126.9" data-minY="33.2" data-maxY="33.6" onclick="loadRegion(this)">제주</button>
|
|
</div>
|
|
<div class="list-wrap" id="list-wrap">
|
|
<div class="list-empty">지역을 선택하면 CCTV 목록이 로드됩니다.</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- 메인 뷰어 -->
|
|
<main class="main" id="main-area">
|
|
<div class="viewer-hdr" id="viewer-hdr">
|
|
<button class="btn-sm" id="mob-list-btn" onclick="mobToggleSidebar()" style="display:none">☰ 목록</button>
|
|
<span class="status-dot" id="status-dot"></span>
|
|
<span class="viewer-title" id="viewer-title">카메라를 선택하세요</span>
|
|
<button class="btn-sm" id="btn-reload" onclick="reloadCurrent()" style="display:none">↺ 새로고침</button>
|
|
<div class="view-toggle" style="margin-left:auto;">
|
|
<button class="vt-btn active" id="btn-single" onclick="setView('single')">단일</button>
|
|
<button class="vt-btn" id="btn-grid" onclick="setView('grid')">그리드</button>
|
|
</div>
|
|
</div>
|
|
<div class="video-wrap" id="video-wrap">
|
|
<div class="placeholder" id="placeholder">← 목록에서 CCTV를 선택하면<br>실시간 영상이 재생됩니다.<br><br>📡 ITS 국가교통정보 · HLS 스트리밍</div>
|
|
<video id="player" style="display:none" controls muted autoplay playsinline></video>
|
|
<div class="loading-overlay" id="loading-overlay">⏳ 스트림 연결 중...</div>
|
|
</div>
|
|
<div class="grid-wrap" id="grid-wrap" style="display:none"></div>
|
|
</main>
|
|
</div>
|
|
|
|
<!-- UTIC 탭 -->
|
|
<div class="tab-pane" id="pane-utic">
|
|
<iframe class="utic-frame" id="utic-frame" src="https://www.utic.go.kr/map/map.do?menu=cctv" allowfullscreen></iframe>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- hls.js CDN -->
|
|
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest/dist/hls.min.js"></script>
|
|
<script>
|
|
const TOKEN_KEY='smallclaw_token';
|
|
function getToken(){try{return sessionStorage.getItem(TOKEN_KEY)||localStorage.getItem(TOKEN_KEY)||'';}catch{return '';}}
|
|
function authH(extra){const t=getToken();const h={...(extra||{})};if(t)h['Authorization']='Bearer '+t;return h;}
|
|
function toggleTheme(){const d=document.documentElement;const n=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('homeclaw_theme',n);}catch{}}
|
|
|
|
let allItems=[];
|
|
let currentUrl=null;
|
|
let currentHls=null;
|
|
let currentView='single';
|
|
let gridHlsList=[];
|
|
let lastRegionBtn=null;
|
|
|
|
function mobToggleSidebar(){
|
|
const sb=document.querySelector('.sidebar');
|
|
const bd=document.getElementById('mob-backdrop');
|
|
const isOpen=sb.classList.contains('mob-open');
|
|
if(isOpen){sb.classList.remove('mob-open');bd.classList.remove('show');}
|
|
else{sb.classList.add('mob-open');bd.classList.add('show');}
|
|
}
|
|
function mobCloseSidebar(){
|
|
document.querySelector('.sidebar').classList.remove('mob-open');
|
|
document.getElementById('mob-backdrop').classList.remove('show');
|
|
}
|
|
|
|
function switchTab(t){
|
|
document.getElementById('tab-utic').classList.toggle('active',t==='utic');
|
|
document.getElementById('tab-its').classList.toggle('active',t==='its');
|
|
document.getElementById('pane-utic').classList.toggle('active',t==='utic');
|
|
document.getElementById('pane-its').classList.toggle('active',t==='its');
|
|
}
|
|
|
|
function setView(v){
|
|
currentView=v;
|
|
document.getElementById('btn-single').classList.toggle('active',v==='single');
|
|
document.getElementById('btn-grid').classList.toggle('active',v==='grid');
|
|
document.getElementById('video-wrap').style.display=v==='single'?'flex':'none';
|
|
document.getElementById('grid-wrap').style.display=v==='grid'?'grid':'none';
|
|
if(v==='grid') renderGrid();
|
|
}
|
|
|
|
// ── 지역 로드 ──
|
|
async function loadRegion(btn){
|
|
document.querySelectorAll('#region-bar .r-btn').forEach(b=>b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
lastRegionBtn=btn;
|
|
const minX=btn.getAttribute('data-minX'),maxX=btn.getAttribute('data-maxX');
|
|
const minY=btn.getAttribute('data-minY'),maxY=btn.getAttribute('data-maxY');
|
|
try{localStorage.setItem('traffic_region',JSON.stringify({minX,maxX,minY,maxY,label:btn.textContent.trim()}));}catch{}
|
|
|
|
const listWrap=document.getElementById('list-wrap');
|
|
listWrap.innerHTML='<div class="list-empty">⏳ 로딩 중...</div>';
|
|
destroyAllGrid();
|
|
|
|
try{
|
|
const r=await fetch(`/api/traffic/cctv?minX=${minX}&maxX=${maxX}&minY=${minY}&maxY=${maxY}&cctvType=1`,{headers:authH()});
|
|
const d=await r.json();
|
|
allItems=d.items||[];
|
|
document.getElementById('count-badge').textContent=allItems.length+'개';
|
|
renderList(allItems);
|
|
if(currentView==='grid')renderGrid();
|
|
}catch(e){
|
|
listWrap.innerHTML=`<div class="list-empty">오류: ${e.message}</div>`;
|
|
}
|
|
}
|
|
|
|
// ── 목록 렌더링 ──
|
|
function renderList(items){
|
|
const wrap=document.getElementById('list-wrap');
|
|
if(!items.length){wrap.innerHTML='<div class="list-empty">해당 지역에 CCTV 없음</div>';return;}
|
|
wrap.innerHTML=items.map(it=>{
|
|
const origIdx=allItems.indexOf(it);
|
|
return `<div class="cctv-item" id="item-${origIdx}" onclick="playCctv(${origIdx})">
|
|
<div class="ci-name">${escHtml(it.cctvname||'')}</div>
|
|
<div class="ci-road">📍 ${it.cctvformat||'HLS'} · ${it.coordy?.toFixed(4)}, ${it.coordx?.toFixed(4)}</div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function filterList(){
|
|
const q=document.getElementById('search').value.toLowerCase();
|
|
const filtered=q?allItems.filter(it=>(it.cctvname||'').toLowerCase().includes(q)):allItems;
|
|
renderList(filtered);
|
|
}
|
|
|
|
function escHtml(s){return String(s ?? '').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
|
|
|
// ── 내 위치 근처 검색 ──
|
|
function haversine(lat1,lon1,lat2,lon2){
|
|
const R=6371,r=Math.PI/180;
|
|
const dLat=(lat2-lat1)*r,dLon=(lon2-lon1)*r;
|
|
const a=Math.sin(dLat/2)**2+Math.cos(lat1*r)*Math.cos(lat2*r)*Math.sin(dLon/2)**2;
|
|
return R*2*Math.atan2(Math.sqrt(a),Math.sqrt(1-a));
|
|
}
|
|
|
|
let userLat=null,userLon=null;
|
|
|
|
async function findNearMe(){
|
|
const btn=document.getElementById('loc-btn');
|
|
if(!navigator.geolocation){alert('이 브라우저는 위치 서비스를 지원하지 않습니다.');return;}
|
|
btn.disabled=true;btn.textContent='📍 위치 확인 중...';
|
|
navigator.geolocation.getCurrentPosition(async pos=>{
|
|
userLat=pos.coords.latitude;
|
|
userLon=pos.coords.longitude;
|
|
const R=0.15; // ~15km 반경 바운딩박스
|
|
const minX=(userLon-R).toFixed(4),maxX=(userLon+R).toFixed(4);
|
|
const minY=(userLat-R).toFixed(4),maxY=(userLat+R).toFixed(4);
|
|
const listWrap=document.getElementById('list-wrap');
|
|
listWrap.innerHTML='<div class="list-empty">⏳ 근처 CCTV 검색 중...</div>';
|
|
document.querySelectorAll('#region-bar .r-btn').forEach(b=>b.classList.remove('active'));
|
|
destroyAllGrid();
|
|
try{
|
|
const r=await fetch(`/api/traffic/cctv?minX=${minX}&maxX=${maxX}&minY=${minY}&maxY=${maxY}&cctvType=1`,{headers:authH()});
|
|
const d=await r.json();
|
|
allItems=(d.items||[]).map(it=>({
|
|
...it,
|
|
_dist:haversine(userLat,userLon,it.coordy,it.coordx)
|
|
})).sort((a,b)=>a._dist-b._dist);
|
|
document.getElementById('count-badge').textContent=allItems.length+'개';
|
|
renderListWithDist(allItems);
|
|
if(currentView==='grid')renderGrid();
|
|
}catch(e){
|
|
listWrap.innerHTML=`<div class="list-empty">오류: ${e.message}</div>`;
|
|
}
|
|
btn.disabled=false;btn.textContent='📍 내 위치 근처 검색';
|
|
},err=>{
|
|
btn.disabled=false;btn.textContent='📍 내 위치 근처 검색';
|
|
alert('위치 권한이 거부되었거나 위치를 가져올 수 없습니다.');
|
|
},{timeout:10000});
|
|
}
|
|
|
|
function renderListWithDist(items){
|
|
const wrap=document.getElementById('list-wrap');
|
|
if(!items.length){wrap.innerHTML='<div class="list-empty">근처에 CCTV가 없습니다</div>';return;}
|
|
wrap.innerHTML=items.map((it,i)=>{
|
|
const dist=it._dist!=null?`<span class="ci-dist">${it._dist<1?(it._dist*1000).toFixed(0)+'m':it._dist.toFixed(1)+'km'}</span>`:'';
|
|
return `<div class="cctv-item" id="item-${i}" onclick="playByIndex(${i})">
|
|
<div class="ci-name">${dist}${escHtml(it.cctvname||'')}</div>
|
|
<div class="ci-road">📍 ${it.cctvformat||'HLS'}</div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function playByIndex(i){
|
|
document.querySelectorAll('.cctv-item').forEach(el=>el.classList.remove('active'));
|
|
const el=document.getElementById('item-'+i);if(el)el.classList.add('active');
|
|
if(window.innerWidth<=768)mobCloseSidebar();
|
|
const it=allItems[i];if(!it)return;
|
|
document.getElementById('viewer-title').textContent=it.cctvname||'CCTV';
|
|
document.getElementById('btn-reload').style.display='';
|
|
currentUrl=it.cctvurl;
|
|
playHls(currentUrl,document.getElementById('player'),document.getElementById('status-dot'));
|
|
}
|
|
|
|
// ── 단일 재생 ──
|
|
function playCctv(idx){
|
|
document.querySelectorAll('.cctv-item').forEach(el=>el.classList.remove('active'));
|
|
const el=document.getElementById('item-'+idx);
|
|
if(el)el.classList.add('active');
|
|
if(window.innerWidth<=768) mobCloseSidebar();
|
|
|
|
const it=allItems[idx];
|
|
if(!it)return;
|
|
document.getElementById('viewer-title').textContent=it.cctvname||'CCTV';
|
|
document.getElementById('btn-reload').style.display='';
|
|
currentUrl=it.cctvurl;
|
|
playHls(currentUrl, document.getElementById('player'), document.getElementById('status-dot'));
|
|
}
|
|
|
|
function reloadCurrent(){if(currentUrl)playHls(currentUrl, document.getElementById('player'), document.getElementById('status-dot'));}
|
|
|
|
function proxyUrl(url){return '/api/traffic/hls-proxy?url='+encodeURIComponent(url);}
|
|
|
|
function playHls(url, videoEl, dotEl){
|
|
url=proxyUrl(url);
|
|
document.getElementById('placeholder').style.display='none';
|
|
videoEl.style.display='block';
|
|
document.getElementById('loading-overlay').classList.add('show');
|
|
if(dotEl)dotEl.className='status-dot';
|
|
|
|
if(currentHls){currentHls.destroy();currentHls=null;}
|
|
|
|
if(Hls.isSupported()){
|
|
const hls=new Hls({enableWorker:false});
|
|
currentHls=hls;
|
|
hls.loadSource(url);
|
|
hls.attachMedia(videoEl);
|
|
hls.on(Hls.Events.MANIFEST_PARSED,()=>{
|
|
videoEl.play().catch(()=>{});
|
|
document.getElementById('loading-overlay').classList.remove('show');
|
|
if(dotEl)dotEl.className='status-dot live';
|
|
});
|
|
hls.on(Hls.Events.ERROR,(_,data)=>{
|
|
if(data.fatal){
|
|
document.getElementById('loading-overlay').classList.remove('show');
|
|
if(dotEl)dotEl.className='status-dot';
|
|
}
|
|
});
|
|
} else if(videoEl.canPlayType('application/vnd.apple.mpegurl')){
|
|
videoEl.src=url;
|
|
videoEl.play().catch(()=>{});
|
|
document.getElementById('loading-overlay').classList.remove('show');
|
|
if(dotEl)dotEl.className='status-dot live';
|
|
}
|
|
}
|
|
|
|
// ── 그리드 뷰 (최대 9개) ──
|
|
function destroyAllGrid(){
|
|
gridHlsList.forEach(h=>h.destroy());
|
|
gridHlsList=[];
|
|
}
|
|
|
|
function renderGrid(){
|
|
destroyAllGrid();
|
|
const wrap=document.getElementById('grid-wrap');
|
|
const items=allItems.slice(0,9);
|
|
if(!items.length){wrap.innerHTML='<div style="color:var(--muted);font-size:12px;grid-column:1/-1;text-align:center;padding:20px">CCTV 없음</div>';return;}
|
|
wrap.innerHTML=items.map((it,i)=>`
|
|
<div class="grid-item" onclick="switchToSingle(${i})">
|
|
<video id="gvid-${i}" muted autoplay playsinline></video>
|
|
<div class="grid-label">${escHtml(it.cctvname||'')}</div>
|
|
</div>`).join('');
|
|
|
|
items.forEach((it,i)=>{
|
|
const v=document.getElementById('gvid-'+i);
|
|
if(!v||!it.cctvurl)return;
|
|
const purl=proxyUrl(it.cctvurl);
|
|
if(Hls.isSupported()){
|
|
const hls=new Hls({enableWorker:false,maxBufferLength:10});
|
|
gridHlsList.push(hls);
|
|
hls.loadSource(purl);
|
|
hls.attachMedia(v);
|
|
hls.on(Hls.Events.MANIFEST_PARSED,()=>v.play().catch(()=>{}));
|
|
} else if(v.canPlayType('application/vnd.apple.mpegurl')){
|
|
v.src=purl;
|
|
v.play().catch(()=>{});
|
|
}
|
|
});
|
|
}
|
|
|
|
function switchToSingle(idx){
|
|
setView('single');
|
|
playCctv(idx);
|
|
}
|
|
|
|
// ── Init ──
|
|
(async()=>{
|
|
// 저장된 지역 복원, 없으면 전국
|
|
let restored=false;
|
|
try{
|
|
const saved=JSON.parse(localStorage.getItem('traffic_region')||'null');
|
|
if(saved){
|
|
const btn=document.querySelector(
|
|
`.r-btn[data-minX="${saved.minX}"][data-maxX="${saved.maxX}"][data-minY="${saved.minY}"][data-maxY="${saved.maxY}"]`
|
|
);
|
|
if(btn){loadRegion(btn);restored=true;}
|
|
}
|
|
}catch{}
|
|
if(!restored){
|
|
const btn=document.querySelector('.r-btn[data-minX="124.0"]');
|
|
if(btn)loadRegion(btn);
|
|
}
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|