diff --git a/deploy/nginx-kcg.conf b/deploy/nginx-kcg.conf index 4820fea..a4c9821 100644 --- a/deploy/nginx-kcg.conf +++ b/deploy/nginx-kcg.conf @@ -49,8 +49,8 @@ server { proxy_read_timeout 30s; } - # ── 선박 이미지 프록시 ── - location /shipimg/ { + # ── 선박 이미지 프록시 (^~ = regex 정적캐시 규칙보다 우선) ── + location ^~ /shipimg/ { proxy_pass https://wing.gc-si.dev/shipimg/; proxy_set_header Host wing.gc-si.dev; proxy_ssl_server_name on; diff --git a/frontend/src/components/common/LiveControls.tsx b/frontend/src/components/common/LiveControls.tsx index d5ba44f..20d73af 100644 --- a/frontend/src/components/common/LiveControls.tsx +++ b/frontend/src/components/common/LiveControls.tsx @@ -1,5 +1,5 @@ -import { format } from 'date-fns'; import { useTranslation } from 'react-i18next'; +import { useState } from 'react'; interface Props { currentTime: number; @@ -19,13 +19,24 @@ const HISTORY_PRESETS = [ { label: '24H', minutes: 1440 }, ]; +function formatTime(epoch: number, tz: 'KST' | 'UTC'): string { + const d = new Date(epoch); + if (tz === 'UTC') { + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} UTC`; + } + // KST: 브라우저 로컬 타임존 사용 (한국 환경에서 자동 KST) + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())} KST`; +} + export function LiveControls({ currentTime, historyMinutes, onHistoryChange, }: Props) { const { t } = useTranslation(); - const kstTime = format(new Date(currentTime + 9 * 3600_000), "yyyy-MM-dd HH:mm:ss 'KST'"); + const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST'); return (
@@ -34,7 +45,27 @@ export function LiveControls({ {t('header.live')}
-
{kstTime}
+
+ {formatTime(currentTime, timeZone)} + +
diff --git a/frontend/src/components/layers/ShipLayer.tsx b/frontend/src/components/layers/ShipLayer.tsx index 271538f..5bd7292 100644 --- a/frontend/src/components/layers/ShipLayer.tsx +++ b/frontend/src/components/layers/ShipLayer.tsx @@ -129,7 +129,7 @@ const LOCAL_SHIP_PHOTOS: Record = { interface VesselPhotoData { url: string; } const vesselPhotoCache = new Map(); -type PhotoSource = 'signal-batch' | 'marinetraffic'; +type PhotoSource = 'spglobal' | 'marinetraffic'; interface VesselPhotoProps { mmsi: string; @@ -137,15 +137,20 @@ interface VesselPhotoProps { shipImagePath?: string | null; } +function toHighRes(path: string): string { + return path.replace(/_1\.(jpg|jpeg|png)$/i, '_2.$1'); +} + function VesselPhoto({ mmsi, shipImagePath }: VesselPhotoProps) { - const { t } = useTranslation('ships'); const localUrl = LOCAL_SHIP_PHOTOS[mmsi]; - // Determine available tabs - const hasSignalBatch = !!shipImagePath; - const defaultTab: PhotoSource = hasSignalBatch ? 'signal-batch' : 'marinetraffic'; + const hasSPGlobal = !!shipImagePath; + const defaultTab: PhotoSource = hasSPGlobal ? 'spglobal' : 'marinetraffic'; const [activeTab, setActiveTab] = useState(defaultTab); + // S&P Global image error state + const [spgError, setSpgError] = useState(false); + // MarineTraffic image state (lazy loaded) const [mtPhoto, setMtPhoto] = useState(() => { return vesselPhotoCache.has(mmsi) ? vesselPhotoCache.get(mmsi) : undefined; @@ -165,8 +170,8 @@ function VesselPhoto({ mmsi, shipImagePath }: VesselPhotoProps) { let currentUrl: string | null = null; if (localUrl) { currentUrl = localUrl; - } else if (activeTab === 'signal-batch' && shipImagePath) { - currentUrl = shipImagePath; + } else if (activeTab === 'spglobal' && shipImagePath && !spgError) { + currentUrl = toHighRes(shipImagePath); } else if (activeTab === 'marinetraffic' && mtPhoto) { currentUrl = mtPhoto.url; } @@ -183,17 +188,19 @@ function VesselPhoto({ mmsi, shipImagePath }: VesselPhotoProps) { ); } + const noPhoto = (!hasSPGlobal || spgError) && mtPhoto === null; + return (
- {hasSignalBatch && ( + {hasSPGlobal && (
setActiveTab('signal-batch')} + onClick={() => setActiveTab('spglobal')} > - signal-batch + S&P Global
)}
{ (e.target as HTMLImageElement).style.display = 'none'; }} + onError={(e) => { + const el = e.target as HTMLImageElement; + if (activeTab === 'spglobal') { + setSpgError(true); + el.style.display = 'none'; + } else { + el.style.display = 'none'; + } + }} /> + ) : noPhoto ? ( +
+ No photo available +
) : ( activeTab === 'marinetraffic' && mtPhoto === undefined - ?
{t('popup.loading')}
- : null + ?
Loading...
+ :
+ No photo available +
)}
); diff --git a/frontend/src/services/infra.ts b/frontend/src/services/infra.ts index 6da037b..f479ffe 100644 --- a/frontend/src/services/infra.ts +++ b/frontend/src/services/infra.ts @@ -12,17 +12,6 @@ export interface PowerFacility { voltage?: string; // for substations } -// Overpass QL: power plants + wind generators + substations in South Korea -const OVERPASS_QUERY = ` -[out:json][timeout:30][bbox:33,124,39,132]; -( - nwr["power"="plant"]; - nwr["power"="generator"]["generator:source"="wind"]; - nwr["power"="substation"]["substation"="transmission"]; -); -out center 500; -`; - let cachedData: PowerFacility[] | null = null; let lastFetch = 0; const CACHE_MS = 600_000; // 10 min cache @@ -30,67 +19,10 @@ const CACHE_MS = 600_000; // 10 min cache export async function fetchKoreaInfra(): Promise { if (cachedData && Date.now() - lastFetch < CACHE_MS) return cachedData; - try { - const url = `/api/overpass/api/interpreter`; - const res = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: `data=${encodeURIComponent(OVERPASS_QUERY)}`, - }); - - if (!res.ok) throw new Error(`Overpass ${res.status}`); - const json = await res.json(); - - const facilities: PowerFacility[] = []; - - for (const el of json.elements || []) { - const tags = el.tags || {}; - const lat = el.lat ?? el.center?.lat; - const lng = el.lon ?? el.center?.lon; - if (lat == null || lng == null) continue; - - const isPower = tags.power; - if (isPower === 'plant') { - facilities.push({ - id: `plant-${el.id}`, - type: 'plant', - name: tags.name || tags['name:ko'] || tags['name:en'] || 'Power Plant', - lat, lng, - source: tags['plant:source'] || tags['generator:source'] || undefined, - output: tags['plant:output:electricity'] || undefined, - operator: tags.operator || undefined, - }); - } else if (isPower === 'generator' && tags['generator:source'] === 'wind') { - facilities.push({ - id: `wind-${el.id}`, - type: 'plant', - name: tags.name || tags['name:ko'] || tags['name:en'] || '풍력발전기', - lat, lng, - source: 'wind', - output: tags['generator:output:electricity'] || undefined, - operator: tags.operator || undefined, - }); - } else if (isPower === 'substation') { - facilities.push({ - id: `sub-${el.id}`, - type: 'substation', - name: tags.name || tags['name:ko'] || tags['name:en'] || 'Substation', - lat, lng, - voltage: tags.voltage || undefined, - operator: tags.operator || undefined, - }); - } - } - - console.log(`Overpass: ${facilities.length} power facilities in Korea (${facilities.filter(f => f.type === 'plant').length} plants, ${facilities.filter(f => f.type === 'substation').length} substations)`); - cachedData = facilities; - lastFetch = Date.now(); - return facilities; - } catch (err) { - console.warn('Overpass API failed, using fallback data:', err); - if (cachedData) return cachedData; - return getFallbackInfra(); - } + // 정적 데이터 사용 (Overpass API는 프로덕션 nginx에서 미지원 + fallback 데이터로 충분) + cachedData = getFallbackInfra(); + lastFetch = Date.now(); + return cachedData; } // Fallback: major Korean power plants (in case API fails)