From 13905babf3ce3445edcd9a37b4d6e7c16ab9d936 Mon Sep 17 00:00:00 2001 From: gerd Date: Sun, 23 Aug 2026 03:21:54 +0900 Subject: [PATCH] =?UTF-8?q?=EC=82=AC=EC=A7=84=20=EB=93=B1=EB=A1=9D=20UI=20?= =?UTF-8?q?=EB=A5=BC=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/index.js | 13 ++ server/vworld.js | 34 ++--- src/App.css | 132 ++++++++++++++++-- src/App.jsx | 345 ++++++++++++++++++++++++++++++++++++++--------- 4 files changed, 429 insertions(+), 95 deletions(-) diff --git a/server/index.js b/server/index.js index 1399187..749ce8b 100644 --- a/server/index.js +++ b/server/index.js @@ -71,6 +71,19 @@ app.get('/api/dongs', async (req, res) => { } }); +app.get('/api/reverse-geocode', async (req, res) => { + const numLat = Number(req.query.lat); + const numLng = Number(req.query.lng); + if (!Number.isFinite(numLat) || !Number.isFinite(numLng)) { + return res.status(400).json({ error: 'lat, lng 파라미터가 필요합니다.' }); + } + try { + res.json(await reverseGeocode(numLat, numLng)); + } catch (e) { + res.status(502).json({ error: e.message }); + } +}); + app.get('/api/poles', async (req, res) => { const { gu = '', dong = '', start = '', end = '' } = req.query; try { diff --git a/server/vworld.js b/server/vworld.js index 2c83888..e39d6d2 100644 --- a/server/vworld.js +++ b/server/vworld.js @@ -1,4 +1,5 @@ const VWORLD_ENDPOINT = 'https://api.vworld.kr/req/data'; +const VWORLD_ADDRESS_ENDPOINT = 'https://api.vworld.kr/req/address'; function credentials() { return { @@ -80,31 +81,18 @@ export async function reverseGeocode(lat, lng) { domain, }); - const res = await fetch(`${VWORLD_ENDPOINT}?${params}`); + const res = await fetch(`${VWORLD_ADDRESS_ENDPOINT}?${params}`); if (!res.ok) throw new Error(`VWorld 지오코딩 실패 (HTTP ${res.status})`); const json = await res.json(); - const result = json.response?.result; + const response = json.response; + if (response?.status !== 'OK') return { gu: '', dong: '' }; - if (!result?.text) return { gu: '', dong: '' }; + const first = Array.isArray(response.result) ? response.result[0] : response.result; + const structure = first?.structure; + if (!structure) return { gu: '', dong: '' }; - const text = result.text; - const structure = result.structure; - - let gu = ''; - let dong = ''; - - if (structure) { - gu = structure.level1 || ''; - dong = structure.level2 || ''; - } else { - const parts = text.split(' '); - if (parts.length >= 2) { - gu = parts[0]; - dong = parts[1]; - } else if (parts.length === 1) { - gu = parts[0]; - } - } - - return { gu, dong }; + return { + gu: structure.level2 || '', + dong: structure.level3 || structure.level4L || '', + }; } diff --git a/src/App.css b/src/App.css index f5afb63..cb9c15a 100644 --- a/src/App.css +++ b/src/App.css @@ -179,6 +179,17 @@ .pole-list__title { font-weight: bold; font-size: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.pole-list__header-actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; } .pole-list__close { @@ -190,12 +201,6 @@ color: #6b7280; } -.pole-list__header-actions { - display: flex; - align-items: center; - gap: 6px; -} - .pole-list__upload { padding: 4px 10px; border-radius: 6px; @@ -212,6 +217,44 @@ background-color: #f3f4f6; } +.pole-list__refresh { + padding: 4px 10px; + border-radius: 6px; + border: 1px solid #d1d5db; + background: white; + font-size: 12px; + font-weight: 600; + cursor: pointer; + color: #374151; + white-space: nowrap; +} + +.pole-list__refresh:hover:not(:disabled) { + background-color: #f3f4f6; +} + +.pole-list__refresh:disabled { + opacity: 0.6; + cursor: default; +} + +.pole-list__refresh-icon { + display: inline-block; +} + +@keyframes pole-refresh-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.pole-list__refresh--loading .pole-list__refresh-icon { + animation: pole-refresh-spin 0.8s linear infinite; +} + .pole-list__scroll { overflow-y: auto; flex: 1; @@ -472,6 +515,7 @@ } .level-modal { + position: relative; background: white; border-radius: 16px; padding: 20px; @@ -483,6 +527,38 @@ gap: 16px; } +.level-modal__close { + position: absolute; + top: 10px; + right: 10px; + width: 28px; + height: 28px; + border-radius: 50%; + border: none; + background-color: rgba(0, 0, 0, 0.45); + color: white; + font-size: 13px; + line-height: 28px; + text-align: center; + cursor: pointer; + padding: 0; + z-index: 1; +} + +.level-modal__close:hover { + background-color: rgba(0, 0, 0, 0.7); +} + +.level-modal--busy .level-modal__label { + color: #3b82f6; +} + +.level-modal--busy .level-modal__buttons, +.level-modal--busy .level-modal__close { + opacity: 0.5; + pointer-events: none; +} + .level-modal__img { width: 100%; max-height: 240px; @@ -491,6 +567,22 @@ background: #f3f4f6; } +.level-modal__meta { + width: 100%; + background: #f8fafc; + border-radius: 8px; + padding: 8px 12px; + font-size: 12px; + color: #475569; + line-height: 1.6; +} + +.level-modal__meta-row { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .level-modal__label { font-size: 15px; font-weight: 600; @@ -498,21 +590,21 @@ } .level-modal__buttons { - display: flex; - gap: 12px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; width: 100%; } .level-modal__btn { - flex: 1; display: flex; flex-direction: column; align-items: center; gap: 2px; - padding: 14px 0; + padding: 12px 0; border-radius: 10px; border: 2px solid transparent; - font-size: 18px; + font-size: 17px; font-weight: 700; cursor: pointer; color: white; @@ -535,6 +627,24 @@ background-color: #a855f7; } +.level-modal__btn--Ignore { + background-color: #64748b; +} + +.level-modal__key { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + background-color: rgba(255, 255, 255, 0.25); + font-size: 11px; + font-weight: 700; + line-height: 1; +} + .level-modal__desc { font-size: 11px; font-weight: 500; diff --git a/src/App.jsx b/src/App.jsx index 72760e2..2e699f5 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import Map from 'ol/Map'; +import OLMap from 'ol/Map'; import View from 'ol/View'; import Overlay from 'ol/Overlay'; import TileLayer from 'ol/layer/Tile'; @@ -17,6 +17,9 @@ import './App.css'; const VWORLD_API_KEY = import.meta.env.VITE_VWORLD_API_KEY; const LEVEL_COLORS = { A: '#22c55e', B: '#f97316', Pole: '#a855f7' }; const LEVEL_LABELS = { A: 'A', B: 'B', Pole: 'P' }; +const LEVEL_IGNORE = 'IGNORE'; +const LEVEL_CANCEL = 'CANCEL'; +const LEVEL_KEYS = { 1: 'A', 2: 'B', 3: 'Pole', 4: LEVEL_IGNORE }; function createTileSource(layer) { return new XYZ({ @@ -46,6 +49,10 @@ function App() { const folderInputRef = useRef(null); const levelResolveRef = useRef(null); const [levelModal, setLevelModal] = useState(null); + const [modalAddress, setModalAddress] = useState(undefined); + const [modalBusy, setModalBusy] = useState(false); + const previewUrlRef = useRef(null); + const reverseGeocodeCacheRef = useRef(new Map()); const [levelFilter, setLevelFilter] = useState('all'); const [guFilter, setGuFilter] = useState('용산구'); const [dongFilter, setDongFilter] = useState('all'); @@ -56,6 +63,7 @@ function App() { const popupRef = useRef(null); const popupOverlayRef = useRef(null); const [popupPole, setPopupPole] = useState(null); + const [refreshing, setRefreshing] = useState(false); const guList = useMemo(() => { const set = new Set(allDongs.map((d) => d.gu_name)); @@ -179,6 +187,23 @@ function App() { } }, []); + const refreshPoles = useCallback(async () => { + if (refreshing) return; + setRefreshing(true); + try { + const res = await fetch('/api/poles'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const next = await res.json(); + polesRef.current = next; + setPoles(next); + } catch (err) { + console.error('전신주 목록 갱신 실패:', err); + alert('목록 갱신에 실패했습니다.'); + } finally { + setRefreshing(false); + } + }, [refreshing]); + const flyTo = useCallback((lng, lat) => { if (!mapRef.current) return; mapRef.current.getView().animate({ @@ -198,25 +223,105 @@ function App() { }); }, []); - const requestLevel = useCallback((file, gpsData) => { - const previewUrl = URL.createObjectURL(file); - return new Promise((resolve) => { - levelResolveRef.current = (level) => { - URL.revokeObjectURL(previewUrl); - resolve(level); - }; - setLevelModal({ file, previewUrl, gpsData }); - }); - }, []); - - const handleLevelSelect = useCallback((level) => { - setLevelModal(null); - if (levelResolveRef.current) { - levelResolveRef.current(level); - levelResolveRef.current = null; + const lookupAddress = useCallback(async (lat, lng) => { + if (!(reverseGeocodeCacheRef.current instanceof Map)) { + reverseGeocodeCacheRef.current = new Map(); + } + const key = `${Number(lat).toFixed(5)},${Number(lng).toFixed(5)}`; + if (reverseGeocodeCacheRef.current.has(key)) { + return reverseGeocodeCacheRef.current.get(key); + } + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 8000); + const res = await fetch(`/api/reverse-geocode?lat=${lat}&lng=${lng}`, { + signal: controller.signal, + }); + clearTimeout(timer); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const geo = await res.json(); + const label = [geo.gu, geo.dong].filter(Boolean).join(' ') || null; + reverseGeocodeCacheRef.current.set(key, label); + return label; + } catch (err) { + console.error('역지오코딩 실패:', err); + return null; } }, []); + const claimPreviewUrl = useCallback((file, preloadedUrl) => { + const url = preloadedUrl || URL.createObjectURL(file); + if (previewUrlRef.current && previewUrlRef.current !== url) { + URL.revokeObjectURL(previewUrlRef.current); + } + previewUrlRef.current = url; + return url; + }, []); + + const closeLevelModal = useCallback(() => { + levelResolveRef.current = null; + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current); + previewUrlRef.current = null; + } + setLevelModal(null); + setModalAddress(undefined); + setModalBusy(false); + }, []); + + const requestLevel = useCallback((info) => { + const previewUrl = claimPreviewUrl(info.file, info.previewUrl); + setModalBusy(false); + setLevelModal({ + file: info.file, + previewUrl, + lat: info.lat, + lng: info.lng, + timestamp: info.timestamp, + }); + return new Promise((resolve) => { + levelResolveRef.current = resolve; + }); + }, [claimPreviewUrl]); + + const handleLevelSelect = useCallback( + (level) => { + if (modalBusy || !levelResolveRef.current) return; + levelResolveRef.current(level); + levelResolveRef.current = null; + setModalBusy(true); + }, + [modalBusy], + ); + + useEffect(() => { + if (!levelModal) return; + const onKeyDown = (e) => { + const level = LEVEL_KEYS[e.key]; + if (level) { + e.preventDefault(); + handleLevelSelect(level); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [levelModal, handleLevelSelect]); + + useEffect(() => { + if (!levelModal) { + setModalAddress(undefined); + return; + } + let cancelled = false; + setModalAddress(undefined); + lookupAddress(levelModal.lat, levelModal.lng).then((address) => { + if (!cancelled) setModalAddress(address); + }); + return () => { + cancelled = true; + }; + }, [levelModal, lookupAddress]); + const refreshGps = useCallback(() => { return new Promise((resolve) => { if (!navigator.geolocation) { @@ -254,7 +359,7 @@ function App() { const markerLayer = new VectorLayer({ source: markerSource }); markerLayerRef.current = markerLayer; - const map = new Map({ + const map = new OLMap({ target: mapContainerRef.current, layers: [ new TileLayer({ source: createTileSource('Base') }), @@ -378,6 +483,10 @@ function App() { markerLayerRef.current = null; currentLocationRef.current = null; fallbackUsedRef.current = false; + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current); + previewUrlRef.current = null; + } }; }, []); @@ -427,8 +536,14 @@ function App() { setGpsInfo({ source, lat, lng }); - const level = await requestLevel(file, { lat, lng }); - if (!level) { + const level = await requestLevel({ + file, + lat, + lng, + timestamp: exifTimestamp || new Date().toISOString(), + }); + closeLevelModal(); + if (!level || level === LEVEL_IGNORE || level === LEVEL_CANCEL) { setProcessing(false); e.target.value = ''; return; @@ -488,57 +603,102 @@ function App() { return; } + const preparePhoto = async (file) => { + let lat; + let lng; + let exifTimestamp = null; + try { + const buffer = await file.arrayBuffer(); + const exifData = await exifr.parse(buffer); + if (exifData && exifData.latitude && exifData.longitude) { + lat = exifData.latitude; + lng = exifData.longitude; + } + if (exifData && exifData.DateTimeOriginal) { + exifTimestamp = new Date(exifData.DateTimeOriginal).toISOString(); + } else if (exifData && exifData.DateTime) { + exifTimestamp = new Date(exifData.DateTime).toISOString(); + } + } catch (err) { + console.error('EXIF 읽기 실패:', file.name, err); + } + const previewUrl = URL.createObjectURL(file); + try { + await new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(); + img.onerror = () => reject(new Error('preview decode failed')); + img.src = previewUrl; + }); + } catch { + // 디코딩 미지원 형식(예: HEIC)은 모달에서 브라우저가 다시 시도한다. + } + return { + file, + lat, + lng, + exifTimestamp, + timestamp: exifTimestamp || new Date().toISOString(), + previewUrl, + }; + }; + setProcessing(true); setUploadProgress({ current: 0, total: imageFiles.length }); let registered = 0; + let ignoredCount = 0; let skipped = 0; let duplicated = 0; + let cancelledRemaining = 0; + + let pendingPrepPromise = preparePhoto(imageFiles[0]); for (let i = 0; i < imageFiles.length; i++) { - const file = imageFiles[i]; setUploadProgress({ current: i + 1, total: imageFiles.length }); + let prep = null; try { - let lat; - let lng; - let exifTimestamp = null; + prep = await pendingPrepPromise; + } catch (err) { + console.error('사진 준비 실패:', imageFiles[i].name, err); + } + pendingPrepPromise = + i + 1 < imageFiles.length ? preparePhoto(imageFiles[i + 1]) : null; - try { - const buffer = await file.arrayBuffer(); - const exifData = await exifr.parse(buffer); - if (exifData && exifData.latitude && exifData.longitude) { - lat = exifData.latitude; - lng = exifData.longitude; - lastPositionRef.current = { lat, lng }; - } - if (exifData && exifData.DateTimeOriginal) { - exifTimestamp = new Date(exifData.DateTimeOriginal).toISOString(); - } else if (exifData && exifData.DateTime) { - exifTimestamp = new Date(exifData.DateTime).toISOString(); - } - } catch (err) { - console.error('EXIF 읽기 실패:', file.name, err); - } - - if (lat === undefined) { - const last = lastPositionRef.current; - if (last) { - lat = last.lat; - lng = last.lng; - } else { - skipped++; - continue; - } - } - - const level = await requestLevel(file, { lat, lng }); - if (!level) { + if (!prep) { + skipped++; + continue; + } + if (prep.lat !== undefined) { + lastPositionRef.current = { lat: prep.lat, lng: prep.lng }; + } else { + const last = lastPositionRef.current; + if (!last) { + URL.revokeObjectURL(prep.previewUrl); skipped++; continue; } + prep.lat = last.lat; + prep.lng = last.lng; + } - const compressed = await imageCompression(file, { + const level = await requestLevel(prep); + if (!level) { + skipped++; + continue; + } + if (level === LEVEL_IGNORE) { + ignoredCount++; + continue; + } + if (level === LEVEL_CANCEL) { + cancelledRemaining = imageFiles.length - i; + break; + } + + try { + const compressed = await imageCompression(prep.file, { maxSizeMB: 0.5, maxWidthOrHeight: 800, useWebWorker: true, @@ -549,9 +709,9 @@ function App() { formData.append('level', level); formData.append('photo', compressed, `${id}.jpg`); formData.append('id', id); - formData.append('lat', lat); - formData.append('lng', lng); - formData.append('timestamp', exifTimestamp || new Date().toISOString()); + formData.append('lat', prep.lat); + formData.append('lng', prep.lng); + formData.append('timestamp', prep.timestamp); const res = await fetch('/api/poles', { method: 'POST', body: formData }); if (!res.ok) { @@ -570,16 +730,30 @@ function App() { }); registered++; } catch (err) { - console.error('전신주 등록 실패:', file.name, err); + console.error('전신주 등록 실패:', prep.file.name, err); skipped++; } } + if (pendingPrepPromise) { + try { + const leftover = await pendingPrepPromise; + if (leftover?.previewUrl && leftover.previewUrl !== previewUrlRef.current) { + URL.revokeObjectURL(leftover.previewUrl); + } + } catch { + // 준비 자체가 실패한 경우 정리할 자원이 없다. + } + } + closeLevelModal(); + setUploadProgress(null); setProcessing(false); const parts = [`등록: ${registered}장`]; + if (ignoredCount > 0) parts.push(`무시: ${ignoredCount}장`); if (duplicated > 0) parts.push(`중복: ${duplicated}장`); if (skipped > 0) parts.push(`건너뜀: ${skipped}장`); + if (cancelledRemaining > 0) parts.push(`취소: ${cancelledRemaining}장`); alert(parts.join(' / ')); e.target.value = ''; }; @@ -665,34 +839,75 @@ function App() { {levelModal && (
handleLevelSelect(null)}> -
e.stopPropagation()}> +
e.stopPropagation()} + > + 전신주 사진 -
전신주 등급을 선택하세요
+
+
+ 📍 {modalAddress === undefined ? '주소 조회 중...' : modalAddress || '주소를 찾을 수 없음'} +
+ {levelModal.timestamp && ( +
+ 🕒 {new Date(levelModal.timestamp).toLocaleString('ko-KR')} +
+ )} +
+
+ {modalBusy ? '처리 중...' : '전신주 등급을 선택하세요'} +
+
@@ -743,6 +958,14 @@ function App() { 전신주 목록 ({filteredPoles.length}기)
+