Merge pull request 'release: 2026-03-18.5 (5건 커밋)' (#61) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m21s

This commit is contained in:
htlee 2026-03-18 11:04:28 +09:00
커밋 5e85e80142
12개의 변경된 파일420개의 추가작업 그리고 118개의 파일을 삭제

파일 보기

@ -92,37 +92,22 @@ jobs:
chmod 600 ~/.ssh/id_deploy
ssh-keyscan -T 5 $DEPLOY_HOST >> ~/.ssh/known_hosts 2>/dev/null || true
SSH_CMD="ssh -i ~/.ssh/id_deploy -o StrictHostKeyChecking=no -o ConnectTimeout=10 -o ServerAliveInterval=15 root@$DEPLOY_HOST"
SSH_OPTS="-i ~/.ssh/id_deploy -o StrictHostKeyChecking=no -o ConnectTimeout=10 -o ServerAliveInterval=15"
# SSH 연결 테스트 (최대 3회, kex_exchange 거부 대응)
for attempt in 1 2 3; do
echo "SSH connectivity test $attempt/3..."
if $SSH_CMD echo "SSH OK"; then
break
fi
if [ "$attempt" -eq 3 ]; then
echo "ERROR: SSH connection failed after 3 attempts"
exit 1
fi
echo "SSH failed, retrying in 10s..."
sleep 10
done
$SSH_CMD bash -s << 'RESTART'
# 재시작 스크립트를 SCP로 업로드 후 SSH로 실행 (각각 재시도)
cat > /tmp/restart-kcg.sh << 'SCRIPT'
#!/bin/bash
DEPLOY_DIR=/devdata/services/kcg/backend
SYSTEMD_DIR=/etc/systemd/system
# systemd 서비스 파일 갱신
if [ -f "$DEPLOY_DIR/kcg-backend.service" ] && ! diff -q "$DEPLOY_DIR/kcg-backend.service" "$SYSTEMD_DIR/kcg-backend.service" >/dev/null 2>&1; then
cp "$DEPLOY_DIR/kcg-backend.service" "$SYSTEMD_DIR/kcg-backend.service"
systemctl daemon-reload
fi
# 백엔드 재시작
echo "--- Restarting kcg-backend ---"
systemctl restart kcg-backend
# 기동 확인 (최대 60초)
for i in $(seq 1 60); do
HTTP=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/api/aircraft 2>/dev/null || echo "000")
if [ "$HTTP" = "200" ] || [ "$HTTP" = "401" ] || [ "$HTTP" = "403" ]; then
@ -134,7 +119,29 @@ jobs:
echo "WARNING: Startup timeout"
journalctl -u kcg-backend --no-pager -n 10
exit 1
RESTART
SCRIPT
# SCP 업로드 (최대 3회 재시도)
for attempt in 1 2 3; do
echo "SCP upload attempt $attempt/3..."
if scp $SSH_OPTS /tmp/restart-kcg.sh root@$DEPLOY_HOST:/tmp/restart-kcg.sh; then
break
fi
[ "$attempt" -eq 3 ] && { echo "ERROR: SCP failed after 3 attempts"; exit 1; }
sleep 10
done
# SSH 실행 (최대 3회 재시도)
for attempt in 1 2 3; do
echo "SSH execute attempt $attempt/3..."
if ssh $SSH_OPTS root@$DEPLOY_HOST "bash /tmp/restart-kcg.sh && rm -f /tmp/restart-kcg.sh"; then
exit 0
fi
SSH_EXIT=$?
[ "$attempt" -eq 3 ] && { echo "ERROR: SSH failed after 3 attempts (exit $SSH_EXIT)"; exit 1; }
echo "SSH failed (exit $SSH_EXIT), retrying in 10s..."
sleep 10
done
- name: Cleanup
if: always()

파일 보기

@ -4,6 +4,23 @@
## [Unreleased]
## [2026-03-18.5]
### 추가
- 지진 포인트 클릭 → 지도 flyTo + SeismicMarker 진도별 펄스 원형 영향범위 표시
- SatelliteMap flyTo 지원
### 변경
- 히스토리 프리셋: 10M/30M/1H/3H/6H → 1H/2H/3H/6H (최소 1시간)
- 기압 그래프: 해수면 기압 보정(SLP), 원본 포인트 기반 렌더링
- 그래프 데이터 범위: 표시 범위보다 1칸 확장 (y축 시작점 연결)
- Tooltip: KST 시간 포맷, 상단 고정, 전체 스타일 통일
- OilFacilityLayer: planned ring SVG 내부 이동 (아이콘 중심 정렬)
- 밝은 테마: 지도 라벨 text-shadow CSS 변수 분리
### 수정
- deploy.yml: SSH SCP+실행 각 3회 재시도 (kex_exchange 거부 대응)
## [2026-03-18.4]
### 추가

파일 보기

