feat(trackReplay): 항적 기간 조정/재조회 + CSV 내보내기 #47

병합
htlee feature/announcement-popup 에서 develop 로 1 commits 를 머지했습니다 2026-02-25 03:08:25 +09:00
6개의 변경된 파일390개의 추가작업 그리고 51개의 파일을 삭제

파일 보기

@ -0,0 +1,141 @@
import { DISPLAY_TZ } from '../../../shared/lib/datetime';
import { haversineNm } from '../../../shared/lib/geo/haversineNm';
import type { ProcessedTrack, TrackQueryContext } from '../model/track.types';
const BOM = '\uFEFF';
function escCsv(value: string | number | undefined | null): string {
if (value == null) return '';
const s = String(value);
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
}
function fmtTimestamp(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '';
return new Date(ms).toLocaleString('sv-SE', { timeZone: DISPLAY_TZ });
}
/** 두 포인트 간 거리(NM)·시간차(h)로 속력(knots) 계산 */
function calcSpeedKnots(track: ProcessedTrack, index: number): number {
if (index <= 0) return 0;
const [lon1, lat1] = track.geometry[index - 1];
const [lon2, lat2] = track.geometry[index];
const dtMs = track.timestampsMs[index] - track.timestampsMs[index - 1];
if (dtMs <= 0) return 0;
const distNm = haversineNm(lat1, lon1, lat2, lon2);
const hours = dtMs / 3_600_000;
return Math.round((distNm / hours) * 100) / 100;
}
/** 포인트별 1행: mmsi, longitude, latitude, timestamp, speedKnots */
export function buildDynamicCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null): string {
const header = ['mmsi', 'longitude', 'latitude', 'timestamp', 'timestampMs', 'speedKnots'];
const rows: string[] = [header.join(',')];
const mmsi = ctx?.mmsi ?? '';
for (const track of tracks) {
const trackMmsi = mmsi || track.targetId;
for (let i = 0; i < track.geometry.length; i++) {
rows.push(
[
escCsv(trackMmsi),
escCsv(track.geometry[i][0]),
escCsv(track.geometry[i][1]),
escCsv(fmtTimestamp(track.timestampsMs[i])),
escCsv(track.timestampsMs[i]),
escCsv(calcSpeedKnots(track, i)),
].join(','),
);
}
}
return BOM + rows.join('\n');
}
/** 선박별 1행: mmsi, 선명, 업종, 소유주, 선단, 허가번호 등 전체 메타 */
export function buildStaticCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null): string {
const header = [
'mmsi',
'shipName',
'vesselType',
'ownerCn',
'ownerRoman',
'permitNo',
'pairPermitNo',
'ton',
'callSign',
'workSeaArea',
'nationalCode',
'totalDistanceNm',
'avgSpeed',
'maxSpeed',
'pointCount',
'startTime',
'endTime',
'chnPrmShipName',
'chnPrmVesselType',
'chnPrmCallsign',
'chnPrmImo',
];
const rows: string[] = [header.join(',')];
for (const track of tracks) {
const firstTs = track.timestampsMs[0] ?? 0;
const lastTs = track.timestampsMs[track.timestampsMs.length - 1] ?? 0;
const info = track.chnPrmShipInfo;
rows.push(
[
escCsv(ctx?.mmsi ?? track.targetId),
escCsv(track.shipName),
escCsv(ctx?.vesselType ?? ''),
escCsv(ctx?.ownerCn),
escCsv(ctx?.ownerRoman),
escCsv(ctx?.permitNo),
escCsv(ctx?.pairPermitNo),
escCsv(ctx?.ton),
escCsv(ctx?.callSign),
escCsv(ctx?.workSeaArea),
escCsv(track.nationalCode),
escCsv(track.stats.totalDistanceNm),
escCsv(track.stats.avgSpeed),
escCsv(track.stats.maxSpeed),
escCsv(track.stats.pointCount),
escCsv(fmtTimestamp(firstTs)),
escCsv(fmtTimestamp(lastTs)),
escCsv(info?.name),
escCsv(info?.vesselType),
escCsv(info?.callsign),
escCsv(info?.imo),
].join(','),
);
}
return BOM + rows.join('\n');
}
function downloadCsv(csvContent: string, filename: string): void {
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export function exportTrackCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null): void {
const now = new Date();
const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
downloadCsv(buildDynamicCsv(tracks, ctx), `track-points-${ts}.csv`);
setTimeout(() => {
downloadCsv(buildStaticCsv(tracks, ctx), `track-vessel-${ts}.csv`);
}, 100);
}

