feat(dashboard): alarms filter + legend/palette sync + map polish
This commit is contained in:
부모
30e6e584ee
커밋
3497b8c7e2
@ -2,9 +2,9 @@
|
|||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>906척 실시간 조업 감시 — 선단 연관관계</title>
|
<title>WING 조업감시 데모</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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;
|
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 */
|
/* Relation panel */
|
||||||
.rel-panel {
|
.rel-panel {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
|
|||||||
@ -301,14 +301,33 @@ export function computeLegacyAlarms(args: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Most recent first by timeLabel (approx), then by severity.
|
// Fixed category priority (independent of severity):
|
||||||
const sevScore = (s: "cr" | "hi") => (s === "cr" ? 2 : 1);
|
// 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) => {
|
alarms.sort((a, b) => {
|
||||||
const av = sevScore(b.severity) - sevScore(a.severity);
|
const ak = kindPriority[a.kind] ?? 999;
|
||||||
if (av !== 0) return av;
|
const bk = kindPriority[b.kind] ?? 999;
|
||||||
const at = Number(a.timeLabel.replace(/[^0-9]/g, "")) || 0;
|
if (ak !== bk) return ak - bk;
|
||||||
const bt = Number(b.timeLabel.replace(/[^0-9]/g, "")) || 0;
|
const am = parseAgeMin(a.timeLabel);
|
||||||
return at - bt;
|
const bm = parseAgeMin(b.timeLabel);
|
||||||
|
if (am !== bm) return am - bm;
|
||||||
|
// Stable tie-break.
|
||||||
|
return a.text.localeCompare(b.text);
|
||||||
});
|
});
|
||||||
|
|
||||||
return alarms;
|
return alarms;
|
||||||
|
|||||||
@ -72,3 +72,19 @@ export type LegacyAlarm = {
|
|||||||
text: string;
|
text: string;
|
||||||
relatedMmsi: number[];
|
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 type { MapToggleState } from "../../features/mapToggles/MapToggles";
|
||||||
import { MapToggles } from "../../features/mapToggles/MapToggles";
|
import { MapToggles } from "../../features/mapToggles/MapToggles";
|
||||||
import { TypeFilterGrid } from "../../features/typeFilter/TypeFilterGrid";
|
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 { useLegacyVessels } from "../../entities/legacyVessel/api/useLegacyVessels";
|
||||||
import { buildLegacyVesselIndex } from "../../entities/legacyVessel/lib";
|
import { buildLegacyVesselIndex } from "../../entities/legacyVessel/lib";
|
||||||
import type { LegacyVesselIndex } 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 [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 [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined);
|
||||||
|
|
||||||
const [settings, setSettings] = useState<Map3DSettings>({
|
const [settings, setSettings] = useState<Map3DSettings>({
|
||||||
@ -183,6 +188,26 @@ export function DashboardPage() {
|
|||||||
const fcLinksAll = useMemo(() => computeFcLinks(legacyVesselsAll), [legacyVesselsAll]);
|
const fcLinksAll = useMemo(() => computeFcLinks(legacyVesselsAll), [legacyVesselsAll]);
|
||||||
const alarms = useMemo(() => computeLegacyAlarms({ vessels: legacyVesselsAll, pairLinks: pairLinksAll, fcLinks: fcLinksAll }), [legacyVesselsAll, pairLinksAll, fcLinksAll]);
|
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 pairLinksForMap = useMemo(() => computePairLinks(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
||||||
const fcLinksForMap = useMemo(() => computeFcLinks(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
const fcLinksForMap = useMemo(() => computeFcLinks(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
||||||
const fleetCirclesForMap = useMemo(() => computeFleetCircles(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 enabledTypes = useMemo(() => VESSEL_TYPE_ORDER.filter((c) => typeEnabled[c]), [typeEnabled]);
|
||||||
const speedPanelType = (selectedLegacyVessel?.shipCode ?? (enabledTypes.length === 1 ? enabledTypes[0] : "PT")) as VesselTypeCode;
|
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 (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
@ -339,7 +366,7 @@ export function DashboardPage() {
|
|||||||
지구본
|
지구본
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div className="sb">
|
<div className="sb">
|
||||||
@ -421,9 +448,69 @@ export function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sb" style={{ maxHeight: 130, overflowY: "auto" }}>
|
<div className="sb" style={{ maxHeight: 130, display: "flex", flexDirection: "column", overflow: "visible" }}>
|
||||||
<div className="sb-t">실시간 경고</div>
|
<div className="sb-t sb-t-row" style={{ marginBottom: 6 }}>
|
||||||
<AlarmsPanel alarms={alarms} onSelectMmsi={setSelectedMmsi} />
|
<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>
|
</div>
|
||||||
|
|
||||||
{adminMode ? (
|
{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";
|
import type { LegacyAlarm } from "../../features/legacyDashboard/model/types";
|
||||||
|
|
||||||
export function AlarmsPanel({ alarms, onSelectMmsi }: { alarms: LegacyAlarm[]; onSelectMmsi?: (mmsi: number) => void }) {
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{shown.map((a, idx) => (
|
{alarms.map((a, idx) => (
|
||||||
<div
|
<div
|
||||||
key={`${a.kind}-${idx}`}
|
key={`${a.kind}-${a.relatedMmsi.join("-")}-${a.timeLabel}-${idx}`}
|
||||||
className={`ai ${a.severity}`}
|
className={`ai ${a.severity}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!onSelectMmsi) return;
|
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 { SPEED_MAX, VESSEL_TYPES } from "../../entities/vessel/model/meta";
|
||||||
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
|
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
|
||||||
import { haversineNm } from "../../shared/lib/geo/haversineNm";
|
import { haversineNm } from "../../shared/lib/geo/haversineNm";
|
||||||
|
import { OVERLAY_RGB, rgbToHex } from "../../shared/lib/map/palette";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
vessel: DerivedLegacyVessel;
|
vessel: DerivedLegacyVessel;
|
||||||
@ -93,7 +94,7 @@ export function VesselInfoPanel({ vessel: v, allVessels, onClose, onSelectMmsi }
|
|||||||
</div>
|
</div>
|
||||||
<div className="ir">
|
<div className="ir">
|
||||||
<span className="il">쌍 이격</span>
|
<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 ? "⚠" : "✓"}
|
{pairDist.toFixed(2)}NM {pairDist > 3 ? "⚠" : "✓"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { ZONE_IDS, ZONE_META } from "../../entities/zone/model/meta";
|
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() {
|
export function MapLegend() {
|
||||||
return (
|
return (
|
||||||
@ -12,48 +13,56 @@ export function MapLegend() {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="lt" style={{ marginTop: 8 }}>
|
<div className="lt" style={{ marginTop: 8 }}>
|
||||||
AIS 선박(속도)
|
기타 AIS 선박(속도)
|
||||||
</div>
|
</div>
|
||||||
<div className="li">
|
<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
|
SOG ≥ 10 kt
|
||||||
</div>
|
</div>
|
||||||
<div className="li">
|
<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
|
1 ≤ SOG < 10 kt
|
||||||
</div>
|
</div>
|
||||||
<div className="li">
|
<div className="li">
|
||||||
<div className="ls" style={{ background: "#64748B", borderRadius: 999 }} />
|
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.stopped, borderRadius: 999 }} />
|
||||||
SOG < 1 kt (or unknown)
|
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>
|
||||||
|
|
||||||
<div className="lt" style={{ marginTop: 8 }}>
|
<div className="lt" style={{ marginTop: 8 }}>
|
||||||
CN Permit(업종)
|
CN Permit(업종)
|
||||||
</div>
|
</div>
|
||||||
<div className="li">
|
<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 + 색상)
|
PT 본선 (ring + 색상)
|
||||||
</div>
|
</div>
|
||||||
<div className="li">
|
<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 부속선
|
PT-S 부속선
|
||||||
</div>
|
</div>
|
||||||
<div className="li">
|
<div className="li">
|
||||||
<div className="ls" style={{ background: "#10B981", borderRadius: 999 }} />
|
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.GN, borderRadius: 999 }} />
|
||||||
GN 유망
|
GN 유망
|
||||||
</div>
|
</div>
|
||||||
<div className="li">
|
<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척식
|
OT 1척식
|
||||||
</div>
|
</div>
|
||||||
<div className="li">
|
<div className="li">
|
||||||
<div className="ls" style={{ background: "#EF4444", borderRadius: 999 }} />
|
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.PS, borderRadius: 999 }} />
|
||||||
PS 위망
|
PS 위망
|
||||||
</div>
|
</div>
|
||||||
<div className="li">
|
<div className="li">
|
||||||
<div className="ls" style={{ background: "#F59E0B", borderRadius: 999 }} />
|
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.FC, borderRadius: 999 }} />
|
||||||
FC 운반선
|
FC 운반선
|
||||||
</div>
|
</div>
|
||||||
|
<div className="li">
|
||||||
|
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.C21, borderRadius: 999 }} />
|
||||||
|
C21
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="lt" style={{ marginTop: 8 }}>
|
<div className="lt" style={{ marginTop: 8 }}>
|
||||||
밀도(3D)
|
밀도(3D)
|
||||||
@ -66,25 +75,29 @@ export function MapLegend() {
|
|||||||
연결선
|
연결선
|
||||||
</div>
|
</div>
|
||||||
<div className="li">
|
<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 쌍 (정상)
|
PT↔PT-S 쌍 (정상)
|
||||||
</div>
|
</div>
|
||||||
<div className="li">
|
<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>
|
||||||
<div className="li">
|
<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)
|
쌍 이격 경고 (>3NM)
|
||||||
</div>
|
</div>
|
||||||
<div className="li">
|
<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)
|
FC 환적 연결 (dashed)
|
||||||
</div>
|
</div>
|
||||||
<div className="li">
|
<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>
|
||||||
|
<div className="li">
|
||||||
|
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.suspicious), borderRadius: 1 }} />
|
||||||
|
FC 환적 연결 (의심)
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -2,6 +2,7 @@ import { useMemo, type MouseEvent } from "react";
|
|||||||
import { VESSEL_TYPES } from "../../entities/vessel/model/meta";
|
import { VESSEL_TYPES } from "../../entities/vessel/model/meta";
|
||||||
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
|
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
|
||||||
import { haversineNm } from "../../shared/lib/geo/haversineNm";
|
import { haversineNm } from "../../shared/lib/geo/haversineNm";
|
||||||
|
import { OVERLAY_RGB, rgbToHex, rgba } from "../../shared/lib/map/palette";
|
||||||
|
|
||||||
type FleetSortMode = "count" | "range";
|
type FleetSortMode = "count" | "range";
|
||||||
|
|
||||||
@ -161,8 +162,8 @@ export function RelationsPanel({
|
|||||||
<span
|
<span
|
||||||
className="rel-dist"
|
className="rel-dist"
|
||||||
style={{
|
style={{
|
||||||
background: warn ? "#F59E0B22" : "#22C55E22",
|
background: warn ? rgba(OVERLAY_RGB.pairWarn, 0.13) : "#22C55E22",
|
||||||
color: warn ? "#F59E0B" : "#22C55E",
|
color: warn ? rgbToHex(OVERLAY_RGB.pairWarn) : "#22C55E",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{dist.toFixed(2)}NM
|
{dist.toFixed(2)}NM
|
||||||
@ -197,7 +198,7 @@ export function RelationsPanel({
|
|||||||
>
|
>
|
||||||
{fc.permitNo}
|
{fc.permitNo}
|
||||||
</span>
|
</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
|
{dist.toFixed(1)}NM
|
||||||
</span>
|
</span>
|
||||||
{isSameOwner ? (
|
{isSameOwner ? (
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user