@ -1132,8 +1132,10 @@
border-top: 1px solid var(--kcg-border);
flex-shrink: 0;
height: 110px;
overflow: hidden;
overflow: visible;
box-shadow: var(--kcg-panel-shadow);
position: relative;
z-index: 5;
}
.sensor-chart h3 {
@ -1856,7 +1858,7 @@
font-weight: 600;
font-size: 11px;
font-family: 'Courier New', monospace;
text-shadow: 0 0 3px rgba(0,0,0,0.9), 0 0 6px rgba(0,0,0,0.6);
text-shadow: var(--kcg-map-label-shadow);
background: var(--kcg-glass);
border: 1px solid var(--kcg-border);
border-radius: 3px;
@ -1874,7 +1876,7 @@
font-weight: 700;
font-size: 10px;
font-family: 'Courier New', monospace;
text-shadow: 0 0 4px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.7);
text-shadow: var(--kcg-map-impact-shadow);
background: rgba(40, 0, 0, 0.85);
border: 1px solid var(--kcg-event-impact);
border-radius: 3px;

파일 보기

@ -122,6 +122,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST');
const [hoveredShipMmsi, setHoveredShipMmsi] = useState<string | null>(null);
const [focusShipMmsi, setFocusShipMmsi] = useState<string | null>(null);
const [seismicMarker, setSeismicMarker] = useState<{ lat: number; lng: number; magnitude: number; place: string } | null>(null);
const replay = useReplay();
const monitor = useMonitor();
@ -371,6 +372,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
hoveredShipMmsi={hoveredShipMmsi}
focusShipMmsi={focusShipMmsi}
onFocusShipClear={() => setFocusShipMmsi(null)}
seismicMarker={seismicMarker}
/>
) : mapMode === 'globe' ? (
<GlobeMap
@ -392,6 +394,9 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
hoveredShipMmsi={hoveredShipMmsi}
focusShipMmsi={focusShipMmsi}
onFocusShipClear={() => setFocusShipMmsi(null)}
flyToTarget={flyToTarget}
onFlyToDone={() => setFlyToTarget(null)}
seismicMarker={seismicMarker}
/>
)}
<div className="map-overlay-left">
@ -445,6 +450,10 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
pressureData={iranData.pressureData}
currentTime={currentTime}
historyMinutes={monitor.state.historyMinutes}
onSeismicClick={(lat, lng, magnitude, place) => {
setFlyToTarget({ lat, lng, zoom: 8 });
setSeismicMarker({ lat, lng, magnitude, place });
}}
/>
</section>
)}

파일 보기

@ -12,9 +12,8 @@ interface Props {
}
const HISTORY_PRESETS = [
{ label: '10M', minutes: 10 },
{ label: '30M', minutes: 30 },
{ label: '1H', minutes: 60 },
{ label: '2H', minutes: 120 },
{ label: '3H', minutes: 180 },
{ label: '6H', minutes: 360 },
];

파일 보기

