Merge pull request 'feat(replay): 어구/선단 히스토리 보간 애니메이션 강화' (#206) from feature/gear-replay-marker into develop
This commit is contained in:
커밋
3407d37f9b
@ -8,6 +8,194 @@ import { fetchFleetCompanies, fetchGroupHistory } from '../../services/vesselAna
|
||||
import type { FleetCompany, GroupPolygonDto } from '../../services/vesselAnalysis';
|
||||
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
|
||||
|
||||
/**
|
||||
* Convex hull + buffer 기반 폴리곤 생성 (Python polygon_builder.py 동일 로직)
|
||||
* - 1점: 원형 버퍼 (GEAR_BUFFER_DEG=0.01 ≈ 1.1km)
|
||||
* - 2점: 두 점 잇는 직선 양쪽 버퍼
|
||||
* - 3점+: convex hull + 버퍼
|
||||
*/
|
||||
const GEAR_BUFFER_DEG = 0.01;
|
||||
const CIRCLE_SEGMENTS = 16;
|
||||
|
||||
function buildInterpPolygon(points: [number, number][]): GeoJSON.Polygon | null {
|
||||
if (points.length === 0) return null;
|
||||
|
||||
if (points.length === 1) {
|
||||
// Point.buffer → 원형
|
||||
const [cx, cy] = points[0];
|
||||
const ring: [number, number][] = [];
|
||||
for (let i = 0; i <= CIRCLE_SEGMENTS; i++) {
|
||||
const angle = (2 * Math.PI * i) / CIRCLE_SEGMENTS;
|
||||
ring.push([cx + GEAR_BUFFER_DEG * Math.cos(angle), cy + GEAR_BUFFER_DEG * Math.sin(angle)]);
|
||||
}
|
||||
return { type: 'Polygon', coordinates: [ring] };
|
||||
}
|
||||
|
||||
if (points.length === 2) {
|
||||
// LineString.buffer → 캡슐 형태
|
||||
const [p1, p2] = points;
|
||||
const dx = p2[0] - p1[0];
|
||||
const dy = p2[1] - p1[1];
|
||||
const len = Math.sqrt(dx * dx + dy * dy) || 1e-10;
|
||||
const nx = (-dy / len) * GEAR_BUFFER_DEG;
|
||||
const ny = (dx / len) * GEAR_BUFFER_DEG;
|
||||
// 양쪽 오프셋 + 반원 엔드캡
|
||||
const ring: [number, number][] = [];
|
||||
const half = CIRCLE_SEGMENTS / 2;
|
||||
// p1→p2 오른쪽
|
||||
ring.push([p1[0] + nx, p1[1] + ny]);
|
||||
ring.push([p2[0] + nx, p2[1] + ny]);
|
||||
// p2 반원
|
||||
const a2 = Math.atan2(ny, nx);
|
||||
for (let i = 0; i <= half; i++) {
|
||||
const angle = a2 - Math.PI * i / half;
|
||||
ring.push([p2[0] + GEAR_BUFFER_DEG * Math.cos(angle), p2[1] + GEAR_BUFFER_DEG * Math.sin(angle)]);
|
||||
}
|
||||
// p2→p1 왼쪽
|
||||
ring.push([p1[0] - nx, p1[1] - ny]);
|
||||
// p1 반원
|
||||
const a1 = Math.atan2(-ny, -nx);
|
||||
for (let i = 0; i <= half; i++) {
|
||||
const angle = a1 - Math.PI * i / half;
|
||||
ring.push([p1[0] + GEAR_BUFFER_DEG * Math.cos(angle), p1[1] + GEAR_BUFFER_DEG * Math.sin(angle)]);
|
||||
}
|
||||
ring.push(ring[0]); // 닫기
|
||||
return { type: 'Polygon', coordinates: [ring] };
|
||||
}
|
||||
|
||||
// 3점+: convex hull + buffer
|
||||
const hull = convexHull(points);
|
||||
return bufferPolygon(hull, GEAR_BUFFER_DEG);
|
||||
}
|
||||
|
||||
/** 단순 convex hull (Graham scan) */
|
||||
function convexHull(points: [number, number][]): [number, number][] {
|
||||
const pts = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||
if (pts.length <= 2) return pts;
|
||||
|
||||
const cross = (o: [number, number], a: [number, number], b: [number, number]) =>
|
||||
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
||||
|
||||
const lower: [number, number][] = [];
|
||||
for (const p of pts) {
|
||||
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop();
|
||||
lower.push(p);
|
||||
}
|
||||
const upper: [number, number][] = [];
|
||||
for (let i = pts.length - 1; i >= 0; i--) {
|
||||
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], pts[i]) <= 0) upper.pop();
|
||||
upper.push(pts[i]);
|
||||
}
|
||||
lower.pop();
|
||||
upper.pop();
|
||||
return lower.concat(upper);
|
||||
}
|
||||
|
||||
/** 폴리곤 외곽에 buffer 적용 (각 변의 오프셋 + 꼭짓점 라운드) */
|
||||
function bufferPolygon(hull: [number, number][], buf: number): GeoJSON.Polygon {
|
||||
const ring: [number, number][] = [];
|
||||
const n = hull.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const p = hull[i];
|
||||
const prev = hull[(i - 1 + n) % n];
|
||||
const next = hull[(i + 1) % n];
|
||||
// 이전 변과 다음 변의 외향 법선
|
||||
const a1 = Math.atan2(p[1] - prev[1], p[0] - prev[0]) - Math.PI / 2;
|
||||
const a2 = Math.atan2(next[1] - p[1], next[0] - p[0]) - Math.PI / 2;
|
||||
// 꼭짓점 라운딩 (a1 → a2)
|
||||
const startA = a1;
|
||||
let endA = a2;
|
||||
if (endA < startA) endA += 2 * Math.PI;
|
||||
const steps = Math.max(2, Math.round((endA - startA) / (Math.PI / 8)));
|
||||
for (let s = 0; s <= steps; s++) {
|
||||
const a = startA + (endA - startA) * s / steps;
|
||||
ring.push([p[0] + buf * Math.cos(a), p[1] + buf * Math.sin(a)]);
|
||||
}
|
||||
}
|
||||
ring.push(ring[0]);
|
||||
return { type: 'Polygon', coordinates: [ring] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 히스토리 데이터의 gap 구간에 보간 프레임을 삽입하여 완성 데이터셋 반환.
|
||||
* - gap ≤ 30분: 5분 간격 직선 보간 (이전 폴리곤 유지, 중심만 직선 이동)
|
||||
* - gap > 30분: 30분 간격으로 멤버 위치 보간 + 가상 폴리곤 생성
|
||||
*/
|
||||
function fillGapFrames(snapshots: GroupPolygonDto[]): HistoryFrame[] {
|
||||
if (snapshots.length < 2) return snapshots;
|
||||
const STEP_SHORT_MS = 300_000; // 5분
|
||||
const STEP_LONG_MS = 1_800_000; // 30분
|
||||
const THRESHOLD_MS = 1_800_000; // 30분 경계
|
||||
const result: GroupPolygonDto[] = [];
|
||||
|
||||
for (let i = 0; i < snapshots.length; i++) {
|
||||
result.push(snapshots[i]);
|
||||
if (i >= snapshots.length - 1) continue;
|
||||
|
||||
const prev = snapshots[i];
|
||||
const next = snapshots[i + 1];
|
||||
const t0 = new Date(prev.snapshotTime).getTime();
|
||||
const t1 = new Date(next.snapshotTime).getTime();
|
||||
const gap = t1 - t0;
|
||||
if (gap <= STEP_SHORT_MS) continue;
|
||||
|
||||
const nextMap = new Map(next.members.map(m => [m.mmsi, m]));
|
||||
const common = prev.members.filter(m => nextMap.has(m.mmsi));
|
||||
if (common.length === 0) continue;
|
||||
|
||||
if (gap <= THRESHOLD_MS) {
|
||||
// ≤30분: 5분 간격 직선 보간 (중심만 이동, 폴리곤은 이전 것 유지)
|
||||
for (let t = t0 + STEP_SHORT_MS; t < t1; t += STEP_SHORT_MS) {
|
||||
const ratio = (t - t0) / gap;
|
||||
const cLon = prev.centerLon + (next.centerLon - prev.centerLon) * ratio;
|
||||
const cLat = prev.centerLat + (next.centerLat - prev.centerLat) * ratio;
|
||||
result.push({
|
||||
...prev,
|
||||
snapshotTime: new Date(t).toISOString(),
|
||||
centerLon: cLon,
|
||||
centerLat: cLat,
|
||||
_interp: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// >30분: 30분 간격으로 멤버 위치 보간 + 가상 폴리곤
|
||||
for (let t = t0 + STEP_LONG_MS; t < t1; t += STEP_LONG_MS) {
|
||||
const ratio = (t - t0) / gap;
|
||||
const positions: [number, number][] = [];
|
||||
const members: MemberInfo[] = [];
|
||||
|
||||
for (const pm of common) {
|
||||
const nm = nextMap.get(pm.mmsi)!;
|
||||
const lon = pm.lon + (nm.lon - pm.lon) * ratio;
|
||||
const lat = pm.lat + (nm.lat - pm.lat) * ratio;
|
||||
const dLon = nm.lon - pm.lon;
|
||||
const dLat = nm.lat - pm.lat;
|
||||
const cog = (dLon === 0 && dLat === 0) ? pm.cog : (Math.atan2(dLon, dLat) * 180 / Math.PI + 360) % 360;
|
||||
members.push({ mmsi: pm.mmsi, name: pm.name, lon, lat, sog: pm.sog, cog, role: pm.role, isParent: pm.isParent });
|
||||
positions.push([lon, lat]);
|
||||
}
|
||||
|
||||
const cLon = positions.reduce((s, p) => s + p[0], 0) / positions.length;
|
||||
const cLat = positions.reduce((s, p) => s + p[1], 0) / positions.length;
|
||||
const polygon = buildInterpPolygon(positions);
|
||||
|
||||
result.push({
|
||||
...prev,
|
||||
snapshotTime: new Date(t).toISOString(),
|
||||
polygon,
|
||||
centerLon: cLon,
|
||||
centerLat: cLat,
|
||||
memberCount: members.length,
|
||||
members,
|
||||
_interp: true,
|
||||
_longGap: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface SelectedGearGroupData {
|
||||
parent: Ship | null;
|
||||
gears: Ship[];
|
||||
@ -20,6 +208,9 @@ export interface SelectedFleetData {
|
||||
companyName: string;
|
||||
}
|
||||
|
||||
/** 히스토리 스냅샷 + 보간 플래그 */
|
||||
type HistoryFrame = GroupPolygonDto & { _interp?: boolean; _longGap?: boolean };
|
||||
|
||||
interface Props {
|
||||
ships: Ship[];
|
||||
analysisMap?: Map<string, VesselAnalysisDto>;
|
||||
@ -51,7 +242,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
||||
} | null>(null);
|
||||
const [pickerHoveredGroup, setPickerHoveredGroup] = useState<string | null>(null);
|
||||
// 히스토리 애니메이션 — 12시간 실시간 타임라인
|
||||
const [historyData, setHistoryData] = useState<GroupPolygonDto[] | null>(null);
|
||||
const [historyData, setHistoryData] = useState<HistoryFrame[] | null>(null);
|
||||
const [, setHistoryGroupKey] = useState<string | null>(null);
|
||||
const [timelinePos, setTimelinePos] = useState(0); // 0~1 (12시간 내 위치)
|
||||
const [isPlaying, setIsPlaying] = useState(true);
|
||||
@ -76,10 +267,11 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
||||
setIsPlaying(true);
|
||||
const history = await fetchGroupHistory(groupKey, 12);
|
||||
const sorted = history.reverse(); // 시간 오름차순
|
||||
const filled = fillGapFrames(sorted); // 빈 구간 보간 삽입
|
||||
const now = Date.now();
|
||||
historyStartRef.current = now - TIMELINE_DURATION_MS;
|
||||
historyEndRef.current = now;
|
||||
setHistoryData(sorted);
|
||||
setHistoryData(filled);
|
||||
};
|
||||
|
||||
const closeHistory = useCallback(() => {
|
||||
@ -117,17 +309,20 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
||||
const diff = Math.abs(t - currentTimeMs);
|
||||
if (diff < bestDiff) { bestDiff = diff; best = i; }
|
||||
}
|
||||
// 5분(300초) 이내 스냅샷만 유효
|
||||
return bestDiff < 300_000 ? best : -1;
|
||||
// 보간 프레임 포함 데이터셋에서 가장 가까운 프레임 매칭 (최대 gap = 30분)
|
||||
return bestDiff < 1_800_000 ? best : -1;
|
||||
}, [historyData, currentTimeMs]);
|
||||
|
||||
// 스냅샷 존재 구간 맵 (프로그레스 바 갭 표시용)
|
||||
// 프로그레스 바: 원본 데이터만 표시 (보간 프레임 제외)
|
||||
const snapshotRanges = useMemo(() => {
|
||||
if (!historyData) return [];
|
||||
return historyData.map(h => {
|
||||
const t = new Date(h.snapshotTime).getTime();
|
||||
return (t - historyStartRef.current) / TIMELINE_DURATION_MS;
|
||||
});
|
||||
return historyData
|
||||
.filter(h => !h._interp)
|
||||
.map(h => {
|
||||
const t = new Date(h.snapshotTime).getTime();
|
||||
return (t - historyStartRef.current) / TIMELINE_DURATION_MS;
|
||||
});
|
||||
}, [historyData]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -477,21 +672,6 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
||||
// ── 히스토리 애니메이션 GeoJSON ──
|
||||
const EMPTY_HIST_FC: GeoJSON = { type: 'FeatureCollection', features: [] };
|
||||
|
||||
const centerTrailGeoJson = useMemo((): GeoJSON => {
|
||||
if (!historyData) return EMPTY_HIST_FC;
|
||||
const coords = historyData.map(h => [h.centerLon, h.centerLat]);
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{ type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } },
|
||||
...historyData.map(h => ({
|
||||
type: 'Feature' as const, properties: {},
|
||||
geometry: { type: 'Point' as const, coordinates: [h.centerLon, h.centerLat] },
|
||||
})),
|
||||
],
|
||||
};
|
||||
}, [historyData]);
|
||||
|
||||
const memberTrailsGeoJson = useMemo((): GeoJSON => {
|
||||
if (!historyData) return EMPTY_HIST_FC;
|
||||
const tracks = new Map<string, [number, number][]>();
|
||||
@ -521,7 +701,63 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
||||
return -1;
|
||||
}, [historyData, currentSnapIdx, currentTimeMs]);
|
||||
|
||||
const isStale = currentSnapIdx < 0 && effectiveSnapIdx >= 0; // 신호없음이지만 이전 데이터 유지
|
||||
const isStale = currentSnapIdx < 0 && effectiveSnapIdx >= 0;
|
||||
|
||||
const currentFrame = historyData && effectiveSnapIdx >= 0 ? historyData[effectiveSnapIdx] : null;
|
||||
const isLongGap = !!currentFrame?._longGap;
|
||||
const showGray = isLongGap || (isStale && !currentFrame?._interp);
|
||||
|
||||
// center trail: historyData에 이미 보간 프레임 포함 → 전체 좌표 연결
|
||||
const centerTrailGeoJson = useMemo((): GeoJSON => {
|
||||
if (!historyData || historyData.length === 0) return EMPTY_HIST_FC;
|
||||
|
||||
const features: GeoJSON.Feature[] = [];
|
||||
|
||||
// 연속된 같은 타입끼리 세그먼트 분리
|
||||
let segStart = 0;
|
||||
for (let i = 1; i <= historyData.length; i++) {
|
||||
const curInterp = i < historyData.length && !!historyData[i]._longGap;
|
||||
const startInterp = !!historyData[segStart]._longGap;
|
||||
if (i < historyData.length && curInterp === startInterp) continue;
|
||||
|
||||
const from = segStart > 0 ? segStart - 1 : segStart;
|
||||
const seg = historyData.slice(from, i);
|
||||
if (seg.length >= 2) {
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: { interpolated: startInterp ? 1 : 0 },
|
||||
geometry: { type: 'LineString', coordinates: seg.map(s => [s.centerLon, s.centerLat]) },
|
||||
});
|
||||
}
|
||||
segStart = i;
|
||||
}
|
||||
|
||||
// 실데이터 도트만
|
||||
for (const h of historyData) {
|
||||
if (h.color === '#94a3b8') continue;
|
||||
features.push({
|
||||
type: 'Feature', properties: { interpolated: 0 },
|
||||
geometry: { type: 'Point', coordinates: [h.centerLon, h.centerLat] },
|
||||
});
|
||||
}
|
||||
|
||||
return { type: 'FeatureCollection', features };
|
||||
}, [historyData]);
|
||||
|
||||
// 현재 재생 위치 포인트
|
||||
const currentCenterGeoJson = useMemo((): GeoJSON => {
|
||||
if (!historyData || effectiveSnapIdx < 0) return EMPTY_HIST_FC;
|
||||
const snap = historyData[effectiveSnapIdx];
|
||||
if (!snap) return EMPTY_HIST_FC;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: [{
|
||||
type: 'Feature',
|
||||
properties: { interpolated: showGray ? 1 : 0 },
|
||||
geometry: { type: 'Point', coordinates: [snap.centerLon, snap.centerLat] },
|
||||
}],
|
||||
};
|
||||
}, [historyData, effectiveSnapIdx, showGray]);
|
||||
|
||||
const animPolygonGeoJson = useMemo((): GeoJSON => {
|
||||
if (!historyData || effectiveSnapIdx < 0) return EMPTY_HIST_FC;
|
||||
@ -529,9 +765,9 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
||||
if (!snap?.polygon) return EMPTY_HIST_FC;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: [{ type: 'Feature', properties: { stale: isStale ? 1 : 0 }, geometry: snap.polygon }],
|
||||
features: [{ type: 'Feature', properties: { stale: isStale ? 1 : 0, interpolated: showGray ? 1 : 0 }, geometry: snap.polygon }],
|
||||
};
|
||||
}, [historyData, effectiveSnapIdx, isStale]);
|
||||
}, [historyData, effectiveSnapIdx, isStale, showGray]);
|
||||
|
||||
// 현재 프레임의 멤버 위치 (가상 아이콘)
|
||||
const animMembersGeoJson = useMemo((): GeoJSON => {
|
||||
@ -542,11 +778,11 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
||||
type: 'FeatureCollection',
|
||||
features: snap.members.map(m => ({
|
||||
type: 'Feature' as const,
|
||||
properties: { mmsi: m.mmsi, name: m.name, cog: m.cog ?? 0, role: m.role, isGear: m.role === 'GEAR' ? 1 : 0, stale: isStale ? 1 : 0 },
|
||||
properties: { mmsi: m.mmsi, name: m.name, cog: m.cog ?? 0, role: m.role, isGear: m.role === 'GEAR' ? 1 : 0, stale: isStale ? 1 : 0, interpolated: showGray ? 1 : 0 },
|
||||
geometry: { type: 'Point' as const, coordinates: [m.lon, m.lat] },
|
||||
})),
|
||||
};
|
||||
}, [historyData, effectiveSnapIdx, isStale]);
|
||||
}, [historyData, effectiveSnapIdx, isStale, showGray]);
|
||||
|
||||
// 선단 목록 (멤버 수 내림차순)
|
||||
const fleetList = useMemo(() => {
|
||||
@ -902,23 +1138,38 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
||||
{historyData && (
|
||||
<Source id="history-center-trail" type="geojson" data={centerTrailGeoJson}>
|
||||
<Layer id="history-center-trail-line" type="line" paint={{
|
||||
'line-color': '#fbbf24', 'line-width': 2, 'line-dasharray': [4, 4], 'line-opacity': 0.7,
|
||||
}} />
|
||||
'line-color': ['case', ['==', ['get', 'interpolated'], 1], '#f97316', '#fbbf24'],
|
||||
'line-width': 2,
|
||||
'line-dasharray': [4, 4],
|
||||
'line-opacity': ['case', ['==', ['get', 'interpolated'], 1], 0.8, 0.7],
|
||||
}} filter={['==', '$type', 'LineString']} />
|
||||
<Layer id="history-center-dots" type="circle" paint={{
|
||||
'circle-radius': 2.5, 'circle-color': '#fbbf24', 'circle-opacity': 0.6,
|
||||
}} filter={['==', '$type', 'Point']} />
|
||||
</Source>
|
||||
)}
|
||||
{/* 보간 경로는 centerTrailGeoJson에 통합됨 (interpolated=1 세그먼트) */}
|
||||
{/* 현재 재생 위치 포인트 (실데이터=빨강, 보간=주황) */}
|
||||
{historyData && effectiveSnapIdx >= 0 && (
|
||||
<Source id="history-current-center" type="geojson" data={currentCenterGeoJson}>
|
||||
<Layer id="history-current-center-dot" type="circle" paint={{
|
||||
'circle-radius': 7,
|
||||
'circle-color': ['case', ['==', ['get', 'interpolated'], 1], '#f97316', '#ef4444'],
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#ffffff',
|
||||
}} />
|
||||
</Source>
|
||||
)}
|
||||
{historyData && (
|
||||
<Source id="history-anim-polygon" type="geojson" data={animPolygonGeoJson}>
|
||||
<Layer id="history-anim-fill" type="fill" paint={{
|
||||
'fill-color': isStale ? '#64748b' : '#fbbf24',
|
||||
'fill-opacity': isStale ? 0.08 : 0.15,
|
||||
'fill-color': ['case', ['==', ['get', 'interpolated'], 1], '#94a3b8', isStale ? '#64748b' : '#fbbf24'],
|
||||
'fill-opacity': ['case', ['==', ['get', 'interpolated'], 1], 0.12, isStale ? 0.08 : 0.15],
|
||||
}} />
|
||||
<Layer id="history-anim-line" type="line" paint={{
|
||||
'line-color': isStale ? '#64748b' : '#fbbf24',
|
||||
'line-width': isStale ? 1 : 2,
|
||||
'line-opacity': isStale ? 0.4 : 0.7,
|
||||
'line-color': ['case', ['==', ['get', 'interpolated'], 1], '#94a3b8', isStale ? '#64748b' : '#fbbf24'],
|
||||
'line-width': ['case', ['==', ['get', 'interpolated'], 1], 1.5, isStale ? 1 : 2],
|
||||
'line-opacity': ['case', ['==', ['get', 'interpolated'], 1], 0.5, isStale ? 0.4 : 0.7],
|
||||
'line-dasharray': isStale ? [3, 3] : [1, 0],
|
||||
}} />
|
||||
</Source>
|
||||
@ -934,11 +1185,16 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
||||
'icon-allow-overlap': true,
|
||||
}} paint={{
|
||||
'icon-color': ['case',
|
||||
['==', ['get', 'interpolated'], 1], '#94a3b8',
|
||||
['==', ['get', 'stale'], 1], '#64748b',
|
||||
['==', ['get', 'isGear'], 0], '#fbbf24',
|
||||
'#a8b8c8',
|
||||
],
|
||||
'icon-opacity': ['case', ['==', ['get', 'stale'], 1], 0.4, 0.9],
|
||||
'icon-opacity': ['case',
|
||||
['==', ['get', 'interpolated'], 1], 0.5,
|
||||
['==', ['get', 'stale'], 1], 0.4,
|
||||
0.9,
|
||||
],
|
||||
}} />
|
||||
<Layer id="history-anim-members-label" type="symbol" layout={{
|
||||
'text-field': ['get', 'name'],
|
||||
@ -946,7 +1202,11 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
||||
'text-offset': [0, 1.5],
|
||||
'text-allow-overlap': false,
|
||||
}} paint={{
|
||||
'text-color': ['case', ['==', ['get', 'isGear'], 0], '#fbbf24', '#e2e8f0'],
|
||||
'text-color': ['case',
|
||||
['==', ['get', 'interpolated'], 1], '#94a3b8',
|
||||
['==', ['get', 'isGear'], 0], '#fbbf24',
|
||||
'#e2e8f0',
|
||||
],
|
||||
'text-halo-color': 'rgba(0,0,0,0.8)',
|
||||
'text-halo-width': 1,
|
||||
}} />
|
||||
@ -993,7 +1253,6 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
||||
<span style={{ color: hasSnap ? '#fbbf24' : '#ef4444', minWidth: 40, textAlign: 'center' }}>
|
||||
{timeStr}
|
||||
</span>
|
||||
{!hasSnap && <span style={{ color: '#ef4444', fontSize: 9 }}>신호없음</span>}
|
||||
<input type="range" min={0} max={1000} value={Math.round(timelinePos * 1000)}
|
||||
onChange={e => { setIsPlaying(false); setTimelinePos(Number(e.target.value) / 1000); }}
|
||||
style={{ flex: 1, cursor: 'pointer', accentColor: '#fbbf24' }}
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user