파일 보기

@ -7,6 +7,13 @@ export interface TrackStats {
pointCount: number;
}
export interface ChnPrmShipInfo {
name?: string;
vesselType?: string;
callsign?: string;
imo?: number;
}
export interface ProcessedTrack {
vesselId: string;
targetId: string;
@ -18,6 +25,7 @@ export interface ProcessedTrack {
timestampsMs: number[];
speeds: number[];
stats: TrackStats;
chnPrmShipInfo?: ChnPrmShipInfo;
}
export interface CurrentVesselPosition {
@ -38,6 +46,24 @@ export interface TrackQueryRequest {
minutes: number;
}
export interface TrackQueryContext {
mmsi: number;
startTimeIso: string;
endTimeIso: string;
shipNameHint?: string;
shipKindCodeHint?: string;
nationalCodeHint?: string;
isPermitted: boolean;
vesselType?: string;
ownerCn?: string | null;
ownerRoman?: string | null;
permitNo?: string;
pairPermitNo?: string | null;
ton?: number | null;
callSign?: string;
workSeaArea?: string;
}
export interface ReplayStreamQueryRequest {
startTime: string;
endTime: string;

파일 보기

@ -9,6 +9,16 @@ type QueryTrackByMmsiParams = {
isPermitted?: boolean;
};
export type QueryTrackByDateRangeParams = {
mmsi: number;
startTimeIso: string;
endTimeIso: string;
shipNameHint?: string;
shipKindCodeHint?: string;
nationalCodeHint?: string;
isPermitted?: boolean;
};
type V2TrackResponse = {
vesselId?: string;
targetId?: string;
@ -100,23 +110,26 @@ function convertV2Tracks(rows: V2TrackResponse[]): ProcessedTrack[] {
maxSpeed: row.maxSpeed || 0,
pointCount: row.pointCount || geometry.length,
},
chnPrmShipInfo: row.chnPrmShipInfo ? { ...row.chnPrmShipInfo } : undefined,
});
}
return out;
}
async function queryV2Track(params: QueryTrackByMmsiParams): Promise<ProcessedTrack[]> {
async function fetchV2Tracks(
startTimeIso: string,
endTimeIso: string,
mmsi: number,
isPermitted: boolean,
): Promise<ProcessedTrack[]> {
const base = (import.meta.env.VITE_TRACK_V2_BASE_URL || '/signal-batch').trim();
const end = new Date();
const start = new Date(end.getTime() - params.minutes * 60_000);
const requestBody = {
startTime: start.toISOString(),
endTime: end.toISOString(),
vessels: [String(params.mmsi)],
includeChnPrmShip: params.isPermitted ?? false,
startTime: startTimeIso,
endTime: endTimeIso,
vessels: [String(mmsi)],
includeChnPrmShip: isPermitted,
};
const endpoint = `${base.replace(/\/$/, '')}/api/v2/tracks/vessels`;
@ -141,5 +154,11 @@ async function queryV2Track(params: QueryTrackByMmsiParams): Promise<ProcessedTr
}
export async function queryTrackByMmsi(params: QueryTrackByMmsiParams): Promise<ProcessedTrack[]> {
return queryV2Track(params);
const end = new Date();
const start = new Date(end.getTime() - params.minutes * 60_000);
return fetchV2Tracks(start.toISOString(), end.toISOString(), params.mmsi, params.isPermitted ?? false);
}
export async function queryTrackByDateRange(params: QueryTrackByDateRangeParams): Promise<ProcessedTrack[]> {
return fetchV2Tracks(params.startTimeIso, params.endTimeIso, params.mmsi, params.isPermitted ?? false);
}

파일 보기

@ -1,6 +1,7 @@
import { create } from 'zustand';
import { getTracksTimeRange } from '../lib/adapters';
import type { ProcessedTrack } from '../model/track.types';
import type { ProcessedTrack, TrackQueryContext } from '../model/track.types';
import { queryTrackByDateRange } from '../services/trackQueryService';
import { useTrackPlaybackStore } from './trackPlaybackStore';
export type TrackQueryStatus = 'idle' | 'loading' | 'ready' | 'error';
@ -14,16 +15,18 @@ interface TrackQueryState {
queryState: TrackQueryStatus;
renderEpoch: number;
lastQueryKey: string | null;
queryContext: TrackQueryContext | null;
showPoints: boolean;
showVirtualShip: boolean;
showLabels: boolean;
showTrail: boolean;
hideLiveShips: boolean;
beginQuery: (queryKey: string) => void;
beginQuery: (queryKey: string, context?: TrackQueryContext) => void;
applyTracksSuccess: (tracks: ProcessedTrack[], queryKey?: string | null) => void;
applyQueryError: (error: string, queryKey?: string | null) => void;
closeQuery: () => void;
requery: (startTimeIso: string, endTimeIso: string) => Promise<void>;
setTracks: (tracks: ProcessedTrack[]) => void;
clearTracks: () => void;
@ -49,13 +52,14 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
queryState: 'idle',
renderEpoch: 0,
lastQueryKey: null,
queryContext: null,
showPoints: true,
showVirtualShip: true,
showLabels: true,
showTrail: true,
hideLiveShips: false,
beginQuery: (queryKey: string) => {
beginQuery: (queryKey: string, context?: TrackQueryContext) => {
useTrackPlaybackStore.getState().reset();
set((state) => ({
tracks: [],
@ -67,13 +71,13 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
renderEpoch: state.renderEpoch + 1,
lastQueryKey: queryKey,
hideLiveShips: false,
queryContext: context ?? state.queryContext,
}));
},
applyTracksSuccess: (tracks: ProcessedTrack[], queryKey?: string | null) => {
const currentQueryKey = get().lastQueryKey;
if (queryKey != null && queryKey !== currentQueryKey) {
// Ignore stale async responses from an older query.
return;
}
@ -113,7 +117,6 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
applyQueryError: (error: string, queryKey?: string | null) => {
const currentQueryKey = get().lastQueryKey;
if (queryKey != null && queryKey !== currentQueryKey) {
// Ignore stale async errors from an older query.
return;
}
@ -142,10 +145,41 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
queryState: 'idle',
renderEpoch: state.renderEpoch + 1,
lastQueryKey: null,
queryContext: null,
hideLiveShips: false,
}));
},
requery: async (startTimeIso: string, endTimeIso: string) => {
const ctx = get().queryContext;
if (!ctx) return;
const queryKey = `requery:${ctx.mmsi}:${Date.now()}`;
const updatedContext: TrackQueryContext = { ...ctx, startTimeIso, endTimeIso };
get().beginQuery(queryKey, updatedContext);
try {
const tracks = await queryTrackByDateRange({
mmsi: updatedContext.mmsi,
startTimeIso: updatedContext.startTimeIso,
endTimeIso: updatedContext.endTimeIso,
shipNameHint: updatedContext.shipNameHint,
shipKindCodeHint: updatedContext.shipKindCodeHint,
nationalCodeHint: updatedContext.nationalCodeHint,
isPermitted: updatedContext.isPermitted,
});
if (tracks.length > 0) {
get().applyTracksSuccess(tracks, queryKey);
} else {
get().applyQueryError('항적 데이터가 없습니다.', queryKey);
}
} catch (e) {
get().applyQueryError(e instanceof Error ? e.message : '항적 조회에 실패했습니다.', queryKey);
}
},
setTracks: (tracks: ProcessedTrack[]) => {
get().applyTracksSuccess(tracks, get().lastQueryKey);
},
@ -200,6 +234,7 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
queryState: 'idle',
renderEpoch: state.renderEpoch + 1,
lastQueryKey: null,
queryContext: null,
showPoints: true,
showVirtualShip: true,
showLabels: true,

파일 보기

@ -22,6 +22,7 @@ import type { ShipImageInfo } from "../../entities/shipImage/model/types";
import ShipImageModal from "../../widgets/shipImage/ShipImageModal";
import { queryTrackByMmsi } from "../../features/trackReplay/services/trackQueryService";
import { useTrackQueryStore } from "../../features/trackReplay/stores/trackQueryStore";
import type { TrackQueryContext } from "../../features/trackReplay/model/track.types";
import { GlobalTrackReplayPanel } from "../../widgets/trackReplay/GlobalTrackReplayPanel";
import { useWeatherPolling } from "../../features/weatherOverlay/useWeatherPolling";
import { useWeatherOverlay } from "../../features/weatherOverlay/useWeatherOverlay";
@ -134,11 +135,33 @@ export function DashboardPage() {
const handleRequestTrack = useCallback(async (mmsi: number, minutes: number) => {
const trackStore = useTrackQueryStore.getState();
const queryKey = `${mmsi}:${minutes}:${Date.now()}`;
trackStore.beginQuery(queryKey);
const target = targets.find((item) => item.mmsi === mmsi);
const isPermitted = legacyHits.has(mmsi);
const endDate = new Date();
const startDate = new Date(endDate.getTime() - minutes * 60_000);
const legacy = legacyHits.get(mmsi);
const context: TrackQueryContext = {
mmsi,
startTimeIso: startDate.toISOString(),
endTimeIso: endDate.toISOString(),
shipNameHint: target?.name,
shipKindCodeHint: target?.shipKindCode,
nationalCodeHint: target?.nationalCode,
isPermitted,
vesselType: legacy?.shipCode,
ownerCn: legacy?.ownerCn,
ownerRoman: legacy?.ownerRoman,
permitNo: legacy?.permitNo,
pairPermitNo: legacy?.pairPermitNo,
ton: legacy?.ton,
callSign: legacy?.callSign,
workSeaArea: legacy?.workSeaArea,
};
trackStore.beginQuery(queryKey, context);
try {
const target = targets.find((item) => item.mmsi === mmsi);
const isPermitted = legacyHits.has(mmsi);
const tracks = await queryTrackByMmsi({
mmsi,
minutes,

파일 보기

@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
import { useTrackPlaybackStore, TRACK_PLAYBACK_SPEED_OPTIONS } from '../../features/trackReplay/stores/trackPlaybackStore';
import { useTrackQueryStore } from '../../features/trackReplay/stores/trackQueryStore';
import { exportTrackCsv } from '../../features/trackReplay/lib/csvExport';
function formatDateTime(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '--';
@ -11,6 +12,38 @@ function formatDateTime(ms: number): string {
)}:${pad(date.getSeconds())}`;
}
/** ms → datetime-local input value (KST = UTC+9) */
function toDateTimeLocalKST(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '';
const kstDate = new Date(ms + 9 * 3600_000);
return kstDate.toISOString().slice(0, 16);
}
/** datetime-local value (KST) → ISO string */
function fromDateTimeLocalKST(value: string): string {
return `${value}:00+09:00`;
}
const inputStyle: React.CSSProperties = {
flex: 1,
fontSize: 11,
padding: '3px 6px',
borderRadius: 4,
border: '1px solid rgba(148,163,184,0.35)',
background: 'rgba(30,41,59,0.8)',
color: '#e2e8f0',
colorScheme: 'dark',
};
const btnBase: React.CSSProperties = {
padding: '6px 10px',
borderRadius: 6,
border: '1px solid rgba(148,163,184,0.45)',
background: 'rgba(30,41,59,0.8)',
color: '#e2e8f0',
cursor: 'pointer',
};
export function GlobalTrackReplayPanel() {
const PANEL_WIDTH = 420;
const PANEL_MARGIN = 12;
@ -50,30 +83,52 @@ export function GlobalTrackReplayPanel() {
const tracks = useTrackQueryStore((state) => state.tracks);
const isLoading = useTrackQueryStore((state) => state.isLoading);
const error = useTrackQueryStore((state) => state.error);
const queryContext = useTrackQueryStore((state) => state.queryContext);
const showPoints = useTrackQueryStore((state) => state.showPoints);
const showVirtualShip = useTrackQueryStore((state) => state.showVirtualShip);
const showLabels = useTrackQueryStore((state) => state.showLabels);
const showTrail = useTrackQueryStore((state) => state.showTrail);
const hideLiveShips = useTrackQueryStore((state) => state.hideLiveShips);
const setShowPoints = useTrackQueryStore((state) => state.setShowPoints);
const setShowVirtualShip = useTrackQueryStore((state) => state.setShowVirtualShip);
const setShowLabels = useTrackQueryStore((state) => state.setShowLabels);
const setShowTrail = useTrackQueryStore((state) => state.setShowTrail);
const setHideLiveShips = useTrackQueryStore((state) => state.setHideLiveShips);
const closeTrackQuery = useTrackQueryStore((state) => state.closeQuery);
const requery = useTrackQueryStore((state) => state.requery);
const isPlaying = useTrackPlaybackStore((state) => state.isPlaying);
const currentTime = useTrackPlaybackStore((state) => state.currentTime);
const startTime = useTrackPlaybackStore((state) => state.startTime);
const endTime = useTrackPlaybackStore((state) => state.endTime);
const playbackSpeed = useTrackPlaybackStore((state) => state.playbackSpeed);
const loop = useTrackPlaybackStore((state) => state.loop);
const play = useTrackPlaybackStore((state) => state.play);
const pause = useTrackPlaybackStore((state) => state.pause);
const stop = useTrackPlaybackStore((state) => state.stop);
const setCurrentTime = useTrackPlaybackStore((state) => state.setCurrentTime);
const setPlaybackSpeed = useTrackPlaybackStore((state) => state.setPlaybackSpeed);
const toggleLoop = useTrackPlaybackStore((state) => state.toggleLoop);
const timeSyncKey = `${startTime}:${endTime}`;
const [editState, setEditState] = useState({ start: '', end: '', syncKey: '' });
if (editState.syncKey !== timeSyncKey && startTime > 0 && endTime > 0) {
setEditState({
start: toDateTimeLocalKST(startTime),
end: toDateTimeLocalKST(endTime),
syncKey: timeSyncKey,
});
}
const editStartTime = editState.start;
const editEndTime = editState.end;
const setEditStartTime = (v: string) => setEditState((prev) => ({ ...prev, start: v }));
const setEditEndTime = (v: string) => setEditState((prev) => ({ ...prev, end: v }));
const handleRequery = useCallback(() => {
if (!editStartTime || !editEndTime) return;
requery(fromDateTimeLocalKST(editStartTime), fromDateTimeLocalKST(editEndTime));
}, [editStartTime, editEndTime, requery]);
const handleExportCsv = useCallback(() => {
if (tracks.length === 0) return;
exportTrackCsv(tracks, queryContext);
}, [tracks, queryContext]);
const progress = useMemo(() => {
if (endTime <= startTime) return 0;
@ -161,6 +216,7 @@ export function GlobalTrackReplayPanel() {
boxShadow: '0 8px 24px rgba(2,6,23,0.45)',
}}
>
{/* Header */}
<div
onPointerDown={handleHeaderPointerDown}
style={{
@ -198,23 +254,78 @@ export function GlobalTrackReplayPanel() {
{isLoading ? <div style={{ marginBottom: 8, fontSize: 12 }}> ...</div> : null}
<div style={{ fontSize: 11, color: '#93c5fd', marginBottom: 8 }}>
{tracks.length} · {formatDateTime(startTime)} ~ {formatDateTime(endTime)}
{/* Vessel count */}
<div style={{ fontSize: 11, color: '#93c5fd', marginBottom: 6 }}>
{tracks.length}
</div>
{/* Date range editing */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<label style={{ fontSize: 11, minWidth: 28, color: '#94a3b8' }}></label>
<input
type="datetime-local"
value={editStartTime}
onChange={(e) => setEditStartTime(e.target.value)}
disabled={isLoading || !queryContext}
style={inputStyle}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<label style={{ fontSize: 11, minWidth: 28, color: '#94a3b8' }}></label>
<input
type="datetime-local"
value={editEndTime}
onChange={(e) => setEditEndTime(e.target.value)}
disabled={isLoading || !queryContext}
style={inputStyle}
/>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button
type="button"
onClick={handleRequery}
disabled={isLoading || !queryContext || !editStartTime || !editEndTime}
style={{
flex: 1,
padding: '5px 8px',
fontSize: 11,
borderRadius: 6,
border: '1px solid rgba(96,165,250,0.5)',
background: 'rgba(37,99,235,0.25)',
color: '#93c5fd',
cursor: 'pointer',
}}
>
</button>
<button
type="button"
onClick={handleExportCsv}
disabled={isLoading || tracks.length === 0}
style={{
flex: 1,
padding: '5px 8px',
fontSize: 11,
borderRadius: 6,
border: '1px solid rgba(74,222,128,0.5)',
background: 'rgba(22,163,74,0.2)',
color: '#86efac',
cursor: 'pointer',
}}
>
CSV
</button>
</div>
</div>
{/* Playback controls */}
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
<button
type="button"
onClick={() => (isPlaying ? pause() : play())}
disabled={tracks.length === 0}
style={{
padding: '6px 10px',
borderRadius: 6,
border: '1px solid rgba(148,163,184,0.45)',
background: 'rgba(30,41,59,0.8)',
color: '#e2e8f0',
cursor: 'pointer',
}}
style={btnBase}
>
{isPlaying ? '일시정지' : '재생'}
</button>
@ -222,14 +333,7 @@ export function GlobalTrackReplayPanel() {
type="button"
onClick={() => stop()}
disabled={tracks.length === 0}
style={{
padding: '6px 10px',
borderRadius: 6,
border: '1px solid rgba(148,163,184,0.45)',
background: 'rgba(30,41,59,0.8)',
color: '#e2e8f0',
cursor: 'pointer',
}}
style={btnBase}
>
</button>
@ -256,6 +360,7 @@ export function GlobalTrackReplayPanel() {
</label>
</div>
{/* Timeline slider */}
<div style={{ marginBottom: 10 }}>
<input
type="range"
@ -272,18 +377,11 @@ export function GlobalTrackReplayPanel() {
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 6, fontSize: 12 }}>
{/* Display toggles */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 6, fontSize: 12 }}>
<label>
<input type="checkbox" checked={showPoints} onChange={(event) => setShowPoints(event.target.checked)} />
</label>
<label>
<input
type="checkbox"
checked={showVirtualShip}
onChange={(event) => setShowVirtualShip(event.target.checked)}
/>{' '}
</label>
<label>
<input type="checkbox" checked={showLabels} onChange={(event) => setShowLabels(event.target.checked)} />
</label>
@ -298,9 +396,6 @@ export function GlobalTrackReplayPanel() {
/>{' '}
</label>
<label>
<input type="checkbox" checked={loop} onChange={() => toggleLoop()} />
</label>
</div>
</div>
);