diff --git a/LoginInfo.txt b/LoginInfo.txt deleted file mode 100644 index 32f94a8..0000000 --- a/LoginInfo.txt +++ /dev/null @@ -1,3 +0,0 @@ -supabase - -DBPW : TnvkEngineSori1! \ No newline at end of file diff --git a/server/db.js b/server/db.js index 9d62675..75cc656 100644 --- a/server/db.js +++ b/server/db.js @@ -83,6 +83,21 @@ export async function insertPole(pole) { if (error) throw error; } +export async function findDuplicatePole(lat, lng, timestamp) { + const tolerance = 0.0001; + const { data, error } = await supabase + .from('poles') + .select('id') + .gte('lat', lat - tolerance) + .lte('lat', lat + tolerance) + .gte('lng', lng - tolerance) + .lte('lng', lng + tolerance) + .eq('timestamp', timestamp) + .limit(1); + if (error) throw error; + return data?.length > 0; +} + export async function getPole(id) { const { data, error } = await supabase .from('poles') diff --git a/server/index.js b/server/index.js index bf76a94..1399187 100644 --- a/server/index.js +++ b/server/index.js @@ -4,13 +4,19 @@ import cors from 'cors'; import multer from 'multer'; import fs from 'fs'; import path from 'path'; +import crypto from 'crypto'; import { fileURLToPath } from 'url'; import * as db from './db.js'; -import { fetchGus, fetchDongs } from './vworld.js'; +import { fetchGus, fetchDongs, reverseGeocode } from './vworld.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const UPLOAD_DIR = path.join(__dirname, 'uploads'); +const LEVEL_DIRS = ['A', 'B', 'Pole']; + fs.mkdirSync(UPLOAD_DIR, { recursive: true }); +for (const dir of LEVEL_DIRS) { + fs.mkdirSync(path.join(UPLOAD_DIR, dir), { recursive: true }); +} const PORT = Number(process.env.PORT || 4000); const CACHE_TTL_MS = 24 * 60 * 60 * 1000; @@ -22,7 +28,12 @@ app.use('/uploads', express.static(UPLOAD_DIR)); const upload = multer({ storage: multer.diskStorage({ - destination: (_req, _file, cb) => cb(null, UPLOAD_DIR), + destination: (req, _file, cb) => { + const level = req.body?.level || 'uncategorized'; + const dir = path.join(UPLOAD_DIR, level); + fs.mkdirSync(dir, { recursive: true }); + cb(null, dir); + }, filename: (req, file, cb) => { const id = req.body?.id || `pole-${Date.now()}`; const ext = path.extname(file.originalname) || '.jpg'; @@ -70,19 +81,40 @@ app.get('/api/poles', async (req, res) => { }); app.post('/api/poles', upload.single('photo'), async (req, res) => { - const { id, lat, lng, gu = '', dong = '', timestamp, level = '' } = req.body ?? {}; + const { id, lat, lng, timestamp, level = '' } = req.body ?? {}; + let { gu = '', dong = '' } = req.body ?? {}; if (lat === undefined || lng === undefined || !timestamp) { return res.status(400).json({ error: 'lat, lng, timestamp는 필수입니다.' }); } + const numLat = Number(lat); + const numLng = Number(lng); + try { + if (await db.findDuplicatePole(numLat, numLng, timestamp)) { + if (req.file) fs.rm(req.file.path, () => {}); + return res.status(409).json({ error: 'duplicate', message: '이미 등록된 전신주입니다.' }); + } + } catch (e) { + return res.status(500).json({ error: e.message }); + } + if (!gu || !dong) { + try { + const geo = await reverseGeocode(numLat, numLng); + if (!gu) gu = geo.gu; + if (!dong) dong = geo.dong; + } catch (e) { + console.warn('지오코딩 실패:', e.message); + } + } + const photoPath = req.file ? `${level || 'uncategorized'}/${req.file.filename}` : null; const pole = { - id: id || `pole-${Date.now()}`, - lat: Number(lat), - lng: Number(lng), + id: id || `pole-${crypto.randomUUID()}`, + lat: numLat, + lng: numLng, gu, dong, timestamp, level, - photo_path: req.file ? req.file.filename : null, + photo_path: photoPath, }; try { await db.insertPole(pole); @@ -118,7 +150,7 @@ app.delete('/api/poles/:id', async (req, res) => { const existing = await db.getPole(req.params.id); if (!existing) return res.status(404).json({ error: '전신주를 찾을 수 없습니다.' }); if (existing.photo_path) { - fs.rm(path.join(UPLOAD_DIR, path.basename(existing.photo_path)), () => {}); + fs.rm(path.join(UPLOAD_DIR, existing.photo_path), () => {}); } try { await db.deletePole(existing.id); @@ -128,6 +160,10 @@ app.delete('/api/poles/:id', async (req, res) => { res.json({ removed: true }); }); -app.listen(PORT, () => { +const server = app.listen(PORT, () => { console.log(`Pole Mapper API server: http://localhost:${PORT}`); }); +server.on('error', (err) => { + console.error('서버 시작 실패:', err); + process.exit(1); +}); diff --git a/server/migrate-safety.js b/server/migrate-safety.js index cb90250..f2b329f 100644 --- a/server/migrate-safety.js +++ b/server/migrate-safety.js @@ -1,11 +1,13 @@ import 'dotenv/config'; import { createClient } from '@supabase/supabase-js'; import { writeFileSync, mkdirSync, existsSync } from 'fs'; -import { join, basename } from 'path'; +import { join, basename, dirname } from 'path'; +import { fileURLToPath } from 'url'; +const __dirname = dirname(fileURLToPath(import.meta.url)); const SOURCE_URL = 'https://saftyovhcable.kr/addMarker.html'; const THUMB_BASE = 'https://saftyovhcable.kr/res/Thumb/'; -const UPLOAD_DIR = join(import.meta.dirname, 'uploads'); +const UPLOAD_DIR = join(__dirname, 'uploads'); const supabaseUrl = process.env.SUPABASE_URL; const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY; diff --git a/server/vworld.js b/server/vworld.js index d55683e..2c83888 100644 --- a/server/vworld.js +++ b/server/vworld.js @@ -63,3 +63,48 @@ export async function fetchDongs(guCode) { const features = await fetchVworldFeatures('LT_C_ADEMD_INFO', `emd_cd:like:${guCode}`); return features.map((f) => f.properties.emd_kor_nm).filter(Boolean); } + +export async function reverseGeocode(lat, lng) { + const { key, domain } = credentials(); + if (!key) throw new Error('VWORLD_API_KEY 환경변수가 설정되지 않았습니다.'); + + const params = new URLSearchParams({ + service: 'address', + request: 'getaddress', + version: '2.0', + crs: 'epsg:4326', + point: `${lng},${lat}`, + format: 'json', + type: 'PARCEL', + key, + domain, + }); + + const res = await fetch(`${VWORLD_ENDPOINT}?${params}`); + if (!res.ok) throw new Error(`VWorld 지오코딩 실패 (HTTP ${res.status})`); + const json = await res.json(); + const result = json.response?.result; + + if (!result?.text) 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 }; +} diff --git a/src/App.css b/src/App.css index 696731a..99010a4 100644 --- a/src/App.css +++ b/src/App.css @@ -377,6 +377,86 @@ margin-left: 4px; } +.level-modal-backdrop { + position: absolute; + inset: 0; + background-color: rgba(0, 0, 0, 0.6); + z-index: 60; + display: flex; + align-items: center; + justify-content: center; +} + +.level-modal { + background: white; + border-radius: 16px; + padding: 20px; + width: min(320px, calc(100vw - 40px)); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} + +.level-modal__img { + width: 100%; + max-height: 240px; + object-fit: contain; + border-radius: 8px; + background: #f3f4f6; +} + +.level-modal__label { + font-size: 15px; + font-weight: 600; + color: #374151; +} + +.level-modal__buttons { + display: flex; + gap: 12px; + width: 100%; +} + +.level-modal__btn { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 14px 0; + border-radius: 10px; + border: 2px solid transparent; + font-size: 18px; + font-weight: 700; + cursor: pointer; + color: white; + transition: transform 0.1s; +} + +.level-modal__btn:active { + transform: scale(0.95); +} + +.level-modal__btn--A { + background-color: #22c55e; +} + +.level-modal__btn--B { + background-color: #f97316; +} + +.level-modal__btn--Pole { + background-color: #a855f7; +} + +.level-modal__desc { + font-size: 11px; + font-weight: 500; + opacity: 0.85; +} + @media (min-width: 900px) { .list-toggle, .backdrop, diff --git a/src/App.jsx b/src/App.jsx index 543aa3e..feb16d3 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -14,7 +14,7 @@ import imageCompression from 'browser-image-compression'; import exifr from 'exifr'; import './App.css'; -const VWORLD_API_KEY = '2C00322C-5037-37EB-A547-7001C13840E9'; +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' }; @@ -44,6 +44,8 @@ function App() { const [listOpen, setListOpen] = useState(false); const [uploadProgress, setUploadProgress] = useState(null); const folderInputRef = useRef(null); + const levelResolveRef = useRef(null); + const [levelModal, setLevelModal] = useState(null); const [levelFilter, setLevelFilter] = useState('all'); const [guFilter, setGuFilter] = useState('용산구'); const [dongFilter, setDongFilter] = useState('all'); @@ -66,8 +68,8 @@ function App() { const filteredPoles = useMemo(() => { return poles.filter( (p) => (levelFilter === 'all' || p.level === levelFilter) && - (guFilter === 'all' || p.dong.includes(guFilter)) && - (dongFilter === 'all' || p.dong.includes(dongFilter)) + (guFilter === 'all' || p.gu === guFilter) && + (dongFilter === 'all' || p.dong === dongFilter) ); }, [poles, levelFilter, guFilter, dongFilter]); @@ -84,11 +86,11 @@ function App() { if (f.get('poleId') !== 'current-location') source.removeFeature(f); }); const idSet = new Set(); - polesRef.current.forEach((pole) => { + poles.forEach((pole) => { if (idSet.has(pole.id)) return; if (levelFilter !== 'all' && pole.level !== levelFilter) return; - if (guFilter !== 'all' && !pole.dong.includes(guFilter)) return; - if (dongFilter !== 'all' && !pole.dong.includes(dongFilter)) return; + if (guFilter !== 'all' && pole.gu !== guFilter) return; + if (dongFilter !== 'all' && pole.dong !== dongFilter) return; idSet.add(pole.id); const feature = new Feature({ geometry: new Point(fromLonLat([pole.lng, pole.lat])), @@ -112,51 +114,20 @@ function App() { ); source.addFeature(feature); }); - }, [levelFilter, guFilter, dongFilter]); + }, [poles, levelFilter, guFilter, dongFilter]); useEffect(() => { syncMarkers(); }, [syncMarkers]); - const addMarker = useCallback((lng, lat, id, level) => { - if (!markerLayerRef.current) return; - - const isCurrentLocation = id === 'current-location'; - const color = isCurrentLocation ? '#3b82f6' : (LEVEL_COLORS[level] || '#ef4444'); - const label = isCurrentLocation ? '' : (LEVEL_LABELS[level] || '전신주'); - const feature = new Feature({ - geometry: new Point(fromLonLat([lng, lat])), - poleId: id, - }); - feature.setStyle( - new Style({ - image: new CircleStyle({ - radius: isCurrentLocation ? 6 : 8, - fill: new Fill({ color }), - stroke: new Stroke({ color: 'white', width: 2 }), - }), - text: new Text({ - text: label, - offsetY: -20, - font: 'bold 12px sans-serif', - fill: new Fill({ color: '#1e293b' }), - stroke: new Stroke({ color: 'white', width: 3 }), - }), - }), - ); - markerLayerRef.current.getSource().addFeature(feature); - if (isCurrentLocation) { - currentLocationRef.current = feature; - } - }, []); - const handleDelete = useCallback(async (id) => { try { const res = await fetch(`/api/poles/${id}`, { method: 'DELETE' }); if (!res.ok) throw new Error(`삭제 실패 (HTTP ${res.status})`); setPoles((prev) => { - polesRef.current = prev.filter((p) => p.id !== id); - return prev.filter((p) => p.id !== id); + const next = prev.filter((p) => p.id !== id); + polesRef.current = next; + return next; }); } catch (err) { console.error('전신주 삭제 실패:', err); @@ -183,6 +154,25 @@ 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; + } + }, []); + useEffect(() => { if (mapRef.current) return; @@ -234,7 +224,22 @@ function App() { popupOverlay.setPosition(evt.coordinate); }); - addMarker(lng, lat, 'current-location'); + const currentFeature = new Feature({ + geometry: new Point(fromLonLat([lng, lat])), + poleId: 'current-location', + }); + currentFeature.setStyle( + new Style({ + image: new CircleStyle({ + radius: 6, + fill: new Fill({ color: '#3b82f6' }), + stroke: new Stroke({ color: 'white', width: 2 }), + }), + text: new Text({ text: '' }), + }), + ); + markerSource.addFeature(currentFeature); + currentLocationRef.current = currentFeature; Promise.allSettled([ fetch('/api/poles').then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }), fetch('/api/all-dongs').then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }), @@ -322,7 +327,7 @@ function App() { currentLocationRef.current = null; fallbackUsedRef.current = false; }; - }, [addMarker]); + }, []); const handleCapture = async (e) => { const file = e.target.files?.[0]; @@ -334,9 +339,9 @@ function App() { try { let lat; let lng; + let exifTimestamp = null; let source = ''; - // 1차: 사진 EXIF에서 GPS 추출 (ArrayBuffer로 읽어 Android 호환성 확보) try { const buffer = await file.arrayBuffer(); console.log('파일 타입:', file.type, '크기:', file.size, 'buffer 크기:', buffer.byteLength); @@ -347,14 +352,16 @@ function App() { lng = exifData.longitude; source = 'EXIF'; lastPositionRef.current = { lat, lng }; - } else { - console.log('EXIF에 GPS 데이터 없음'); + } + 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 읽기 실패:', err); } - // 2차: 브라우저 GPS (EXIF에 GPS가 없는 경우) if (lat === undefined && navigator.geolocation) { try { console.log('브라우저 GPS 요청 중...'); @@ -374,7 +381,6 @@ function App() { } } - // 3차: 마지막 알려진 위치 (watchPosition 또는 초기 위치) if (lat === undefined) { const last = lastPositionRef.current; if (last) { @@ -388,27 +394,45 @@ function App() { setGpsInfo({ source, lat, lng }); + const level = await requestLevel(file, { lat, lng }); + if (!level) { + setProcessing(false); + e.target.value = ''; + return; + } + const compressed = await imageCompression(file, { maxSizeMB: 0.5, maxWidthOrHeight: 800, useWebWorker: true, }); - const id = `pole-${Date.now()}`; + const id = `pole-${crypto.randomUUID()}`; const formData = new FormData(); + 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', new Date().toISOString()); + formData.append('timestamp', exifTimestamp || new Date().toISOString()); const res = await fetch('/api/poles', { method: 'POST', body: formData }); - if (!res.ok) throw new Error(`등록 실패 (HTTP ${res.status})`); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + if (res.status === 409) { + alert('이미 등록된 전신주입니다 (GPS 좌표 + 촬영 시간 중복).'); + setProcessing(false); + e.target.value = ''; + return; + } + throw new Error(body.error || `등록 실패 (HTTP ${res.status})`); + } const pole = await res.json(); setPoles((prev) => { - polesRef.current = [...prev, pole]; - return [...prev, pole]; + const next = [...prev, pole]; + polesRef.current = next; + return next; }); } catch (err) { console.error('전신주 등록 실패:', err); @@ -436,6 +460,7 @@ function App() { let registered = 0; let skipped = 0; + let duplicated = 0; for (let i = 0; i < imageFiles.length; i++) { const file = imageFiles[i]; @@ -444,6 +469,7 @@ function App() { try { let lat; let lng; + let exifTimestamp = null; try { const buffer = await file.arrayBuffer(); @@ -453,6 +479,11 @@ function App() { 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); } @@ -462,27 +493,41 @@ function App() { continue; } + const level = await requestLevel(file, { lat, lng }); + if (!level) { + skipped++; + continue; + } + const compressed = await imageCompression(file, { maxSizeMB: 0.5, maxWidthOrHeight: 800, useWebWorker: true, }); - const id = `pole-${Date.now()}-${i}`; + const id = `pole-${crypto.randomUUID()}-${i}`; const formData = new FormData(); + 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', new Date().toISOString()); + formData.append('timestamp', exifTimestamp || new Date().toISOString()); const res = await fetch('/api/poles', { method: 'POST', body: formData }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (!res.ok) { + if (res.status === 409) { + duplicated++; + continue; + } + throw new Error(`HTTP ${res.status}`); + } const pole = await res.json(); setPoles((prev) => { - polesRef.current = [...prev, pole]; - return [...prev, pole]; + const next = [...prev, pole]; + polesRef.current = next; + return next; }); registered++; } catch (err) { @@ -493,7 +538,10 @@ function App() { setUploadProgress(null); setProcessing(false); - alert(`등록 완료: ${registered}장${skipped > 0 ? ` / 건너뜀: ${skipped}장 (GPS 없음)` : ''}`); + const parts = [`등록: ${registered}장`]; + if (duplicated > 0) parts.push(`중복: ${duplicated}장`); + if (skipped > 0) parts.push(`건너뜀: ${skipped}장`); + alert(parts.join(' / ')); e.target.value = ''; }; @@ -572,6 +620,41 @@ function App() { )} + {levelModal && ( +
handleLevelSelect(null)}> +
e.stopPropagation()}> + 전신주 사진 +
전신주 등급을 선택하세요
+
+ + + +
+
+
+ )} + {poles.length > 0 && (