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:
부모
5e55a495bc
커밋
1a610b73f2
@ -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)
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user