@ -13,6 +13,7 @@ import {
ZAxis,
Cell,
} from 'recharts';
import type { TooltipProps } from 'recharts';
import type { SeismicDto, PressureDto } from '../../services/sensorApi';
interface Props {
@ -20,6 +21,7 @@ interface Props {
pressureData: PressureDto[];
currentTime: number;
historyMinutes: number;
onSeismicClick?: (lat: number, lng: number, magnitude: number, place: string) => void;
}
const MINUTE = 60_000;
@ -47,32 +49,46 @@ function prepareSeismicPoints(
data: SeismicDto[],
rangeStart: number,
rangeEnd: number,
): { time: number; magnitude: number; place: string }[] {
): { time: number; magnitude: number; place: string; lat: number; lng: number }[] {
return data
.filter(ev => ev.timestamp >= rangeStart && ev.timestamp <= rangeEnd)
.map(ev => ({ time: ev.timestamp, magnitude: ev.magnitude, place: ev.place }));
.map(ev => ({ time: ev.timestamp, magnitude: ev.magnitude, place: ev.place, lat: ev.lat, lng: ev.lng }));
}
/**
* 데이터: 관측소별 ( )
* connectNulls ,
* 데이터: 원본
* timestamp의 row로
*
*/
function preparePressureByStation(
function preparePressureMerged(
data: PressureDto[],
rangeStart: number,
rangeEnd: number,
): Record<string, { time: number; value: number }[]> {
const byStation: Record<string, { time: number; value: number }[]> = {};
for (const r of data) {
if (r.timestamp < rangeStart || r.timestamp > rangeEnd) continue;
if (!byStation[r.station]) byStation[r.station] = [];
byStation[r.station].push({ time: r.timestamp, value: r.pressureHpa });
): { time: number; [station: string]: number | null }[] {
// 범위 내 데이터만 필터
const filtered = data.filter(r => r.timestamp >= rangeStart && r.timestamp <= rangeEnd);
const stations = new Set<string>();
for (const r of filtered) stations.add(r.station);
// timestamp별 그룹핑 (해수면 보정 적용)
const timeMap = new Map<number, Record<string, number>>();
for (const r of filtered) {
if (!timeMap.has(r.timestamp)) timeMap.set(r.timestamp, {});
const alt = STATION_ALTITUDE[r.station] ?? 0;
timeMap.get(r.timestamp)![r.station] = toSeaLevelPressure(r.pressureHpa, alt);
}
// 시간순 정렬
for (const arr of Object.values(byStation)) {
arr.sort((a, b) => a.time - b.time);
// 시간순 정렬하여 row 배열 생성
const stationList = Array.from(stations);
const times = Array.from(timeMap.keys()).sort((a, b) => a - b);
return times.map(t => {
const vals = timeMap.get(t)!;
const row: Record<string, number | null> = { time: t };
for (const s of stationList) {
row[s] = vals[s] ?? null;
}
return byStation;
return row as { time: number; [station: string]: number | null };
});
}
function generateDemoData(
@ -98,7 +114,51 @@ const STATION_COLORS: Record<string, string> = {
'tabriz': '#a855f7',
};
// 관측소 고도(m) — 해수면 기압 보정용
const STATION_ALTITUDE: Record<string, number> = {
'tehran': 1190,
'isfahan': 1590,
'bandar-abbas': 10,
'shiraz': 1486,
'tabriz': 1352,
};
// 기압 해수면 보정 (barometric formula 근사)
// SLP ≈ station_pressure × (1 + 0.0065 × altitude / (temperature + 0.0065 × altitude + 273.15))^5.257
// 간단 근사: SLP ≈ station_pressure + altitude / 8.3 (1 hPa per 8.3m at low altitude)
function toSeaLevelPressure(stationHpa: number, altitudeM: number): number {
return stationHpa + altitudeM / 8.3;
}
const MAGNITUDE_COLORS = ['#fbbf24', '#f97316', '#ef4444', '#dc2626', '#991b1b'];
const TOOLTIP_STYLE = {
contentStyle: { background: '#1a1a2e', border: '1px solid #333', fontSize: 10, color: '#e0e0e0', padding: '4px 8px' },
itemStyle: { color: '#e0e0e0' },
labelStyle: { color: '#aaa' },
wrapperStyle: { zIndex: 10 },
position: { y: -10 } as { y: number },
};
function formatTooltipTime(epoch: number): string {
const d = new Date(epoch);
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())} KST`;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function SeismicTooltip({ active, payload }: TooltipProps<any, any>) {
if (!active || !payload?.length) return null;
const point = payload[0]?.payload as { time: number; magnitude: number; place: string; lat: number; lng: number } | undefined;
if (!point) return null;
return (
<div style={{ background: '#1a1a2e', border: '1px solid #333', padding: '4px 8px', borderRadius: 4, fontSize: 10, color: '#e0e0e0' }}>
<div style={{ color: '#aaa', marginBottom: 2 }}>{formatTooltipTime(point.time)}</div>
<div><span style={{ color: getMagnitudeColor(point.magnitude), fontWeight: 700 }}>M{point.magnitude.toFixed(1)}</span> {point.place}</div>
<div style={{ color: '#888', fontSize: 9 }}>{point.lat.toFixed(3)}N, {point.lng.toFixed(3)}E click to focus</div>
</div>
);
}
function getMagnitudeColor(mag: number): string {
if (mag < 2.5) return MAGNITUDE_COLORS[0];
if (mag < 3.5) return MAGNITUDE_COLORS[1];
@ -107,32 +167,41 @@ function getMagnitudeColor(mag: number): string {
return MAGNITUDE_COLORS[4];
}
export function SensorChart({ seismicData, pressureData, currentTime, historyMinutes }: Props) {
export function SensorChart({ seismicData, pressureData, currentTime, historyMinutes, onSeismicClick }: Props) {
const { t } = useTranslation();
const totalMinutes = historyMinutes * 8;
const rangeStart = currentTime - totalMinutes * MINUTE;
const rangeEnd = currentTime;
// 데이터 수집 범위: 표시 범위보다 충분히 과거 (선이 y축에서 시작되도록)
// 기압 데이터는 1시간 간격이므로 최소 60분 + 1칸 여유
const dataStart = rangeStart - Math.max(historyMinutes, 60) * MINUTE;
const ticks = useMemo(() => buildTicks(currentTime, historyMinutes), [currentTime, historyMinutes]);
const seismicPoints = useMemo(
() => prepareSeismicPoints(seismicData, rangeStart, rangeEnd),
[seismicData, rangeStart, rangeEnd],
() => prepareSeismicPoints(seismicData, dataStart, rangeEnd),
[seismicData, dataStart, rangeEnd],
);
const pressureByStation = useMemo(
() => preparePressureByStation(pressureData, rangeStart, rangeEnd),
[pressureData, rangeStart, rangeEnd],
const pressureMerged = useMemo(
() => preparePressureMerged(pressureData, dataStart, rangeEnd),
[pressureData, dataStart, rangeEnd],
);
const pressureStations = useMemo(() => {
const stations = new Set<string>();
for (const r of pressureData) stations.add(r.station);
return Array.from(stations);
}, [pressureData]);
const noiseChart = useMemo(
() => generateDemoData(rangeStart, rangeEnd, 45, 30),
[rangeStart, rangeEnd],
() => generateDemoData(dataStart, rangeEnd, 45, 30),
[dataStart, rangeEnd],
);
const radiationChart = useMemo(
() => generateDemoData(rangeStart, rangeEnd, 0.08, 0.06),
[rangeStart, rangeEnd],
() => generateDemoData(dataStart, rangeEnd, 0.08, 0.06),
[dataStart, rangeEnd],
);
const commonXAxis = {
@ -169,15 +238,15 @@ export function SensorChart({ seismicData, pressureData, currentTime, historyMin
<XAxis dataKey="time" {...commonXAxis} />
<YAxis dataKey="magnitude" domain={[0, seismicYMax]} tick={{ fontSize: 10, fill: '#888' }} name="M" />
<ZAxis dataKey="magnitude" range={[30, 200]} name="Size" />
<Tooltip
contentStyle={{ background: '#1a1a2e', border: '1px solid #333', fontSize: 10 }}
labelFormatter={formatTickTime}
formatter={(v: number, name: string) => {
if (name === 'M') return [`M${v.toFixed(1)}`, 'Magnitude'];
return [v, name];
<Tooltip content={<SeismicTooltip />} {...TOOLTIP_STYLE} />
<Scatter
data={seismicPoints}
shape="circle"
cursor="pointer"
onClick={(entry) => {
if (entry && onSeismicClick) onSeismicClick(entry.lat, entry.lng, entry.magnitude, entry.place);
}}
/>
<Scatter data={seismicPoints} shape="circle">
>
{seismicPoints.map((p, i) => (
<Cell key={i} fill={getMagnitudeColor(p.magnitude)} />
))}
@ -186,24 +255,23 @@ export function SensorChart({ seismicData, pressureData, currentTime, historyMin
</ResponsiveContainer>
</div>
{/* 기압: 관측소별 개별 라인 */}
{/* 기압: 관측소별 개별 라인 (해수면 보정) */}
<div className="chart-item">
<h4>{t('sensor.airPressureHpa')}</h4>
<h4>{t('sensor.airPressureHpa')} <span className="text-[8px] text-kcg-muted">(SLP)</span></h4>
<ResponsiveContainer width="100%" height={80}>
<LineChart>
<LineChart data={pressureMerged}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis dataKey="time" {...commonXAxis} />
<YAxis domain={['auto', 'auto']} tick={{ fontSize: 10, fill: '#888' }} />
<Tooltip
contentStyle={{ background: '#1a1a2e', border: '1px solid #333', fontSize: 10 }}
labelFormatter={formatTickTime}
formatter={(v: number, name: string) => [`${v.toFixed(1)} hPa`, name]}
{...TOOLTIP_STYLE}
labelFormatter={formatTooltipTime}
formatter={(v: number, name: string) => [v != null ? `${v.toFixed(1)} hPa` : '-', name]}
/>
{Object.entries(pressureByStation).map(([station, points]) => (
{pressureStations.map(station => (
<Line
key={station}
data={points}
dataKey="value"
dataKey={station}
name={station}
stroke={STATION_COLORS[station] || '#888'}
dot={false}
@ -227,8 +295,8 @@ export function SensorChart({ seismicData, pressureData, currentTime, historyMin
<XAxis dataKey="time" {...commonXAxis} />
<YAxis domain={[0, 140]} tick={{ fontSize: 10, fill: '#888' }} />
<Tooltip
contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }}
labelFormatter={formatTickTime}
{...TOOLTIP_STYLE}
labelFormatter={formatTooltipTime}
formatter={(v: number) => [v.toFixed(1), 'dB']}
/>
<Line type="monotone" dataKey="value" stroke="#f97316" dot={false} strokeWidth={1.5} />
@ -247,8 +315,8 @@ export function SensorChart({ seismicData, pressureData, currentTime, historyMin
<XAxis dataKey="time" {...commonXAxis} />
<YAxis domain={[0, 0.3]} tick={{ fontSize: 10, fill: '#888' }} />
<Tooltip
contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }}
labelFormatter={formatTickTime}
{...TOOLTIP_STYLE}
labelFormatter={formatTooltipTime}
formatter={(v: number) => [v.toFixed(3), 'μSv/h']}
/>
<Line type="monotone" dataKey="value" stroke="#22c55e" dot={false} strokeWidth={1.5} />

파일 보기

@ -28,13 +28,23 @@ function getTooltipLabel(f: OilFacility): string {
return '';
}
function getIconSize(f: OilFacility): number {
if (f.type === 'desalination') { const m = f.capacityMgd ?? 0; return m >= 200 ? 14 : m >= 80 ? 12 : 10; }
if (f.type === 'terminal') return (f.capacityBpd ?? 0) >= 1_000_000 ? 20 : 16;
if (f.type === 'oilfield') { const b = f.reservesBbl ?? 0; return b >= 20 ? 20 : b >= 10 ? 18 : 14; }
if (f.type === 'gasfield') { const t = f.reservesTcf ?? 0; return t >= 100 ? 20 : t >= 50 ? 18 : 14; }
if (f.type === 'refinery') { const b = f.capacityBpd ?? 0; return b >= 300_000 ? 20 : b >= 100_000 ? 18 : 14; }
return 16;
// Planned strike targeting ring (SVG 내부 — 위치 정확도)
function PlannedOverlay() {
return (
<>
<circle cx={18} cy={18} r={16} fill="none" stroke="#ff6600" strokeWidth={2}
strokeDasharray="4 3" opacity={0.9}>
<animate attributeName="r" values="14;17;14" dur="2s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.9;0.5;0.9" dur="2s" repeatCount="indefinite" />
</circle>
{/* Crosshair lines */}
<line x1={18} y1={0} x2={18} y2={4} stroke="#ff6600" strokeWidth={1.5} opacity={0.7} />
<line x1={18} y1={32} x2={18} y2={36} stroke="#ff6600" strokeWidth={1.5} opacity={0.7} />
<line x1={0} y1={18} x2={4} y2={18} stroke="#ff6600" strokeWidth={1.5} opacity={0.7} />
<line x1={32} y1={18} x2={36} y2={18} stroke="#ff6600" strokeWidth={1.5} opacity={0.7} />
</>
);
}
// Shared damage overlay (X mark + circle)
@ -49,7 +59,7 @@ function DamageOverlay() {
}
// SVG icon renderers (JSX versions)
function RefineryIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
function RefineryIcon({ size, color, damaged, planned }: { size: number; color: string; damaged: boolean; planned: boolean }) {
const sc = damaged ? '#ff0000' : color;
return (
<svg viewBox="0 0 36 36" width={size} height={size}>
@ -74,11 +84,12 @@ function RefineryIcon({ size, color, damaged }: { size: number; color: string; d
<line x1={13} y1={15} x2={16} y2={15} stroke={color} strokeWidth={0.8} opacity={0.5} />
<line x1={20} y1={13} x2={23} y2={13} stroke={color} strokeWidth={0.8} opacity={0.5} />
{damaged && <DamageOverlay />}
{planned && <PlannedOverlay />}
</svg>
);
}
function OilFieldIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
function OilFieldIcon({ size, color, damaged, planned }: { size: number; color: string; damaged: boolean; planned: boolean }) {
const sc = damaged ? '#ff0000' : color;
return (
<svg viewBox="0 0 36 36" width={size} height={size}>
@ -97,11 +108,12 @@ function OilFieldIcon({ size, color, damaged }: { size: number; color: string; d
<path d="M5.5 29 C5.5 29 4.5 30 4.5 30.5 C4.5 31 5 31.2 5.5 31.2 C6 31.2 6.5 31 6.5 30.5 C6.5 30 5.5 29 5.5 29Z"
fill={color} opacity={0.85} />
{damaged && <DamageOverlay />}
{planned && <PlannedOverlay />}
</svg>
);
}
function GasFieldIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
function GasFieldIcon({ size, color, damaged, planned }: { size: number; color: string; damaged: boolean; planned: boolean }) {
return (
<svg viewBox="0 0 36 36" width={size} height={size}>
<line x1={10} y1={24} x2={8} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
@ -118,11 +130,12 @@ function GasFieldIcon({ size, color, damaged }: { size: number; color: string; d
<rect x={16.5} y={4} width={3} height={3} rx={0.5} fill={color} opacity={0.7} />
<line x1={18} y1={4} x2={18} y2={6} stroke={color} strokeWidth={1.5} opacity={0.7} />
{damaged && <DamageOverlay />}
{planned && <PlannedOverlay />}
</svg>
);
}
function TerminalIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
function TerminalIcon({ size, color, damaged, planned }: { size: number; color: string; damaged: boolean; planned: boolean }) {
return (
<svg viewBox="0 0 36 36" width={size} height={size}>
<circle cx={18} cy={10} r={4} fill="none" stroke={damaged ? '#ff0000' : color} strokeWidth={2} />
@ -135,11 +148,12 @@ function TerminalIcon({ size, color, damaged }: { size: number; color: string; d
<polygon points="5,24 8,22 8,26" fill={color} opacity={0.7} />
<polygon points="31,24 28,22 28,26" fill={color} opacity={0.7} />
{damaged && <DamageOverlay />}
{planned && <PlannedOverlay />}
</svg>
);
}
function PetrochemIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
function PetrochemIcon({ size, color, damaged, planned }: { size: number; color: string; damaged: boolean; planned: boolean }) {
return (
<svg viewBox="0 0 36 36" width={size} height={size}>
<path d="M14 6 L14 16 L8 30 L28 30 L22 16 L22 6Z" fill={color} opacity={0.7} stroke={damaged ? '#ff0000' : '#fff'} strokeWidth={1} />
@ -149,11 +163,12 @@ function PetrochemIcon({ size, color, damaged }: { size: number; color: string;
<circle cx={20} cy={23} r={1} fill="#c4b5fd" opacity={0.6} />
<circle cx={18} cy={27} r={1.2} fill="#c4b5fd" opacity={0.5} />
{damaged && <DamageOverlay />}
{planned && <PlannedOverlay />}
</svg>
);
}
function DesalIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
function DesalIcon({ size, color, damaged, planned }: { size: number; color: string; damaged: boolean; planned: boolean }) {
const sc = damaged ? '#ff0000' : color;
return (
<svg viewBox="0 0 36 36" width={size} height={size}>
@ -175,20 +190,24 @@ function DesalIcon({ size, color, damaged }: { size: number; color: string; dama
<line x1={27.5} y1={32} x2={27.5} y2={34} stroke={color} strokeWidth={1} opacity={0.55} />
<line x1={4} y1={34} x2={33} y2={34} stroke={color} strokeWidth={1} opacity={0.25} />
{damaged && <DamageOverlay />}
{planned && <PlannedOverlay />}
</svg>
);
}
function FacilityIconSvg({ facility, damaged }: { facility: OilFacility; damaged: boolean }) {
// 모든 아이콘을 36x36 고정 크기로 렌더링 (anchor="center" 정렬용)
const ICON_RENDER_SIZE = 36;
function FacilityIconSvg({ facility, damaged, planned }: { facility: OilFacility; damaged: boolean; planned: boolean }) {
const color = TYPE_COLORS[facility.type];
const size = getIconSize(facility);
const props = { size: ICON_RENDER_SIZE, color, damaged, planned };
switch (facility.type) {
case 'refinery': return <RefineryIcon size={size} color={color} damaged={damaged} />;
case 'oilfield': return <OilFieldIcon size={size} color={color} damaged={damaged} />;
case 'gasfield': return <GasFieldIcon size={size} color={color} damaged={damaged} />;
case 'terminal': return <TerminalIcon size={size} color={color} damaged={damaged} />;
case 'petrochemical': return <PetrochemIcon size={size} color={color} damaged={damaged} />;
case 'desalination': return <DesalIcon size={size} color={color} damaged={damaged} />;
case 'refinery': return <RefineryIcon {...props} />;
case 'oilfield': return <OilFieldIcon {...props} />;
case 'gasfield': return <GasFieldIcon {...props} />;
case 'terminal': return <TerminalIcon {...props} />;
case 'petrochemical': return <PetrochemIcon {...props} />;
case 'desalination': return <DesalIcon {...props} />;
}
}
@ -212,28 +231,19 @@ function FacilityMarker({ facility, currentTime }: { facility: OilFacility; curr
return (
<>
<Marker longitude={facility.lng} latitude={facility.lat} anchor="center">
<div className="relative">
{/* Planned strike targeting ring */}
{isPlanned && (
<Marker longitude={facility.lng} latitude={facility.lat} anchor="top-left" style={{ overflow: 'visible' }}>
<div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-9 h-9 rounded-full pointer-events-none"
className="cursor-pointer"
style={{
border: '2px dashed #ff6600',
animation: 'planned-pulse 2s ease-in-out infinite',
width: ICON_RENDER_SIZE,
height: ICON_RENDER_SIZE,
position: 'relative',
transform: `translate(-${ICON_RENDER_SIZE / 2}px, -${ICON_RENDER_SIZE / 2}px)`,
}}
onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}
>
{/* Crosshair lines */}
<div className="absolute -top-1.5 left-1/2 -translate-x-1/2 w-px h-1.5 bg-[#ff6600] opacity-70" />
<div className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-px h-1.5 bg-[#ff6600] opacity-70" />
<div className="absolute -left-1.5 top-1/2 -translate-y-1/2 w-1.5 h-px bg-[#ff6600] opacity-70" />
<div className="absolute -right-1.5 top-1/2 -translate-y-1/2 w-1.5 h-px bg-[#ff6600] opacity-70" />
</div>
)}
<div className="cursor-pointer"
onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}>
<FacilityIconSvg facility={facility} damaged={isDamaged} />
</div>
<FacilityIconSvg facility={facility} damaged={isDamaged} planned={isPlanned} />
{/* Label */}
<div className="gl-marker-label text-[8px]" style={{
color: isDamaged ? '#ff0000' : isPlanned ? '#ff6600' : color,
}}>

파일 보기

@ -6,6 +6,7 @@ import { AircraftLayer } from '../layers/AircraftLayer';
import { SatelliteLayer } from '../layers/SatelliteLayer';
import { ShipLayer } from '../layers/ShipLayer';
import { DamagedShipLayer } from '../layers/DamagedShipLayer';
import { SeismicMarker } from '../layers/SeismicMarker';
import { OilFacilityLayer } from './OilFacilityLayer';
import { AirportLayer } from './AirportLayer';
import { iranOilFacilities } from '../../data/oilFacilities';
@ -34,6 +35,7 @@ interface Props {
hoveredShipMmsi?: string | null;
focusShipMmsi?: string | null;
onFocusShipClear?: () => void;
seismicMarker?: { lat: number; lng: number; magnitude: number; place: string } | null;
}
// MarineTraffic-style: dark ocean + satellite land + nautical overlay
@ -108,7 +110,7 @@ const EVENT_RADIUS: Record<GeoEvent['type'], number> = {
osint: 8,
};
export function ReplayMap({ events, currentTime, aircraft, satellites, ships, layers, flyToTarget, onFlyToDone, initialCenter, initialZoom, hoveredShipMmsi, focusShipMmsi, onFocusShipClear }: Props) {
export function ReplayMap({ events, currentTime, aircraft, satellites, ships, layers, flyToTarget, onFlyToDone, initialCenter, initialZoom, hoveredShipMmsi, focusShipMmsi, onFocusShipClear, seismicMarker }: Props) {
const { t } = useTranslation();
const mapRef = useRef<MapRef>(null);
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
@ -428,6 +430,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
{layers.satellites && <SatelliteLayer satellites={satellites} />}
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} koreanOnly={layers.koreanShips} hoveredMmsi={hoveredShipMmsi} focusMmsi={focusShipMmsi} onFocusClear={onFocusShipClear} />}
{layers.ships && <DamagedShipLayer currentTime={currentTime} />}
{seismicMarker && <SeismicMarker {...seismicMarker} />}
{layers.airports && <AirportLayer airports={middleEastAirports} />}
{layers.oilFacilities && <OilFacilityLayer facilities={iranOilFacilities} currentTime={currentTime} />}
</Map>

파일 보기

@ -1,4 +1,4 @@
import { useMemo, useState, useRef } from 'react';
import { useMemo, useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
@ -6,6 +6,7 @@ import { AircraftLayer } from '../layers/AircraftLayer';
import { SatelliteLayer } from '../layers/SatelliteLayer';
import { ShipLayer } from '../layers/ShipLayer';
import { DamagedShipLayer } from '../layers/DamagedShipLayer';
import { SeismicMarker } from '../layers/SeismicMarker';
import { OilFacilityLayer } from './OilFacilityLayer';
import { AirportLayer } from './AirportLayer';
import { iranOilFacilities } from '../../data/oilFacilities';
@ -25,6 +26,9 @@ interface Props {
hoveredShipMmsi?: string | null;
focusShipMmsi?: string | null;
onFocusShipClear?: () => void;
flyToTarget?: { lat: number; lng: number; zoom?: number } | null;
onFlyToDone?: () => void;
seismicMarker?: { lat: number; lng: number; magnitude: number; place: string } | null;
}
// ESRI World Imagery + ESRI boundaries overlay
@ -89,11 +93,22 @@ const EVENT_RADIUS: Record<GeoEvent['type'], number> = {
osint: 8,
};
export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, layers, hoveredShipMmsi, focusShipMmsi, onFocusShipClear }: Props) {
export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, layers, hoveredShipMmsi, focusShipMmsi, onFocusShipClear, flyToTarget, onFlyToDone, seismicMarker }: Props) {
const { t } = useTranslation();
const mapRef = useRef<MapRef>(null);
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
useEffect(() => {
if (flyToTarget && mapRef.current) {
mapRef.current.flyTo({
center: [flyToTarget.lng, flyToTarget.lat],
zoom: flyToTarget.zoom ?? 8,
duration: 1200,
});
onFlyToDone?.();
}
}, [flyToTarget, onFlyToDone]);
const visibleEvents = useMemo(() => {
if (!layers.events) return [];
return events.filter(e => e.timestamp <= currentTime);
@ -253,6 +268,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
{layers.satellites && <SatelliteLayer satellites={satellites} />}
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} koreanOnly={layers.koreanShips} hoveredMmsi={hoveredShipMmsi} focusMmsi={focusShipMmsi} onFocusClear={onFocusShipClear} />}
<DamagedShipLayer currentTime={currentTime} />
{seismicMarker && <SeismicMarker {...seismicMarker} />}
{layers.oilFacilities && <OilFacilityLayer facilities={iranOilFacilities} currentTime={currentTime} />}
{layers.airports && <AirportLayer airports={middleEastAirports} />}
</Map>

파일 보기

@ -0,0 +1,81 @@
.seismic-marker-container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.seismic-pulse-ring {
position: absolute;
border-radius: 50%;
border: 2px solid;
opacity: 0;
animation: seismic-pulse 2.5s ease-out infinite;
}
.seismic-pulse-ring-inner {
animation-delay: 0.8s;
}
.seismic-fill {
position: absolute;
border-radius: 50%;
animation: seismic-breathe 2s ease-in-out infinite;
}
.seismic-center-dot {
position: absolute;
width: 8px;
height: 8px;
border-radius: 50%;
z-index: 1;
box-shadow: 0 0 6px currentColor;
}
.seismic-label {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: 4px;
white-space: nowrap;
text-align: center;
font-family: 'Courier New', monospace;
text-shadow: 0 0 4px rgba(0, 0, 0, 0.9);
pointer-events: none;
}
.seismic-magnitude {
display: block;
font-size: 13px;
font-weight: 800;
}
.seismic-place {
display: block;
font-size: 9px;
opacity: 0.8;
}
@keyframes seismic-pulse {
0% {
transform: scale(0.3);
opacity: 0.8;
}
100% {
transform: scale(1.2);
opacity: 0;
}
}
@keyframes seismic-breathe {
0%, 100% {
opacity: 0.5;
transform: scale(0.9);
}
50% {
opacity: 0.8;
transform: scale(1);
}
}

파일 보기

@ -0,0 +1,82 @@
import { Marker } from 'react-map-gl/maplibre';
import './SeismicMarker.css';
interface Props {
lat: number;
lng: number;
magnitude: number;
place: string;
}
/**
* (km px , zoom 8 )
* M2: ~5km, M3: ~15km, M4: ~40km, M5: ~100km, M6: ~200km
* zoom 8 1km 0.6px
*/
function getRadiusPx(magnitude: number): number {
const radiusKm = Math.pow(10, 0.5 * magnitude - 0.5);
return Math.max(20, Math.min(radiusKm * 0.6, 200));
}
function getMagnitudeColor(magnitude: number): string {
if (magnitude < 3) return 'rgba(251, 191, 36, 0.4)'; // yellow
if (magnitude < 4) return 'rgba(249, 115, 22, 0.4)'; // orange
if (magnitude < 5) return 'rgba(239, 68, 68, 0.4)'; // red
if (magnitude < 6) return 'rgba(220, 38, 38, 0.5)'; // dark red
return 'rgba(153, 27, 27, 0.6)'; // maroon
}
function getStrokeColor(magnitude: number): string {
if (magnitude < 3) return '#fbbf24';
if (magnitude < 4) return '#f97316';
if (magnitude < 5) return '#ef4444';
if (magnitude < 6) return '#dc2626';
return '#991b1b';
}
export function SeismicMarker({ lat, lng, magnitude, place }: Props) {
const size = getRadiusPx(magnitude) * 2;
const color = getMagnitudeColor(magnitude);
const stroke = getStrokeColor(magnitude);
return (
<Marker longitude={lng} latitude={lat} anchor="center">
<div className="seismic-marker-container" style={{ width: size, height: size }}>
{/* Outer pulse ring */}
<div
className="seismic-pulse-ring"
style={{
width: size,
height: size,
borderColor: stroke,
}}
/>
{/* Inner pulse ring */}
<div
className="seismic-pulse-ring seismic-pulse-ring-inner"
style={{
width: size * 0.6,
height: size * 0.6,
borderColor: stroke,
}}
/>
{/* Fill circle */}
<div
className="seismic-fill"
style={{
width: size,
height: size,
background: `radial-gradient(circle, ${color} 0%, transparent 70%)`,
}}
/>
{/* Center dot */}
<div className="seismic-center-dot" style={{ background: stroke }} />
{/* Label */}
<div className="seismic-label" style={{ color: stroke }}>
<span className="seismic-magnitude">M{magnitude.toFixed(1)}</span>
<span className="seismic-place">{place}</span>
</div>
</div>
</Marker>
);
}

파일 보기

@ -80,6 +80,10 @@
/* 패널 그림자 */
--kcg-panel-shadow: none;
/* 지도 라벨 그림자 — dark에서 검정 기반 */
--kcg-map-label-shadow: 0 0 3px rgba(0,0,0,0.9), 0 0 6px rgba(0,0,0,0.6);
--kcg-map-impact-shadow: 0 0 4px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.7);
}
/* ── Light Theme ── */
@ -116,6 +120,10 @@
/* 패널 그림자 — light에서 영역 구분 강화 (outline 제거 — 폰트 가독성) */
--kcg-panel-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
/* 지도 라벨 그림자 — light에서 흰 배경 기반 */
--kcg-map-label-shadow: 0 0 3px rgba(255,255,255,0.9), 0 0 6px rgba(255,255,255,0.6);
--kcg-map-impact-shadow: 0 0 4px rgba(255,255,255,0.9), 0 0 8px rgba(255,255,255,0.7);
}
/* ── Tailwind @theme mapping ── */