fix(frontend): 시간 표시 버그 수정 + 선박 모달 UI 개선 + 프록시 수정

- LiveControls: KST 이중 오프셋(+9h×2) 제거 + KST/UTC 토글 버튼 추가
- ShipLayer: 사진 탭명 signal-batch → S&P Global, 고화질(_2) 기본 표시
  - S&P Global 우선 활성화, 양쪽 사진 없을 때 안정적 fallback UI
- nginx: /shipimg/ ^~ 추가 (정적파일 regex 우선매칭 방지)
- infra.ts: Overpass 외부 API 호출 제거 (정적 fallback 데이터 사용)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-03-18 07:38:09 +09:00
부모 5e55a495bc
커밋 1a610b73f2
4개의 변경된 파일75개의 추가작업 그리고 91개의 파일을 삭제

파일 보기

@ -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;

파일 보기

@ -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 (
<div className="live-controls">
@ -34,7 +45,27 @@ export function LiveControls({
<span className="live-label">{t('header.live')}</span>
</div>
<div className="live-clock">{kstTime}</div>
<div className="live-clock" style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span>{formatTime(currentTime, timeZone)}</span>
<button
type="button"
onClick={() => setTimeZone(prev => prev === 'KST' ? 'UTC' : 'KST')}
style={{
background: 'none',
border: '1px solid var(--kcg-border, rgba(100, 116, 139, 0.3))',
color: 'var(--kcg-text-secondary, #94a3b8)',
borderRadius: '3px',
padding: '1px 5px',
cursor: 'pointer',
fontSize: '10px',
fontFamily: 'monospace',
lineHeight: 1.2,
}}
title="KST/UTC 전환"
>
{timeZone}
</button>
</div>
<div className="flex-1" />

파일 보기

@ -129,7 +129,7 @@ const LOCAL_SHIP_PHOTOS: Record<string, string> = {
interface VesselPhotoData { url: string; }
const vesselPhotoCache = new Map<string, VesselPhotoData | null>();
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<PhotoSource>(defaultTab);
// S&P Global image error state
const [spgError, setSpgError] = useState(false);
// MarineTraffic image state (lazy loaded)
const [mtPhoto, setMtPhoto] = useState<VesselPhotoData | null | undefined>(() => {
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 (
<div className="mb-1.5">
<div className="flex gap-0.5 mb-1 rounded bg-kcg-bg p-0.5">
{hasSignalBatch && (
{hasSPGlobal && (
<div
className={`flex-1 py-0.5 text-center cursor-pointer rounded transition-all text-[9px] font-semibold ${
activeTab === 'signal-batch' ? 'bg-[#1565c0] text-white' : 'bg-transparent text-kcg-muted'
activeTab === 'spglobal' ? 'bg-[#1565c0] text-white' : 'bg-transparent text-kcg-muted'
}`}
onClick={() => setActiveTab('signal-batch')}
onClick={() => setActiveTab('spglobal')}
>
signal-batch
S&P Global
</div>
)}
<div
@ -208,12 +215,26 @@ function VesselPhoto({ mmsi, shipImagePath }: VesselPhotoProps) {
{currentUrl ? (
<img src={currentUrl} alt="Vessel"
className="w-full rounded block"
onError={(e) => { (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 ? (
<div className="text-center py-3 text-kcg-dim text-[10px] border border-dashed border-kcg-border-light rounded">
No photo available
</div>
) : (
activeTab === 'marinetraffic' && mtPhoto === undefined
? <div className="text-center p-2 text-kcg-dim text-[10px]">{t('popup.loading')}</div>
: null
? <div className="text-center py-3 text-kcg-dim text-[10px]">Loading...</div>
: <div className="text-center py-3 text-kcg-dim text-[10px] border border-dashed border-kcg-border-light rounded">
No photo available
</div>
)}
</div>
);

파일 보기

@ -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<PowerFacility[]> {
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)