feat: 재생 컨트롤 확장 — 항적/이름 토글 + 일치율 필터 + 개별 on/off
재생 컨트롤러: - 항적 on/off → showTrails (TripsLayer + PathLayer + 센터도트) - 이름 on/off → showLabels (TextLayer) - 일치율 드롭다운 (50~90%) → enabledVessels 일괄 필터 패널 토글: - 행 전체 클릭으로 체크박스 토글 (cursor: pointer) - 체크박스 항상 표시, OFF 시 opacity 0.5 - correlationTracks prop 제거 (미사용) enabledVessels OFF 효과: - corrPositions 제외 → 아이콘/라벨/트레일/폴리곤 모두 제외 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
21df325010
커밋
5002105d18
@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import type { GearCorrelationItem, CorrelationVesselTrack } from '../../services/vesselAnalysis';
|
||||
import type { GearCorrelationItem } from '../../services/vesselAnalysis';
|
||||
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
|
||||
import { FONT_MONO } from '../../styles/fonts';
|
||||
import { MODEL_ORDER, MODEL_COLORS, MODEL_DESC } from './fleetClusterConstants';
|
||||
@ -11,7 +11,6 @@ interface CorrelationPanelProps {
|
||||
groupPolygons: UseGroupPolygonsResult | undefined;
|
||||
correlationByModel: Map<string, GearCorrelationItem[]>;
|
||||
availableModels: { name: string; count: number; isDefault: boolean }[];
|
||||
correlationTracks: CorrelationVesselTrack[];
|
||||
enabledModels: Set<string>;
|
||||
enabledVessels: Set<string>;
|
||||
correlationLoading: boolean;
|
||||
@ -30,7 +29,6 @@ const CorrelationPanel = ({
|
||||
groupPolygons,
|
||||
correlationByModel,
|
||||
availableModels,
|
||||
correlationTracks,
|
||||
enabledModels,
|
||||
enabledVessels,
|
||||
correlationLoading,
|
||||
@ -127,44 +125,36 @@ const CorrelationPanel = ({
|
||||
};
|
||||
|
||||
// Common row renderer (correlation target — with score bar, model-independent hover)
|
||||
const toggleVessel = (mmsi: string) => {
|
||||
onEnabledVesselsChange(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(mmsi)) next.delete(mmsi); else next.add(mmsi);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const renderRow = (c: GearCorrelationItem, color: string, modelName: string) => {
|
||||
const pct = (c.score * 100).toFixed(0);
|
||||
const barW = Math.max(2, c.score * 30);
|
||||
const barColor = c.score >= 0.7 ? '#22c55e' : c.score >= 0.5 ? '#eab308' : '#94a3b8';
|
||||
const isVessel = c.targetType === 'VESSEL';
|
||||
const hasTrack = correlationTracks.some(v => v.mmsi === c.targetMmsi);
|
||||
const isEnabled = enabledVessels.has(c.targetMmsi);
|
||||
const isHovered = hoveredTarget?.mmsi === c.targetMmsi && hoveredTarget?.model === modelName;
|
||||
return (
|
||||
<div
|
||||
key={c.targetMmsi}
|
||||
style={{
|
||||
fontSize: 9,
|
||||
marginBottom: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 3,
|
||||
padding: '1px 2px',
|
||||
borderRadius: 2,
|
||||
cursor: 'default',
|
||||
fontSize: 9, marginBottom: 1, display: 'flex', alignItems: 'center', gap: 3,
|
||||
padding: '1px 2px', borderRadius: 2, cursor: 'pointer',
|
||||
background: isHovered ? `${color}22` : 'transparent',
|
||||
opacity: isEnabled ? 1 : 0.5,
|
||||
}}
|
||||
onClick={() => toggleVessel(c.targetMmsi)}
|
||||
onMouseEnter={() => onHoveredTargetChange({ mmsi: c.targetMmsi, model: modelName })}
|
||||
onMouseLeave={() => onHoveredTargetChange(null)}
|
||||
>
|
||||
{hasTrack && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabledVessels.has(c.targetMmsi)}
|
||||
onChange={() => onEnabledVesselsChange(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(c.targetMmsi)) next.delete(c.targetMmsi);
|
||||
else next.add(c.targetMmsi);
|
||||
return next;
|
||||
})}
|
||||
style={{ accentColor: color, width: 9, height: 9, flexShrink: 0 }}
|
||||
title="맵 표시"
|
||||
/>
|
||||
)}
|
||||
<input type="checkbox" checked={isEnabled} readOnly title="맵 표시"
|
||||
style={{ accentColor: color, width: 9, height: 9, flexShrink: 0, pointerEvents: 'none' }} />
|
||||
<span style={{ color: isVessel ? '#60a5fa' : '#f97316', width: 10, textAlign: 'center', flexShrink: 0 }}>
|
||||
{isVessel ? '⛴' : '◆'}
|
||||
</span>
|
||||
|
||||
@ -454,7 +454,6 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
||||
groupPolygons={groupPolygons}
|
||||
correlationByModel={geo.correlationByModel}
|
||||
availableModels={geo.availableModels}
|
||||
correlationTracks={correlationTracks}
|
||||
enabledModels={enabledModels}
|
||||
enabledVessels={enabledVessels}
|
||||
correlationLoading={correlationLoading}
|
||||
@ -469,6 +468,20 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
||||
{historyActive && (
|
||||
<HistoryReplayController
|
||||
onClose={closeHistory}
|
||||
onFilterByScore={(minPct) => {
|
||||
if (minPct === null) {
|
||||
// 전체: 모든 연관 선박 ON
|
||||
setEnabledVessels(new Set(correlationTracks.map(v => v.mmsi)));
|
||||
} else {
|
||||
// 해당 퍼센트 이상만 ON
|
||||
const threshold = minPct / 100;
|
||||
const filtered = new Set<string>();
|
||||
for (const c of correlationData) {
|
||||
if (c.score >= threshold) filtered.add(c.targetMmsi);
|
||||
}
|
||||
setEnabledVessels(filtered);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -4,20 +4,20 @@ import { useGearReplayStore } from '../../stores/gearReplayStore';
|
||||
|
||||
interface HistoryReplayControllerProps {
|
||||
onClose: () => void;
|
||||
onFilterByScore: (minPct: number | null) => void;
|
||||
}
|
||||
|
||||
const HistoryReplayController = ({ onClose }: HistoryReplayControllerProps) => {
|
||||
// React selectors (infrequent changes)
|
||||
const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayControllerProps) => {
|
||||
const isPlaying = useGearReplayStore(s => s.isPlaying);
|
||||
const snapshotRanges = useGearReplayStore(s => s.snapshotRanges);
|
||||
const frameCount = useGearReplayStore(s => s.historyFrames.length);
|
||||
const showTrails = useGearReplayStore(s => s.showTrails);
|
||||
const showLabels = useGearReplayStore(s => s.showLabels);
|
||||
|
||||
// DOM refs for imperative updates
|
||||
const progressBarRef = useRef<HTMLInputElement>(null);
|
||||
const progressIndicatorRef = useRef<HTMLDivElement>(null);
|
||||
const timeDisplayRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
// Subscribe to currentTime for DOM updates (no React re-render)
|
||||
useEffect(() => {
|
||||
const unsub = useGearReplayStore.subscribe(
|
||||
s => s.currentTime,
|
||||
@ -36,124 +36,91 @@ const HistoryReplayController = ({ onClose }: HistoryReplayControllerProps) => {
|
||||
}, []);
|
||||
|
||||
const store = useGearReplayStore;
|
||||
const btnStyle: React.CSSProperties = {
|
||||
background: 'none', border: '1px solid rgba(99,179,237,0.3)', borderRadius: 4,
|
||||
color: '#e2e8f0', cursor: 'pointer', padding: '2px 6px', fontSize: 10, fontFamily: FONT_MONO,
|
||||
};
|
||||
const btnActiveStyle: React.CSSProperties = {
|
||||
...btnStyle, background: 'rgba(99,179,237,0.15)', color: '#93c5fd',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'rgba(12,24,37,0.95)',
|
||||
border: '1px solid rgba(99,179,237,0.25)',
|
||||
borderRadius: 8,
|
||||
padding: '8px 14px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
zIndex: 20,
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: 10,
|
||||
color: '#e2e8f0',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
|
||||
pointerEvents: 'auto',
|
||||
minWidth: 360,
|
||||
}}>
|
||||
{/* 프로그레스 바 — 갭 표시 */}
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
height: 8,
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)',
|
||||
background: 'rgba(12,24,37,0.95)', border: '1px solid rgba(99,179,237,0.25)',
|
||||
borderRadius: 8, padding: '8px 14px', display: 'flex', flexDirection: 'column', gap: 4,
|
||||
zIndex: 20, fontFamily: FONT_MONO, fontSize: 10, color: '#e2e8f0',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.5)', pointerEvents: 'auto', minWidth: 420,
|
||||
}}>
|
||||
{/* 프로그레스 바 */}
|
||||
<div style={{ position: 'relative', height: 8, background: 'rgba(255,255,255,0.05)', borderRadius: 4, overflow: 'hidden' }}>
|
||||
{snapshotRanges.map((pos, i) => (
|
||||
<div key={i} style={{
|
||||
position: 'absolute',
|
||||
left: `${pos * 100}%`,
|
||||
top: 0,
|
||||
width: 2,
|
||||
height: '100%',
|
||||
position: 'absolute', left: `${pos * 100}%`, top: 0, width: 2, height: '100%',
|
||||
background: 'rgba(251,191,36,0.4)',
|
||||
}} />
|
||||
))}
|
||||
{/* 현재 위치 인디케이터 (DOM ref로 업데이트) */}
|
||||
<div ref={progressIndicatorRef} style={{
|
||||
position: 'absolute',
|
||||
left: '0%',
|
||||
top: -1,
|
||||
width: 3,
|
||||
height: 10,
|
||||
background: '#fbbf24',
|
||||
borderRadius: 1,
|
||||
transform: 'translateX(-50%)',
|
||||
position: 'absolute', left: '0%', top: -1, width: 3, height: 10,
|
||||
background: '#fbbf24', borderRadius: 1, transform: 'translateX(-50%)',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* 컨트롤 행 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (isPlaying) store.getState().pause(); else store.getState().play(); }}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: '1px solid rgba(99,179,237,0.3)',
|
||||
borderRadius: 4,
|
||||
color: '#e2e8f0',
|
||||
cursor: 'pointer',
|
||||
padding: '2px 6px',
|
||||
fontSize: 12,
|
||||
fontFamily: FONT_MONO,
|
||||
}}
|
||||
>
|
||||
{/* 컨트롤 행 1: 재생 + 타임라인 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<button type="button" onClick={() => { if (isPlaying) store.getState().pause(); else store.getState().play(); }}
|
||||
style={{ ...btnStyle, fontSize: 12 }}>
|
||||
{isPlaying ? '⏸' : '▶'}
|
||||
</button>
|
||||
|
||||
<span
|
||||
ref={timeDisplayRef}
|
||||
style={{ color: '#fbbf24', minWidth: 40, textAlign: 'center' }}
|
||||
>
|
||||
--:--
|
||||
</span>
|
||||
|
||||
<input
|
||||
ref={progressBarRef}
|
||||
type="range"
|
||||
min={0}
|
||||
max={1000}
|
||||
defaultValue={0}
|
||||
<span ref={timeDisplayRef} style={{ color: '#fbbf24', minWidth: 40, textAlign: 'center' }}>--:--</span>
|
||||
<input ref={progressBarRef} type="range" min={0} max={1000} defaultValue={0}
|
||||
onChange={e => {
|
||||
const { startTime, endTime } = store.getState();
|
||||
const progress = Number(e.target.value) / 1000;
|
||||
const seekTime = startTime + progress * (endTime - startTime);
|
||||
store.getState().pause();
|
||||
store.getState().seek(seekTime);
|
||||
store.getState().seek(startTime + progress * (endTime - startTime));
|
||||
}}
|
||||
style={{ flex: 1, cursor: 'pointer', accentColor: '#fbbf24' }}
|
||||
title="히스토리 타임라인"
|
||||
aria-label="히스토리 타임라인"
|
||||
/>
|
||||
|
||||
<span style={{ color: '#64748b', fontSize: 9 }}>
|
||||
{frameCount}건
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: '1px solid rgba(239,68,68,0.3)',
|
||||
borderRadius: 4,
|
||||
color: '#ef4444',
|
||||
cursor: 'pointer',
|
||||
padding: '2px 6px',
|
||||
fontSize: 11,
|
||||
fontFamily: FONT_MONO,
|
||||
}}
|
||||
>
|
||||
title="히스토리 타임라인" aria-label="히스토리 타임라인" />
|
||||
<span style={{ color: '#64748b', fontSize: 9 }}>{frameCount}건</span>
|
||||
<button type="button" onClick={onClose}
|
||||
style={{ background: 'none', border: '1px solid rgba(239,68,68,0.3)', borderRadius: 4, color: '#ef4444', cursor: 'pointer', padding: '2px 6px', fontSize: 11, fontFamily: FONT_MONO }}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 컨트롤 행 2: 표시 옵션 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 4 }}>
|
||||
<button type="button" onClick={() => store.getState().setShowTrails(!showTrails)}
|
||||
style={showTrails ? btnActiveStyle : btnStyle} title="전체 항적 표시">
|
||||
항적
|
||||
</button>
|
||||
<button type="button" onClick={() => store.getState().setShowLabels(!showLabels)}
|
||||
style={showLabels ? btnActiveStyle : btnStyle} title="이름 표시">
|
||||
이름
|
||||
</button>
|
||||
<span style={{ color: '#475569', margin: '0 2px' }}>|</span>
|
||||
<span style={{ color: '#64748b', fontSize: 9 }}>일치율</span>
|
||||
<select
|
||||
onChange={e => {
|
||||
const val = e.target.value;
|
||||
onFilterByScore(val === '' ? null : Number(val));
|
||||
}}
|
||||
style={{
|
||||
background: 'rgba(15,23,42,0.9)', border: '1px solid rgba(99,179,237,0.3)',
|
||||
borderRadius: 4, color: '#e2e8f0', fontSize: 9, fontFamily: FONT_MONO,
|
||||
padding: '1px 4px', cursor: 'pointer',
|
||||
}}
|
||||
title="일치율 이상만 표시" aria-label="일치율 필터"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
<option value="50">50%+</option>
|
||||
<option value="60">60%+</option>
|
||||
<option value="70">70%+</option>
|
||||
<option value="80">80%+</option>
|
||||
<option value="90">90%+</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -64,6 +64,8 @@ export function useGearReplayLayers(
|
||||
const enabledVessels = useGearReplayStore(s => s.enabledVessels);
|
||||
const hoveredMmsi = useGearReplayStore(s => s.hoveredMmsi);
|
||||
const correlationByModel = useGearReplayStore(s => s.correlationByModel);
|
||||
const showTrails = useGearReplayStore(s => s.showTrails);
|
||||
const showLabels = useGearReplayStore(s => s.showLabels);
|
||||
|
||||
// ── Refs ─────────────────────────────────────────────────────────────────
|
||||
const cursorRef = useRef(0); // frame cursor for O(1) forward lookup
|
||||
@ -95,7 +97,8 @@ export function useGearReplayLayers(
|
||||
|
||||
// ── Static layers (center trail + dots) ───────────────────────────────
|
||||
|
||||
// Center trail segments (PathLayer)
|
||||
// Center trail segments (PathLayer) — showTrails 제어
|
||||
if (showTrails) {
|
||||
for (let i = 0; i < centerTrailSegments.length; i++) {
|
||||
const seg = centerTrailSegments[i];
|
||||
if (seg.path.length < 2) continue;
|
||||
@ -109,9 +112,10 @@ export function useGearReplayLayers(
|
||||
widthMinPixels: 2,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Center dots (real data only)
|
||||
if (centerDotsPositions.length > 0) {
|
||||
// Center dots (real data only) — showTrails 제어
|
||||
if (showTrails && centerDotsPositions.length > 0) {
|
||||
layers.push(new ScatterplotLayer({
|
||||
id: 'replay-center-dots',
|
||||
data: centerDotsPositions,
|
||||
@ -141,8 +145,8 @@ export function useGearReplayLayers(
|
||||
|
||||
// 1. Identity 모델: 멤버 트레일 + 폴리곤 + 마커 (enabledModels 체크)
|
||||
if (enabledModels.has('identity')) {
|
||||
// TripsLayer — member trails (GPU animated)
|
||||
if (memberTripsData.length > 0) {
|
||||
// TripsLayer — member trails (GPU animated, showTrails 제어)
|
||||
if (showTrails && memberTripsData.length > 0) {
|
||||
layers.push(new TripsLayer({
|
||||
id: 'replay-member-trails',
|
||||
data: memberTripsData,
|
||||
@ -173,8 +177,8 @@ export function useGearReplayLayers(
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Correlation trails (GPU animated, enabledModels 체크)
|
||||
if (correlationTripsData.length > 0) {
|
||||
// 2. Correlation trails (GPU animated, showTrails + enabledModels 체크)
|
||||
if (showTrails && correlationTripsData.length > 0) {
|
||||
// 활성 모델에 속하는 선박의 트랙만 표시
|
||||
const activeMmsis = new Set<string>();
|
||||
for (const [mn, items] of correlationByModel) {
|
||||
@ -231,8 +235,8 @@ export function useGearReplayLayers(
|
||||
billboard: false,
|
||||
}));
|
||||
|
||||
// Member labels
|
||||
layers.push(new TextLayer<MemberPosition>({
|
||||
// Member labels — showLabels 제어
|
||||
if (showLabels) layers.push(new TextLayer<MemberPosition>({
|
||||
id: 'replay-member-labels',
|
||||
data: members,
|
||||
getPosition: d => [d.lon, d.lat],
|
||||
@ -263,6 +267,7 @@ export function useGearReplayLayers(
|
||||
const [r, g, b] = hexToRgb(color);
|
||||
|
||||
for (const c of items as GearCorrelationItem[]) {
|
||||
if (!enabledVessels.has(c.targetMmsi)) continue; // OFF → 아이콘+트레일+폴리곤 모두 제외
|
||||
if (corrPositions.some(p => p.mmsi === c.targetMmsi)) continue;
|
||||
|
||||
let lon: number | undefined;
|
||||
@ -373,7 +378,7 @@ export function useGearReplayLayers(
|
||||
billboard: false,
|
||||
}));
|
||||
|
||||
layers.push(new TextLayer<CorrPosition>({
|
||||
if (showLabels) layers.push(new TextLayer<CorrPosition>({
|
||||
id: 'replay-corr-labels',
|
||||
data: corrPositions,
|
||||
getPosition: d => [d.lon, d.lat],
|
||||
@ -541,6 +546,7 @@ export function useGearReplayLayers(
|
||||
historyFrames, memberTripsData, correlationTripsData,
|
||||
centerTrailSegments, centerDotsPositions,
|
||||
enabledModels, enabledVessels, hoveredMmsi, correlationByModel,
|
||||
showTrails, showLabels,
|
||||
replayLayerRef, requestRender,
|
||||
]);
|
||||
|
||||
|
||||
@ -64,11 +64,13 @@ interface GearReplayState {
|
||||
centerDotsPositions: [number, number][];
|
||||
snapshotRanges: number[];
|
||||
|
||||
// Filter state
|
||||
// Filter / display state
|
||||
enabledModels: Set<string>;
|
||||
enabledVessels: Set<string>;
|
||||
hoveredMmsi: string | null;
|
||||
correlationByModel: Map<string, GearCorrelationItem[]>;
|
||||
showTrails: boolean;
|
||||
showLabels: boolean;
|
||||
|
||||
// Actions
|
||||
loadHistory: (
|
||||
@ -85,6 +87,8 @@ interface GearReplayState {
|
||||
setEnabledModels: (models: Set<string>) => void;
|
||||
setEnabledVessels: (vessels: Set<string>) => void;
|
||||
setHoveredMmsi: (mmsi: string | null) => void;
|
||||
setShowTrails: (show: boolean) => void;
|
||||
setShowLabels: (show: boolean) => void;
|
||||
updateCorrelation: (corrData: GearCorrelationItem[], corrTracks: CorrelationVesselTrack[]) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
@ -135,10 +139,12 @@ export const useGearReplayStore = create<GearReplayState>()(
|
||||
centerDotsPositions: [],
|
||||
snapshotRanges: [],
|
||||
|
||||
// Filter state
|
||||
// Filter / display state
|
||||
enabledModels: new Set<string>(),
|
||||
enabledVessels: new Set<string>(),
|
||||
hoveredMmsi: null,
|
||||
showTrails: true,
|
||||
showLabels: true,
|
||||
correlationByModel: new Map<string, GearCorrelationItem[]>(),
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────
|
||||
@ -214,6 +220,8 @@ export const useGearReplayStore = create<GearReplayState>()(
|
||||
setEnabledVessels: (vessels) => set({ enabledVessels: vessels }),
|
||||
|
||||
setHoveredMmsi: (mmsi) => set({ hoveredMmsi: mmsi }),
|
||||
setShowTrails: (show) => set({ showTrails: show }),
|
||||
setShowLabels: (show) => set({ showLabels: show }),
|
||||
|
||||
updateCorrelation: (corrData, corrTracks) => {
|
||||
const state = get();
|
||||
@ -260,6 +268,8 @@ export const useGearReplayStore = create<GearReplayState>()(
|
||||
enabledModels: new Set<string>(),
|
||||
enabledVessels: new Set<string>(),
|
||||
hoveredMmsi: null,
|
||||
showTrails: true,
|
||||
showLabels: true,
|
||||
correlationByModel: new Map<string, GearCorrelationItem[]>(),
|
||||
});
|
||||
},
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user