feat: 센서 그래프 개선 + 지진 마커 + 시설 아이콘 정렬 + SSH 재시도 #59
@ -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,21 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 추가
|
||||
- 지진 포인트 클릭 → 지도 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);
|
||||
}
|
||||
return byStation;
|
||||
|
||||
// 시간순 정렬하여 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 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 && (
|
||||
<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"
|
||||
style={{
|
||||
border: '2px dashed #ff6600',
|
||||
animation: 'planned-pulse 2s ease-in-out infinite',
|
||||
}}
|
||||
>
|
||||
{/* 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>
|
||||
<Marker longitude={facility.lng} latitude={facility.lat} anchor="top-left" style={{ overflow: 'visible' }}>
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
style={{
|
||||
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); }}
|
||||
>
|
||||
<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>
|
||||
|
||||
81
frontend/src/components/layers/SeismicMarker.css
Normal file
81
frontend/src/components/layers/SeismicMarker.css
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
82
frontend/src/components/layers/SeismicMarker.tsx
Normal file
82
frontend/src/components/layers/SeismicMarker.tsx
Normal file
@ -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 ── */
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user