168 lines
6.6 KiB
TypeScript
168 lines
6.6 KiB
TypeScript
import { ZONE_META } from "../../entities/zone/model/meta";
|
|
import { SPEED_MAX, VESSEL_TYPES } from "../../entities/vessel/model/meta";
|
|
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
|
|
import { haversineNm } from "../../shared/lib/geo/haversineNm";
|
|
import { OVERLAY_RGB, rgbToHex } from "../../shared/lib/map/palette";
|
|
|
|
type Props = {
|
|
vessel: DerivedLegacyVessel;
|
|
allVessels: DerivedLegacyVessel[];
|
|
onClose: () => void;
|
|
onSelectMmsi?: (mmsi: number) => void;
|
|
};
|
|
|
|
export function VesselInfoPanel({ vessel: v, allVessels, onClose, onSelectMmsi }: Props) {
|
|
const t = VESSEL_TYPES[v.shipCode];
|
|
const ownerLabel = v.ownerCn || v.ownerRoman || v.ownerKey || "-";
|
|
const primary = t.speedProfile.filter((s) => s.primary);
|
|
const inRange = v.sog !== null && primary.length ? primary.some((s) => v.sog! >= s.range[0] && v.sog! <= s.range[1]) : false;
|
|
|
|
const pair = v.pairPermitNo ? allVessels.find((v2) => v2.permitNo === v.pairPermitNo) ?? null : null;
|
|
const pairDist = pair ? haversineNm(v.lat, v.lon, pair.lat, pair.lon) : null;
|
|
|
|
const month = new Date().getMonth();
|
|
|
|
const zone = v.zoneId ? ZONE_META[v.zoneId] : null;
|
|
const allowed = v.zoneId ? t.allowedZones.includes(v.zoneId) : null;
|
|
|
|
return (
|
|
<div className="map-info" role="dialog" aria-label="vessel info">
|
|
<button className="close-btn" onClick={onClose} aria-label="close">
|
|
✕
|
|
</button>
|
|
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
|
|
<span style={{ fontSize: 20 }}>{t.icon}</span>
|
|
<div style={{ minWidth: 0 }}>
|
|
<div style={{ fontSize: 16, fontWeight: 900, color: t.color, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
|
{v.permitNo}
|
|
</div>
|
|
<div style={{ fontSize: 10, color: "var(--muted)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
|
{v.shipCode} · {t.name} · {v.name}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="ir">
|
|
<span className="il">속도</span>
|
|
<span className="iv" style={{ color: inRange ? "#22C55E" : "var(--muted)" }}>
|
|
{v.sog !== null ? v.sog.toFixed(1) : "?"}kt {inRange ? "(조업범위)" : "(범위외)"}
|
|
</span>
|
|
</div>
|
|
<div className="ir">
|
|
<span className="il">상태</span>
|
|
<span className="iv">{v.state.label}</span>
|
|
</div>
|
|
<div className="ir">
|
|
<span className="il">수역</span>
|
|
<span className="iv" style={{ color: zone?.color ?? "var(--text)" }}>
|
|
{zone ? `${zone.label} ${allowed === null ? "" : allowed ? "✓허가" : "⚠이탈"}` : "-"}
|
|
</span>
|
|
</div>
|
|
<div className="ir">
|
|
<span className="il">위치</span>
|
|
<span className="iv">
|
|
{v.lat.toFixed(3)}°N {v.lon.toFixed(3)}°E
|
|
</span>
|
|
</div>
|
|
<div className="ir">
|
|
<span className="il">MMSI</span>
|
|
<span className="iv">{v.mmsi}</span>
|
|
</div>
|
|
<div className="ir">
|
|
<span className="il">CallSign</span>
|
|
<span className="iv">{v.callsign || "-"}</span>
|
|
</div>
|
|
<div className="ir">
|
|
<span className="il">Msg TS</span>
|
|
<span className="iv">{v.messageTimestamp || "-"}</span>
|
|
</div>
|
|
<div className="ir">
|
|
<span className="il">소유주</span>
|
|
<span className="iv" style={{ maxWidth: 160, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }} title={ownerLabel}>
|
|
{ownerLabel}
|
|
</span>
|
|
</div>
|
|
|
|
{pair && pairDist !== null ? (
|
|
<>
|
|
<div className="ir">
|
|
<span className="il">쌍 선박</span>
|
|
<span className="iv" style={{ color: VESSEL_TYPES[pair.shipCode].color, cursor: onSelectMmsi ? "pointer" : undefined }} onClick={() => onSelectMmsi?.(pair.mmsi)}>
|
|
{pair.permitNo} ({pair.shipCode})
|
|
</span>
|
|
</div>
|
|
<div className="ir">
|
|
<span className="il">쌍 이격</span>
|
|
<span className="iv" style={{ color: pairDist > 3 ? rgbToHex(OVERLAY_RGB.pairWarn) : "#22C55E" }}>
|
|
{pairDist.toFixed(2)}NM {pairDist > 3 ? "⚠" : "✓"}
|
|
</span>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
|
|
<div style={{ marginTop: 8 }}>
|
|
<div style={{ fontSize: 8, fontWeight: 700, color: "var(--muted)", marginBottom: 2 }}>속도 vs 조업범위</div>
|
|
<div style={{ position: "relative", height: 14, background: "var(--bg)", borderRadius: 3, overflow: "visible" }}>
|
|
{t.speedProfile.map((s) => {
|
|
const left = (s.range[0] / SPEED_MAX) * 100;
|
|
const width = ((s.range[1] - s.range[0]) / SPEED_MAX) * 100;
|
|
return (
|
|
<div
|
|
key={`${s.label}-${s.range[0]}`}
|
|
style={{
|
|
position: "absolute",
|
|
top: s.primary ? 0 : 2,
|
|
height: s.primary ? 14 : 8,
|
|
left: `${left}%`,
|
|
width: `${width}%`,
|
|
background: s.color,
|
|
borderRadius: 2,
|
|
opacity: s.primary ? 0.9 : 0.4,
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
top: -2,
|
|
left: `${Math.min(((v.sog ?? 0) / SPEED_MAX) * 100, 100)}%`,
|
|
width: 2,
|
|
height: 18,
|
|
background: "#FFF",
|
|
borderRadius: 1,
|
|
boxShadow: "0 0 4px #FFF",
|
|
}}
|
|
/>
|
|
</div>
|
|
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 1 }}>
|
|
{[0, 5, 10, 15].map((k) => (
|
|
<span key={k} style={{ fontSize: 6, color: "rgba(255,255,255,.2)" }}>
|
|
{k}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ marginTop: 6 }}>
|
|
<div style={{ fontSize: 8, fontWeight: 700, color: "var(--muted)", marginBottom: 2 }}>월별 강도</div>
|
|
<div className="month-row">
|
|
{t.monthlyIntensity.map((val, i) => {
|
|
const cur = i === month;
|
|
const bg = val === 0 ? "#EF444433" : `${t.color}${Math.round(val * 200).toString(16).padStart(2, "0")}`;
|
|
const border = cur ? "1.5px solid #FFF" : "none";
|
|
const color = val === 0 ? "#EF4444" : cur ? "#FFF" : "transparent";
|
|
const text = val === 0 ? "✗" : cur ? "◉" : "";
|
|
return (
|
|
<div key={i} className="month-cell" style={{ background: bg, border, color }}>
|
|
{text}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|