feat: STM32 Blue Pill 에뮬레이터 UI — 보드 SVG + GPIO LED 시각화
- Blue Pill SVG: 핀 서클(r=3), 폰트 4px bold, 온보드 LED/PWR/RST 버튼 확대 - GPIO 텔레메트리: _RN_PNAME()+Serial.write(STX/ETX)로 digitalWrite() 후 핀 상태 전송 - PA0 등 아날로그 핀 번호(0xC0=192) → digitalPinToPinName()으로 런타임 복원 - PC13 toggle 시 핀 서클 + 온보드 LED 동시 업데이트 (active LOW) - PA9(TX)/PA10(RX) 시리얼 신호 깜빡임 - 코드 에디터 AI에 선택된 보드 이름(boardContext) 전달 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+132
-9
@@ -66,6 +66,10 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
||||
.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;}
|
||||
@@ -77,6 +81,43 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
||||
|
||||
/* ── 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>
|
||||
@@ -91,22 +132,18 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
||||
<div style="display:flex;flex-direction:column;flex:1;min-height:0;">
|
||||
<!-- 탭 바 -->
|
||||
<div class="tab-bar">
|
||||
<button class="tab-btn active" id="tab-utic" onclick="switchTab('utic')">🗺️ UTIC 교통지도</button>
|
||||
<button class="tab-btn" id="tab-its" onclick="switchTab('its')">📡 ITS 고속도로 CCTV</button>
|
||||
</div>
|
||||
|
||||
<!-- UTIC 탭 -->
|
||||
<div class="tab-pane active" id="pane-utic">
|
||||
<iframe class="utic-frame" src="https://www.utic.go.kr/map/map.do?menu=cctv"
|
||||
id="utic-frame" allowfullscreen></iframe>
|
||||
<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 class="tab-pane" id="pane-its" style="flex-direction:row;">
|
||||
<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>
|
||||
@@ -127,6 +164,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
||||
<!-- 메인 뷰어 -->
|
||||
<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>
|
||||
@@ -143,6 +181,11 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
||||
<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 -->
|
||||
@@ -160,6 +203,18 @@ 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');
|
||||
@@ -222,11 +277,79 @@ function filterList(){
|
||||
|
||||
function escHtml(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||||
|
||||
// ── 내 위치 근처 검색 ──
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user