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:
@@ -1953,6 +1953,24 @@ app.post('/api/arduino/renode/start', async (req, res) => {
|
||||
// SystemClock_Config() 제거 — HAL이 비트밴드(0x42xxxxxx)로 RCC에 접근하는데
|
||||
// Renode가 비트밴드를 처리 못해서 무한 루프 → 호출 제거 (Renode는 클럭 설정 불필요)
|
||||
patchedCode = patchedCode.replace(/SystemClock_Config\s*\(\s*\)\s*;/g, '/* SystemClock_Config skipped for Renode */;');
|
||||
// GPIO 텔레메트리 — digitalWrite() 호출 시 STX "P:name:val" ETX 형식으로 핀 상태 전송
|
||||
// Serial.write()로 STX(0x02)/ETX(0x03) 바이트를 직접 전송 (print("\x02")보다 명확)
|
||||
// _RN_PNAME: digitalPinToPinName()으로 런타임에 핀 이름 복원 (PA0=192 같은 숫자 매크로 대응)
|
||||
const gpioHeader = [
|
||||
'static char _rn_pbuf[8];',
|
||||
'static const char* _RN_PNAME(uint32_t p) {',
|
||||
' PinName pn = digitalPinToPinName(p);',
|
||||
' if (pn == NC) return "?";',
|
||||
' int port = ((uint32_t)pn >> 4) & 0xF;',
|
||||
' int pin = (uint32_t)pn & 0xF;',
|
||||
' _rn_pbuf[0]=\'P\'; _rn_pbuf[1]=\'A\'+port;',
|
||||
' if (pin>=10){_rn_pbuf[2]=\'1\';_rn_pbuf[3]=\'0\'+(pin-10);_rn_pbuf[4]=0;}',
|
||||
' else {_rn_pbuf[2]=\'0\'+pin;_rn_pbuf[3]=0;}',
|
||||
' return _rn_pbuf;',
|
||||
'}',
|
||||
'#define _RN_DW(p,v) do{uint32_t _rv=(uint32_t)(v);digitalWrite(p,_rv);Serial.write(2);Serial.print("P:");Serial.print(_RN_PNAME((uint32_t)(p)));Serial.print(":");Serial.print(_rv);Serial.write(3);}while(0)',
|
||||
].join('\n') + '\n';
|
||||
patchedCode = gpioHeader + patchedCode.replace(/\bdigitalWrite\s*\(/g, '_RN_DW(');
|
||||
require('fs').writeFileSync(sketchDir + '/sketch.ino', patchedCode);
|
||||
|
||||
const execQ = (cmd: string, ms = 60000): Promise<{ stdout: string; stderr: string }> =>
|
||||
|
||||
@@ -10102,7 +10102,7 @@ app.get('/api/status', async (_req, res) => {
|
||||
provider,
|
||||
currentModel: activeModel,
|
||||
workspace: (config as any).workspace?.path || '',
|
||||
search: rawCfg.search?.google_api_key ? 'google' : (rawCfg.search?.tavily_api_key ? 'tavily' : 'none'),
|
||||
search: rawCfg.search?.google_api_key ? 'google' : (rawCfg.search?.tavily_api_key ? 'tavily' : (rawCfg.search?.searxng_url ? 'searxng' : 'none')),
|
||||
orchestration: orchCfg ? {
|
||||
enabled: orchCfg.enabled,
|
||||
secondary: orchCfg.secondary,
|
||||
|
||||
+16
-4
@@ -8925,8 +8925,11 @@ function mobCloseMenu() {
|
||||
const RIGHT_COLLAPSE_KEY = 'rightPanelCollapsed';
|
||||
function applyRightPanelCollapsed(collapsed) {
|
||||
document.body.classList.toggle('right-collapsed', !!collapsed);
|
||||
// Only override on desktop — narrow viewports use the mobile single-column layout.
|
||||
const isDesktop = window.matchMedia('(min-width: 1025px)').matches;
|
||||
// Only override on the true 3-column desktop layout. At ≤1200px the tablet/mobile
|
||||
// media queries own the grid (sidebar hidden), so inline 3-column styles here would
|
||||
// fight them — e.g. restoring a wide saved width and letting the context panel eat
|
||||
// most of the screen. Clear inline styles below that width so CSS takes over.
|
||||
const isDesktop = window.matchMedia('(min-width: 1201px)').matches;
|
||||
if (!isDesktop) { document.body.style.gridTemplateColumns = ''; return; }
|
||||
if (collapsed) {
|
||||
document.body.style.gridTemplateColumns = '280px 1fr 36px';
|
||||
@@ -8949,6 +8952,13 @@ function toggleRightPanel() {
|
||||
applyRightPanelCollapsed(collapsed);
|
||||
})();
|
||||
|
||||
// Re-evaluate on viewport change (e.g. tablet rotation crossing the 1200px boundary)
|
||||
// so inline desktop grid styles are cleared/applied to match the active CSS layout.
|
||||
window.addEventListener('resize', () => {
|
||||
const stored = localStorage.getItem(RIGHT_COLLAPSE_KEY);
|
||||
applyRightPanelCollapsed(stored === null ? true : stored === '1');
|
||||
});
|
||||
|
||||
// ── Right-panel resize ──────────────────────────────────────────────────────
|
||||
(function initRightPanelResize() {
|
||||
const MIN_W = 280;
|
||||
@@ -8963,8 +8973,10 @@ function toggleRightPanel() {
|
||||
return Math.max(MIN_W, Math.min(Math.floor(window.innerWidth * MAX_W_RATIO), px));
|
||||
}
|
||||
|
||||
// Restore saved width (only if not collapsed)
|
||||
if (!document.body.classList.contains('right-collapsed')) {
|
||||
// Restore saved width — desktop only. Below 1201px the tablet/mobile CSS owns the
|
||||
// grid, so applying a saved (often wide) width here would override it.
|
||||
const isDesktop = () => window.matchMedia('(min-width: 1201px)').matches;
|
||||
if (isDesktop() && !document.body.classList.contains('right-collapsed')) {
|
||||
const saved = parseInt(localStorage.getItem(STORE_KEY), 10);
|
||||
if (saved && saved >= MIN_W) applyWidth(clamp(saved));
|
||||
}
|
||||
|
||||
@@ -3023,8 +3023,153 @@ function initEsp32Mode(sessionId, fname, fqbn) {
|
||||
let _renodeSessionId = null;
|
||||
let _renodeEvtSource = null;
|
||||
|
||||
// Arduino 번호 → STM32 핀 이름 (STM32duino BLUEPILL_F103C8 순서)
|
||||
const _STM32_ARDUINO_PINS = [
|
||||
'PA0','PA1','PA2','PA3','PA4','PA5','PA6','PA7', // D0-D7
|
||||
'PB0','PB1','PB10','PB11','PB12','PB13','PB14','PB15', // D8-D15
|
||||
'PA8','PA9','PA10','PA11','PA12','PA15', // D16-D21
|
||||
'PB3','PB4','PB5','PB6','PB7','PB8','PB9', // D22-D28
|
||||
'PC13','PC14','PC15', // D29-D31
|
||||
];
|
||||
|
||||
// HIGH/LOW/1/0/0x1/0x0 → 0 or 1
|
||||
// Arduino: #define HIGH 0x1 이므로 _RN_S(HIGH) → "0x1" 로 전달됨
|
||||
function _stm32PinVal(v) {
|
||||
if (v === 'HIGH' || v === 'true') return 1;
|
||||
if (v === 'LOW' || v === 'false') return 0;
|
||||
const n = parseInt(v, 16);
|
||||
return (!isNaN(n) && n !== 0) ? 1 : 0;
|
||||
}
|
||||
|
||||
function _stm32UpdatePin(pinName, val) {
|
||||
const resolved = _stm32PinResolve(pinName);
|
||||
const on = _stm32PinVal(val);
|
||||
if (resolved === 'PC13' || resolved === 'LED_BUILTIN') {
|
||||
// PC13 active LOW — 핀 서클 + 온보드 LED 둘 다 업데이트
|
||||
const lit = on === 0;
|
||||
const fill = lit ? '#ffcc00' : '#1a1a0a';
|
||||
const flt = lit ? 'drop-shadow(0 0 5px #ffcc00)' : 'none';
|
||||
['stm32-pc13', 'stm32-led-pc13'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) { el.style.fill = fill; el.style.filter = flt; }
|
||||
});
|
||||
} else {
|
||||
const id = 'stm32-' + resolved.toLowerCase();
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.style.fill = on ? '#22cc55' : '#0d2a0d';
|
||||
el.style.filter = on ? 'drop-shadow(0 0 3px #22cc55)' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Arduino 핀 번호/PinName 숫자 → STM32 포트·핀 이름
|
||||
function _stm32PinResolve(s) {
|
||||
if (!s) return '?';
|
||||
// 이미 이름 형식("PA0", "PC13" 등)이면 그대로 반환
|
||||
if (/^P[A-Z]\d/.test(s) || s === 'LED_BUILTIN') return s;
|
||||
const num = parseInt(s, 0); // "0xC0"→192, "192"→192, "45"→45
|
||||
if (isNaN(num)) return s;
|
||||
// Digital pin 0-31 (STMduino BLUEPILL 순서)
|
||||
if (num >= 0 && num < _STM32_ARDUINO_PINS.length) return _STM32_ARDUINO_PINS[num];
|
||||
// Analog pin (PNUM_ANALOG_BASE=0xC0): PA0-PA7, PB0, PB1
|
||||
const _AP = ['PA0','PA1','PA2','PA3','PA4','PA5','PA6','PA7','PB0','PB1'];
|
||||
if ((num & 0xC0) === 0xC0 && (num - 0xC0) < _AP.length) return _AP[num - 0xC0];
|
||||
// PinName 인코딩: 상위 니블=포트, 하위 니블=핀 (PA_0=0x00, PC_13=0x2D)
|
||||
if (num < 0xC0) {
|
||||
const port = (num >> 4) & 0xF, pin = num & 0xF;
|
||||
if (port < 12) return 'P' + String.fromCharCode(65 + port) + pin;
|
||||
}
|
||||
return 'D' + num;
|
||||
}
|
||||
|
||||
let _renodeGpioBuf = '';
|
||||
function _stm32ParseGpio(raw) {
|
||||
// 텔레메트리: STX(0x02) "P:name:val" ETX(0x03) 패킷 추출, 나머지는 시리얼로
|
||||
_renodeGpioBuf += raw;
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i <= _renodeGpioBuf.length) {
|
||||
const s = _renodeGpioBuf.indexOf('\x02', i);
|
||||
if (s < 0) { out += _renodeGpioBuf.slice(i); _renodeGpioBuf = ''; break; }
|
||||
out += _renodeGpioBuf.slice(i, s);
|
||||
const e = _renodeGpioBuf.indexOf('\x03', s + 1);
|
||||
if (e < 0) { _renodeGpioBuf = _renodeGpioBuf.slice(s); break; } // 패킷 미완성
|
||||
const pkt = _renodeGpioBuf.slice(s + 1, e); // "P:PA0:1"
|
||||
_renodeGpioBuf = _renodeGpioBuf.slice(e + 1);
|
||||
i = 0;
|
||||
const parts = pkt.split(':');
|
||||
if (parts[0] === 'P' && parts.length >= 3)
|
||||
_stm32UpdatePin(_stm32PinResolve(parts[1]), parts[2]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function _buildBluePillSVG(isF4) {
|
||||
const leftPins = [
|
||||
['GND','#666'],['GND','#666'],['3V3','#c0c090'],['NRST','#b0b090'],
|
||||
['PA0','#0d2a0d'],['PA1','#0d2a0d'],['PA2','#0d2a0d'],['PA3','#0d2a0d'],
|
||||
['PA4','#0d2a0d'],['PA5','#0d2a0d'],['PA6','#0d2a0d'],['PA7','#0d2a0d'],
|
||||
['PB0','#0d2a0d'],['PB1','#0d2a0d'],['PB10','#0d2a0d'],['PB11','#0d2a0d'],
|
||||
['PB12','#0d2a0d'],['PB13','#0d2a0d'],['PB14','#0d2a0d'],['PB15','#0d2a0d'],
|
||||
];
|
||||
const rightPins = [
|
||||
['GND','#666'],['GND','#666'],['3V3','#c0c090'],['PA8','#0d2a0d'],
|
||||
['PA9','#0d2a0d','TX'],['PA10','#0d2a0d','RX'],['PA11','#0d2a0d'],['PA12','#0d2a0d'],
|
||||
['PA15','#0d2a0d'],['PB3','#0d2a0d'],['PB4','#0d2a0d'],['PB5','#0d2a0d'],
|
||||
['PB6','#0d2a0d'],['PB7','#0d2a0d'],['PB8','#0d2a0d'],['PB9','#0d2a0d'],
|
||||
['PC13','#1a1a0a'],['PC14','#0d2a0d'],['PC15','#0d2a0d'],['5V','#c09090'],
|
||||
];
|
||||
// sp=핀 간격, y0=시작 y, cx_l/r=좌우 서클 x
|
||||
const sp = 9, y0 = 18, cx_l = 5, cx_r = 95;
|
||||
let lp = '', rp = '';
|
||||
leftPins.forEach(([name, fill], i) => {
|
||||
const y = y0 + i * sp;
|
||||
const isGpio = name.startsWith('P');
|
||||
const id = isGpio ? ` id="stm32-${name.toLowerCase()}"` : '';
|
||||
const sc = isGpio ? '#1a5a1a' : '#555';
|
||||
lp += `<circle${id} cx="${cx_l}" cy="${y}" r="3" fill="${fill}" stroke="${sc}" stroke-width="0.7"/>`;
|
||||
lp += `<text x="${cx_l+6}" y="${y+1.5}" fill="#6aadee" font-size="4" font-family="monospace" font-weight="600">${name}</text>`;
|
||||
});
|
||||
rightPins.forEach(([name, fill, tag], i) => {
|
||||
const y = y0 + i * sp;
|
||||
const isGpio = name.startsWith('P');
|
||||
const id = isGpio ? ` id="stm32-${name.toLowerCase()}"` : '';
|
||||
const sc = isGpio ? '#1a5a1a' : '#555';
|
||||
rp += `<circle${id} cx="${cx_r}" cy="${y}" r="3" fill="${fill}" stroke="${sc}" stroke-width="0.7"/>`;
|
||||
const lbl = tag ? `${name}<tspan fill="#ff9944" font-size="3"> ${tag}</tspan>` : name;
|
||||
rp += `<text x="${cx_r-6}" y="${y+1.5}" text-anchor="end" fill="#6aadee" font-size="4" font-family="monospace" font-weight="600">${lbl}</text>`;
|
||||
});
|
||||
const chipLines = isF4 ? ['STM32F4','F411CE'] : ['STM32F1','F103C8T6'];
|
||||
const h = y0 + leftPins.length * sp + 16; // 보드 높이 자동
|
||||
const w = 100;
|
||||
const chipY = Math.round(h * 0.28), chipH = Math.round(h * 0.42);
|
||||
const ledY = Math.round(chipY * 0.55);
|
||||
return `<svg viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1" y="1" width="${w-2}" height="${h-2}" rx="4" fill="#152060" stroke="#2a40aa" stroke-width="2"/>
|
||||
<rect x="1.5" y="12" width="7" height="${h-24}" rx="2" fill="#0d1428" stroke="#2a4080" stroke-width="0.6"/>
|
||||
<rect x="${w-8.5}" y="12" width="7" height="${h-24}" rx="2" fill="#0d1428" stroke="#2a4080" stroke-width="0.6"/>
|
||||
${lp}${rp}
|
||||
<rect x="20" y="${chipY}" width="60" height="${chipH}" rx="3" fill="#1a1a1a" stroke="#888" stroke-width="1"/>
|
||||
<ellipse cx="50" cy="${chipY}" rx="4" ry="2" fill="#333"/>
|
||||
<text x="50" y="${chipY + chipH*0.45}" text-anchor="middle" fill="#ccc" font-size="6.5" font-family="monospace" font-weight="bold">${chipLines[0]}</text>
|
||||
<text x="50" y="${chipY + chipH*0.65}" text-anchor="middle" fill="#aaa" font-size="5" font-family="monospace">${chipLines[1]}</text>
|
||||
<circle id="stm32-pwr-led" cx="62" cy="${ledY}" r="5" fill="#0d2a0d" stroke="#1a5a1a" stroke-width="1"/>
|
||||
<text x="62" y="${ledY-7}" text-anchor="middle" fill="#5a9a5a" font-size="4" font-family="monospace" font-weight="bold">PWR</text>
|
||||
<circle id="stm32-led-pc13" cx="38" cy="${ledY}" r="5" fill="#1a1a0a" stroke="#4a4a1a" stroke-width="1"/>
|
||||
<text x="38" y="${ledY-7}" text-anchor="middle" fill="#8a8a40" font-size="4" font-family="monospace" font-weight="bold">LED</text>
|
||||
<g style="cursor:pointer" onclick="document.dispatchEvent(new CustomEvent('renode-reset'))">
|
||||
<circle cx="80" cy="${ledY}" r="5" fill="#cc2020" stroke="#ff5050" stroke-width="1"/>
|
||||
<text x="80" y="${ledY+1.5}" text-anchor="middle" fill="#ffbbbb" font-size="3.5" font-family="monospace" font-weight="bold">RST</text>
|
||||
</g>
|
||||
<rect x="35" y="${h-12}" width="30" height="10" rx="2" fill="#888" stroke="#bbb" stroke-width="0.6"/>
|
||||
<rect x="38" y="${h-10}" width="24" height="6" rx="1" fill="#444"/>
|
||||
<text x="50" y="${h-14}" text-anchor="middle" fill="#6aadee" font-size="3.5" font-family="monospace">Micro-USB</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function initSTM32RenodeMode(sessionId, fname, fqbn) {
|
||||
_renodeSessionId = sessionId;
|
||||
_renodeGpioBuf = '';
|
||||
document.querySelector('.header h1').textContent = '🖥️ STM32 Emulator (Renode)';
|
||||
document.getElementById('filename').textContent = fname;
|
||||
|
||||
@@ -3032,13 +3177,23 @@ function initSTM32RenodeMode(sessionId, fname, fqbn) {
|
||||
const badge = document.createElement('span');
|
||||
badge.id = 'chip-badge';
|
||||
badge.style.cssText = 'display:inline-block;padding:2px 7px;border-radius:4px;font-size:10px;font-weight:600;border:1px solid;flex-shrink:0;';
|
||||
const family = fqbn.includes('GenF4') || fqbn.includes('f4') ? 'STM32F4' : 'STM32F1';
|
||||
badge.textContent = family + ' · Renode';
|
||||
const isF4 = fqbn.includes('GenF4') || fqbn.includes('f4');
|
||||
badge.textContent = (isF4 ? 'STM32F4' : 'STM32F1') + ' · Renode';
|
||||
badge.style.background = '#1a0a3a'; badge.style.color = '#c084fc'; badge.style.borderColor = '#7c3aed';
|
||||
document.getElementById('filename').after(badge);
|
||||
|
||||
document.querySelector('.board-svg')?.style && (document.querySelector('.board-svg').style.display = 'none');
|
||||
document.querySelector('.board-title')?.style && (document.querySelector('.board-title').style.display = 'none');
|
||||
// 보드 SVG를 Blue Pill로 교체
|
||||
const boardSvgEl = document.querySelector('.board-svg');
|
||||
const boardTitleEl = document.querySelector('.board-title');
|
||||
if (boardSvgEl) { boardSvgEl.innerHTML = _buildBluePillSVG(isF4); boardSvgEl.style.display = ''; }
|
||||
if (boardTitleEl) { boardTitleEl.textContent = isF4 ? 'Black Pill · STM32F4' : 'Blue Pill · STM32F1'; boardTitleEl.style.display = ''; }
|
||||
|
||||
// PWR LED 켜기
|
||||
setTimeout(() => {
|
||||
const pwr = document.getElementById('stm32-pwr-led');
|
||||
if (pwr) { pwr.style.fill = '#22cc44'; pwr.style.filter = 'drop-shadow(0 0 3px #22cc44)'; }
|
||||
}, 500);
|
||||
|
||||
document.getElementById('btn-stop')?.style && (document.getElementById('btn-stop').style.display = 'none');
|
||||
document.getElementById('btn-reset')?.style && (document.getElementById('btn-reset').style.display = 'none');
|
||||
|
||||
@@ -3052,6 +3207,8 @@ function initSTM32RenodeMode(sessionId, fname, fqbn) {
|
||||
fetch('/api/arduino/renode/' + _renodeSessionId, { method: 'DELETE', credentials: 'include', keepalive: true }).catch(() => {});
|
||||
if (_renodeEvtSource) { _renodeEvtSource.close(); _renodeEvtSource = null; }
|
||||
setStatus('off', '정지됨');
|
||||
const pwr = document.getElementById('stm32-pwr-led');
|
||||
if (pwr) { pwr.style.fill = '#0d2a0d'; pwr.style.filter = 'none'; }
|
||||
};
|
||||
toolbar.append(stopBtn);
|
||||
}
|
||||
@@ -3077,11 +3234,44 @@ function initSTM32RenodeMode(sessionId, fname, fqbn) {
|
||||
_renodeEvtSource = new EventSource('/api/arduino/renode/' + sessionId + '/output', { withCredentials: true });
|
||||
_renodeEvtSource.onopen = () => setStatus('running', '실행 중');
|
||||
_renodeEvtSource.onerror = () => setStatus('error', '연결 오류');
|
||||
// TX 핀(PA9) 깜빡임 — 시리얼 데이터 수신 시
|
||||
let _txTimer = null;
|
||||
function _blinkTx() {
|
||||
const el = document.getElementById('stm32-pa9');
|
||||
if (!el) return;
|
||||
el.style.fill = '#ff8800'; el.style.filter = 'drop-shadow(0 0 2px #ff8800)';
|
||||
clearTimeout(_txTimer);
|
||||
_txTimer = setTimeout(() => { el.style.fill = '#0d2a0d'; el.style.filter = 'none'; }, 80);
|
||||
}
|
||||
// RX 핀(PA10) 깜빡임 — 사용자 입력 전송 시
|
||||
let _rxTimer = null;
|
||||
function _blinkRx() {
|
||||
const el = document.getElementById('stm32-pa10');
|
||||
if (!el) return;
|
||||
el.style.fill = '#00aaff'; el.style.filter = 'drop-shadow(0 0 2px #00aaff)';
|
||||
clearTimeout(_rxTimer);
|
||||
_rxTimer = setTimeout(() => { el.style.fill = '#0d2a0d'; el.style.filter = 'none'; }, 80);
|
||||
}
|
||||
// doSend에 RX 깜빡임 추가
|
||||
const _origDoSend = doSend;
|
||||
const doSendWithRx = () => { _blinkRx(); _origDoSend(); };
|
||||
serialInput?.removeEventListener('keydown', e => { if (e.key === 'Enter') doSend(); });
|
||||
serialSend?.removeEventListener('click', doSend);
|
||||
serialInput?.addEventListener('keydown', e => { if (e.key === 'Enter') doSendWithRx(); });
|
||||
serialSend?.addEventListener('click', doSendWithRx);
|
||||
|
||||
_renodeEvtSource.onmessage = e => {
|
||||
try {
|
||||
const d = JSON.parse(e.data);
|
||||
if (d.serial) appendSerial(d.serial);
|
||||
if (d.exit !== undefined) setStatus('off', '종료');
|
||||
if (d.serial) {
|
||||
const visible = _stm32ParseGpio(d.serial);
|
||||
if (visible) { appendSerial(visible); _blinkTx(); }
|
||||
}
|
||||
if (d.exit !== undefined) {
|
||||
setStatus('off', '종료');
|
||||
const pwr = document.getElementById('stm32-pwr-led');
|
||||
if (pwr) { pwr.style.fill = '#0d2a0d'; pwr.style.filter = 'none'; }
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
window.addEventListener('beforeunload', () => {
|
||||
|
||||
@@ -2823,6 +2823,7 @@ async function _codeAiOneRound(messages, onToken) {
|
||||
tools: CLIENT_TOOL_DEFS,
|
||||
sessionId: codeAiSessionId,
|
||||
...(codeAiModel ? { model: codeAiModel } : {}),
|
||||
...(_csbSelBoard?.fqbn ? { boardContext: `${_csbSelBoard.name} (${_csbSelBoard.fqbn})` } : {}),
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`/api/code/round ${resp.status}: ${await resp.text()}`);
|
||||
|
||||
+1
-1
@@ -806,12 +806,12 @@
|
||||
<div class="right-section-title" style="margin-bottom:8px">웹 검색</div>
|
||||
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:6px">검색 제공자</label>
|
||||
<select id="settings-provider" onchange="toggleOllamaModelRow(this.value);toggleSearxngUrlRow(this.value)" style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:13px">
|
||||
<option value="searxng">SearXNG (셀프호스트)</option>
|
||||
<option value="ddg">DuckDuckGo</option>
|
||||
<option value="ollama">Ollama (모델 보조 검색)</option>
|
||||
<option value="tavily">Tavily</option>
|
||||
<option value="google">Google CSE</option>
|
||||
<option value="brave">Brave</option>
|
||||
<option value="searxng">SearXNG (셀프호스트)</option>
|
||||
</select>
|
||||
<div id="search-ollama-model-row" style="display:none;margin-top:8px">
|
||||
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:4px">Ollama 검색 모델</label>
|
||||
|
||||
@@ -456,6 +456,45 @@ input.tb-num{background:#1e1e30;border:1px solid rgba(255,255,255,.1);color:#c8c
|
||||
}
|
||||
.drum-back-btn:hover{background:rgba(147,51,234,.3);border-color:var(--studio-accent2);color:#e9d5ff;}
|
||||
|
||||
/* ── Folder popup ── */
|
||||
#fp-overlay{display:none;position:fixed;inset:0;z-index:950;background:rgba(0,0,0,.6);backdrop-filter:blur(3px);}
|
||||
#fp-overlay.open{display:block;}
|
||||
#fp-sheet{
|
||||
position:absolute;bottom:0;left:0;right:0;
|
||||
max-height:72vh;background:var(--studio-panel);
|
||||
border-radius:16px 16px 0 0;
|
||||
border-top:1px solid var(--studio-border2);
|
||||
display:flex;flex-direction:column;overflow:hidden;
|
||||
box-shadow:0 -8px 40px rgba(0,0,0,.5);
|
||||
transform:translateY(100%);transition:transform .25s ease;
|
||||
}
|
||||
#fp-overlay.open #fp-sheet{transform:translateY(0);}
|
||||
@media(min-width:1025px){
|
||||
#fp-sheet{
|
||||
position:absolute;top:50%;left:50%;bottom:auto;right:auto;
|
||||
width:400px;max-height:65vh;
|
||||
border-radius:14px;border:1px solid var(--studio-border2);
|
||||
box-shadow:0 20px 60px rgba(0,0,0,.6);
|
||||
transform:translate(-50%,-46%);transition:none;
|
||||
}
|
||||
#fp-overlay.open #fp-sheet{transform:translate(-50%,-50%);}
|
||||
}
|
||||
.fp-hdr{display:flex;align-items:center;padding:13px 16px 10px;flex-shrink:0;border-bottom:1px solid var(--studio-border);gap:6px;}
|
||||
.fp-title{font-size:13px;font-weight:700;color:var(--studio-text);flex:1;}
|
||||
.fp-icon-btn{background:none;border:none;color:var(--studio-muted);cursor:pointer;font-size:14px;padding:3px 6px;border-radius:6px;transition:.15s;font-family:inherit;}
|
||||
.fp-icon-btn:hover{color:var(--studio-accent2);background:var(--studio-glow2);}
|
||||
.fp-tabs{display:flex;gap:4px;padding:8px 12px 6px;flex-shrink:0;}
|
||||
.fp-tab{flex:1;padding:6px;font-size:11px;font-weight:700;border:1px solid var(--studio-border2);border-radius:8px;background:transparent;color:var(--studio-muted);cursor:pointer;font-family:inherit;transition:.2s;}
|
||||
.fp-tab.active{background:var(--studio-accent);border-color:var(--studio-accent);color:#fff;box-shadow:0 0 10px var(--studio-glow);}
|
||||
.fp-body{flex:1;overflow-y:auto;padding:4px 8px 14px;min-height:0;}
|
||||
.fp-item{display:flex;align-items:center;gap:6px;padding:7px 8px;border-radius:8px;transition:.15s;}
|
||||
.fp-item:hover{background:var(--studio-glow2);}
|
||||
.fp-item-icon{font-size:15px;flex-shrink:0;}
|
||||
.fp-item-name{flex:1;font-size:12px;color:var(--studio-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer;}
|
||||
.fp-item-del{background:none;border:none;color:var(--studio-muted);cursor:pointer;font-size:13px;padding:2px 5px;border-radius:4px;flex-shrink:0;transition:.15s;line-height:1;}
|
||||
.fp-item-del:hover{color:var(--studio-red);}
|
||||
.fp-empty{font-size:12px;color:var(--studio-muted);padding:24px 12px;text-align:center;}
|
||||
|
||||
/* ── Mobile ── */
|
||||
@media(max-width:1024px){
|
||||
.ms-hdr{padding:0 10px;gap:6px;}
|
||||
@@ -564,6 +603,7 @@ input.tb-num{background:#1e1e30;border:1px solid rgba(255,255,255,.1);color:#c8c
|
||||
<div class="ms-divider"></div>
|
||||
<div class="ms-file-hdr">
|
||||
<span>MIDI 다운로드</span>
|
||||
<button onclick="openFolderPopup('midi')" title="저장된 파일 보기" style="font-size:13px;padding:1px 6px;border:1px solid var(--studio-border2);border-radius:6px;background:var(--studio-panel2);color:var(--studio-muted);cursor:pointer;transition:.15s;" onmouseover="this.style.borderColor='var(--studio-accent2)';this.style.color='var(--studio-accent2)'" onmouseout="this.style.borderColor='var(--studio-border2)';this.style.color='var(--studio-muted)'">📂</button>
|
||||
</div>
|
||||
<!-- MIDI download site picker -->
|
||||
<div style="padding:4px 6px 2px;display:flex;gap:3px;">
|
||||
@@ -581,6 +621,7 @@ input.tb-num{background:#1e1e30;border:1px solid rgba(255,255,255,.1);color:#c8c
|
||||
<div class="ms-divider"></div>
|
||||
<div class="ms-file-hdr">
|
||||
<span>Guitar Pro</span>
|
||||
<button onclick="openFolderPopup('gp')" title="저장된 파일 보기" style="font-size:13px;padding:1px 6px;border:1px solid var(--studio-border2);border-radius:6px;background:var(--studio-panel2);color:var(--studio-muted);cursor:pointer;transition:.15s;" onmouseover="this.style.borderColor='var(--studio-accent2)';this.style.color='var(--studio-accent2)'" onmouseout="this.style.borderColor='var(--studio-border2)';this.style.color='var(--studio-muted)'">📂</button>
|
||||
</div>
|
||||
<!-- GP 검색 -->
|
||||
<div style="padding:4px 6px 2px;display:flex;gap:3px;">
|
||||
@@ -4378,7 +4419,106 @@ function meStatus(msg){const el=document.getElementById('status-txt');if(!el)ret
|
||||
document.getElementById('chat-input').focus();
|
||||
}
|
||||
})();
|
||||
|
||||
// ── Folder popup ──────────────────────────────────────────────────────────────
|
||||
let _fpTab = 'midi';
|
||||
|
||||
function openFolderPopup(tab = 'midi') {
|
||||
_fpTab = tab;
|
||||
document.getElementById('fp-overlay').classList.add('open');
|
||||
fpSetTab(tab);
|
||||
fpLoadMidi();
|
||||
fpLoadGp();
|
||||
}
|
||||
|
||||
function closeFolderPopup() {
|
||||
document.getElementById('fp-overlay').classList.remove('open');
|
||||
}
|
||||
|
||||
function fpSetTab(tab) {
|
||||
_fpTab = tab;
|
||||
document.getElementById('fp-tab-midi').classList.toggle('active', tab === 'midi');
|
||||
document.getElementById('fp-tab-gp').classList.toggle('active', tab === 'gp');
|
||||
document.getElementById('fp-midi-body').style.display = tab === 'midi' ? '' : 'none';
|
||||
document.getElementById('fp-gp-body').style.display = tab === 'gp' ? '' : 'none';
|
||||
}
|
||||
|
||||
function fpReload() { fpLoadMidi(); fpLoadGp(); }
|
||||
|
||||
async function fpLoadMidi() {
|
||||
const el = document.getElementById('fp-midi-body');
|
||||
el.innerHTML = '<div class="fp-empty">로딩 중...</div>';
|
||||
try {
|
||||
const r = await fetch('/api/music/list', { headers: authH() });
|
||||
const data = await r.json();
|
||||
if (!data.files?.length) { el.innerHTML = '<div class="fp-empty">저장된 MIDI 파일 없음</div>'; return; }
|
||||
el.innerHTML = data.files.map(f => `
|
||||
<div class="fp-item">
|
||||
<span class="fp-item-icon">🎵</span>
|
||||
<span class="fp-item-name" onclick="fpOpenMidi('${escAttr(f.path)}','${escAttr(f.name)}')" title="${escHtml(f.name)}">${escHtml(f.name)}</span>
|
||||
<button class="fp-item-del" onclick="fpDeleteMidi('${escAttr(f.name)}',this)" title="삭제">✕</button>
|
||||
</div>`).join('');
|
||||
} catch { el.innerHTML = '<div class="fp-empty" style="color:var(--studio-red)">로드 실패</div>'; }
|
||||
}
|
||||
|
||||
async function fpLoadGp() {
|
||||
const el = document.getElementById('fp-gp-body');
|
||||
el.innerHTML = '<div class="fp-empty">로딩 중...</div>';
|
||||
try {
|
||||
const r = await fetch('/api/music/gp/list', { headers: authH() });
|
||||
const data = await r.json();
|
||||
if (!data.files?.length) { el.innerHTML = '<div class="fp-empty">저장된 GP 파일 없음</div>'; return; }
|
||||
el.innerHTML = data.files.map(f => `
|
||||
<div class="fp-item">
|
||||
<span class="fp-item-icon">🎸</span>
|
||||
<span class="fp-item-name" onclick="fpOpenGp('${escAttr(f.path)}','${escAttr(f.name)}')" title="${escHtml(f.name)}">${escHtml(f.name)}</span>
|
||||
<button class="fp-item-del" onclick="fpDeleteGp('${escAttr(f.name)}',this)" title="삭제">✕</button>
|
||||
</div>`).join('');
|
||||
} catch { el.innerHTML = '<div class="fp-empty" style="color:var(--studio-red)">로드 실패</div>'; }
|
||||
}
|
||||
|
||||
async function fpOpenMidi(path, name) { closeFolderPopup(); await loadMidiToStage(path, name); }
|
||||
async function fpOpenGp(path, name) { closeFolderPopup(); await loadGpFromServer(path, name); }
|
||||
|
||||
async function fpDeleteMidi(name, btn) {
|
||||
if (!confirm(`"${name}" 삭제할까요?`)) return;
|
||||
try {
|
||||
const r = await fetch('/api/music/workspace-file/' + encodeURIComponent(name), { method: 'DELETE', headers: authH() });
|
||||
if (!r.ok) throw new Error((await r.json()).error || '삭제 실패');
|
||||
btn.closest('.fp-item').remove();
|
||||
loadMidiFiles();
|
||||
} catch(e) { alert('삭제 실패: ' + e.message); }
|
||||
}
|
||||
|
||||
async function fpDeleteGp(name, btn) {
|
||||
if (!confirm(`"${name}" 삭제할까요?`)) return;
|
||||
try {
|
||||
const r = await fetch(`/api/music/gp-file/${encodeURIComponent(name)}`, { method: 'DELETE', headers: authH() });
|
||||
if (!r.ok) throw new Error((await r.json()).error || '삭제 실패');
|
||||
btn.closest('.fp-item').remove();
|
||||
loadGpFileList();
|
||||
} catch(e) { alert('삭제 실패: ' + e.message); }
|
||||
}
|
||||
</script>
|
||||
<!-- ── Folder popup ── -->
|
||||
<div id="fp-overlay" onclick="if(event.target===this)closeFolderPopup()">
|
||||
<div id="fp-sheet">
|
||||
<div class="fp-hdr">
|
||||
<span class="fp-title">📂 저장된 파일</span>
|
||||
<button class="fp-icon-btn" onclick="fpReload()" title="새로고침">↺</button>
|
||||
<button class="fp-icon-btn" onclick="closeFolderPopup()" style="font-size:16px;">✕</button>
|
||||
</div>
|
||||
<div class="fp-tabs">
|
||||
<button class="fp-tab active" id="fp-tab-midi" onclick="fpSetTab('midi')">🎵 MIDI</button>
|
||||
<button class="fp-tab" id="fp-tab-gp" onclick="fpSetTab('gp')">🎸 Guitar Pro</button>
|
||||
</div>
|
||||
<div class="fp-body">
|
||||
<div id="fp-midi-body"><div class="fp-empty">로딩 중...</div></div>
|
||||
<div id="fp-gp-body" style="display:none"><div class="fp-empty">로딩 중...</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AudioMass scripts (loaded but not initialized until panel opens) -->
|
||||
<script src="/audiomass/dist/wavesurfer.js"></script>
|
||||
<script src="/audiomass/dist/plugin/wavesurfer.regions.js"></script>
|
||||
|
||||
+9
-1
@@ -1866,12 +1866,20 @@
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
body {
|
||||
grid-template-columns: 250px 1fr;
|
||||
/* main(채팅)이 유연한 공간을, 컨텍스트 패널은 제한된 폭을 갖도록.
|
||||
이전엔 컬럼이 뒤바뀌어 컨텍스트가 1fr(화면 절반 이상)을 차지했음. */
|
||||
grid-template-columns: 1fr minmax(160px, 200px);
|
||||
grid-template-areas:
|
||||
"header header"
|
||||
"main right";
|
||||
}
|
||||
aside { display: none; }
|
||||
/* No manual resize on tablet — CSS owns the column widths here. */
|
||||
#right-panel-resize-handle { display: none !important; }
|
||||
/* Collapsed → shrink the right column to just the rail instead of a 300px gap. */
|
||||
body.right-collapsed {
|
||||
grid-template-columns: 1fr 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
|
||||
+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