mirror of
https://github.com/15000rpm/Pole-Mapper.git
synced 2026-10-09 10:45:10 +09:00
테스트 서버에 올리기 위한 준비
ID 중복 방지 - UUID v4 사용
This commit is contained in:
@@ -1,3 +0,0 @@
|
|||||||
supabase
|
|
||||||
|
|
||||||
DBPW : TnvkEngineSori1!
|
|
||||||
@@ -83,6 +83,21 @@ export async function insertPole(pole) {
|
|||||||
if (error) throw error;
|
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) {
|
export async function getPole(id) {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('poles')
|
.from('poles')
|
||||||
|
|||||||
+45
-9
@@ -4,13 +4,19 @@ import cors from 'cors';
|
|||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import crypto from 'crypto';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import * as db from './db.js';
|
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 __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const UPLOAD_DIR = path.join(__dirname, 'uploads');
|
const UPLOAD_DIR = path.join(__dirname, 'uploads');
|
||||||
|
const LEVEL_DIRS = ['A', 'B', 'Pole'];
|
||||||
|
|
||||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
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 PORT = Number(process.env.PORT || 4000);
|
||||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
@@ -22,7 +28,12 @@ app.use('/uploads', express.static(UPLOAD_DIR));
|
|||||||
|
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage: multer.diskStorage({
|
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) => {
|
filename: (req, file, cb) => {
|
||||||
const id = req.body?.id || `pole-${Date.now()}`;
|
const id = req.body?.id || `pole-${Date.now()}`;
|
||||||
const ext = path.extname(file.originalname) || '.jpg';
|
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) => {
|
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) {
|
if (lat === undefined || lng === undefined || !timestamp) {
|
||||||
return res.status(400).json({ error: 'lat, lng, 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 = {
|
const pole = {
|
||||||
id: id || `pole-${Date.now()}`,
|
id: id || `pole-${crypto.randomUUID()}`,
|
||||||
lat: Number(lat),
|
lat: numLat,
|
||||||
lng: Number(lng),
|
lng: numLng,
|
||||||
gu,
|
gu,
|
||||||
dong,
|
dong,
|
||||||
timestamp,
|
timestamp,
|
||||||
level,
|
level,
|
||||||
photo_path: req.file ? req.file.filename : null,
|
photo_path: photoPath,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await db.insertPole(pole);
|
await db.insertPole(pole);
|
||||||
@@ -118,7 +150,7 @@ app.delete('/api/poles/:id', async (req, res) => {
|
|||||||
const existing = await db.getPole(req.params.id);
|
const existing = await db.getPole(req.params.id);
|
||||||
if (!existing) return res.status(404).json({ error: '전신주를 찾을 수 없습니다.' });
|
if (!existing) return res.status(404).json({ error: '전신주를 찾을 수 없습니다.' });
|
||||||
if (existing.photo_path) {
|
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 {
|
try {
|
||||||
await db.deletePole(existing.id);
|
await db.deletePole(existing.id);
|
||||||
@@ -128,6 +160,10 @@ app.delete('/api/poles/:id', async (req, res) => {
|
|||||||
res.json({ removed: true });
|
res.json({ removed: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
const server = app.listen(PORT, () => {
|
||||||
console.log(`Pole Mapper API server: http://localhost:${PORT}`);
|
console.log(`Pole Mapper API server: http://localhost:${PORT}`);
|
||||||
});
|
});
|
||||||
|
server.on('error', (err) => {
|
||||||
|
console.error('서버 시작 실패:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import { createClient } from '@supabase/supabase-js';
|
import { createClient } from '@supabase/supabase-js';
|
||||||
import { writeFileSync, mkdirSync, existsSync } from 'fs';
|
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 SOURCE_URL = 'https://saftyovhcable.kr/addMarker.html';
|
||||||
const THUMB_BASE = 'https://saftyovhcable.kr/res/Thumb/';
|
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 supabaseUrl = process.env.SUPABASE_URL;
|
||||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
|||||||
@@ -63,3 +63,48 @@ export async function fetchDongs(guCode) {
|
|||||||
const features = await fetchVworldFeatures('LT_C_ADEMD_INFO', `emd_cd:like:${guCode}`);
|
const features = await fetchVworldFeatures('LT_C_ADEMD_INFO', `emd_cd:like:${guCode}`);
|
||||||
return features.map((f) => f.properties.emd_kor_nm).filter(Boolean);
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
+80
@@ -377,6 +377,86 @@
|
|||||||
margin-left: 4px;
|
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) {
|
@media (min-width: 900px) {
|
||||||
.list-toggle,
|
.list-toggle,
|
||||||
.backdrop,
|
.backdrop,
|
||||||
|
|||||||
+147
-62
@@ -14,7 +14,7 @@ import imageCompression from 'browser-image-compression';
|
|||||||
import exifr from 'exifr';
|
import exifr from 'exifr';
|
||||||
import './App.css';
|
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_COLORS = { A: '#22c55e', B: '#f97316', Pole: '#a855f7' };
|
||||||
const LEVEL_LABELS = { A: 'A', B: 'B', Pole: 'P' };
|
const LEVEL_LABELS = { A: 'A', B: 'B', Pole: 'P' };
|
||||||
|
|
||||||
@@ -44,6 +44,8 @@ function App() {
|
|||||||
const [listOpen, setListOpen] = useState(false);
|
const [listOpen, setListOpen] = useState(false);
|
||||||
const [uploadProgress, setUploadProgress] = useState(null);
|
const [uploadProgress, setUploadProgress] = useState(null);
|
||||||
const folderInputRef = useRef(null);
|
const folderInputRef = useRef(null);
|
||||||
|
const levelResolveRef = useRef(null);
|
||||||
|
const [levelModal, setLevelModal] = useState(null);
|
||||||
const [levelFilter, setLevelFilter] = useState('all');
|
const [levelFilter, setLevelFilter] = useState('all');
|
||||||
const [guFilter, setGuFilter] = useState('용산구');
|
const [guFilter, setGuFilter] = useState('용산구');
|
||||||
const [dongFilter, setDongFilter] = useState('all');
|
const [dongFilter, setDongFilter] = useState('all');
|
||||||
@@ -66,8 +68,8 @@ function App() {
|
|||||||
const filteredPoles = useMemo(() => {
|
const filteredPoles = useMemo(() => {
|
||||||
return poles.filter(
|
return poles.filter(
|
||||||
(p) => (levelFilter === 'all' || p.level === levelFilter) &&
|
(p) => (levelFilter === 'all' || p.level === levelFilter) &&
|
||||||
(guFilter === 'all' || p.dong.includes(guFilter)) &&
|
(guFilter === 'all' || p.gu === guFilter) &&
|
||||||
(dongFilter === 'all' || p.dong.includes(dongFilter))
|
(dongFilter === 'all' || p.dong === dongFilter)
|
||||||
);
|
);
|
||||||
}, [poles, levelFilter, guFilter, dongFilter]);
|
}, [poles, levelFilter, guFilter, dongFilter]);
|
||||||
|
|
||||||
@@ -84,11 +86,11 @@ function App() {
|
|||||||
if (f.get('poleId') !== 'current-location') source.removeFeature(f);
|
if (f.get('poleId') !== 'current-location') source.removeFeature(f);
|
||||||
});
|
});
|
||||||
const idSet = new Set();
|
const idSet = new Set();
|
||||||
polesRef.current.forEach((pole) => {
|
poles.forEach((pole) => {
|
||||||
if (idSet.has(pole.id)) return;
|
if (idSet.has(pole.id)) return;
|
||||||
if (levelFilter !== 'all' && pole.level !== levelFilter) return;
|
if (levelFilter !== 'all' && pole.level !== levelFilter) return;
|
||||||
if (guFilter !== 'all' && !pole.dong.includes(guFilter)) return;
|
if (guFilter !== 'all' && pole.gu !== guFilter) return;
|
||||||
if (dongFilter !== 'all' && !pole.dong.includes(dongFilter)) return;
|
if (dongFilter !== 'all' && pole.dong !== dongFilter) return;
|
||||||
idSet.add(pole.id);
|
idSet.add(pole.id);
|
||||||
const feature = new Feature({
|
const feature = new Feature({
|
||||||
geometry: new Point(fromLonLat([pole.lng, pole.lat])),
|
geometry: new Point(fromLonLat([pole.lng, pole.lat])),
|
||||||
@@ -112,51 +114,20 @@ function App() {
|
|||||||
);
|
);
|
||||||
source.addFeature(feature);
|
source.addFeature(feature);
|
||||||
});
|
});
|
||||||
}, [levelFilter, guFilter, dongFilter]);
|
}, [poles, levelFilter, guFilter, dongFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
syncMarkers();
|
syncMarkers();
|
||||||
}, [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) => {
|
const handleDelete = useCallback(async (id) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/poles/${id}`, { method: 'DELETE' });
|
const res = await fetch(`/api/poles/${id}`, { method: 'DELETE' });
|
||||||
if (!res.ok) throw new Error(`삭제 실패 (HTTP ${res.status})`);
|
if (!res.ok) throw new Error(`삭제 실패 (HTTP ${res.status})`);
|
||||||
setPoles((prev) => {
|
setPoles((prev) => {
|
||||||
polesRef.current = prev.filter((p) => p.id !== id);
|
const next = prev.filter((p) => p.id !== id);
|
||||||
return prev.filter((p) => p.id !== id);
|
polesRef.current = next;
|
||||||
|
return next;
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('전신주 삭제 실패:', 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(() => {
|
useEffect(() => {
|
||||||
if (mapRef.current) return;
|
if (mapRef.current) return;
|
||||||
|
|
||||||
@@ -234,7 +224,22 @@ function App() {
|
|||||||
popupOverlay.setPosition(evt.coordinate);
|
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([
|
Promise.allSettled([
|
||||||
fetch('/api/poles').then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }),
|
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(); }),
|
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;
|
currentLocationRef.current = null;
|
||||||
fallbackUsedRef.current = false;
|
fallbackUsedRef.current = false;
|
||||||
};
|
};
|
||||||
}, [addMarker]);
|
}, []);
|
||||||
|
|
||||||
const handleCapture = async (e) => {
|
const handleCapture = async (e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@@ -334,9 +339,9 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
let lat;
|
let lat;
|
||||||
let lng;
|
let lng;
|
||||||
|
let exifTimestamp = null;
|
||||||
let source = '';
|
let source = '';
|
||||||
|
|
||||||
// 1차: 사진 EXIF에서 GPS 추출 (ArrayBuffer로 읽어 Android 호환성 확보)
|
|
||||||
try {
|
try {
|
||||||
const buffer = await file.arrayBuffer();
|
const buffer = await file.arrayBuffer();
|
||||||
console.log('파일 타입:', file.type, '크기:', file.size, 'buffer 크기:', buffer.byteLength);
|
console.log('파일 타입:', file.type, '크기:', file.size, 'buffer 크기:', buffer.byteLength);
|
||||||
@@ -347,14 +352,16 @@ function App() {
|
|||||||
lng = exifData.longitude;
|
lng = exifData.longitude;
|
||||||
source = 'EXIF';
|
source = 'EXIF';
|
||||||
lastPositionRef.current = { lat, lng };
|
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) {
|
} catch (err) {
|
||||||
console.error('EXIF 읽기 실패:', err);
|
console.error('EXIF 읽기 실패:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2차: 브라우저 GPS (EXIF에 GPS가 없는 경우)
|
|
||||||
if (lat === undefined && navigator.geolocation) {
|
if (lat === undefined && navigator.geolocation) {
|
||||||
try {
|
try {
|
||||||
console.log('브라우저 GPS 요청 중...');
|
console.log('브라우저 GPS 요청 중...');
|
||||||
@@ -374,7 +381,6 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3차: 마지막 알려진 위치 (watchPosition 또는 초기 위치)
|
|
||||||
if (lat === undefined) {
|
if (lat === undefined) {
|
||||||
const last = lastPositionRef.current;
|
const last = lastPositionRef.current;
|
||||||
if (last) {
|
if (last) {
|
||||||
@@ -388,27 +394,45 @@ function App() {
|
|||||||
|
|
||||||
setGpsInfo({ source, lat, lng });
|
setGpsInfo({ source, lat, lng });
|
||||||
|
|
||||||
|
const level = await requestLevel(file, { lat, lng });
|
||||||
|
if (!level) {
|
||||||
|
setProcessing(false);
|
||||||
|
e.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const compressed = await imageCompression(file, {
|
const compressed = await imageCompression(file, {
|
||||||
maxSizeMB: 0.5,
|
maxSizeMB: 0.5,
|
||||||
maxWidthOrHeight: 800,
|
maxWidthOrHeight: 800,
|
||||||
useWebWorker: true,
|
useWebWorker: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const id = `pole-${Date.now()}`;
|
const id = `pole-${crypto.randomUUID()}`;
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
formData.append('level', level);
|
||||||
formData.append('photo', compressed, `${id}.jpg`);
|
formData.append('photo', compressed, `${id}.jpg`);
|
||||||
formData.append('id', id);
|
formData.append('id', id);
|
||||||
formData.append('lat', lat);
|
formData.append('lat', lat);
|
||||||
formData.append('lng', lng);
|
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 });
|
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();
|
const pole = await res.json();
|
||||||
|
|
||||||
setPoles((prev) => {
|
setPoles((prev) => {
|
||||||
polesRef.current = [...prev, pole];
|
const next = [...prev, pole];
|
||||||
return [...prev, pole];
|
polesRef.current = next;
|
||||||
|
return next;
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('전신주 등록 실패:', err);
|
console.error('전신주 등록 실패:', err);
|
||||||
@@ -436,6 +460,7 @@ function App() {
|
|||||||
|
|
||||||
let registered = 0;
|
let registered = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
|
let duplicated = 0;
|
||||||
|
|
||||||
for (let i = 0; i < imageFiles.length; i++) {
|
for (let i = 0; i < imageFiles.length; i++) {
|
||||||
const file = imageFiles[i];
|
const file = imageFiles[i];
|
||||||
@@ -444,6 +469,7 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
let lat;
|
let lat;
|
||||||
let lng;
|
let lng;
|
||||||
|
let exifTimestamp = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const buffer = await file.arrayBuffer();
|
const buffer = await file.arrayBuffer();
|
||||||
@@ -453,6 +479,11 @@ function App() {
|
|||||||
lng = exifData.longitude;
|
lng = exifData.longitude;
|
||||||
lastPositionRef.current = { lat, lng };
|
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) {
|
} catch (err) {
|
||||||
console.error('EXIF 읽기 실패:', file.name, err);
|
console.error('EXIF 읽기 실패:', file.name, err);
|
||||||
}
|
}
|
||||||
@@ -462,27 +493,41 @@ function App() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const level = await requestLevel(file, { lat, lng });
|
||||||
|
if (!level) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const compressed = await imageCompression(file, {
|
const compressed = await imageCompression(file, {
|
||||||
maxSizeMB: 0.5,
|
maxSizeMB: 0.5,
|
||||||
maxWidthOrHeight: 800,
|
maxWidthOrHeight: 800,
|
||||||
useWebWorker: true,
|
useWebWorker: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const id = `pole-${Date.now()}-${i}`;
|
const id = `pole-${crypto.randomUUID()}-${i}`;
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
formData.append('level', level);
|
||||||
formData.append('photo', compressed, `${id}.jpg`);
|
formData.append('photo', compressed, `${id}.jpg`);
|
||||||
formData.append('id', id);
|
formData.append('id', id);
|
||||||
formData.append('lat', lat);
|
formData.append('lat', lat);
|
||||||
formData.append('lng', lng);
|
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 });
|
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();
|
const pole = await res.json();
|
||||||
|
|
||||||
setPoles((prev) => {
|
setPoles((prev) => {
|
||||||
polesRef.current = [...prev, pole];
|
const next = [...prev, pole];
|
||||||
return [...prev, pole];
|
polesRef.current = next;
|
||||||
|
return next;
|
||||||
});
|
});
|
||||||
registered++;
|
registered++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -493,7 +538,10 @@ function App() {
|
|||||||
|
|
||||||
setUploadProgress(null);
|
setUploadProgress(null);
|
||||||
setProcessing(false);
|
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 = '';
|
e.target.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -572,6 +620,41 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{levelModal && (
|
||||||
|
<div className="level-modal-backdrop" onClick={() => handleLevelSelect(null)}>
|
||||||
|
<div className="level-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<img src={levelModal.previewUrl} alt="전신주 사진" className="level-modal__img" />
|
||||||
|
<div className="level-modal__label">전신주 등급을 선택하세요</div>
|
||||||
|
<div className="level-modal__buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="level-modal__btn level-modal__btn--A"
|
||||||
|
onClick={() => handleLevelSelect('A')}
|
||||||
|
>
|
||||||
|
A
|
||||||
|
<span className="level-modal__desc">양호</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="level-modal__btn level-modal__btn--B"
|
||||||
|
onClick={() => handleLevelSelect('B')}
|
||||||
|
>
|
||||||
|
B
|
||||||
|
<span className="level-modal__desc">불량</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="level-modal__btn level-modal__btn--Pole"
|
||||||
|
onClick={() => handleLevelSelect('Pole')}
|
||||||
|
>
|
||||||
|
Pole
|
||||||
|
<span className="level-modal__desc">기준</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{poles.length > 0 && (
|
{poles.length > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -599,7 +682,9 @@ function App() {
|
|||||||
<div
|
<div
|
||||||
className="backdrop"
|
className="backdrop"
|
||||||
onClick={() => setListOpen(false)}
|
onClick={() => setListOpen(false)}
|
||||||
onKeyDown={() => setListOpen(false)}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') setListOpen(false);
|
||||||
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
/>
|
/>
|
||||||
@@ -660,7 +745,7 @@ function App() {
|
|||||||
>
|
>
|
||||||
<option value="all">전체 구</option>
|
<option value="all">전체 구</option>
|
||||||
{guList.map((gu) => {
|
{guList.map((gu) => {
|
||||||
const count = poles.filter((p) => p.dong.includes(gu)).length;
|
const count = poles.filter((p) => p.gu === gu).length;
|
||||||
return (
|
return (
|
||||||
<option key={gu} value={gu}>{gu} ({count}기)</option>
|
<option key={gu} value={gu}>{gu} ({count}기)</option>
|
||||||
);
|
);
|
||||||
@@ -674,7 +759,7 @@ function App() {
|
|||||||
>
|
>
|
||||||
<option value="all">전체 동</option>
|
<option value="all">전체 동</option>
|
||||||
{dongList.map((d) => {
|
{dongList.map((d) => {
|
||||||
const count = poles.filter((p) => p.dong.includes(d.dong)).length;
|
const count = poles.filter((p) => p.gu === guFilter && p.dong === d.dong).length;
|
||||||
return (
|
return (
|
||||||
<option key={d.dong} value={d.dong}>{d.dong} ({count}기)</option>
|
<option key={d.dong} value={d.dong}>{d.dong} ({count}기)</option>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user