feat(dashboard): alarms filter + legend/palette sync + map polish
This commit is contained in:
부모
30e6e584ee
커밋
3497b8c7e2
@ -2,9 +2,9 @@
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>906척 실시간 조업 감시 — 선단 연관관계</title>
|
||||
<title>WING 조업감시 데모</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
21
apps/web/public/favicon.svg
Normal file
21
apps/web/public/favicon.svg
Normal file
@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="WING">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="20" y1="20" x2="108" y2="108" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#60A5FA" />
|
||||
<stop offset="1" stop-color="#2563EB" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<circle cx="64" cy="64" r="58" fill="#0F172A" stroke="#1E3A5F" stroke-width="8" />
|
||||
|
||||
<!-- Stylized "W" mark -->
|
||||
<path
|
||||
d="M28 38 L44 92 L64 54 L84 92 L100 38"
|
||||
fill="none"
|
||||
stroke="url(#g)"
|
||||
stroke-width="14"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | 크기: 640 B |
@ -465,6 +465,75 @@ body {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Alarm filter (dropdown) */
|
||||
.alarm-filter {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.alarm-filter__summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(30, 41, 59, 0.55);
|
||||
color: var(--text);
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.alarm-filter__summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.alarm-filter__menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 22px;
|
||||
z-index: 2000;
|
||||
min-width: 170px;
|
||||
padding: 6px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(15, 23, 42, 0.98);
|
||||
box-shadow: 0 16px 50px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.alarm-filter__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
color: var(--text);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.alarm-filter__row:hover {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
}
|
||||
|
||||
.alarm-filter__row input {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.alarm-filter__cnt {
|
||||
margin-left: auto;
|
||||
font-size: 9px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.alarm-filter__sep {
|
||||
height: 1px;
|
||||
background: rgba(30, 58, 95, 0.85);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* Relation panel */
|
||||
.rel-panel {
|
||||
background: var(--card);
|
||||
|
||||
@ -301,14 +301,33 @@ export function computeLegacyAlarms(args: {
|
||||
});
|
||||
}
|
||||
|
||||
// Most recent first by timeLabel (approx), then by severity.
|
||||
const sevScore = (s: "cr" | "hi") => (s === "cr" ? 2 : 1);
|
||||
// Fixed category priority (independent of severity):
|
||||
// 1) 수역 이탈 2) 쌍 이격 경고 3) 환적 의심 4) 휴어기 조업 의심 5) AIS 지연
|
||||
// Within each category: most recent first (smaller N in "-N분" is more recent).
|
||||
const kindPriority: Record<LegacyAlarm["kind"], number> = {
|
||||
zone_violation: 0,
|
||||
pair_separation: 1,
|
||||
transshipment: 2,
|
||||
closed_season: 3,
|
||||
ais_stale: 4,
|
||||
};
|
||||
|
||||
const parseAgeMin = (label: string) => {
|
||||
if (label === "방금") return 0;
|
||||
const m = /-(\\d+)분/.exec(label);
|
||||
if (m) return Number(m[1]);
|
||||
return Number.POSITIVE_INFINITY;
|
||||
};
|
||||
|
||||
alarms.sort((a, b) => {
|
||||
const av = sevScore(b.severity) - sevScore(a.severity);
|
||||
if (av !== 0) return av;
|
||||
const at = Number(a.timeLabel.replace(/[^0-9]/g, "")) || 0;
|
||||
const bt = Number(b.timeLabel.replace(/[^0-9]/g, "")) || 0;
|
||||
return at - bt;
|
||||
const ak = kindPriority[a.kind] ?? 999;
|
||||
const bk = kindPriority[b.kind] ?? 999;
|
||||
if (ak !== bk) return ak - bk;
|
||||
const am = parseAgeMin(a.timeLabel);
|
||||
const bm = parseAgeMin(b.timeLabel);
|
||||
if (am !== bm) return am - bm;
|
||||
// Stable tie-break.
|
||||
return a.text.localeCompare(b.text);
|
||||
});
|
||||
|
||||
return alarms;
|
||||
|
||||
@ -72,3 +72,19 @@ export type LegacyAlarm = {
|
||||
text: string;
|
||||
relatedMmsi: number[];
|
||||
};
|
||||
|
||||
export const LEGACY_ALARM_KINDS: LegacyAlarmKind[] = [
|
||||
"pair_separation",
|
||||
"transshipment",
|
||||
"closed_season",
|
||||
"ais_stale",
|
||||
"zone_violation",
|
||||
];
|
||||
|
||||
export const LEGACY_ALARM_KIND_LABEL: Record<LegacyAlarmKind, string> = {
|
||||
pair_separation: "쌍 이격 경고",
|
||||
transshipment: "환적 의심",
|
||||
closed_season: "휴어기 조업 의심",
|
||||
ais_stale: "AIS 지연",
|
||||
zone_violation: "수역 이탈",
|
||||
};
|
||||
|
||||
@ -4,7 +4,8 @@ import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettings
|
||||
import type { MapToggleState } from "../../features/mapToggles/MapToggles";
|
||||
import { MapToggles } from "../../features/mapToggles/MapToggles";
|
||||
import { TypeFilterGrid } from "../../features/typeFilter/TypeFilterGrid";
|
||||
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
|
||||
import type { DerivedLegacyVessel, LegacyAlarmKind } from "../../features/legacyDashboard/model/types";
|
||||
import { LEGACY_ALARM_KIND_LABEL, LEGACY_ALARM_KINDS } from "../../features/legacyDashboard/model/types";
|
||||
import { useLegacyVessels } from "../../entities/legacyVessel/api/useLegacyVessels";
|
||||
import { buildLegacyVesselIndex } from "../../entities/legacyVessel/lib";
|
||||
import type { LegacyVesselIndex } from "../../entities/legacyVessel/lib";
|
||||
@ -117,6 +118,10 @@ export function DashboardPage() {
|
||||
});
|
||||
const [fleetRelationSortMode, setFleetRelationSortMode] = useState<FleetRelationSortMode>("count");
|
||||
|
||||
const [alarmKindEnabled, setAlarmKindEnabled] = useState<Record<LegacyAlarmKind, boolean>>(() => {
|
||||
return Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record<LegacyAlarmKind, boolean>;
|
||||
});
|
||||
|
||||
const [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined);
|
||||
|
||||
const [settings, setSettings] = useState<Map3DSettings>({
|
||||
@ -183,6 +188,26 @@ export function DashboardPage() {
|
||||
const fcLinksAll = useMemo(() => computeFcLinks(legacyVesselsAll), [legacyVesselsAll]);
|
||||
const alarms = useMemo(() => computeLegacyAlarms({ vessels: legacyVesselsAll, pairLinks: pairLinksAll, fcLinks: fcLinksAll }), [legacyVesselsAll, pairLinksAll, fcLinksAll]);
|
||||
|
||||
const alarmKindCounts = useMemo(() => {
|
||||
const base = Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, 0])) as Record<LegacyAlarmKind, number>;
|
||||
for (const a of alarms) {
|
||||
base[a.kind] = (base[a.kind] ?? 0) + 1;
|
||||
}
|
||||
return base;
|
||||
}, [alarms]);
|
||||
|
||||
const enabledAlarmKinds = useMemo(() => {
|
||||
return LEGACY_ALARM_KINDS.filter((k) => alarmKindEnabled[k]);
|
||||
}, [alarmKindEnabled]);
|
||||
|
||||
const allAlarmKindsEnabled = enabledAlarmKinds.length === LEGACY_ALARM_KINDS.length;
|
||||
|
||||
const filteredAlarms = useMemo(() => {
|
||||
if (allAlarmKindsEnabled) return alarms;
|
||||
const enabled = new Set(enabledAlarmKinds);
|
||||
return alarms.filter((a) => enabled.has(a.kind));
|
||||
}, [alarms, enabledAlarmKinds, allAlarmKindsEnabled]);
|
||||
|
||||
const pairLinksForMap = useMemo(() => computePairLinks(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
||||
const fcLinksForMap = useMemo(() => computeFcLinks(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
||||
const fleetCirclesForMap = useMemo(() => computeFleetCircles(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
||||
@ -253,6 +278,8 @@ export function DashboardPage() {
|
||||
|
||||
const enabledTypes = useMemo(() => VESSEL_TYPE_ORDER.filter((c) => typeEnabled[c]), [typeEnabled]);
|
||||
const speedPanelType = (selectedLegacyVessel?.shipCode ?? (enabledTypes.length === 1 ? enabledTypes[0] : "PT")) as VesselTypeCode;
|
||||
const enabledAlarmKindCount = enabledAlarmKinds.length;
|
||||
const alarmFilterSummary = allAlarmKindsEnabled ? "전체" : `${enabledAlarmKindCount}/${LEGACY_ALARM_KINDS.length}`;
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
@ -339,7 +366,7 @@ export function DashboardPage() {
|
||||
지구본
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: "var(--muted)" }}>지도 우하단 Attribution(라이센스) 표기 유지</div>
|
||||
{/* Attribution (license) stays visible in the map UI; no need to repeat it here. */}
|
||||
</div>
|
||||
|
||||
<div className="sb">
|
||||
@ -421,9 +448,69 @@ export function DashboardPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sb" style={{ maxHeight: 130, overflowY: "auto" }}>
|
||||
<div className="sb-t">실시간 경고</div>
|
||||
<AlarmsPanel alarms={alarms} onSelectMmsi={setSelectedMmsi} />
|
||||
<div className="sb" style={{ maxHeight: 130, display: "flex", flexDirection: "column", overflow: "visible" }}>
|
||||
<div className="sb-t sb-t-row" style={{ marginBottom: 6 }}>
|
||||
<div>
|
||||
실시간 경고{" "}
|
||||
<span style={{ color: "var(--accent)", fontSize: 8 }}>
|
||||
({filteredAlarms.length}/{alarms.length})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{LEGACY_ALARM_KINDS.length <= 3 ? (
|
||||
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||||
{LEGACY_ALARM_KINDS.map((k) => (
|
||||
<label key={k} style={{ display: "inline-flex", gap: 4, alignItems: "center", cursor: "pointer", userSelect: "none" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!alarmKindEnabled[k]}
|
||||
onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))}
|
||||
/>
|
||||
<span style={{ fontSize: 8, color: "var(--muted)", whiteSpace: "nowrap" }}>{LEGACY_ALARM_KIND_LABEL[k]}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<details className="alarm-filter">
|
||||
<summary className="alarm-filter__summary" title="경고 종류 필터">
|
||||
{alarmFilterSummary}
|
||||
</summary>
|
||||
<div className="alarm-filter__menu" role="menu" aria-label="alarm kind filter">
|
||||
<label className="alarm-filter__row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allAlarmKindsEnabled}
|
||||
onChange={() =>
|
||||
setAlarmKindEnabled((prev) => {
|
||||
const allOn = LEGACY_ALARM_KINDS.every((k) => prev[k]);
|
||||
const nextVal = allOn ? false : true;
|
||||
return Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, nextVal])) as Record<LegacyAlarmKind, boolean>;
|
||||
})
|
||||
}
|
||||
/>
|
||||
전체
|
||||
<span className="alarm-filter__cnt">{alarms.length}</span>
|
||||
</label>
|
||||
<div className="alarm-filter__sep" />
|
||||
{LEGACY_ALARM_KINDS.map((k) => (
|
||||
<label key={k} className="alarm-filter__row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!alarmKindEnabled[k]}
|
||||
onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))}
|
||||
/>
|
||||
{LEGACY_ALARM_KIND_LABEL[k]}
|
||||
<span className="alarm-filter__cnt">{alarmKindCounts[k] ?? 0}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ overflowY: "auto", minHeight: 0, flex: 1 }}>
|
||||
<AlarmsPanel alarms={filteredAlarms} onSelectMmsi={setSelectedMmsi} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{adminMode ? (
|
||||
|
||||
58
apps/web/src/shared/lib/map/palette.ts
Normal file
58
apps/web/src/shared/lib/map/palette.ts
Normal file
@ -0,0 +1,58 @@
|
||||
export type Rgb = [number, number, number];
|
||||
export type DeckRgba = [number, number, number, number];
|
||||
|
||||
export function rgbToHex(rgb: Rgb): string {
|
||||
const toHex = (v: number) => {
|
||||
const clamped = Math.max(0, Math.min(255, Math.round(v)));
|
||||
return clamped.toString(16).padStart(2, "0");
|
||||
};
|
||||
return `#${toHex(rgb[0])}${toHex(rgb[1])}${toHex(rgb[2])}`;
|
||||
}
|
||||
|
||||
export function rgba(rgb: Rgb, alpha01: number): string {
|
||||
const a = Math.max(0, Math.min(1, alpha01));
|
||||
return `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`;
|
||||
}
|
||||
|
||||
export function deckRgba(rgb: Rgb, alpha255: number): DeckRgba {
|
||||
const a = Math.max(0, Math.min(255, Math.round(alpha255)));
|
||||
return [rgb[0], rgb[1], rgb[2], a];
|
||||
}
|
||||
|
||||
// CN-permit (legacy) ship category colors. Used by both map layers and legend.
|
||||
export const LEGACY_CODE_COLORS_RGB: Record<string, Rgb> = {
|
||||
PT: [30, 64, 175], // #1e40af
|
||||
"PT-S": [234, 88, 12], // #ea580c
|
||||
GN: [16, 185, 129], // #10b981
|
||||
OT: [139, 92, 246], // #8b5cf6
|
||||
PS: [239, 68, 68], // #ef4444
|
||||
FC: [245, 158, 11], // #f59e0b
|
||||
C21: [236, 72, 153], // #ec4899
|
||||
};
|
||||
|
||||
export const LEGACY_CODE_COLORS_HEX: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(LEGACY_CODE_COLORS_RGB).map(([k, rgb]) => [k, rgbToHex(rgb)]),
|
||||
) as Record<string, string>;
|
||||
|
||||
// Non-target AIS ships should be visible but muted (speed encoded mainly via brightness).
|
||||
export const OTHER_AIS_SPEED_RGB = {
|
||||
fast: [148, 163, 184] as Rgb, // SOG >= 10
|
||||
moving: [100, 116, 139] as Rgb, // 1 <= SOG < 10
|
||||
stopped: [71, 85, 105] as Rgb, // SOG < 1
|
||||
};
|
||||
|
||||
export const OTHER_AIS_SPEED_HEX = {
|
||||
fast: rgbToHex(OTHER_AIS_SPEED_RGB.fast),
|
||||
moving: rgbToHex(OTHER_AIS_SPEED_RGB.moving),
|
||||
stopped: rgbToHex(OTHER_AIS_SPEED_RGB.stopped),
|
||||
};
|
||||
|
||||
// Overlay palette: keep a cohesive "warm alert" family, but ensure each overlay type is distinguishable.
|
||||
export const OVERLAY_RGB = {
|
||||
pairNormal: [59, 130, 246] as Rgb, // blue-500
|
||||
pairWarn: [251, 113, 133] as Rgb, // rose-400 (쌍 이격 경고)
|
||||
fcTransfer: [249, 115, 22] as Rgb, // orange-500 (환적 연결)
|
||||
fleetRange: [250, 204, 21] as Rgb, // yellow-400 (선단 범위)
|
||||
suspicious: [239, 68, 68] as Rgb, // red-500
|
||||
};
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
import type { LegacyAlarm } from "../../features/legacyDashboard/model/types";
|
||||
|
||||
export function AlarmsPanel({ alarms, onSelectMmsi }: { alarms: LegacyAlarm[]; onSelectMmsi?: (mmsi: number) => void }) {
|
||||
const shown = alarms.slice(0, 6);
|
||||
if (alarms.length === 0) {
|
||||
return <div style={{ fontSize: 11, color: "var(--muted)" }}>(현재 경고 없음)</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{shown.map((a, idx) => (
|
||||
{alarms.map((a, idx) => (
|
||||
<div
|
||||
key={`${a.kind}-${idx}`}
|
||||
key={`${a.kind}-${a.relatedMmsi.join("-")}-${a.timeLabel}-${idx}`}
|
||||
className={`ai ${a.severity}`}
|
||||
onClick={() => {
|
||||
if (!onSelectMmsi) return;
|
||||
|
||||
@ -2,6 +2,7 @@ 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;
|
||||
@ -93,7 +94,7 @@ export function VesselInfoPanel({ vessel: v, allVessels, onClose, onSelectMmsi }
|
||||
</div>
|
||||
<div className="ir">
|
||||
<span className="il">쌍 이격</span>
|
||||
<span className="iv" style={{ color: pairDist > 3 ? "#F59E0B" : "#22C55E" }}>
|
||||
<span className="iv" style={{ color: pairDist > 3 ? rgbToHex(OVERLAY_RGB.pairWarn) : "#22C55E" }}>
|
||||
{pairDist.toFixed(2)}NM {pairDist > 3 ? "⚠" : "✓"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { ZONE_IDS, ZONE_META } from "../../entities/zone/model/meta";
|
||||
import { LEGACY_CODE_COLORS_HEX, OTHER_AIS_SPEED_HEX, OVERLAY_RGB, rgbToHex, rgba } from "../../shared/lib/map/palette";
|
||||
|
||||
export function MapLegend() {
|
||||
return (
|
||||
@ -12,48 +13,56 @@ export function MapLegend() {
|
||||
))}
|
||||
|
||||
<div className="lt" style={{ marginTop: 8 }}>
|
||||
AIS 선박(속도)
|
||||
기타 AIS 선박(속도)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: "#3B82F6", borderRadius: 999 }} />
|
||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.fast, borderRadius: 999 }} />
|
||||
SOG ≥ 10 kt
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: "#22C55E", borderRadius: 999 }} />
|
||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, borderRadius: 999 }} />
|
||||
1 ≤ SOG < 10 kt
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: "#64748B", borderRadius: 999 }} />
|
||||
SOG < 1 kt (or unknown)
|
||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.stopped, borderRadius: 999 }} />
|
||||
SOG < 1 kt
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, opacity: 0.55, borderRadius: 999 }} />
|
||||
SOG unknown
|
||||
</div>
|
||||
|
||||
<div className="lt" style={{ marginTop: 8 }}>
|
||||
CN Permit(업종)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: "#1E40AF", borderRadius: 999 }} />
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.PT, borderRadius: 999 }} />
|
||||
PT 본선 (ring + 색상)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: "#EA580C", borderRadius: 999 }} />
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX["PT-S"], borderRadius: 999 }} />
|
||||
PT-S 부속선
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: "#10B981", borderRadius: 999 }} />
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.GN, borderRadius: 999 }} />
|
||||
GN 유망
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: "#8B5CF6", borderRadius: 999 }} />
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.OT, borderRadius: 999 }} />
|
||||
OT 1척식
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: "#EF4444", borderRadius: 999 }} />
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.PS, borderRadius: 999 }} />
|
||||
PS 위망
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: "#F59E0B", borderRadius: 999 }} />
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.FC, borderRadius: 999 }} />
|
||||
FC 운반선
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.C21, borderRadius: 999 }} />
|
||||
C21
|
||||
</div>
|
||||
|
||||
<div className="lt" style={{ marginTop: 8 }}>
|
||||
밀도(3D)
|
||||
@ -66,25 +75,29 @@ export function MapLegend() {
|
||||
연결선
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 20, height: 2, background: "rgba(59,130,246,.35)", borderRadius: 1 }} />
|
||||
<div style={{ width: 20, height: 2, background: rgba(OVERLAY_RGB.pairNormal, 0.35), borderRadius: 1 }} />
|
||||
PT↔PT-S 쌍 (정상)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 14, height: 14, borderRadius: "50%", border: "1px solid rgba(59,130,246,.6)" }} />
|
||||
<div style={{ width: 14, height: 14, borderRadius: "50%", border: `1px solid ${rgba(OVERLAY_RGB.pairNormal, 0.6)}` }} />
|
||||
쌍 연결범위
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 20, height: 2, background: "#F59E0B", borderRadius: 1 }} />
|
||||
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.pairWarn), borderRadius: 1 }} />
|
||||
쌍 이격 경고 (>3NM)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 20, height: 2, background: "#D97706", borderRadius: 1 }} />
|
||||
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.fcTransfer), borderRadius: 1 }} />
|
||||
FC 환적 연결 (dashed)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 14, height: 14, borderRadius: "50%", border: "1px solid rgba(245,158,11,.55)", opacity: 0.7 }} />
|
||||
<div style={{ width: 14, height: 14, borderRadius: "50%", border: `1px solid ${rgba(OVERLAY_RGB.fleetRange, 0.75)}`, opacity: 0.8 }} />
|
||||
선단 범위
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.suspicious), borderRadius: 1 }} />
|
||||
FC 환적 연결 (의심)
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -2,6 +2,7 @@ import { useMemo, type MouseEvent } from "react";
|
||||
import { 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, rgba } from "../../shared/lib/map/palette";
|
||||
|
||||
type FleetSortMode = "count" | "range";
|
||||
|
||||
@ -161,8 +162,8 @@ export function RelationsPanel({
|
||||
<span
|
||||
className="rel-dist"
|
||||
style={{
|
||||
background: warn ? "#F59E0B22" : "#22C55E22",
|
||||
color: warn ? "#F59E0B" : "#22C55E",
|
||||
background: warn ? rgba(OVERLAY_RGB.pairWarn, 0.13) : "#22C55E22",
|
||||
color: warn ? rgbToHex(OVERLAY_RGB.pairWarn) : "#22C55E",
|
||||
}}
|
||||
>
|
||||
{dist.toFixed(2)}NM
|
||||
@ -197,7 +198,7 @@ export function RelationsPanel({
|
||||
>
|
||||
{fc.permitNo}
|
||||
</span>
|
||||
<span className="rel-dist" style={{ background: "#D9770622", color: "#D97706" }}>
|
||||
<span className="rel-dist" style={{ background: rgba(OVERLAY_RGB.fcTransfer, 0.13), color: rgbToHex(OVERLAY_RGB.fcTransfer) }}>
|
||||
{dist.toFixed(1)}NM
|
||||
</span>
|
||||
{isSameOwner ? (
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user