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)