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:
kim
2026-06-29 22:07:06 +09:00
co-authored by Claude Sonnet 4.6
parent c13fa4fae5
commit e520d09ca1
9 changed files with 514 additions and 22 deletions
+18
View File
@@ -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 }> =>
+1 -1
View File
@@ -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
View File
@@ -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));
}
+196 -6
View File
@@ -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', () => {
+1
View File
@@ -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
View File
@@ -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>
+140
View File
@@ -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
View File
@@ -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
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
// ── 내 위치 근처 검색 ──
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;