chore: initial commit
This commit is contained in:
커밋
e69ace4434
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
|
||||||
|
dist
|
||||||
|
**/dist
|
||||||
|
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
*.log
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
||||||
60
README.md
Normal file
60
README.md
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# WING Fleet Dashboard (Prototype to Project)
|
||||||
|
|
||||||
|
This repo started as a single HTML prototype and is being refactored into a structured React + TypeScript project.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- `legacy/`: original single-file HTML prototype (reference)
|
||||||
|
- `data/raw/zones/`: raw GeoJSON zone polygons as received
|
||||||
|
- `apps/web/`: React + TS frontend (MapLibre GL JS + Deck.gl, single WebGL context)
|
||||||
|
- `apps/api/`: optional API server (proxy/stub)
|
||||||
|
- `scripts/`: data preparation scripts (e.g. zone reprojection)
|
||||||
|
|
||||||
|
## What Works Now (GIS Demo)
|
||||||
|
|
||||||
|
- Live AIS polling (initial 10 minutes, then every 1 minute / last 2 minutes incremental) with MMSI upsert store:
|
||||||
|
- frontend hook: `apps/web/src/features/aisPolling/useAisTargetPolling.ts`
|
||||||
|
- API proxy (CORS safe): `apps/api/src/index.ts` (`GET /api/ais-target/search?minutes=...&bbox=...`)
|
||||||
|
- MapLibre basemap:
|
||||||
|
- default (no MapTiler key): local raster style + OpenSeaMap seamark overlay: `apps/web/public/map/styles/osm-seamark.json`
|
||||||
|
- optional (recommended): MapTiler `dataviz-dark` + Ocean (bathymetry) vector overlays, plus OpenSeaMap seamark raster overlay
|
||||||
|
- Deck.gl layers rendered into MapLibre WebGL context (single context via `MapboxOverlay`):
|
||||||
|
- ships `IconLayer`, zones `GeoJsonLayer`, optional 3D density `HexagonLayer`
|
||||||
|
- map widget: `apps/web/src/widgets/map3d/Map3D.tsx`
|
||||||
|
- Zone polygons converted to WGS84 GeoJSON:
|
||||||
|
- input: `data/raw/zones/*.json`
|
||||||
|
- build: `pnpm prepare:data` -> `apps/web/public/data/zones/zones.wgs84.geojson`
|
||||||
|
- Legacy CN-permit overlay (name/callsign/mmsi match; ring + color by 업종):
|
||||||
|
- legacy sources: `legacy/*.xlsx`, `legacy/*.jsx`
|
||||||
|
- build: `pnpm prepare:data` -> `apps/web/public/data/legacy/chinese-permitted.v1.json`
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
1. `pnpm install`
|
||||||
|
2. `pnpm prepare:data`
|
||||||
|
3. `pnpm dev:web`
|
||||||
|
4. (optional) `pnpm dev:api` # only needed when you start adding DB-backed/local APIs
|
||||||
|
|
||||||
|
API server (optional): `pnpm dev:api` (defaults to `http://127.0.0.1:5174`)
|
||||||
|
|
||||||
|
Ports (dev):
|
||||||
|
- Web (Vite): `http://127.0.0.1:5175` (default, strict). Change via `WEB_PORT=....`
|
||||||
|
- API (Fastify): `http://127.0.0.1:5174` (default). Change via `PORT=....` and match `API_PORT=....` for the web proxy.
|
||||||
|
|
||||||
|
Vite proxy (dev):
|
||||||
|
- `/snp-api/*` -> `http://211.208.115.83:8041` (override via `VITE_SNP_API_TARGET=...`)
|
||||||
|
|
||||||
|
## MapTiler (Optional, Bathymetry Vector Map)
|
||||||
|
|
||||||
|
To enable a dark-mode basemap + bathymetry (MapTiler Ocean tiles):
|
||||||
|
|
||||||
|
1. Create a free MapTiler Cloud account and get an API key
|
||||||
|
2. Set env vars for the web app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# enables MapTiler style + ocean bathymetry layers in MapLibre
|
||||||
|
VITE_MAPTILER_KEY="YOUR_KEY"
|
||||||
|
|
||||||
|
# optional: change basemap style id (default: dataviz-dark)
|
||||||
|
VITE_MAPTILER_BASE_MAP_ID="dataviz-dark"
|
||||||
|
```
|
||||||
21
apps/api/package.json
Normal file
21
apps/api/package.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "@wing/api",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "node dist/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cors": "^11.1.0",
|
||||||
|
"fastify": "^5.6.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"tsx": "^4.20.5",
|
||||||
|
"typescript": "~5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
158
apps/api/src/index.ts
Normal file
158
apps/api/src/index.ts
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import cors from "@fastify/cors";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const app = Fastify({ logger: true });
|
||||||
|
|
||||||
|
await app.register(cors, { origin: true });
|
||||||
|
|
||||||
|
app.setErrorHandler((err, req, reply) => {
|
||||||
|
req.log.error({ err }, "Unhandled error");
|
||||||
|
if (reply.sent) return;
|
||||||
|
|
||||||
|
const statusCode =
|
||||||
|
typeof (err as { statusCode?: unknown }).statusCode === "number" ? (err as { statusCode: number }).statusCode : 500;
|
||||||
|
reply.code(statusCode).send({
|
||||||
|
success: false,
|
||||||
|
message: err instanceof Error ? err.message : String(err),
|
||||||
|
data: [],
|
||||||
|
errorCode: "INTERNAL",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/health", async () => ({ ok: true }));
|
||||||
|
|
||||||
|
const AIS_UPSTREAM_BASE = "http://211.208.115.83:8041";
|
||||||
|
const AIS_UPSTREAM_PATH = "/snp-api/api/ais-target/search";
|
||||||
|
|
||||||
|
app.get<{
|
||||||
|
Querystring: {
|
||||||
|
minutes?: string;
|
||||||
|
bbox?: string;
|
||||||
|
};
|
||||||
|
}>("/api/ais-target/search", async (req, reply) => {
|
||||||
|
const minutesRaw = req.query.minutes ?? "60";
|
||||||
|
const minutes = Number(minutesRaw);
|
||||||
|
if (!Number.isFinite(minutes) || minutes <= 0 || minutes > 60 * 24) {
|
||||||
|
return reply.code(400).send({ success: false, message: "invalid minutes", data: [], errorCode: "BAD_REQUEST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bboxRaw = req.query.bbox;
|
||||||
|
const bbox = parseBbox(bboxRaw);
|
||||||
|
if (bboxRaw && !bbox) {
|
||||||
|
return reply.code(400).send({ success: false, message: "invalid bbox", data: [], errorCode: "BAD_REQUEST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const u = new URL(AIS_UPSTREAM_PATH, AIS_UPSTREAM_BASE);
|
||||||
|
u.searchParams.set("minutes", String(minutes));
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutMs = 20_000;
|
||||||
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const res = await fetch(u, { signal: controller.signal, headers: { accept: "application/json" } });
|
||||||
|
const txt = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
req.log.warn({ status: res.status, body: txt.slice(0, 2000) }, "AIS upstream error");
|
||||||
|
return reply.code(502).send({ success: false, message: "upstream error", data: [], errorCode: "UPSTREAM" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply optional bbox filtering server-side to reduce payload to the browser.
|
||||||
|
let json: { data?: unknown; message?: string };
|
||||||
|
try {
|
||||||
|
json = JSON.parse(txt) as { data?: unknown; message?: string };
|
||||||
|
} catch (e) {
|
||||||
|
req.log.warn({ err: e, body: txt.slice(0, 2000) }, "AIS upstream returned invalid JSON");
|
||||||
|
return reply
|
||||||
|
.code(502)
|
||||||
|
.send({ success: false, message: "upstream invalid json", data: [], errorCode: "UPSTREAM_INVALID_JSON" });
|
||||||
|
}
|
||||||
|
if (!json || typeof json !== "object") {
|
||||||
|
req.log.warn({ body: txt.slice(0, 2000) }, "AIS upstream returned non-object JSON");
|
||||||
|
return reply
|
||||||
|
.code(502)
|
||||||
|
.send({ success: false, message: "upstream invalid payload", data: [], errorCode: "UPSTREAM_INVALID_PAYLOAD" });
|
||||||
|
}
|
||||||
|
const rows = Array.isArray(json.data) ? (json.data as unknown[]) : [];
|
||||||
|
const filtered = bbox
|
||||||
|
? rows.filter((r) => {
|
||||||
|
if (!r || typeof r !== "object") return false;
|
||||||
|
const lat = (r as { lat?: unknown }).lat;
|
||||||
|
const lon = (r as { lon?: unknown }).lon;
|
||||||
|
if (typeof lat !== "number" || typeof lon !== "number") return false;
|
||||||
|
return lon >= bbox.lonMin && lon <= bbox.lonMax && lat >= bbox.latMin && lat <= bbox.latMax;
|
||||||
|
})
|
||||||
|
: rows;
|
||||||
|
|
||||||
|
if (bbox) {
|
||||||
|
json.message = `${json.message ?? ""} (bbox: ${filtered.length}/${rows.length})`.trim();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(json as any).data = filtered;
|
||||||
|
|
||||||
|
reply.type("application/json").send(json);
|
||||||
|
} catch (e) {
|
||||||
|
const name = e instanceof Error ? e.name : "";
|
||||||
|
const isTimeout = name === "AbortError";
|
||||||
|
req.log.warn({ err: e, url: u.toString() }, "AIS proxy request failed");
|
||||||
|
return reply.code(isTimeout ? 504 : 502).send({
|
||||||
|
success: false,
|
||||||
|
message: isTimeout ? `upstream timeout (${timeoutMs}ms)` : "upstream fetch failed",
|
||||||
|
data: [],
|
||||||
|
errorCode: isTimeout ? "UPSTREAM_TIMEOUT" : "UPSTREAM_FETCH_FAILED",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseBbox(raw: string | undefined) {
|
||||||
|
if (!raw) return null;
|
||||||
|
const parts = raw
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (parts.length !== 4) return null;
|
||||||
|
|
||||||
|
const [lonMin, latMin, lonMax, latMax] = parts.map((p) => Number(p));
|
||||||
|
const ok =
|
||||||
|
Number.isFinite(lonMin) &&
|
||||||
|
Number.isFinite(latMin) &&
|
||||||
|
Number.isFinite(lonMax) &&
|
||||||
|
Number.isFinite(latMax) &&
|
||||||
|
lonMin >= -180 &&
|
||||||
|
lonMax <= 180 &&
|
||||||
|
latMin >= -90 &&
|
||||||
|
latMax <= 90 &&
|
||||||
|
lonMin < lonMax &&
|
||||||
|
latMin < latMax;
|
||||||
|
if (!ok) return null;
|
||||||
|
return { lonMin, latMin, lonMax, latMax };
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get("/zones", async (_req, reply) => {
|
||||||
|
const zonesPath = path.resolve(
|
||||||
|
process.cwd(),
|
||||||
|
"..",
|
||||||
|
"web",
|
||||||
|
"public",
|
||||||
|
"data",
|
||||||
|
"zones",
|
||||||
|
"zones.wgs84.geojson",
|
||||||
|
);
|
||||||
|
const txt = await fs.readFile(zonesPath, "utf-8");
|
||||||
|
reply.type("application/json").send(JSON.parse(txt));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/vessels", async () => {
|
||||||
|
return {
|
||||||
|
source: "todo",
|
||||||
|
vessels: [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const port = Number(process.env.PORT || 5174);
|
||||||
|
const host = process.env.HOST || "127.0.0.1";
|
||||||
|
|
||||||
|
await app.listen({ port, host });
|
||||||
14
apps/api/tsconfig.json
Normal file
14
apps/api/tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
|
|
||||||
24
apps/web/.gitignore
vendored
Normal file
24
apps/web/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
73
apps/web/README.md
Normal file
73
apps/web/README.md
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
23
apps/web/eslint.config.js
Normal file
23
apps/web/eslint.config.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
apps/web/index.html
Normal file
13
apps/web/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>906척 실시간 조업 감시 — 선단 연관관계</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
35
apps/web/package.json
Normal file
35
apps/web/package.json
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "@wing/web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@deck.gl/aggregation-layers": "^9.2.7",
|
||||||
|
"@deck.gl/core": "^9.2.7",
|
||||||
|
"@deck.gl/layers": "^9.2.7",
|
||||||
|
"@deck.gl/mapbox": "^9.2.7",
|
||||||
|
"maplibre-gl": "^5.18.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.48.0",
|
||||||
|
"vite": "^7.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
apps/web/public/assets/ship.svg
Normal file
21
apps/web/public/assets/ship.svg
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||||
|
<!-- Monochrome top-down ship silhouette (white + transparent) for Deck.gl IconLayer with mask:true -->
|
||||||
|
<path
|
||||||
|
d="M64 6
|
||||||
|
C78 12 90 26 96 42
|
||||||
|
L109 72
|
||||||
|
C111 77 109 83 104 86
|
||||||
|
L86 98
|
||||||
|
L86 114
|
||||||
|
C86 121 81 124 74 124
|
||||||
|
L54 124
|
||||||
|
C47 124 42 121 42 114
|
||||||
|
L42 98
|
||||||
|
L24 86
|
||||||
|
C19 83 17 77 19 72
|
||||||
|
L32 42
|
||||||
|
C38 26 50 12 64 6 Z"
|
||||||
|
fill="#ffffff"
|
||||||
|
/>
|
||||||
|
<path d="M52 54 H76 V74 H52 Z" fill="#ffffff" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | 크기: 565 B |
1
apps/web/public/data/legacy/chinese-permitted.v1.json
Normal file
1
apps/web/public/data/legacy/chinese-permitted.v1.json
Normal file
File diff suppressed because one or more lines are too long
1
apps/web/public/data/zones/zones.wgs84.geojson
Normal file
1
apps/web/public/data/zones/zones.wgs84.geojson
Normal file
File diff suppressed because one or more lines are too long
30
apps/web/public/map/styles/carto-dark.json
Normal file
30
apps/web/public/map/styles/carto-dark.json
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"version": 8,
|
||||||
|
"name": "CARTO Dark (Legacy)",
|
||||||
|
"sources": {
|
||||||
|
"carto-dark": {
|
||||||
|
"type": "raster",
|
||||||
|
"tiles": [
|
||||||
|
"https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png",
|
||||||
|
"https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png",
|
||||||
|
"https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png",
|
||||||
|
"https://d.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png"
|
||||||
|
],
|
||||||
|
"tileSize": 256,
|
||||||
|
"attribution": "© OpenStreetMap contributors © CARTO"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"id": "background",
|
||||||
|
"type": "background",
|
||||||
|
"paint": { "background-color": "#0b1020" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "carto-dark",
|
||||||
|
"type": "raster",
|
||||||
|
"source": "carto-dark",
|
||||||
|
"paint": { "raster-opacity": 1 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
28
apps/web/public/map/styles/osm-seamark.json
Normal file
28
apps/web/public/map/styles/osm-seamark.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"version": 8,
|
||||||
|
"name": "OSM Raster + OpenSeaMap",
|
||||||
|
"sources": {
|
||||||
|
"osm": {
|
||||||
|
"type": "raster",
|
||||||
|
"tiles": ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
|
||||||
|
"tileSize": 256,
|
||||||
|
"attribution": "© OpenStreetMap contributors"
|
||||||
|
},
|
||||||
|
"seamark": {
|
||||||
|
"type": "raster",
|
||||||
|
"tiles": ["https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png"],
|
||||||
|
"tileSize": 256,
|
||||||
|
"attribution": "© OpenSeaMap contributors"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"layers": [
|
||||||
|
{ "id": "osm", "type": "raster", "source": "osm" },
|
||||||
|
{
|
||||||
|
"id": "seamark",
|
||||||
|
"type": "raster",
|
||||||
|
"source": "seamark",
|
||||||
|
"paint": { "raster-opacity": 0.85 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
1
apps/web/public/vite.svg
Normal file
1
apps/web/public/vite.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | 크기: 1.5 KiB |
6
apps/web/src/app/App.tsx
Normal file
6
apps/web/src/app/App.tsx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { DashboardPage } from "../pages/dashboard/DashboardPage";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return <DashboardPage />;
|
||||||
|
}
|
||||||
|
|
||||||
675
apps/web/src/app/styles.css
Normal file
675
apps/web/src/app/styles.css
Normal file
@ -0,0 +1,675 @@
|
|||||||
|
@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700;800;900&display=swap");
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #020617;
|
||||||
|
--panel: #0f172a;
|
||||||
|
--card: #1e293b;
|
||||||
|
--border: #1e3a5f;
|
||||||
|
--text: #e2e8f0;
|
||||||
|
--muted: #64748b;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
|
||||||
|
--crit: #ef4444;
|
||||||
|
--high: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Noto Sans KR", sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 310px 1fr;
|
||||||
|
grid-template-rows: 44px 1fr;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
grid-column: 1/-1;
|
||||||
|
background: var(--panel);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 14px;
|
||||||
|
gap: 10px;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar .logo {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar .logo span {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar .stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar .stat {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar .stat b {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar .time {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background: var(--panel);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-area {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-t {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Type grid */
|
||||||
|
.tg {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb:hover {
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb.on {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb .c {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb .n {
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Speed bar */
|
||||||
|
.sbar {
|
||||||
|
position: relative;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sseg {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 3px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 7px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sseg.p {
|
||||||
|
height: 24px;
|
||||||
|
top: 0;
|
||||||
|
border: 1.5px solid rgba(255, 255, 255, 0.25);
|
||||||
|
box-shadow: 0 0 6px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sseg:not(.p) {
|
||||||
|
height: 16px;
|
||||||
|
top: 4px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vessel list */
|
||||||
|
.vlist {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vi {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 10px;
|
||||||
|
transition: background 0.1s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vi:hover {
|
||||||
|
background: var(--card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vi .dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vi .nm {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vi .sp {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vi .st {
|
||||||
|
font-size: 7px;
|
||||||
|
padding: 1px 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* AIS target list */
|
||||||
|
.ais-q {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(30, 41, 59, 0.75);
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ais-q::placeholder {
|
||||||
|
color: rgba(100, 116, 139, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ais-mode {
|
||||||
|
display: flex;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ais-mode-btn {
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 28px;
|
||||||
|
border: none;
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ais-mode-btn.on {
|
||||||
|
background: rgba(59, 130, 246, 0.18);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ais-list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ais-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: background 0.12s, border-color 0.12s;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ais-row:hover {
|
||||||
|
background: rgba(30, 41, 59, 0.6);
|
||||||
|
border-color: rgba(30, 58, 95, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ais-row.sel {
|
||||||
|
background: rgba(59, 130, 246, 0.14);
|
||||||
|
border-color: rgba(59, 130, 246, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ais-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ais-nm {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ais-nm1 {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ais-nm2 {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ais-right {
|
||||||
|
text-align: right;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ais-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ais-badge {
|
||||||
|
font-size: 8px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(100, 116, 139, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ais-badge.pn {
|
||||||
|
color: var(--muted);
|
||||||
|
background: rgba(30, 41, 59, 0.55);
|
||||||
|
border-color: rgba(30, 58, 95, 0.9);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ais-badge.PT {
|
||||||
|
background: rgba(30, 64, 175, 0.28);
|
||||||
|
border-color: rgba(30, 64, 175, 0.7);
|
||||||
|
}
|
||||||
|
.ais-badge.PT-S {
|
||||||
|
background: rgba(234, 88, 12, 0.22);
|
||||||
|
border-color: rgba(234, 88, 12, 0.7);
|
||||||
|
}
|
||||||
|
.ais-badge.GN {
|
||||||
|
background: rgba(16, 185, 129, 0.22);
|
||||||
|
border-color: rgba(16, 185, 129, 0.7);
|
||||||
|
}
|
||||||
|
.ais-badge.OT {
|
||||||
|
background: rgba(139, 92, 246, 0.22);
|
||||||
|
border-color: rgba(139, 92, 246, 0.7);
|
||||||
|
}
|
||||||
|
.ais-badge.PS {
|
||||||
|
background: rgba(239, 68, 68, 0.22);
|
||||||
|
border-color: rgba(239, 68, 68, 0.7);
|
||||||
|
}
|
||||||
|
.ais-badge.FC {
|
||||||
|
background: rgba(245, 158, 11, 0.22);
|
||||||
|
border-color: rgba(245, 158, 11, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ais-sp {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ais-ts {
|
||||||
|
font-size: 9px;
|
||||||
|
color: rgba(100, 116, 139, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alarm */
|
||||||
|
.ai {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
font-size: 9px;
|
||||||
|
border-left: 3px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai.cr {
|
||||||
|
border-color: var(--crit);
|
||||||
|
background: rgba(239, 68, 68, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai.hi {
|
||||||
|
border-color: var(--high);
|
||||||
|
background: rgba(245, 158, 11, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai .at {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Relation panel */
|
||||||
|
.rel-panel {
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rel-badge {
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rel-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rel-line .dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rel-link {
|
||||||
|
width: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rel-dist {
|
||||||
|
font-size: 8px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fleet network */
|
||||||
|
.fleet-card {
|
||||||
|
background: rgba(30, 41, 59, 0.8);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-owner {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-vessel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 1px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggles */
|
||||||
|
.tog {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tog-btn {
|
||||||
|
font-size: 8px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tog-btn.on {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Map panels */
|
||||||
|
.map-legend {
|
||||||
|
position: absolute;
|
||||||
|
/* Keep attribution visible (bottom-right) for licensing/compliance. */
|
||||||
|
bottom: 44px;
|
||||||
|
right: 12px;
|
||||||
|
z-index: 800;
|
||||||
|
background: rgba(15, 23, 42, 0.92);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 9px;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-legend .lt {
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-legend .li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-legend .ls {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-info {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
z-index: 800;
|
||||||
|
background: rgba(15, 23, 42, 0.95);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
width: 270px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-info .ir {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-info .il {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-info .iv {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 8px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-cell {
|
||||||
|
flex: 1;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 2px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maplibregl-ctrl-group {
|
||||||
|
border: 1px solid var(--border) !important;
|
||||||
|
background: rgba(15, 23, 42, 0.92) !important;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.maplibregl-ctrl-group button {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maplibregl-ctrl-group button + button {
|
||||||
|
border-top: 1px solid var(--border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maplibregl-ctrl-group button span {
|
||||||
|
filter: invert(1);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maplibregl-ctrl-attrib {
|
||||||
|
font-size: 10px !important;
|
||||||
|
background: rgba(15, 23, 42, 0.75) !important;
|
||||||
|
color: var(--text) !important;
|
||||||
|
border: 1px solid var(--border) !important;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.app {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: 44px 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
apps/web/src/entities/aisTarget/api/searchAisTargets.ts
Normal file
38
apps/web/src/entities/aisTarget/api/searchAisTargets.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import type { AisTargetSearchResponse } from "../model/types";
|
||||||
|
|
||||||
|
export type SearchAisTargetsParams = {
|
||||||
|
minutes: number;
|
||||||
|
bbox?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function searchAisTargets(params: SearchAisTargetsParams, signal?: AbortSignal) {
|
||||||
|
// Same convention as the "dark" project:
|
||||||
|
// - dev: default to Vite proxy base `/snp-api`
|
||||||
|
// - prod/other: can be overridden via `VITE_API_URL` (e.g. `http://host:8041/snp-api`)
|
||||||
|
const base = (import.meta.env.VITE_API_URL || "/snp-api").replace(/\/$/, "");
|
||||||
|
const u = new URL(`${base}/api/ais-target/search`, window.location.origin);
|
||||||
|
u.searchParams.set("minutes", String(params.minutes));
|
||||||
|
if (params.bbox) u.searchParams.set("bbox", params.bbox);
|
||||||
|
|
||||||
|
const res = await fetch(u, { signal, headers: { accept: "application/json" } });
|
||||||
|
const txt = await res.text();
|
||||||
|
let json: unknown = null;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(txt);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg =
|
||||||
|
json && typeof json === "object" && typeof (json as { message?: unknown }).message === "string"
|
||||||
|
? (json as { message: string }).message
|
||||||
|
: txt.slice(0, 200) || res.statusText;
|
||||||
|
throw new Error(`AIS target API failed: ${res.status} ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!json || typeof json !== "object") throw new Error("AIS target API returned invalid payload");
|
||||||
|
const parsed = json as AisTargetSearchResponse;
|
||||||
|
if (!parsed.success) throw new Error(parsed.message || "AIS target API returned success=false");
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
31
apps/web/src/entities/aisTarget/model/types.ts
Normal file
31
apps/web/src/entities/aisTarget/model/types.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
export type AisTarget = {
|
||||||
|
mmsi: number;
|
||||||
|
imo: number;
|
||||||
|
name: string;
|
||||||
|
callsign: string;
|
||||||
|
vesselType: string;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
heading: number;
|
||||||
|
sog: number;
|
||||||
|
cog: number;
|
||||||
|
rot: number;
|
||||||
|
length: number;
|
||||||
|
width: number;
|
||||||
|
draught: number;
|
||||||
|
destination: string;
|
||||||
|
eta: string;
|
||||||
|
status: string;
|
||||||
|
messageTimestamp: string;
|
||||||
|
receivedDate: string;
|
||||||
|
source: string;
|
||||||
|
classType: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AisTargetSearchResponse = {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data: AisTarget[];
|
||||||
|
errorCode: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
33
apps/web/src/entities/legacyVessel/api/useLegacyVessels.ts
Normal file
33
apps/web/src/entities/legacyVessel/api/useLegacyVessels.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { LegacyVesselDataset } from "../model/types";
|
||||||
|
|
||||||
|
export function useLegacyVessels(url = "/data/legacy/chinese-permitted.v1.json") {
|
||||||
|
const [data, setData] = useState<LegacyVesselDataset | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`Failed to load legacy vessels: ${res.status} ${res.statusText}`);
|
||||||
|
const json = (await res.json()) as LegacyVesselDataset;
|
||||||
|
if (cancelled) return;
|
||||||
|
setData(json);
|
||||||
|
} catch (e) {
|
||||||
|
if (cancelled) return;
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void run();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
return { data, error };
|
||||||
|
}
|
||||||
|
|
||||||
75
apps/web/src/entities/legacyVessel/lib/index.ts
Normal file
75
apps/web/src/entities/legacyVessel/lib/index.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { normalizeShipName } from "./normalizeShipName";
|
||||||
|
import type { LegacyVesselInfo } from "../model/types";
|
||||||
|
|
||||||
|
export type LegacyVesselIndex = {
|
||||||
|
byName: Map<string, LegacyVesselInfo>;
|
||||||
|
byMmsi: Map<number, LegacyVesselInfo>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function score(v: LegacyVesselInfo) {
|
||||||
|
// Prefer records that were merged from more sources.
|
||||||
|
return Number(!!v.sources.permittedList) + Number(!!v.sources.checklist) + Number(!!v.sources.fleet906);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLegacyVesselIndex(vessels: LegacyVesselInfo[]): LegacyVesselIndex {
|
||||||
|
const byName = new Map<string, LegacyVesselInfo>();
|
||||||
|
const byMmsi = new Map<number, LegacyVesselInfo>();
|
||||||
|
|
||||||
|
for (const v of vessels) {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
if (v.shipNameRoman) keys.add(normalizeShipName(v.shipNameRoman));
|
||||||
|
if (v.shipNameCn) keys.add(normalizeShipName(v.shipNameCn));
|
||||||
|
if (v.callSign) keys.add(normalizeShipName(v.callSign));
|
||||||
|
|
||||||
|
for (const k of keys) {
|
||||||
|
if (!k) continue;
|
||||||
|
const prev = byName.get(k);
|
||||||
|
if (!prev) {
|
||||||
|
byName.set(k, v);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (score(v) > score(prev)) byName.set(k, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const m of v.mmsiList || []) {
|
||||||
|
if (!Number.isFinite(m)) continue;
|
||||||
|
const prev = byMmsi.get(m);
|
||||||
|
if (!prev) {
|
||||||
|
byMmsi.set(m, v);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (score(v) > score(prev)) byMmsi.set(m, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { byName, byMmsi };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LegacyMatchable = {
|
||||||
|
mmsi?: number;
|
||||||
|
name?: string;
|
||||||
|
callsign?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function matchLegacyVessel(t: LegacyMatchable, idx: LegacyVesselIndex): LegacyVesselInfo | null {
|
||||||
|
const mmsi = t.mmsi;
|
||||||
|
if (typeof mmsi === "number") {
|
||||||
|
const hit = idx.byMmsi.get(mmsi);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameKey = t.name ? normalizeShipName(t.name) : "";
|
||||||
|
if (nameKey) {
|
||||||
|
const hit = idx.byName.get(nameKey);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
const csKey = t.callsign ? normalizeShipName(t.callsign) : "";
|
||||||
|
if (csKey) {
|
||||||
|
const hit = idx.byName.get(csKey);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
export function normalizeShipName(x: string) {
|
||||||
|
// User requirement: case-insensitive + whitespace removed.
|
||||||
|
return (x || "").toLowerCase().replace(/\s+/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
35
apps/web/src/entities/legacyVessel/model/types.ts
Normal file
35
apps/web/src/entities/legacyVessel/model/types.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
export type LegacyVesselSources = {
|
||||||
|
permittedList: boolean;
|
||||||
|
checklist: boolean;
|
||||||
|
fleet906: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LegacyVesselInfo = {
|
||||||
|
permitNo: string;
|
||||||
|
shipNameRoman: string;
|
||||||
|
shipNameCn: string | null;
|
||||||
|
shipCode: string; // PT | PT-S | GN | OT | PS | FC | ...
|
||||||
|
ton: number | null;
|
||||||
|
callSign: string;
|
||||||
|
mmsiList: number[];
|
||||||
|
workSeaArea: string;
|
||||||
|
workTerm1: string;
|
||||||
|
workTerm2: string;
|
||||||
|
quota: string;
|
||||||
|
|
||||||
|
ownerCn: string | null;
|
||||||
|
ownerRoman: string | null;
|
||||||
|
|
||||||
|
pairPermitNo: string | null;
|
||||||
|
pairShipNameCn: string | null;
|
||||||
|
|
||||||
|
checklistSheet: string | null;
|
||||||
|
sources: LegacyVesselSources;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LegacyVesselDataset = {
|
||||||
|
generatedAt: string;
|
||||||
|
counts: Record<string, number>;
|
||||||
|
vessels: LegacyVesselInfo[];
|
||||||
|
};
|
||||||
|
|
||||||
34
apps/web/src/entities/vessel/lib/filter.ts
Normal file
34
apps/web/src/entities/vessel/lib/filter.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import type { Vessel, VesselTypeCode } from "../model/types";
|
||||||
|
|
||||||
|
export function isTrawl(code: VesselTypeCode) {
|
||||||
|
return code === "PT" || code === "PT-S";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterVesselsForMap(vessels: readonly Vessel[], selectedType: VesselTypeCode | null) {
|
||||||
|
if (!selectedType) return vessels;
|
||||||
|
|
||||||
|
return vessels.filter((v) => {
|
||||||
|
if (v.code === selectedType) return true;
|
||||||
|
|
||||||
|
// PT and PT-S should be shown together
|
||||||
|
if (selectedType === "PT" && v.code === "PT-S") return true;
|
||||||
|
if (selectedType === "PT-S" && v.code === "PT") return true;
|
||||||
|
|
||||||
|
// FC interacts with trawl; show trawl when FC selected and FC when trawl selected
|
||||||
|
if (selectedType === "FC" && (v.code === "PT" || v.code === "PT-S")) return true;
|
||||||
|
if ((selectedType === "PT" || selectedType === "PT-S") && v.code === "FC") return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterVesselsForList(vessels: readonly Vessel[], selectedType: VesselTypeCode | null) {
|
||||||
|
if (!selectedType) return vessels;
|
||||||
|
return vessels.filter((v) => {
|
||||||
|
if (v.code === selectedType) return true;
|
||||||
|
if (selectedType === "PT" && v.code === "PT-S") return true;
|
||||||
|
if (selectedType === "PT-S" && v.code === "PT") return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
147
apps/web/src/entities/vessel/model/meta.ts
Normal file
147
apps/web/src/entities/vessel/model/meta.ts
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import type { ZoneId } from "../../zone/model/meta";
|
||||||
|
import type { VesselTypeCode } from "./types";
|
||||||
|
|
||||||
|
export const SPEED_MAX = 15;
|
||||||
|
|
||||||
|
export const MONTH_LABELS = [
|
||||||
|
"1월",
|
||||||
|
"2월",
|
||||||
|
"3월",
|
||||||
|
"4월",
|
||||||
|
"5월",
|
||||||
|
"6월",
|
||||||
|
"7월",
|
||||||
|
"8월",
|
||||||
|
"9월",
|
||||||
|
"10월",
|
||||||
|
"11월",
|
||||||
|
"12월",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface SpeedSegment {
|
||||||
|
label: string;
|
||||||
|
range: [number, number];
|
||||||
|
typicalSpeed: number;
|
||||||
|
color: string;
|
||||||
|
primary?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VesselTypeMeta {
|
||||||
|
code: VesselTypeCode;
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
allowedZones: ZoneId[];
|
||||||
|
color: string;
|
||||||
|
icon: string;
|
||||||
|
speedProfile: SpeedSegment[];
|
||||||
|
monthlyIntensity: number[]; // 12 months (0 = closed)
|
||||||
|
trajectory: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VESSEL_TYPE_ORDER: VesselTypeCode[] = ["PT", "PT-S", "GN", "OT", "PS", "FC"];
|
||||||
|
|
||||||
|
export const VESSEL_TYPES: Record<VesselTypeCode, VesselTypeMeta> = {
|
||||||
|
PT: {
|
||||||
|
code: "PT",
|
||||||
|
name: "2척식 저인망(본선)",
|
||||||
|
count: 323,
|
||||||
|
allowedZones: ["2", "3"],
|
||||||
|
color: "#2563EB",
|
||||||
|
icon: "⛵",
|
||||||
|
speedProfile: [
|
||||||
|
{ label: "정박", range: [0, 0.5], typicalSpeed: 0.3, color: "#64748B" },
|
||||||
|
{ label: "투양망", range: [1, 2.5], typicalSpeed: 1.5, color: "#F59E0B" },
|
||||||
|
{ label: "예인조업", range: [2.5, 4.5], typicalSpeed: 3.3, color: "#2563EB", primary: true },
|
||||||
|
{ label: "저속", range: [4.5, 7], typicalSpeed: 5.5, color: "#8B5CF6" },
|
||||||
|
{ label: "고속", range: [7, 15], typicalSpeed: 8.3, color: "#475569" },
|
||||||
|
],
|
||||||
|
monthlyIntensity: [0.5, 0.5, 0.7, 0.8, 0.7, 0.1, 0, 0, 0.8, 1, 0.9, 0.6],
|
||||||
|
trajectory: "직선예인(쌍동기화)",
|
||||||
|
},
|
||||||
|
"PT-S": {
|
||||||
|
code: "PT-S",
|
||||||
|
name: "2척식 저인망(부속선)",
|
||||||
|
count: 323,
|
||||||
|
allowedZones: ["2", "3"],
|
||||||
|
color: "#3B82F6",
|
||||||
|
icon: "⛵",
|
||||||
|
speedProfile: [
|
||||||
|
{ label: "정박", range: [0, 0.5], typicalSpeed: 0.3, color: "#64748B" },
|
||||||
|
{ label: "보조", range: [1, 2.5], typicalSpeed: 1.5, color: "#F59E0B" },
|
||||||
|
{ label: "동기예인", range: [2.5, 4.5], typicalSpeed: 3.3, color: "#3B82F6", primary: true },
|
||||||
|
{ label: "추종", range: [4.5, 7], typicalSpeed: 5.5, color: "#8B5CF6" },
|
||||||
|
{ label: "고속", range: [7, 15], typicalSpeed: 8.3, color: "#475569" },
|
||||||
|
],
|
||||||
|
monthlyIntensity: [0.5, 0.5, 0.7, 0.8, 0.7, 0.1, 0, 0, 0.8, 1, 0.9, 0.6],
|
||||||
|
trajectory: "본선거울상",
|
||||||
|
},
|
||||||
|
GN: {
|
||||||
|
code: "GN",
|
||||||
|
name: "유망",
|
||||||
|
count: 200,
|
||||||
|
allowedZones: ["2", "3", "4"],
|
||||||
|
color: "#059669",
|
||||||
|
icon: "🪢",
|
||||||
|
speedProfile: [
|
||||||
|
{ label: "정박", range: [0, 0.3], typicalSpeed: 0.2, color: "#64748B" },
|
||||||
|
{ label: "표류", range: [0.5, 2], typicalSpeed: 1, color: "#059669", primary: true },
|
||||||
|
{ label: "양망", range: [1, 3], typicalSpeed: 2, color: "#F59E0B" },
|
||||||
|
{ label: "투망", range: [2, 4], typicalSpeed: 3, color: "#EF4444" },
|
||||||
|
{ label: "항해", range: [5, 15], typicalSpeed: 7.5, color: "#475569" },
|
||||||
|
],
|
||||||
|
monthlyIntensity: [0.6, 0.5, 0.7, 0.7, 0.6, 0.3, 0.2, 0.3, 0.9, 1, 0.9, 0.7],
|
||||||
|
trajectory: "투망→표류→양망",
|
||||||
|
},
|
||||||
|
OT: {
|
||||||
|
code: "OT",
|
||||||
|
name: "1척식 저인망",
|
||||||
|
count: 13,
|
||||||
|
allowedZones: ["2", "3"],
|
||||||
|
color: "#7C3AED",
|
||||||
|
icon: "🚢",
|
||||||
|
speedProfile: [
|
||||||
|
{ label: "정박", range: [0, 0.5], typicalSpeed: 0.3, color: "#64748B" },
|
||||||
|
{ label: "투양망", range: [1, 2], typicalSpeed: 1.5, color: "#F59E0B" },
|
||||||
|
{ label: "단독예인", range: [2.5, 5], typicalSpeed: 3.5, color: "#7C3AED", primary: true },
|
||||||
|
{ label: "항해", range: [5, 15], typicalSpeed: 8, color: "#475569" },
|
||||||
|
],
|
||||||
|
monthlyIntensity: [0.5, 0.5, 0.7, 0.7, 0.6, 0.1, 0, 0, 0.8, 1, 0.8, 0.5],
|
||||||
|
trajectory: "단독레이스트랙",
|
||||||
|
},
|
||||||
|
PS: {
|
||||||
|
code: "PS",
|
||||||
|
name: "위망/채낚기",
|
||||||
|
count: 16,
|
||||||
|
allowedZones: ["1", "2", "3", "4"],
|
||||||
|
color: "#DC2626",
|
||||||
|
icon: "🦑",
|
||||||
|
speedProfile: [
|
||||||
|
{ label: "정박", range: [0, 0.3], typicalSpeed: 0.2, color: "#64748B" },
|
||||||
|
{ label: "위망", range: [0.3, 1.5], typicalSpeed: 0.5, color: "#DC2626", primary: true },
|
||||||
|
{ label: "채낚기", range: [0.3, 2], typicalSpeed: 0.8, color: "#F97316", primary: true },
|
||||||
|
{ label: "투양", range: [1.5, 3], typicalSpeed: 2, color: "#F59E0B" },
|
||||||
|
{ label: "항해", range: [5, 15], typicalSpeed: 8, color: "#475569" },
|
||||||
|
],
|
||||||
|
monthlyIntensity: [0.6, 0.7, 0.8, 0.7, 0.4, 0.3, 0.5, 0.7, 0.9, 1, 0.8, 0.6],
|
||||||
|
trajectory: "점군집/야간표류",
|
||||||
|
},
|
||||||
|
FC: {
|
||||||
|
code: "FC",
|
||||||
|
name: "운반선",
|
||||||
|
count: 31,
|
||||||
|
allowedZones: ["1", "2", "3", "4"],
|
||||||
|
color: "#D97706",
|
||||||
|
icon: "🚛",
|
||||||
|
speedProfile: [
|
||||||
|
{ label: "정박", range: [0, 0.3], typicalSpeed: 0.2, color: "#64748B" },
|
||||||
|
{ label: "환적", range: [0.5, 2], typicalSpeed: 1, color: "#D97706", primary: true },
|
||||||
|
{ label: "저속", range: [3, 6], typicalSpeed: 4.5, color: "#8B5CF6" },
|
||||||
|
{ label: "고속", range: [6, 15], typicalSpeed: 9, color: "#475569" },
|
||||||
|
],
|
||||||
|
monthlyIntensity: [0.4, 0.4, 0.6, 0.7, 0.5, 0.2, 0.1, 0.1, 0.8, 1, 0.9, 0.5],
|
||||||
|
trajectory: "허브스포크순회",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VESSEL_TOTAL_TARGET = Object.values(VESSEL_TYPES).reduce((sum, t) => sum + t.count, 0);
|
||||||
|
|
||||||
278
apps/web/src/entities/vessel/model/mockFleet.ts
Normal file
278
apps/web/src/entities/vessel/model/mockFleet.ts
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
import type { ZoneId } from "../../zone/model/meta";
|
||||||
|
import { haversineNm } from "../../../shared/lib/geo/haversineNm";
|
||||||
|
import { VESSEL_TYPES } from "./meta";
|
||||||
|
import type { FleetOwner, FleetState, TrawlPair, Vessel, VesselTypeCode } from "./types";
|
||||||
|
|
||||||
|
const SURNAMES = ["张", "王", "李", "刘", "陈", "杨", "黄", "赵", "周", "吴", "徐", "孙", "马", "朱", "胡", "郭", "林", "何", "高", "罗"];
|
||||||
|
const REGIONS = ["荣成", "石岛", "烟台", "威海", "日照", "青岛", "连云港", "舟山", "象山", "大连"];
|
||||||
|
|
||||||
|
const ZONE_BOUNDS: Record<ZoneId, { lat: [number, number]; lon: [number, number] }> = {
|
||||||
|
"1": { lon: [128.85, 131.70], lat: [36.16, 38.25] },
|
||||||
|
"2": { lon: [126.00, 128.90], lat: [32.18, 34.35] },
|
||||||
|
"3": { lon: [124.12, 126.06], lat: [33.13, 35.00] },
|
||||||
|
"4": { lon: [124.33, 125.85], lat: [35.00, 37.00] },
|
||||||
|
};
|
||||||
|
|
||||||
|
function rnd(a: number, b: number) {
|
||||||
|
return a + Math.random() * (b - a);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick<T>(arr: readonly T[]) {
|
||||||
|
return arr[Math.floor(Math.random() * arr.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomPointInZone(zone: ZoneId) {
|
||||||
|
const b = ZONE_BOUNDS[zone];
|
||||||
|
// Small margin to avoid sitting exactly on the edge.
|
||||||
|
const lat = rnd(b.lat[0] + 0.05, b.lat[1] - 0.05);
|
||||||
|
const lon = rnd(b.lon[0] + 0.05, b.lon[1] - 0.05);
|
||||||
|
return { lat, lon };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePermit(id: number, suffix: "A" | "B") {
|
||||||
|
return `C21-${10000 + id}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMockFleetState(): FleetState {
|
||||||
|
const vessels: Vessel[] = [];
|
||||||
|
const owners: FleetOwner[] = [];
|
||||||
|
const ptPairs: TrawlPair[] = [];
|
||||||
|
|
||||||
|
let vid = 1;
|
||||||
|
|
||||||
|
// PT pairs: PT count == PT-S count, treat as "pair count".
|
||||||
|
for (let i = 0; i < VESSEL_TYPES.PT.count; i++) {
|
||||||
|
const owner = `${pick(SURNAMES)}${pick(SURNAMES)}${pick(["渔业", "海产", "水产", "船务"])}${pick(["有限公司", "合作社", ""])}`;
|
||||||
|
const region = pick(REGIONS);
|
||||||
|
const zone = pick<ZoneId>(["2", "3"]);
|
||||||
|
const { lat, lon } = randomPointInZone(zone);
|
||||||
|
|
||||||
|
const isFishing = Math.random() < 0.55;
|
||||||
|
const sp = isFishing ? rnd(2.5, 4.5) : rnd(6, 11);
|
||||||
|
const crs = rnd(0, 360);
|
||||||
|
|
||||||
|
const pairDist = isFishing ? rnd(0.2, 1.2) : rnd(0, 0.3); // NM (rough)
|
||||||
|
const pairAngle = rnd(0, 360);
|
||||||
|
const lat2 = lat + (pairDist / 60) * Math.cos((pairAngle * Math.PI) / 180);
|
||||||
|
const lon2 = lon + ((pairDist / 60) * Math.sin((pairAngle * Math.PI) / 180)) / Math.cos((lat * Math.PI) / 180);
|
||||||
|
|
||||||
|
const permitBase = vid;
|
||||||
|
const ptId = vid++;
|
||||||
|
const ptsId = vid++;
|
||||||
|
|
||||||
|
const pt: Vessel = {
|
||||||
|
id: ptId,
|
||||||
|
permit: makePermit(permitBase, "A"),
|
||||||
|
code: "PT",
|
||||||
|
color: VESSEL_TYPES.PT.color,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
speed: Number(sp.toFixed(1)),
|
||||||
|
course: Number(crs.toFixed(0)),
|
||||||
|
state: isFishing ? "조업중" : sp < 1 ? "정박" : "항해중",
|
||||||
|
zone,
|
||||||
|
isFishing,
|
||||||
|
owner,
|
||||||
|
region,
|
||||||
|
pairId: null,
|
||||||
|
pairDistNm: Number(pairDist.toFixed(2)),
|
||||||
|
nearVesselIds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const pts: Vessel = {
|
||||||
|
id: ptsId,
|
||||||
|
permit: makePermit(permitBase, "B"),
|
||||||
|
code: "PT-S",
|
||||||
|
color: VESSEL_TYPES["PT-S"].color,
|
||||||
|
lat: Number(lat2.toFixed(4)),
|
||||||
|
lon: Number(lon2.toFixed(4)),
|
||||||
|
speed: Number((sp + rnd(-0.3, 0.3)).toFixed(1)),
|
||||||
|
course: Number((crs + rnd(-10, 10)).toFixed(0)),
|
||||||
|
state: isFishing ? "조업중" : sp < 1 ? "정박" : "항해중",
|
||||||
|
zone,
|
||||||
|
isFishing,
|
||||||
|
owner,
|
||||||
|
region,
|
||||||
|
pairId: null,
|
||||||
|
pairDistNm: pt.pairDistNm,
|
||||||
|
nearVesselIds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
pt.pairId = pts.id;
|
||||||
|
pts.pairId = pt.id;
|
||||||
|
|
||||||
|
vessels.push(pt, pts);
|
||||||
|
ptPairs.push({ mainId: pt.id, subId: pts.id, owner, region });
|
||||||
|
owners.push({ name: owner, region, vessels: [pt.id, pts.id], type: "trawl" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// GN vessels
|
||||||
|
for (let i = 0; i < VESSEL_TYPES.GN.count; i++) {
|
||||||
|
const attachToOwner = Math.random() < 0.3 ? owners[Math.floor(Math.random() * owners.length)] : null;
|
||||||
|
const owner = attachToOwner ? attachToOwner.name : `${pick(SURNAMES)}${pick(SURNAMES)}${pick(["渔业", "水产"])}有限公司`;
|
||||||
|
const region = attachToOwner ? attachToOwner.region : pick(REGIONS);
|
||||||
|
const zone = pick<ZoneId>(["2", "3", "4"]);
|
||||||
|
const { lat, lon } = randomPointInZone(zone);
|
||||||
|
|
||||||
|
const isFishing = Math.random() < 0.5;
|
||||||
|
const sp = isFishing ? rnd(0.5, 2) : rnd(5, 10);
|
||||||
|
|
||||||
|
const id = vid++;
|
||||||
|
const v: Vessel = {
|
||||||
|
id,
|
||||||
|
permit: makePermit(id, "A"),
|
||||||
|
code: "GN",
|
||||||
|
color: VESSEL_TYPES.GN.color,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
speed: Number(sp.toFixed(1)),
|
||||||
|
course: Number(rnd(0, 360).toFixed(0)),
|
||||||
|
state: isFishing ? pick(["표류", "투망", "양망"]) : sp < 1 ? "정박" : "항해중",
|
||||||
|
zone,
|
||||||
|
isFishing,
|
||||||
|
owner,
|
||||||
|
region,
|
||||||
|
pairId: null,
|
||||||
|
pairDistNm: null,
|
||||||
|
nearVesselIds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
vessels.push(v);
|
||||||
|
if (attachToOwner) attachToOwner.vessels.push(v.id);
|
||||||
|
else owners.push({ name: owner, region, vessels: [v.id], type: "gn" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// OT
|
||||||
|
for (let i = 0; i < VESSEL_TYPES.OT.count; i++) {
|
||||||
|
const owner = `${pick(SURNAMES)}${pick(SURNAMES)}远洋渔业`;
|
||||||
|
const region = pick(REGIONS);
|
||||||
|
const zone = pick<ZoneId>(["2", "3"]);
|
||||||
|
const { lat, lon } = randomPointInZone(zone);
|
||||||
|
const isFishing = Math.random() < 0.5;
|
||||||
|
const sp = isFishing ? rnd(2.5, 5) : rnd(5, 10);
|
||||||
|
const id = vid++;
|
||||||
|
const v: Vessel = {
|
||||||
|
id,
|
||||||
|
permit: makePermit(id, "A"),
|
||||||
|
code: "OT",
|
||||||
|
color: VESSEL_TYPES.OT.color,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
speed: Number(sp.toFixed(1)),
|
||||||
|
course: Number(rnd(0, 360).toFixed(0)),
|
||||||
|
state: isFishing ? "조업중" : "항해중",
|
||||||
|
zone,
|
||||||
|
isFishing,
|
||||||
|
owner,
|
||||||
|
region,
|
||||||
|
pairId: null,
|
||||||
|
pairDistNm: null,
|
||||||
|
nearVesselIds: [],
|
||||||
|
};
|
||||||
|
vessels.push(v);
|
||||||
|
owners.push({ name: owner, region, vessels: [v.id], type: "ot" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// PS
|
||||||
|
for (let i = 0; i < VESSEL_TYPES.PS.count; i++) {
|
||||||
|
const owner = `${pick(SURNAMES)}${pick(SURNAMES)}水产`;
|
||||||
|
const region = pick(REGIONS);
|
||||||
|
const zone = pick<ZoneId>(["1", "2", "3", "4"]);
|
||||||
|
const { lat, lon } = randomPointInZone(zone);
|
||||||
|
const isFishing = Math.random() < 0.5;
|
||||||
|
const sp = isFishing ? rnd(0.3, 1.5) : rnd(5, 9);
|
||||||
|
const id = vid++;
|
||||||
|
const v: Vessel = {
|
||||||
|
id,
|
||||||
|
permit: makePermit(id, "A"),
|
||||||
|
code: "PS",
|
||||||
|
color: VESSEL_TYPES.PS.color,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
speed: Number(sp.toFixed(1)),
|
||||||
|
course: Number(rnd(0, 360).toFixed(0)),
|
||||||
|
state: isFishing ? pick(["위망", "채낚기"]) : "항해중",
|
||||||
|
zone,
|
||||||
|
isFishing,
|
||||||
|
owner,
|
||||||
|
region,
|
||||||
|
pairId: null,
|
||||||
|
pairDistNm: null,
|
||||||
|
nearVesselIds: [],
|
||||||
|
};
|
||||||
|
vessels.push(v);
|
||||||
|
owners.push({ name: owner, region, vessels: [v.id], type: "ps" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// FC — assigned to trawl owners (positioned near PT)
|
||||||
|
const trawlOwners = owners.filter((o) => o.type === "trawl");
|
||||||
|
for (let i = 0; i < VESSEL_TYPES.FC.count; i++) {
|
||||||
|
const oi = i < trawlOwners.length ? trawlOwners[i] : pick(trawlOwners);
|
||||||
|
|
||||||
|
const refId = oi.vessels.find((id) => vessels[id - 1]?.code === "PT") ?? oi.vessels[0];
|
||||||
|
const ref = vessels[refId - 1];
|
||||||
|
|
||||||
|
const zone = pick<ZoneId>(["2", "3"]);
|
||||||
|
const lat = ref.lat + rnd(-0.2, 0.2);
|
||||||
|
const lon = ref.lon + rnd(-0.2, 0.2);
|
||||||
|
|
||||||
|
const isNear = Math.random() < 0.4;
|
||||||
|
const sp = isNear ? rnd(0.5, 1.5) : rnd(5, 9);
|
||||||
|
|
||||||
|
const nearVesselIds = isNear ? oi.vessels.filter((id) => vessels[id - 1]?.code !== "FC").slice(0, 2) : [];
|
||||||
|
|
||||||
|
const v: Vessel = {
|
||||||
|
id: vid,
|
||||||
|
permit: makePermit(vid, "A"),
|
||||||
|
code: "FC",
|
||||||
|
color: VESSEL_TYPES.FC.color,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
speed: Number(sp.toFixed(1)),
|
||||||
|
course: Number(rnd(0, 360).toFixed(0)),
|
||||||
|
state: isNear ? "환적" : "항해중",
|
||||||
|
zone,
|
||||||
|
isFishing: isNear, // kept from prototype: treat "환적" as fishing-like activity
|
||||||
|
owner: oi.name,
|
||||||
|
region: oi.region,
|
||||||
|
pairId: null,
|
||||||
|
pairDistNm: null,
|
||||||
|
nearVesselIds,
|
||||||
|
};
|
||||||
|
vid += 1;
|
||||||
|
vessels.push(v);
|
||||||
|
oi.vessels.push(v.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure initial pair distances are consistent with actual coordinates.
|
||||||
|
for (const p of ptPairs) {
|
||||||
|
const a = vessels[p.mainId - 1];
|
||||||
|
const b = vessels[p.subId - 1];
|
||||||
|
const d = haversineNm(a.lat, a.lon, b.lat, b.lon);
|
||||||
|
a.pairDistNm = d;
|
||||||
|
b.pairDistNm = d;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { vessels, owners, ptPairs };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tickMockFleetState(state: FleetState) {
|
||||||
|
for (const v of state.vessels) {
|
||||||
|
v.lat += (0.5 - Math.random()) * 0.003;
|
||||||
|
v.lon += (0.5 - Math.random()) * 0.003;
|
||||||
|
v.speed = Math.max(0, Number((v.speed + (0.5 - Math.random()) * 0.4).toFixed(1)));
|
||||||
|
v.course = Number(((v.course + (0.5 - Math.random()) * 6 + 360) % 360).toFixed(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of state.ptPairs) {
|
||||||
|
const a = state.vessels[p.mainId - 1];
|
||||||
|
const b = state.vessels[p.subId - 1];
|
||||||
|
const d = haversineNm(a.lat, a.lon, b.lat, b.lon);
|
||||||
|
a.pairDistNm = d;
|
||||||
|
b.pairDistNm = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVesselCode(code: string): code is VesselTypeCode {
|
||||||
|
return code === "PT" || code === "PT-S" || code === "GN" || code === "OT" || code === "PS" || code === "FC";
|
||||||
|
}
|
||||||
48
apps/web/src/entities/vessel/model/types.ts
Normal file
48
apps/web/src/entities/vessel/model/types.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import type { ZoneId } from "../../zone/model/meta";
|
||||||
|
|
||||||
|
export type VesselTypeCode = "PT" | "PT-S" | "GN" | "OT" | "PS" | "FC";
|
||||||
|
|
||||||
|
export interface Vessel {
|
||||||
|
id: number;
|
||||||
|
permit: string;
|
||||||
|
code: VesselTypeCode;
|
||||||
|
|
||||||
|
color: string;
|
||||||
|
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
speed: number;
|
||||||
|
course: number;
|
||||||
|
state: string;
|
||||||
|
|
||||||
|
zone: ZoneId;
|
||||||
|
isFishing: boolean;
|
||||||
|
|
||||||
|
owner: string;
|
||||||
|
region: string;
|
||||||
|
|
||||||
|
pairId: number | null;
|
||||||
|
pairDistNm: number | null;
|
||||||
|
|
||||||
|
nearVesselIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrawlPair {
|
||||||
|
mainId: number;
|
||||||
|
subId: number;
|
||||||
|
owner: string;
|
||||||
|
region: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetOwner {
|
||||||
|
name: string;
|
||||||
|
region: string;
|
||||||
|
vessels: number[]; // vessel ids
|
||||||
|
type: "trawl" | "gn" | "ot" | "ps";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetState {
|
||||||
|
vessels: Vessel[];
|
||||||
|
owners: FleetOwner[];
|
||||||
|
ptPairs: TrawlPair[];
|
||||||
|
}
|
||||||
37
apps/web/src/entities/zone/api/useZones.ts
Normal file
37
apps/web/src/entities/zone/api/useZones.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export type ZonesGeoJson = GeoJSON.FeatureCollection<
|
||||||
|
GeoJSON.MultiPolygon,
|
||||||
|
GeoJSON.GeoJsonProperties
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function useZones(url = "/data/zones/zones.wgs84.geojson") {
|
||||||
|
const [data, setData] = useState<ZonesGeoJson | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`Failed to load zones: ${res.status} ${res.statusText}`);
|
||||||
|
const json = (await res.json()) as ZonesGeoJson;
|
||||||
|
if (cancelled) return;
|
||||||
|
setData(json);
|
||||||
|
} catch (e) {
|
||||||
|
if (cancelled) return;
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void run();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
return { data, error };
|
||||||
|
}
|
||||||
|
|
||||||
11
apps/web/src/entities/zone/model/meta.ts
Normal file
11
apps/web/src/entities/zone/model/meta.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export type ZoneId = "1" | "2" | "3" | "4";
|
||||||
|
|
||||||
|
export const ZONE_IDS: ZoneId[] = ["1", "2", "3", "4"];
|
||||||
|
|
||||||
|
export const ZONE_META: Record<ZoneId, { label: string; name: string; color: string }> = {
|
||||||
|
"1": { label: "Ⅰ", name: "수역I(동해)", color: "#3B82F6" },
|
||||||
|
"2": { label: "Ⅱ", name: "수역II(제주남방)", color: "#22C55E" },
|
||||||
|
"3": { label: "Ⅲ", name: "수역III(서해남부)", color: "#F59E0B" },
|
||||||
|
"4": { label: "Ⅳ", name: "수역IV(서해중간)", color: "#A855F7" },
|
||||||
|
};
|
||||||
|
|
||||||
210
apps/web/src/features/aisPolling/useAisTargetPolling.ts
Normal file
210
apps/web/src/features/aisPolling/useAisTargetPolling.ts
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { searchAisTargets } from "../../entities/aisTarget/api/searchAisTargets";
|
||||||
|
import type { AisTarget } from "../../entities/aisTarget/model/types";
|
||||||
|
|
||||||
|
export type AisPollingStatus = "idle" | "loading" | "ready" | "error";
|
||||||
|
|
||||||
|
export type AisPollingSnapshot = {
|
||||||
|
status: AisPollingStatus;
|
||||||
|
error: string | null;
|
||||||
|
lastFetchAt: string | null;
|
||||||
|
lastFetchMinutes: number | null;
|
||||||
|
lastMessage: string | null;
|
||||||
|
total: number;
|
||||||
|
lastUpserted: number;
|
||||||
|
lastInserted: number;
|
||||||
|
lastDeleted: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AisPollingOptions = {
|
||||||
|
initialMinutes?: number;
|
||||||
|
incrementalMinutes?: number;
|
||||||
|
intervalMs?: number;
|
||||||
|
retentionMinutes?: number;
|
||||||
|
bbox?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function upsertByMmsi(store: Map<number, AisTarget>, rows: AisTarget[]) {
|
||||||
|
let inserted = 0;
|
||||||
|
let upserted = 0;
|
||||||
|
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!r || typeof r.mmsi !== "number") continue;
|
||||||
|
|
||||||
|
const prev = store.get(r.mmsi);
|
||||||
|
if (!prev) {
|
||||||
|
store.set(r.mmsi, r);
|
||||||
|
inserted += 1;
|
||||||
|
upserted += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer newer records if the upstream ever returns stale items.
|
||||||
|
const prevTs = prev.messageTimestamp ?? "";
|
||||||
|
const nextTs = r.messageTimestamp ?? "";
|
||||||
|
if (nextTs && prevTs && nextTs < prevTs) continue;
|
||||||
|
|
||||||
|
store.set(r.mmsi, r);
|
||||||
|
upserted += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { inserted, upserted };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBbox(raw: string | undefined) {
|
||||||
|
if (!raw) return null;
|
||||||
|
const parts = raw
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (parts.length !== 4) return null;
|
||||||
|
const [lonMin, latMin, lonMax, latMax] = parts.map((p) => Number(p));
|
||||||
|
const ok =
|
||||||
|
Number.isFinite(lonMin) &&
|
||||||
|
Number.isFinite(latMin) &&
|
||||||
|
Number.isFinite(lonMax) &&
|
||||||
|
Number.isFinite(latMax) &&
|
||||||
|
lonMin >= -180 &&
|
||||||
|
lonMax <= 180 &&
|
||||||
|
latMin >= -90 &&
|
||||||
|
latMax <= 90 &&
|
||||||
|
lonMin < lonMax &&
|
||||||
|
latMin < latMax;
|
||||||
|
if (!ok) return null;
|
||||||
|
return { lonMin, latMin, lonMax, latMax };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneStore(store: Map<number, AisTarget>, retentionMinutes: number, bboxRaw: string | undefined) {
|
||||||
|
const cutoffMs = Date.now() - retentionMinutes * 60_000;
|
||||||
|
const bbox = parseBbox(bboxRaw);
|
||||||
|
let deleted = 0;
|
||||||
|
|
||||||
|
for (const [mmsi, t] of store.entries()) {
|
||||||
|
const ts = Date.parse(t.messageTimestamp || "");
|
||||||
|
if (Number.isFinite(ts) && ts < cutoffMs) {
|
||||||
|
store.delete(mmsi);
|
||||||
|
deleted += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bbox) {
|
||||||
|
const lat = t.lat;
|
||||||
|
const lon = t.lon;
|
||||||
|
if (typeof lat !== "number" || typeof lon !== "number") {
|
||||||
|
store.delete(mmsi);
|
||||||
|
deleted += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lon < bbox.lonMin || lon > bbox.lonMax || lat < bbox.latMin || lat > bbox.latMax) {
|
||||||
|
store.delete(mmsi);
|
||||||
|
deleted += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAisTargetPolling(opts: AisPollingOptions = {}) {
|
||||||
|
const initialMinutes = opts.initialMinutes ?? 60;
|
||||||
|
const incrementalMinutes = opts.incrementalMinutes ?? 1;
|
||||||
|
const intervalMs = opts.intervalMs ?? 60_000;
|
||||||
|
const retentionMinutes = opts.retentionMinutes ?? initialMinutes;
|
||||||
|
const enabled = opts.enabled ?? true;
|
||||||
|
const bbox = opts.bbox;
|
||||||
|
|
||||||
|
const storeRef = useRef<Map<number, AisTarget>>(new Map());
|
||||||
|
const inFlightRef = useRef(false);
|
||||||
|
|
||||||
|
const [rev, setRev] = useState(0);
|
||||||
|
const [snapshot, setSnapshot] = useState<AisPollingSnapshot>({
|
||||||
|
status: "idle",
|
||||||
|
error: null,
|
||||||
|
lastFetchAt: null,
|
||||||
|
lastFetchMinutes: null,
|
||||||
|
lastMessage: null,
|
||||||
|
total: 0,
|
||||||
|
lastUpserted: 0,
|
||||||
|
lastInserted: 0,
|
||||||
|
lastDeleted: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
async function run(minutes: number) {
|
||||||
|
if (inFlightRef.current) return;
|
||||||
|
inFlightRef.current = true;
|
||||||
|
try {
|
||||||
|
setSnapshot((s) => ({ ...s, status: s.status === "idle" ? "loading" : s.status, error: null }));
|
||||||
|
|
||||||
|
const res = await searchAisTargets({ minutes, bbox }, controller.signal);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const { inserted, upserted } = upsertByMmsi(storeRef.current, res.data);
|
||||||
|
const deleted = pruneStore(storeRef.current, retentionMinutes, bbox);
|
||||||
|
const total = storeRef.current.size;
|
||||||
|
const lastFetchAt = new Date().toISOString();
|
||||||
|
|
||||||
|
setSnapshot({
|
||||||
|
status: "ready",
|
||||||
|
error: null,
|
||||||
|
lastFetchAt,
|
||||||
|
lastFetchMinutes: minutes,
|
||||||
|
lastMessage: res.message,
|
||||||
|
total,
|
||||||
|
lastUpserted: upserted,
|
||||||
|
lastInserted: inserted,
|
||||||
|
lastDeleted: deleted,
|
||||||
|
});
|
||||||
|
setRev((r) => r + 1);
|
||||||
|
} catch (e) {
|
||||||
|
if (cancelled) return;
|
||||||
|
setSnapshot((s) => ({
|
||||||
|
...s,
|
||||||
|
status: "error",
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
inFlightRef.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset store when polling config changes (bbox, retention, etc).
|
||||||
|
storeRef.current = new Map();
|
||||||
|
setSnapshot({
|
||||||
|
status: "loading",
|
||||||
|
error: null,
|
||||||
|
lastFetchAt: null,
|
||||||
|
lastFetchMinutes: null,
|
||||||
|
lastMessage: null,
|
||||||
|
total: 0,
|
||||||
|
lastUpserted: 0,
|
||||||
|
lastInserted: 0,
|
||||||
|
lastDeleted: 0,
|
||||||
|
});
|
||||||
|
setRev((r) => r + 1);
|
||||||
|
|
||||||
|
void run(initialMinutes);
|
||||||
|
const id = window.setInterval(() => void run(incrementalMinutes), intervalMs);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
controller.abort();
|
||||||
|
window.clearInterval(id);
|
||||||
|
};
|
||||||
|
}, [initialMinutes, incrementalMinutes, intervalMs, retentionMinutes, bbox, enabled]);
|
||||||
|
|
||||||
|
const targets = useMemo(() => {
|
||||||
|
// `rev` is a version counter so we recompute the array snapshot when the store changes.
|
||||||
|
void rev;
|
||||||
|
return Array.from(storeRef.current.values());
|
||||||
|
}, [rev]);
|
||||||
|
|
||||||
|
return { targets, snapshot };
|
||||||
|
}
|
||||||
310
apps/web/src/features/legacyDashboard/model/derive.ts
Normal file
310
apps/web/src/features/legacyDashboard/model/derive.ts
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
import type { AisTarget } from "../../../entities/aisTarget/model/types";
|
||||||
|
import type { LegacyVesselIndex } from "../../../entities/legacyVessel/lib";
|
||||||
|
import { matchLegacyVessel } from "../../../entities/legacyVessel/lib";
|
||||||
|
import type { LegacyVesselInfo } from "../../../entities/legacyVessel/model/types";
|
||||||
|
import type { ZonesGeoJson } from "../../../entities/zone/api/useZones";
|
||||||
|
import type { ZoneId } from "../../../entities/zone/model/meta";
|
||||||
|
import { ZONE_IDS } from "../../../entities/zone/model/meta";
|
||||||
|
import { VESSEL_TYPES } from "../../../entities/vessel/model/meta";
|
||||||
|
import type { VesselTypeCode } from "../../../entities/vessel/model/types";
|
||||||
|
import { haversineNm } from "../../../shared/lib/geo/haversineNm";
|
||||||
|
import { pointInMultiPolygon } from "../../../shared/lib/geo/pointInPolygon";
|
||||||
|
import type { DerivedLegacyVessel, DerivedVesselState, FcLink, FleetCircle, LegacyAlarm, PairLink } from "./types";
|
||||||
|
|
||||||
|
function isFiniteNumber(x: unknown): x is number {
|
||||||
|
return typeof x === "number" && Number.isFinite(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveVesselState(shipCode: VesselTypeCode, sogRaw: unknown): DerivedVesselState {
|
||||||
|
const sog = isFiniteNumber(sogRaw) ? sogRaw : null;
|
||||||
|
if (sog === null) return { label: "미상", isFishing: false, isTransit: false };
|
||||||
|
if (sog < 0.5) return { label: "정지", isFishing: false, isTransit: false };
|
||||||
|
|
||||||
|
const meta = VESSEL_TYPES[shipCode];
|
||||||
|
const inPrimary = meta.speedProfile.some((s) => s.primary && sog >= s.range[0] && sog <= s.range[1]);
|
||||||
|
if (inPrimary) return { label: "조업", isFishing: true, isTransit: false };
|
||||||
|
|
||||||
|
if (sog >= 5) return { label: "항해", isFishing: false, isTransit: true };
|
||||||
|
return { label: "저속", isFishing: false, isTransit: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLegacyHitMap(targets: AisTarget[], legacyIndex: LegacyVesselIndex | null): Map<number, LegacyVesselInfo> {
|
||||||
|
const hits = new Map<number, LegacyVesselInfo>();
|
||||||
|
if (!legacyIndex) return hits;
|
||||||
|
for (const t of targets) {
|
||||||
|
if (typeof t.mmsi !== "number") continue;
|
||||||
|
const hit = matchLegacyVessel(t, legacyIndex);
|
||||||
|
if (!hit) continue;
|
||||||
|
hits.set(t.mmsi, hit);
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findZoneId(lon: number, lat: number, zones: ZonesGeoJson | null): ZoneId | null {
|
||||||
|
if (!zones) return null;
|
||||||
|
for (const f of zones.features) {
|
||||||
|
const zoneId = (f.properties as { zoneId?: string } | undefined)?.zoneId as ZoneId | undefined;
|
||||||
|
if (!zoneId) continue;
|
||||||
|
if (!ZONE_IDS.includes(zoneId)) continue;
|
||||||
|
const geom = f.geometry;
|
||||||
|
if (!geom || geom.type !== "MultiPolygon") continue;
|
||||||
|
if (pointInMultiPolygon(lon, lat, geom.coordinates as unknown as [number, number][][][])) return zoneId;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveLegacyVessels(args: {
|
||||||
|
targets: AisTarget[];
|
||||||
|
legacyHits: Map<number, LegacyVesselInfo>;
|
||||||
|
zones: ZonesGeoJson | null;
|
||||||
|
}): DerivedLegacyVessel[] {
|
||||||
|
const out: DerivedLegacyVessel[] = [];
|
||||||
|
for (const t of args.targets) {
|
||||||
|
if (typeof t.mmsi !== "number") continue;
|
||||||
|
const legacy = args.legacyHits.get(t.mmsi);
|
||||||
|
if (!legacy) continue;
|
||||||
|
|
||||||
|
const lat = isFiniteNumber(t.lat) ? t.lat : null;
|
||||||
|
const lon = isFiniteNumber(t.lon) ? t.lon : null;
|
||||||
|
if (lat === null || lon === null) continue;
|
||||||
|
|
||||||
|
const code = legacy.shipCode as VesselTypeCode;
|
||||||
|
if (!code || !(code in VESSEL_TYPES)) continue;
|
||||||
|
|
||||||
|
const ownerKey = (legacy.ownerRoman || legacy.ownerCn || "").trim() || null;
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
mmsi: t.mmsi,
|
||||||
|
name: (t.name || "").trim() || legacy.shipNameCn || legacy.shipNameRoman || "(no name)",
|
||||||
|
callsign: (t.callsign || "").trim() || null,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
sog: isFiniteNumber(t.sog) ? t.sog : null,
|
||||||
|
cog: isFiniteNumber(t.cog) ? t.cog : null,
|
||||||
|
heading: isFiniteNumber(t.heading) ? t.heading : null,
|
||||||
|
messageTimestamp: t.messageTimestamp ?? null,
|
||||||
|
receivedDate: t.receivedDate ?? null,
|
||||||
|
ais: t,
|
||||||
|
legacy,
|
||||||
|
shipCode: code,
|
||||||
|
permitNo: legacy.permitNo,
|
||||||
|
ownerKey,
|
||||||
|
ownerCn: legacy.ownerCn ?? null,
|
||||||
|
ownerRoman: legacy.ownerRoman ?? null,
|
||||||
|
workSeaArea: legacy.workSeaArea ?? null,
|
||||||
|
pairPermitNo: legacy.pairPermitNo ?? null,
|
||||||
|
zoneId: findZoneId(lon, lat, args.zones),
|
||||||
|
state: deriveVesselState(code, t.sog),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterByShipCode(vessels: DerivedLegacyVessel[], selected: VesselTypeCode | null): DerivedLegacyVessel[] {
|
||||||
|
if (!selected) return vessels;
|
||||||
|
if (selected === "PT" || selected === "PT-S") return vessels.filter((v) => v.shipCode === "PT" || v.shipCode === "PT-S");
|
||||||
|
return vessels.filter((v) => v.shipCode === selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterByShipCodes(vessels: DerivedLegacyVessel[], enabled: Record<VesselTypeCode, boolean>): DerivedLegacyVessel[] {
|
||||||
|
return vessels.filter((v) => enabled[v.shipCode]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeCountsByType(vessels: DerivedLegacyVessel[]) {
|
||||||
|
const counts: Record<VesselTypeCode, number> = { PT: 0, "PT-S": 0, GN: 0, OT: 0, PS: 0, FC: 0 };
|
||||||
|
for (const v of vessels) counts[v.shipCode] += 1;
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computePairLinks(vessels: DerivedLegacyVessel[]): PairLink[] {
|
||||||
|
const byPermit = new Map<string, DerivedLegacyVessel>();
|
||||||
|
for (const v of vessels) byPermit.set(v.permitNo, v);
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const links: PairLink[] = [];
|
||||||
|
|
||||||
|
for (const v of vessels) {
|
||||||
|
if (!v.pairPermitNo) continue;
|
||||||
|
const pair = byPermit.get(v.pairPermitNo);
|
||||||
|
if (!pair) continue;
|
||||||
|
const a = v.mmsi < pair.mmsi ? v : pair;
|
||||||
|
const b = v.mmsi < pair.mmsi ? pair : v;
|
||||||
|
const key = `${a.mmsi}-${b.mmsi}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
|
||||||
|
const d = haversineNm(a.lat, a.lon, b.lat, b.lon);
|
||||||
|
links.push({
|
||||||
|
aMmsi: a.mmsi,
|
||||||
|
bMmsi: b.mmsi,
|
||||||
|
from: [a.lon, a.lat],
|
||||||
|
to: [b.lon, b.lat],
|
||||||
|
distanceNm: d,
|
||||||
|
warn: d > 3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return links;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeFcLinks(vessels: DerivedLegacyVessel[]): FcLink[] {
|
||||||
|
const others = vessels.filter((v) => v.shipCode !== "FC");
|
||||||
|
const fcs = vessels.filter((v) => v.shipCode === "FC");
|
||||||
|
|
||||||
|
const links: FcLink[] = [];
|
||||||
|
for (const fc of fcs) {
|
||||||
|
let best: DerivedLegacyVessel | null = null;
|
||||||
|
let bestD = Infinity;
|
||||||
|
for (const o of others) {
|
||||||
|
const d = haversineNm(fc.lat, fc.lon, o.lat, o.lon);
|
||||||
|
if (d < bestD) {
|
||||||
|
bestD = d;
|
||||||
|
best = o;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!best || !Number.isFinite(bestD)) continue;
|
||||||
|
if (bestD > 5) continue;
|
||||||
|
links.push({
|
||||||
|
fcMmsi: fc.mmsi,
|
||||||
|
otherMmsi: best.mmsi,
|
||||||
|
from: [fc.lon, fc.lat],
|
||||||
|
to: [best.lon, best.lat],
|
||||||
|
distanceNm: bestD,
|
||||||
|
suspicious: bestD < 0.5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return links;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeFleetCircles(vessels: DerivedLegacyVessel[]): FleetCircle[] {
|
||||||
|
const groups = new Map<string, DerivedLegacyVessel[]>();
|
||||||
|
for (const v of vessels) {
|
||||||
|
if (!v.ownerKey) continue;
|
||||||
|
const list = groups.get(v.ownerKey);
|
||||||
|
if (list) list.push(v);
|
||||||
|
else groups.set(v.ownerKey, [v]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: FleetCircle[] = [];
|
||||||
|
for (const [ownerKey, vs] of groups.entries()) {
|
||||||
|
if (vs.length < 3) continue;
|
||||||
|
const lon = vs.reduce((sum, v) => sum + v.lon, 0) / vs.length;
|
||||||
|
const lat = vs.reduce((sum, v) => sum + v.lat, 0) / vs.length;
|
||||||
|
let radiusNm = 0;
|
||||||
|
for (const v of vs) radiusNm = Math.max(radiusNm, haversineNm(lat, lon, v.lat, v.lon));
|
||||||
|
out.push({
|
||||||
|
ownerKey,
|
||||||
|
center: [lon, lat],
|
||||||
|
radiusNm: Math.max(0.2, radiusNm),
|
||||||
|
count: vs.length,
|
||||||
|
vesselMmsis: vs.map((v) => v.mmsi),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show largest fleets first.
|
||||||
|
out.sort((a, b) => b.count - a.count);
|
||||||
|
return out.slice(0, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtAgoLabel(nowMs: number, iso: string | null): string {
|
||||||
|
if (!iso) return "-";
|
||||||
|
const t = Date.parse(iso);
|
||||||
|
if (!Number.isFinite(t)) return "-";
|
||||||
|
const diffMin = Math.max(0, Math.round((nowMs - t) / 60_000));
|
||||||
|
if (diffMin <= 0) return "방금";
|
||||||
|
return `-${diffMin}분`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeLegacyAlarms(args: {
|
||||||
|
vessels: DerivedLegacyVessel[];
|
||||||
|
pairLinks: PairLink[];
|
||||||
|
fcLinks: FcLink[];
|
||||||
|
now?: Date;
|
||||||
|
}): LegacyAlarm[] {
|
||||||
|
const nowMs = (args.now ?? new Date()).getTime();
|
||||||
|
const month = new Date(nowMs).getMonth(); // 0-11
|
||||||
|
|
||||||
|
const alarms: LegacyAlarm[] = [];
|
||||||
|
|
||||||
|
for (const p of args.pairLinks) {
|
||||||
|
if (!p.warn) continue;
|
||||||
|
const a = args.vessels.find((v) => v.mmsi === p.aMmsi);
|
||||||
|
const b = args.vessels.find((v) => v.mmsi === p.bMmsi);
|
||||||
|
const ts = a?.messageTimestamp ?? b?.messageTimestamp ?? null;
|
||||||
|
alarms.push({
|
||||||
|
severity: "hi",
|
||||||
|
kind: "pair_separation",
|
||||||
|
timeLabel: fmtAgoLabel(nowMs, ts),
|
||||||
|
text: `${a?.permitNo ?? p.aMmsi}↔${b?.permitNo ?? p.bMmsi} 쌍분리 ${p.distanceNm.toFixed(1)}NM`,
|
||||||
|
relatedMmsi: [p.aMmsi, p.bMmsi],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const l of args.fcLinks) {
|
||||||
|
if (!l.suspicious) continue;
|
||||||
|
const fc = args.vessels.find((v) => v.mmsi === l.fcMmsi);
|
||||||
|
const o = args.vessels.find((v) => v.mmsi === l.otherMmsi);
|
||||||
|
const ts = fc?.messageTimestamp ?? o?.messageTimestamp ?? null;
|
||||||
|
alarms.push({
|
||||||
|
severity: "hi",
|
||||||
|
kind: "transshipment",
|
||||||
|
timeLabel: fmtAgoLabel(nowMs, ts),
|
||||||
|
text: `${fc?.permitNo ?? l.fcMmsi}→${o?.permitNo ?? l.otherMmsi} 환적의심 ${l.distanceNm.toFixed(2)}NM`,
|
||||||
|
relatedMmsi: [l.fcMmsi, l.otherMmsi],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const v of args.vessels) {
|
||||||
|
const meta = VESSEL_TYPES[v.shipCode];
|
||||||
|
if (meta.monthlyIntensity[month] !== 0) continue;
|
||||||
|
if (!v.state.isFishing) continue;
|
||||||
|
alarms.push({
|
||||||
|
severity: "cr",
|
||||||
|
kind: "closed_season",
|
||||||
|
timeLabel: fmtAgoLabel(nowMs, v.messageTimestamp),
|
||||||
|
text: `${v.permitNo} [${v.shipCode}] 휴어기 조업 의심 ${v.sog ?? "?"}kt`,
|
||||||
|
relatedMmsi: [v.mmsi],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// AIS stale
|
||||||
|
for (const v of args.vessels) {
|
||||||
|
const ts = Date.parse(v.messageTimestamp || "");
|
||||||
|
if (!Number.isFinite(ts)) continue;
|
||||||
|
const diffMin = (nowMs - ts) / 60_000;
|
||||||
|
if (diffMin < 45) continue;
|
||||||
|
alarms.push({
|
||||||
|
severity: "cr",
|
||||||
|
kind: "ais_stale",
|
||||||
|
timeLabel: fmtAgoLabel(nowMs, v.messageTimestamp),
|
||||||
|
text: `${v.permitNo} [${v.shipCode}] AIS 지연 ${Math.round(diffMin)}분`,
|
||||||
|
relatedMmsi: [v.mmsi],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zone violations (only when we can detect zone).
|
||||||
|
for (const v of args.vessels) {
|
||||||
|
if (!v.zoneId) continue;
|
||||||
|
const allowed = VESSEL_TYPES[v.shipCode].allowedZones;
|
||||||
|
if (allowed.includes(v.zoneId)) continue;
|
||||||
|
alarms.push({
|
||||||
|
severity: "hi",
|
||||||
|
kind: "zone_violation",
|
||||||
|
timeLabel: fmtAgoLabel(nowMs, v.messageTimestamp),
|
||||||
|
text: `${v.permitNo} [${v.shipCode}] 수역 이탈 (${v.zoneId})`,
|
||||||
|
relatedMmsi: [v.mmsi],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Most recent first by timeLabel (approx), then by severity.
|
||||||
|
const sevScore = (s: "cr" | "hi") => (s === "cr" ? 2 : 1);
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
return alarms;
|
||||||
|
}
|
||||||
74
apps/web/src/features/legacyDashboard/model/types.ts
Normal file
74
apps/web/src/features/legacyDashboard/model/types.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import type { AisTarget } from "../../../entities/aisTarget/model/types";
|
||||||
|
import type { LegacyVesselInfo } from "../../../entities/legacyVessel/model/types";
|
||||||
|
import type { ZoneId } from "../../../entities/zone/model/meta";
|
||||||
|
import type { VesselTypeCode } from "../../../entities/vessel/model/types";
|
||||||
|
|
||||||
|
export type DerivedVesselState = {
|
||||||
|
label: string;
|
||||||
|
isFishing: boolean;
|
||||||
|
isTransit: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DerivedLegacyVessel = {
|
||||||
|
mmsi: number;
|
||||||
|
name: string;
|
||||||
|
callsign: string | null;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
sog: number | null;
|
||||||
|
cog: number | null;
|
||||||
|
heading: number | null;
|
||||||
|
messageTimestamp: string | null;
|
||||||
|
receivedDate: string | null;
|
||||||
|
|
||||||
|
ais: AisTarget;
|
||||||
|
legacy: LegacyVesselInfo;
|
||||||
|
|
||||||
|
shipCode: VesselTypeCode;
|
||||||
|
permitNo: string;
|
||||||
|
ownerKey: string | null;
|
||||||
|
ownerCn: string | null;
|
||||||
|
ownerRoman: string | null;
|
||||||
|
workSeaArea: string | null;
|
||||||
|
pairPermitNo: string | null;
|
||||||
|
|
||||||
|
zoneId: ZoneId | null;
|
||||||
|
state: DerivedVesselState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PairLink = {
|
||||||
|
aMmsi: number;
|
||||||
|
bMmsi: number;
|
||||||
|
from: [number, number]; // [lon, lat]
|
||||||
|
to: [number, number];
|
||||||
|
distanceNm: number;
|
||||||
|
warn: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FcLink = {
|
||||||
|
fcMmsi: number;
|
||||||
|
otherMmsi: number;
|
||||||
|
from: [number, number];
|
||||||
|
to: [number, number];
|
||||||
|
distanceNm: number;
|
||||||
|
suspicious: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FleetCircle = {
|
||||||
|
ownerKey: string;
|
||||||
|
center: [number, number];
|
||||||
|
radiusNm: number;
|
||||||
|
count: number;
|
||||||
|
vesselMmsis: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LegacyAlarmKind = "pair_separation" | "transshipment" | "closed_season" | "ais_stale" | "zone_violation";
|
||||||
|
|
||||||
|
export type LegacyAlarm = {
|
||||||
|
severity: "cr" | "hi";
|
||||||
|
kind: LegacyAlarmKind;
|
||||||
|
timeLabel: string;
|
||||||
|
text: string;
|
||||||
|
relatedMmsi: number[];
|
||||||
|
};
|
||||||
|
|
||||||
24
apps/web/src/features/map3dSettings/Map3DSettingsToggles.tsx
Normal file
24
apps/web/src/features/map3dSettings/Map3DSettingsToggles.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import type { Map3DSettings } from "../../widgets/map3d/Map3D";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: Map3DSettings;
|
||||||
|
onToggle: (key: keyof Map3DSettings) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Map3DSettingsToggles({ value, onToggle }: Props) {
|
||||||
|
const items: Array<{ id: keyof Map3DSettings; label: string }> = [
|
||||||
|
{ id: "showShips", label: "선박(Deck)" },
|
||||||
|
{ id: "showDensity", label: "밀도(3D)" },
|
||||||
|
{ id: "showSeamark", label: "OpenSeaMap" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tog">
|
||||||
|
{items.map((t) => (
|
||||||
|
<div key={t.id} className={`tog-btn ${value[t.id] ? "on" : ""}`} onClick={() => onToggle(t.id)}>
|
||||||
|
{t.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
apps/web/src/features/mapToggles/MapToggles.tsx
Normal file
32
apps/web/src/features/mapToggles/MapToggles.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
export type MapToggleState = {
|
||||||
|
pairLines: boolean;
|
||||||
|
pairRange: boolean;
|
||||||
|
fcLines: boolean;
|
||||||
|
zones: boolean;
|
||||||
|
fleetCircles: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: MapToggleState;
|
||||||
|
onToggle: (key: keyof MapToggleState) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MapToggles({ value, onToggle }: Props) {
|
||||||
|
const items: Array<{ id: keyof MapToggleState; label: string }> = [
|
||||||
|
{ id: "pairLines", label: "쌍 연결선" },
|
||||||
|
{ id: "pairRange", label: "쌍 연결범위" },
|
||||||
|
{ id: "fcLines", label: "환적 연결선" },
|
||||||
|
{ id: "zones", label: "수역 표시" },
|
||||||
|
{ id: "fleetCircles", label: "선단 범위" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tog">
|
||||||
|
{items.map((t) => (
|
||||||
|
<div key={t.id} className={`tog-btn ${value[t.id] ? "on" : ""}`} onClick={() => onToggle(t.id)}>
|
||||||
|
{t.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
apps/web/src/features/typeFilter/TypeFilterGrid.tsx
Normal file
37
apps/web/src/features/typeFilter/TypeFilterGrid.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { VESSEL_TYPE_ORDER, VESSEL_TYPES } from "../../entities/vessel/model/meta";
|
||||||
|
import type { VesselTypeCode } from "../../entities/vessel/model/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
enabled: Record<VesselTypeCode, boolean>;
|
||||||
|
totalCount: number;
|
||||||
|
countsByType: Record<VesselTypeCode, number>;
|
||||||
|
onToggle: (type: VesselTypeCode) => void;
|
||||||
|
onToggleAll: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TypeFilterGrid({ enabled, totalCount, countsByType, onToggle, onToggleAll }: Props) {
|
||||||
|
const allOn = VESSEL_TYPE_ORDER.every((c) => enabled[c]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tg">
|
||||||
|
<div className={`tb ${allOn ? "on" : ""}`} onClick={onToggleAll} style={{ gridColumn: "1/-1" }}>
|
||||||
|
<div className="c" style={{ color: "var(--accent)" }}>
|
||||||
|
전체
|
||||||
|
</div>
|
||||||
|
<div className="n">{totalCount}척</div>
|
||||||
|
</div>
|
||||||
|
{VESSEL_TYPE_ORDER.map((code) => {
|
||||||
|
const t = VESSEL_TYPES[code];
|
||||||
|
const cnt = countsByType[code] ?? 0;
|
||||||
|
return (
|
||||||
|
<div key={code} className={`tb ${enabled[code] ? "on" : ""}`} onClick={() => onToggle(code)}>
|
||||||
|
<div className="c" style={{ color: t.color }}>
|
||||||
|
{code}
|
||||||
|
</div>
|
||||||
|
<div className="n">{cnt}척</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
apps/web/src/main.tsx
Normal file
8
apps/web/src/main.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import "maplibre-gl/dist/maplibre-gl.css";
|
||||||
|
import App from "./app/App";
|
||||||
|
import "./app/styles.css";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<App />,
|
||||||
|
)
|
||||||
490
apps/web/src/pages/dashboard/DashboardPage.tsx
Normal file
490
apps/web/src/pages/dashboard/DashboardPage.tsx
Normal file
@ -0,0 +1,490 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
|
||||||
|
import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettingsToggles";
|
||||||
|
import type { MapToggleState } from "../../features/mapToggles/MapToggles";
|
||||||
|
import { MapToggles } from "../../features/mapToggles/MapToggles";
|
||||||
|
import { TypeFilterGrid } from "../../features/typeFilter/TypeFilterGrid";
|
||||||
|
import { useLegacyVessels } from "../../entities/legacyVessel/api/useLegacyVessels";
|
||||||
|
import { buildLegacyVesselIndex } from "../../entities/legacyVessel/lib";
|
||||||
|
import type { LegacyVesselIndex } from "../../entities/legacyVessel/lib";
|
||||||
|
import type { LegacyVesselDataset } from "../../entities/legacyVessel/model/types";
|
||||||
|
import { useZones } from "../../entities/zone/api/useZones";
|
||||||
|
import type { VesselTypeCode } from "../../entities/vessel/model/types";
|
||||||
|
import { AisInfoPanel } from "../../widgets/aisInfo/AisInfoPanel";
|
||||||
|
import { AisTargetList } from "../../widgets/aisTargetList/AisTargetList";
|
||||||
|
import { AlarmsPanel } from "../../widgets/alarms/AlarmsPanel";
|
||||||
|
import { MapLegend } from "../../widgets/legend/MapLegend";
|
||||||
|
import { Map3D, type BaseMapId, type Map3DSettings, type MapProjectionId } from "../../widgets/map3d/Map3D";
|
||||||
|
import { RelationsPanel } from "../../widgets/relations/RelationsPanel";
|
||||||
|
import { SpeedProfilePanel } from "../../widgets/speed/SpeedProfilePanel";
|
||||||
|
import { Topbar } from "../../widgets/topbar/Topbar";
|
||||||
|
import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel";
|
||||||
|
import { VesselList } from "../../widgets/vesselList/VesselList";
|
||||||
|
import {
|
||||||
|
buildLegacyHitMap,
|
||||||
|
computeCountsByType,
|
||||||
|
computeFcLinks,
|
||||||
|
computeFleetCircles,
|
||||||
|
computeLegacyAlarms,
|
||||||
|
computePairLinks,
|
||||||
|
deriveLegacyVessels,
|
||||||
|
filterByShipCodes,
|
||||||
|
} from "../../features/legacyDashboard/model/derive";
|
||||||
|
import { VESSEL_TYPE_ORDER } from "../../entities/vessel/model/meta";
|
||||||
|
|
||||||
|
const AIS_API_BASE = (import.meta.env.VITE_API_URL || "/snp-api").replace(/\/$/, "");
|
||||||
|
|
||||||
|
function fmtLocal(iso: string | null) {
|
||||||
|
if (!iso) return "-";
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return iso;
|
||||||
|
return d.toLocaleString("ko-KR", { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
type Bbox = [number, number, number, number]; // [lonMin, latMin, lonMax, latMax]
|
||||||
|
|
||||||
|
function inBbox(lon: number, lat: number, bbox: Bbox) {
|
||||||
|
const [lonMin, latMin, lonMax, latMax] = bbox;
|
||||||
|
if (lat < latMin || lat > latMax) return false;
|
||||||
|
if (lonMin <= lonMax) return lon >= lonMin && lon <= lonMax;
|
||||||
|
return lon >= lonMin || lon <= lonMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtBbox(b: Bbox | null) {
|
||||||
|
if (!b) return "-";
|
||||||
|
return `${b[0].toFixed(4)},${b[1].toFixed(4)},${b[2].toFixed(4)},${b[3].toFixed(4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useLegacyIndex(data: LegacyVesselDataset | null): LegacyVesselIndex | null {
|
||||||
|
return useMemo(() => (data ? buildLegacyVesselIndex(data.vessels) : null), [data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
const { data: zones, error: zonesError } = useZones();
|
||||||
|
const { data: legacyData, error: legacyError } = useLegacyVessels();
|
||||||
|
const legacyIndex = useLegacyIndex(legacyData);
|
||||||
|
|
||||||
|
const [viewBbox, setViewBbox] = useState<Bbox | null>(null);
|
||||||
|
const [useViewportFilter, setUseViewportFilter] = useState(false);
|
||||||
|
const [useApiBbox, setUseApiBbox] = useState(false);
|
||||||
|
const [apiBbox, setApiBbox] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const { targets, snapshot } = useAisTargetPolling({
|
||||||
|
initialMinutes: 60,
|
||||||
|
incrementalMinutes: 2,
|
||||||
|
intervalMs: 60_000,
|
||||||
|
retentionMinutes: 90,
|
||||||
|
bbox: useApiBbox ? apiBbox : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [selectedMmsi, setSelectedMmsi] = useState<number | null>(null);
|
||||||
|
const [typeEnabled, setTypeEnabled] = useState<Record<VesselTypeCode, boolean>>({
|
||||||
|
PT: true,
|
||||||
|
"PT-S": true,
|
||||||
|
GN: true,
|
||||||
|
OT: true,
|
||||||
|
PS: true,
|
||||||
|
FC: true,
|
||||||
|
});
|
||||||
|
const [showTargets, setShowTargets] = useState(true);
|
||||||
|
const [showOthers, setShowOthers] = useState(false);
|
||||||
|
|
||||||
|
const [baseMap, setBaseMap] = useState<BaseMapId>("enhanced");
|
||||||
|
const [projection, setProjection] = useState<MapProjectionId>("mercator");
|
||||||
|
|
||||||
|
const [overlays, setOverlays] = useState<MapToggleState>({
|
||||||
|
pairLines: true,
|
||||||
|
pairRange: false,
|
||||||
|
fcLines: true,
|
||||||
|
zones: true,
|
||||||
|
fleetCircles: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [settings, setSettings] = useState<Map3DSettings>({
|
||||||
|
showShips: true,
|
||||||
|
showDensity: false,
|
||||||
|
showSeamark: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [clock, setClock] = useState(() => new Date().toLocaleString("ko-KR", { hour12: false }));
|
||||||
|
useEffect(() => {
|
||||||
|
const id = window.setInterval(() => setClock(new Date().toLocaleString("ko-KR", { hour12: false })), 1000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Secret admin toggle: 7 clicks within 900ms on the logo.
|
||||||
|
const [adminMode, setAdminMode] = useState(false);
|
||||||
|
const clicksRef = useRef<number[]>([]);
|
||||||
|
const onLogoClick = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
clicksRef.current = clicksRef.current.filter((t) => now - t < 900);
|
||||||
|
clicksRef.current.push(now);
|
||||||
|
if (clicksRef.current.length >= 7) {
|
||||||
|
clicksRef.current = [];
|
||||||
|
setAdminMode((v) => !v);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetsInScope = useMemo(() => {
|
||||||
|
if (!useViewportFilter || !viewBbox) return targets;
|
||||||
|
return targets.filter((t) => typeof t.lon === "number" && typeof t.lat === "number" && inBbox(t.lon, t.lat, viewBbox));
|
||||||
|
}, [targets, useViewportFilter, viewBbox]);
|
||||||
|
|
||||||
|
const legacyHits = useMemo(() => buildLegacyHitMap(targetsInScope, legacyIndex), [targetsInScope, legacyIndex]);
|
||||||
|
const legacyVesselsAll = useMemo(() => deriveLegacyVessels({ targets: targetsInScope, legacyHits, zones }), [targetsInScope, legacyHits, zones]);
|
||||||
|
const legacyCounts = useMemo(() => computeCountsByType(legacyVesselsAll), [legacyVesselsAll]);
|
||||||
|
|
||||||
|
const legacyVesselsFiltered = useMemo(() => {
|
||||||
|
if (!showTargets) return [];
|
||||||
|
return filterByShipCodes(legacyVesselsAll, typeEnabled);
|
||||||
|
}, [legacyVesselsAll, showTargets, typeEnabled]);
|
||||||
|
|
||||||
|
const legacyMmsiForMap = useMemo(() => new Set(legacyVesselsFiltered.map((v) => v.mmsi)), [legacyVesselsFiltered]);
|
||||||
|
|
||||||
|
const targetsForMap = useMemo(() => {
|
||||||
|
const out = [];
|
||||||
|
for (const t of targetsInScope) {
|
||||||
|
const mmsi = t.mmsi;
|
||||||
|
if (typeof mmsi !== "number") continue;
|
||||||
|
const isLegacy = legacyHits.has(mmsi);
|
||||||
|
if (isLegacy) {
|
||||||
|
if (!showTargets) continue;
|
||||||
|
if (!legacyMmsiForMap.has(mmsi)) continue;
|
||||||
|
out.push(t);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (showOthers) out.push(t);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [targetsInScope, legacyHits, showTargets, showOthers, legacyMmsiForMap]);
|
||||||
|
|
||||||
|
const pairLinksAll = useMemo(() => computePairLinks(legacyVesselsAll), [legacyVesselsAll]);
|
||||||
|
const fcLinksAll = useMemo(() => computeFcLinks(legacyVesselsAll), [legacyVesselsAll]);
|
||||||
|
const alarms = useMemo(() => computeLegacyAlarms({ vessels: legacyVesselsAll, pairLinks: pairLinksAll, fcLinks: fcLinksAll }), [legacyVesselsAll, pairLinksAll, fcLinksAll]);
|
||||||
|
|
||||||
|
const pairLinksForMap = useMemo(() => computePairLinks(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
||||||
|
const fcLinksForMap = useMemo(() => computeFcLinks(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
||||||
|
const fleetCirclesForMap = useMemo(() => computeFleetCircles(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
||||||
|
|
||||||
|
const selectedLegacyVessel = useMemo(() => {
|
||||||
|
if (!selectedMmsi) return null;
|
||||||
|
return legacyVesselsAll.find((v) => v.mmsi === selectedMmsi) ?? null;
|
||||||
|
}, [legacyVesselsAll, selectedMmsi]);
|
||||||
|
|
||||||
|
const selectedTarget = useMemo(() => {
|
||||||
|
if (!selectedMmsi) return null;
|
||||||
|
return targetsInScope.find((t) => t.mmsi === selectedMmsi) ?? null;
|
||||||
|
}, [targetsInScope, selectedMmsi]);
|
||||||
|
|
||||||
|
const selectedLegacyInfo = useMemo(() => {
|
||||||
|
if (!selectedMmsi) return null;
|
||||||
|
return legacyHits.get(selectedMmsi) ?? null;
|
||||||
|
}, [selectedMmsi, legacyHits]);
|
||||||
|
|
||||||
|
const fishingCount = legacyVesselsAll.filter((v) => v.state.isFishing).length;
|
||||||
|
const transitCount = legacyVesselsAll.filter((v) => v.state.isTransit).length;
|
||||||
|
|
||||||
|
const enabledTypes = useMemo(() => VESSEL_TYPE_ORDER.filter((c) => typeEnabled[c]), [typeEnabled]);
|
||||||
|
const speedPanelType = (selectedLegacyVessel?.shipCode ?? (enabledTypes.length === 1 ? enabledTypes[0] : "PT")) as VesselTypeCode;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<Topbar
|
||||||
|
total={legacyVesselsAll.length}
|
||||||
|
fishing={fishingCount}
|
||||||
|
transit={transitCount}
|
||||||
|
pairLinks={pairLinksAll.length}
|
||||||
|
alarms={alarms.length}
|
||||||
|
pollingStatus={snapshot.status}
|
||||||
|
lastFetchMinutes={snapshot.lastFetchMinutes}
|
||||||
|
clock={clock}
|
||||||
|
adminMode={adminMode}
|
||||||
|
onLogoClick={onLogoClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="sidebar">
|
||||||
|
<div className="sb">
|
||||||
|
<div className="sb-t">업종 필터</div>
|
||||||
|
<div className="tog">
|
||||||
|
<div
|
||||||
|
className={`tog-btn ${showTargets ? "on" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
setShowTargets((v) => {
|
||||||
|
const next = !v;
|
||||||
|
if (!next) setSelectedMmsi((m) => (legacyHits.has(m ?? -1) ? null : m));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
title="레거시(CN permit) 대상 선박 표시"
|
||||||
|
>
|
||||||
|
대상 선박
|
||||||
|
</div>
|
||||||
|
<div className={`tog-btn ${showOthers ? "on" : ""}`} onClick={() => setShowOthers((v) => !v)} title="대상 외 AIS 선박 표시">
|
||||||
|
기타 AIS
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TypeFilterGrid
|
||||||
|
enabled={typeEnabled}
|
||||||
|
totalCount={legacyVesselsAll.length}
|
||||||
|
countsByType={legacyCounts}
|
||||||
|
onToggle={(code) => {
|
||||||
|
// When hiding the currently selected legacy vessel's type, clear selection.
|
||||||
|
if (selectedLegacyVessel && selectedLegacyVessel.shipCode === code && typeEnabled[code]) setSelectedMmsi(null);
|
||||||
|
setTypeEnabled((prev) => ({ ...prev, [code]: !prev[code] }));
|
||||||
|
}}
|
||||||
|
onToggleAll={() => {
|
||||||
|
const allOn = VESSEL_TYPE_ORDER.every((c) => typeEnabled[c]);
|
||||||
|
const nextVal = !allOn; // any-off -> true, all-on -> false
|
||||||
|
if (!nextVal && selectedLegacyVessel) setSelectedMmsi(null);
|
||||||
|
setTypeEnabled({
|
||||||
|
PT: nextVal,
|
||||||
|
"PT-S": nextVal,
|
||||||
|
GN: nextVal,
|
||||||
|
OT: nextVal,
|
||||||
|
PS: nextVal,
|
||||||
|
FC: nextVal,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sb">
|
||||||
|
<div className="sb-t">지도 표시 설정</div>
|
||||||
|
<MapToggles value={overlays} onToggle={(k) => setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 700, color: "var(--muted)", letterSpacing: 1.5, marginTop: 8, marginBottom: 6 }}>
|
||||||
|
베이스맵
|
||||||
|
</div>
|
||||||
|
<div className="tog" style={{ flexWrap: "nowrap", alignItems: "center" }}>
|
||||||
|
<div className={`tog-btn ${baseMap === "enhanced" ? "on" : ""}`} onClick={() => setBaseMap("enhanced")} title="현재 지도(해저지형/3D 표현 포함)">
|
||||||
|
기본
|
||||||
|
</div>
|
||||||
|
<div className={`tog-btn ${baseMap === "legacy" ? "on" : ""}`} onClick={() => setBaseMap("legacy")} title="레거시 대시보드(Carto Dark) 베이스맵">
|
||||||
|
레거시
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`tog-btn ${projection === "globe" ? "on" : ""}`}
|
||||||
|
onClick={() => setProjection((p) => (p === "globe" ? "mercator" : "globe"))}
|
||||||
|
title="지구본(globe) 투영: 드래그로 회전, 휠로 확대/축소"
|
||||||
|
>
|
||||||
|
지구본
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: "var(--muted)" }}>지도 우하단 Attribution(라이센스) 표기 유지</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sb">
|
||||||
|
<div className="sb-t">속도 프로파일</div>
|
||||||
|
<SpeedProfilePanel selectedType={speedPanelType} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sb" style={{ maxHeight: 260, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||||
|
<div className="sb-t">
|
||||||
|
선단 연관관계{" "}
|
||||||
|
<span style={{ color: "var(--accent)", fontSize: 8 }}>
|
||||||
|
{selectedLegacyVessel ? `${selectedLegacyVessel.permitNo} 연관` : "(선박 클릭 시 표시)"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ overflowY: "auto", minHeight: 0 }}>
|
||||||
|
<RelationsPanel
|
||||||
|
selectedVessel={selectedLegacyVessel}
|
||||||
|
vessels={legacyVesselsAll}
|
||||||
|
fleetVessels={legacyVesselsFiltered}
|
||||||
|
onSelectMmsi={setSelectedMmsi}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sb" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||||
|
<div className="sb-t">
|
||||||
|
선박 목록{" "}
|
||||||
|
<span style={{ color: "var(--accent)", fontSize: 8 }}>
|
||||||
|
({legacyVesselsFiltered.length}척)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<VesselList vessels={legacyVesselsFiltered} selectedMmsi={selectedMmsi} onSelectMmsi={setSelectedMmsi} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sb" style={{ maxHeight: 130, overflowY: "auto" }}>
|
||||||
|
<div className="sb-t">실시간 경고</div>
|
||||||
|
<AlarmsPanel alarms={alarms} onSelectMmsi={setSelectedMmsi} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{adminMode ? (
|
||||||
|
<>
|
||||||
|
<div className="sb">
|
||||||
|
<div className="sb-t">ADMIN · AIS Target Polling</div>
|
||||||
|
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
||||||
|
<div style={{ color: "var(--muted)", fontSize: 10 }}>엔드포인트</div>
|
||||||
|
<div style={{ wordBreak: "break-all" }}>{AIS_API_BASE}/api/ais-target/search</div>
|
||||||
|
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}>상태</div>
|
||||||
|
<div>
|
||||||
|
<b style={{ color: snapshot.status === "ready" ? "#22C55E" : snapshot.status === "error" ? "#EF4444" : "#F59E0B" }}>
|
||||||
|
{snapshot.status.toUpperCase()}
|
||||||
|
</b>
|
||||||
|
{snapshot.error ? <span style={{ marginLeft: 6, color: "#EF4444" }}>{snapshot.error}</span> : null}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}>최근 fetch</div>
|
||||||
|
<div>
|
||||||
|
{fmtLocal(snapshot.lastFetchAt)}{" "}
|
||||||
|
<span style={{ color: "var(--muted)", fontSize: 10 }}>
|
||||||
|
({snapshot.lastFetchMinutes ?? "-"}m, inserted {snapshot.lastInserted}, upserted {snapshot.lastUpserted})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}>메시지</div>
|
||||||
|
<div style={{ color: "var(--text)", fontSize: 10 }}>{snapshot.lastMessage ?? "-"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sb">
|
||||||
|
<div className="sb-t">ADMIN · Legacy (CN Permit)</div>
|
||||||
|
{legacyError ? (
|
||||||
|
<div style={{ fontSize: 11, color: "#EF4444" }}>legacy load error: {legacyError}</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
||||||
|
<div style={{ color: "var(--muted)", fontSize: 10 }}>데이터셋</div>
|
||||||
|
<div style={{ wordBreak: "break-all", fontSize: 10 }}>/data/legacy/chinese-permitted.v1.json</div>
|
||||||
|
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}>매칭(현재 scope)</div>
|
||||||
|
<div>
|
||||||
|
<b style={{ color: "#F59E0B" }}>{legacyVesselsAll.length}</b>{" "}
|
||||||
|
<span style={{ color: "var(--muted)", fontSize: 10 }}>/ {targetsInScope.length}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}>생성시각</div>
|
||||||
|
<div style={{ fontSize: 10, color: "var(--text)" }}>{legacyData?.generatedAt ? fmtLocal(legacyData.generatedAt) : "loading..."}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sb">
|
||||||
|
<div className="sb-t">ADMIN · Viewport / BBox</div>
|
||||||
|
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
||||||
|
<div style={{ color: "var(--muted)", fontSize: 10 }}>현재 View BBox</div>
|
||||||
|
<div style={{ wordBreak: "break-all", fontSize: 10 }}>{fmtBbox(viewBbox)}</div>
|
||||||
|
<div style={{ marginTop: 8, display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setUseViewportFilter((v) => !v)}
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
padding: "4px 8px",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: useViewportFilter ? "rgba(59,130,246,.18)" : "var(--card)",
|
||||||
|
color: "var(--text)",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
title="지도/리스트에 현재 화면 영역 내 선박만 표시(클라이언트 필터)"
|
||||||
|
>
|
||||||
|
Viewport filter {useViewportFilter ? "ON" : "OFF"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (!viewBbox) return;
|
||||||
|
setUseApiBbox((v) => {
|
||||||
|
const next = !v;
|
||||||
|
if (next && viewBbox) setApiBbox(fmtBbox(viewBbox));
|
||||||
|
if (!next) setApiBbox(undefined);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
padding: "4px 8px",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: useApiBbox ? "rgba(245,158,11,.14)" : "var(--card)",
|
||||||
|
color: viewBbox ? "var(--text)" : "var(--muted)",
|
||||||
|
cursor: viewBbox ? "pointer" : "not-allowed",
|
||||||
|
}}
|
||||||
|
title="서버에서 bbox로 필터링해서 내려받기(페이로드 감소). 켜는 순간 현재 view bbox로 고정됨."
|
||||||
|
disabled={!viewBbox}
|
||||||
|
>
|
||||||
|
API bbox {useApiBbox ? "ON" : "OFF"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (!viewBbox) return;
|
||||||
|
setApiBbox(fmtBbox(viewBbox));
|
||||||
|
setUseApiBbox(true);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
padding: "4px 8px",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "var(--card)",
|
||||||
|
color: viewBbox ? "var(--text)" : "var(--muted)",
|
||||||
|
cursor: viewBbox ? "pointer" : "not-allowed",
|
||||||
|
}}
|
||||||
|
disabled={!viewBbox}
|
||||||
|
title="현재 view bbox로 API bbox를 갱신"
|
||||||
|
>
|
||||||
|
bbox=viewport
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 8, color: "var(--muted)", fontSize: 10 }}>
|
||||||
|
표시 선박: <b style={{ color: "var(--text)" }}>{targetsInScope.length}</b> / 스토어:{" "}
|
||||||
|
<b style={{ color: "var(--text)" }}>{snapshot.total}</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sb">
|
||||||
|
<div className="sb-t">ADMIN · Map (Extras)</div>
|
||||||
|
<Map3DSettingsToggles value={settings} onToggle={(k) => setSettings((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||||
|
<div style={{ fontSize: 10, color: "var(--muted)", marginTop: 6 }}>단일 WebGL 컨텍스트: MapboxOverlay(interleaved)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sb" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||||
|
<div className="sb-t">ADMIN · AIS Targets (All)</div>
|
||||||
|
<AisTargetList
|
||||||
|
targets={targetsInScope}
|
||||||
|
selectedMmsi={selectedMmsi}
|
||||||
|
onSelectMmsi={setSelectedMmsi}
|
||||||
|
legacyIndex={legacyIndex}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sb" style={{ maxHeight: 130, overflowY: "auto" }}>
|
||||||
|
<div className="sb-t">ADMIN · 수역 데이터</div>
|
||||||
|
{zonesError ? (
|
||||||
|
<div style={{ fontSize: 11, color: "#EF4444" }}>zones load error: {zonesError}</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 11, color: "var(--muted)" }}>
|
||||||
|
{zones ? `loaded (${zones.features.length} features)` : "loading..."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="map-area">
|
||||||
|
<Map3D
|
||||||
|
targets={targetsForMap}
|
||||||
|
zones={zones}
|
||||||
|
selectedMmsi={selectedMmsi}
|
||||||
|
settings={settings}
|
||||||
|
baseMap={baseMap}
|
||||||
|
projection={projection}
|
||||||
|
overlays={overlays}
|
||||||
|
onSelectMmsi={setSelectedMmsi}
|
||||||
|
onViewBboxChange={setViewBbox}
|
||||||
|
legacyHits={legacyHits}
|
||||||
|
pairLinks={pairLinksForMap}
|
||||||
|
fcLinks={fcLinksForMap}
|
||||||
|
fleetCircles={fleetCirclesForMap}
|
||||||
|
/>
|
||||||
|
<MapLegend />
|
||||||
|
{selectedLegacyVessel ? (
|
||||||
|
<VesselInfoPanel vessel={selectedLegacyVessel} allVessels={legacyVesselsAll} onClose={() => setSelectedMmsi(null)} onSelectMmsi={setSelectedMmsi} />
|
||||||
|
) : selectedTarget ? (
|
||||||
|
<AisInfoPanel target={selectedTarget} legacy={selectedLegacyInfo} onClose={() => setSelectedMmsi(null)} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
apps/web/src/shared/lib/color/hexToRgb.ts
Normal file
7
apps/web/src/shared/lib/color/hexToRgb.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export function hexToRgb(hex: string): [number, number, number] {
|
||||||
|
const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim());
|
||||||
|
if (!m) return [255, 255, 255];
|
||||||
|
const n = parseInt(m[1], 16);
|
||||||
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||||
|
}
|
||||||
|
|
||||||
12
apps/web/src/shared/lib/geo/haversineNm.ts
Normal file
12
apps/web/src/shared/lib/geo/haversineNm.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export function haversineNm(lat1: number, lon1: number, lat2: number, lon2: number) {
|
||||||
|
const R = 3440.065; // nautical miles
|
||||||
|
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||||
|
const dLon = ((lon2 - lon1) * Math.PI) / 180;
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos((lat1 * Math.PI) / 180) *
|
||||||
|
Math.cos((lat2 * Math.PI) / 180) *
|
||||||
|
Math.sin(dLon / 2) ** 2;
|
||||||
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
}
|
||||||
|
|
||||||
38
apps/web/src/shared/lib/geo/pointInPolygon.ts
Normal file
38
apps/web/src/shared/lib/geo/pointInPolygon.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
type Coord = [number, number]; // [lon, lat]
|
||||||
|
|
||||||
|
function pointInRing(lon: number, lat: number, ring: Coord[]): boolean {
|
||||||
|
// Ray-casting algorithm. Assumes ring is closed or open; works either way.
|
||||||
|
let inside = false;
|
||||||
|
const n = ring.length;
|
||||||
|
if (n < 3) return false;
|
||||||
|
|
||||||
|
for (let i = 0, j = n - 1; i < n; j = i++) {
|
||||||
|
const xi = ring[i][0];
|
||||||
|
const yi = ring[i][1];
|
||||||
|
const xj = ring[j][0];
|
||||||
|
const yj = ring[j][1];
|
||||||
|
|
||||||
|
const intersect = yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi + 0.0) + xi;
|
||||||
|
if (intersect) inside = !inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pointInPolygon(lon: number, lat: number, polygon: Coord[][]): boolean {
|
||||||
|
// polygon: [outerRing, ...holes]
|
||||||
|
if (!polygon.length) return false;
|
||||||
|
if (!pointInRing(lon, lat, polygon[0])) return false;
|
||||||
|
for (let i = 1; i < polygon.length; i++) {
|
||||||
|
if (pointInRing(lon, lat, polygon[i])) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pointInMultiPolygon(lon: number, lat: number, multiPolygon: Coord[][][]): boolean {
|
||||||
|
for (const poly of multiPolygon) {
|
||||||
|
if (pointInPolygon(lon, lat, poly)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
22
apps/web/src/shared/shims/child_process.ts
Normal file
22
apps/web/src/shared/shims/child_process.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
function unsupported(name: string): never {
|
||||||
|
throw new Error(`${name} is not available in the browser build`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spawn() {
|
||||||
|
return unsupported("child_process.spawn");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fork() {
|
||||||
|
return unsupported("child_process.fork");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exec() {
|
||||||
|
return unsupported("child_process.exec");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function execFile() {
|
||||||
|
return unsupported("child_process.execFile");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { spawn, fork, exec, execFile };
|
||||||
|
|
||||||
96
apps/web/src/widgets/aisInfo/AisInfoPanel.tsx
Normal file
96
apps/web/src/widgets/aisInfo/AisInfoPanel.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import type { AisTarget } from "../../entities/aisTarget/model/types";
|
||||||
|
import type { LegacyVesselInfo } from "../../entities/legacyVessel/model/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
target: AisTarget;
|
||||||
|
legacy?: LegacyVesselInfo | null;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AisInfoPanel({ target: t, legacy, onClose }: Props) {
|
||||||
|
const name = (t.name || "").trim() || "(no name)";
|
||||||
|
return (
|
||||||
|
<div className="map-info">
|
||||||
|
<button className="close-btn" onClick={onClose} aria-label="close">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 900, color: "var(--accent)" }}>{name}</div>
|
||||||
|
<div style={{ fontSize: 10, color: "var(--muted)" }}>
|
||||||
|
MMSI {t.mmsi} · {t.vesselType || "Unknown"} · Class {t.classType || "?"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{legacy ? (
|
||||||
|
<div style={{ marginBottom: 8, padding: "8px 8px", borderRadius: 8, border: "1px solid rgba(245,158,11,.35)", background: "rgba(245,158,11,.06)" }}>
|
||||||
|
<div style={{ fontSize: 10, fontWeight: 900, color: "#F59E0B", marginBottom: 4 }}>CN Permit Match</div>
|
||||||
|
<div style={{ fontSize: 10, color: "var(--text)", lineHeight: 1.5 }}>
|
||||||
|
<div>
|
||||||
|
<span style={{ color: "var(--muted)" }}>업종</span> <b>{legacy.shipCode}</b> ·{" "}
|
||||||
|
<span style={{ color: "var(--muted)" }}>허가번호</span> <b>{legacy.permitNo}</b>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style={{ color: "var(--muted)" }}>허가수역</span> <b>{legacy.workSeaArea || "-"}</b> ·{" "}
|
||||||
|
<span style={{ color: "var(--muted)" }}>톤수</span> <b>{legacy.ton ?? "-"}</b>
|
||||||
|
</div>
|
||||||
|
{legacy.ownerCn || legacy.ownerRoman ? (
|
||||||
|
<div>
|
||||||
|
<span style={{ color: "var(--muted)" }}>소유주</span>{" "}
|
||||||
|
<b>{legacy.ownerCn || legacy.ownerRoman}</b>
|
||||||
|
{legacy.ownerCn && legacy.ownerRoman ? <span style={{ color: "var(--muted)" }}> ({legacy.ownerRoman})</span> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{legacy.pairPermitNo ? (
|
||||||
|
<div>
|
||||||
|
<span style={{ color: "var(--muted)" }}>짝</span> <b>{legacy.pairPermitNo}</b>
|
||||||
|
{legacy.pairShipNameCn ? <span style={{ color: "var(--muted)" }}> · {legacy.pairShipNameCn}</span> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="ir">
|
||||||
|
<span className="il">SOG</span>
|
||||||
|
<span className="iv">{t.sog ?? "?"} kt</span>
|
||||||
|
</div>
|
||||||
|
<div className="ir">
|
||||||
|
<span className="il">COG</span>
|
||||||
|
<span className="iv">{t.cog ?? "?"}°</span>
|
||||||
|
</div>
|
||||||
|
<div className="ir">
|
||||||
|
<span className="il">Heading</span>
|
||||||
|
<span className="iv">{t.heading ?? "?"}°</span>
|
||||||
|
</div>
|
||||||
|
<div className="ir">
|
||||||
|
<span className="il">Status</span>
|
||||||
|
<span className="iv">{t.status || "N/A"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="ir">
|
||||||
|
<span className="il">Position</span>
|
||||||
|
<span className="iv">
|
||||||
|
{Number.isFinite(t.lat) ? t.lat.toFixed(5) : "?"}, {Number.isFinite(t.lon) ? t.lon.toFixed(5) : "?"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="ir">
|
||||||
|
<span className="il">Dest</span>
|
||||||
|
<span className="iv">{(t.destination || "").trim() || "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="ir">
|
||||||
|
<span className="il">ETA</span>
|
||||||
|
<span className="iv">{t.eta || "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="ir">
|
||||||
|
<span className="il">Msg TS</span>
|
||||||
|
<span className="iv">{t.messageTimestamp || "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="ir">
|
||||||
|
<span className="il">Received</span>
|
||||||
|
<span className="iv">{t.receivedDate || "-"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
apps/web/src/widgets/aisTargetList/AisTargetList.tsx
Normal file
133
apps/web/src/widgets/aisTargetList/AisTargetList.tsx
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import type { AisTarget } from "../../entities/aisTarget/model/types";
|
||||||
|
import type { LegacyVesselIndex } from "../../entities/legacyVessel/lib";
|
||||||
|
import { matchLegacyVessel } from "../../entities/legacyVessel/lib";
|
||||||
|
|
||||||
|
type SortMode = "recent" | "speed";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
targets: AisTarget[];
|
||||||
|
selectedMmsi: number | null;
|
||||||
|
onSelectMmsi: (mmsi: number) => void;
|
||||||
|
legacyIndex?: LegacyVesselIndex | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isFiniteNumber(x: unknown): x is number {
|
||||||
|
return typeof x === "number" && Number.isFinite(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSpeedColor(sog: unknown) {
|
||||||
|
if (!isFiniteNumber(sog)) return "#64748B";
|
||||||
|
if (sog >= 10) return "#3B82F6";
|
||||||
|
if (sog >= 1) return "#22C55E";
|
||||||
|
return "#64748B";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtLocalTime(iso: string | null | undefined) {
|
||||||
|
if (!iso) return "";
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return String(iso);
|
||||||
|
return d.toLocaleTimeString("ko-KR", { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AisTargetList({ targets, selectedMmsi, onSelectMmsi, legacyIndex }: Props) {
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [mode, setMode] = useState<SortMode>("recent");
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const query = q.trim();
|
||||||
|
let out = targets;
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
const qLower = query.toLowerCase();
|
||||||
|
const isDigits = /^[0-9]+$/.test(query);
|
||||||
|
|
||||||
|
out = targets.filter((t) => {
|
||||||
|
const name = (t.name || "").trim();
|
||||||
|
const callsign = (t.callsign || "").trim();
|
||||||
|
const mmsi = t.mmsi;
|
||||||
|
|
||||||
|
if (isDigits) return String(mmsi).includes(query);
|
||||||
|
return name.toLowerCase().includes(qLower) || callsign.toLowerCase().includes(qLower) || String(mmsi).includes(query);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = out.slice().sort((a, b) => {
|
||||||
|
if (mode === "speed") {
|
||||||
|
const as = isFiniteNumber(a.sog) ? a.sog : -1;
|
||||||
|
const bs = isFiniteNumber(b.sog) ? b.sog : -1;
|
||||||
|
if (bs !== as) return bs - as;
|
||||||
|
}
|
||||||
|
|
||||||
|
const at = Date.parse(a.messageTimestamp || "");
|
||||||
|
const bt = Date.parse(b.messageTimestamp || "");
|
||||||
|
const ats = Number.isFinite(at) ? at : 0;
|
||||||
|
const bts = Number.isFinite(bt) ? bt : 0;
|
||||||
|
if (bts !== ats) return bts - ats;
|
||||||
|
|
||||||
|
return (a.mmsi ?? 0) - (b.mmsi ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
return sorted.slice(0, 200);
|
||||||
|
}, [targets, q, mode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }}>
|
||||||
|
<div style={{ display: "flex", gap: 6, marginBottom: 6 }}>
|
||||||
|
<input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="검색: MMSI / 선박명 / CallSign"
|
||||||
|
className="ais-q"
|
||||||
|
/>
|
||||||
|
<div className="ais-mode">
|
||||||
|
<button className={`ais-mode-btn ${mode === "recent" ? "on" : ""}`} onClick={() => setMode("recent")}>
|
||||||
|
최신
|
||||||
|
</button>
|
||||||
|
<button className={`ais-mode-btn ${mode === "speed" ? "on" : ""}`} onClick={() => setMode("speed")}>
|
||||||
|
속도
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ais-list">
|
||||||
|
{rows.map((t) => {
|
||||||
|
const name = (t.name || "").trim() || "(no name)";
|
||||||
|
const sel = selectedMmsi && t.mmsi === selectedMmsi;
|
||||||
|
const sp = isFiniteNumber(t.sog) ? t.sog.toFixed(1) : "?";
|
||||||
|
const sc = getSpeedColor(t.sog);
|
||||||
|
const ts = fmtLocalTime(t.messageTimestamp);
|
||||||
|
const legacy = legacyIndex ? matchLegacyVessel(t, legacyIndex) : null;
|
||||||
|
const legacyCode = legacy?.shipCode || "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={t.mmsi} className={`ais-row ${sel ? "sel" : ""}`} onClick={() => onSelectMmsi(t.mmsi)} title={t.vesselType || ""}>
|
||||||
|
<div className="ais-dot" style={{ background: sc }} />
|
||||||
|
<div className="ais-nm">
|
||||||
|
<div className="ais-nm1">{name}</div>
|
||||||
|
<div className="ais-nm2">
|
||||||
|
MMSI <b>{t.mmsi}</b>
|
||||||
|
{t.callsign ? <span style={{ marginLeft: 6, opacity: 0.8 }}>{t.callsign}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ais-right">
|
||||||
|
{legacy ? (
|
||||||
|
<div className="ais-badges">
|
||||||
|
<span className={`ais-badge ${legacyCode}`}>
|
||||||
|
CN {legacyCode}
|
||||||
|
</span>
|
||||||
|
<span className="ais-badge pn">{legacy.permitNo}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="ais-sp" style={{ color: legacy ? "var(--text)" : sc }}>{sp}kt</div>
|
||||||
|
<div className="ais-ts">{ts}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{rows.length === 0 ? <div style={{ fontSize: 11, color: "var(--muted)" }}>(검색 결과 없음)</div> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
apps/web/src/widgets/alarms/AlarmsPanel.tsx
Normal file
26
apps/web/src/widgets/alarms/AlarmsPanel.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{shown.map((a, idx) => (
|
||||||
|
<div
|
||||||
|
key={`${a.kind}-${idx}`}
|
||||||
|
className={`ai ${a.severity}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (!onSelectMmsi) return;
|
||||||
|
const m = a.relatedMmsi[0];
|
||||||
|
if (typeof m === "number") onSelectMmsi(m);
|
||||||
|
}}
|
||||||
|
style={{ cursor: onSelectMmsi ? "pointer" : undefined }}
|
||||||
|
title={onSelectMmsi ? "클릭: 관련 선박 선택" : undefined}
|
||||||
|
>
|
||||||
|
<span className="at">{a.timeLabel}</span>
|
||||||
|
<span style={{ flex: 1 }}>{a.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
apps/web/src/widgets/info/VesselInfoPanel.tsx
Normal file
166
apps/web/src/widgets/info/VesselInfoPanel.tsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
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 ? "#F59E0B" : "#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>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
apps/web/src/widgets/legend/MapLegend.tsx
Normal file
90
apps/web/src/widgets/legend/MapLegend.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { ZONE_IDS, ZONE_META } from "../../entities/zone/model/meta";
|
||||||
|
|
||||||
|
export function MapLegend() {
|
||||||
|
return (
|
||||||
|
<div className="map-legend">
|
||||||
|
<div className="lt">수역</div>
|
||||||
|
{ZONE_IDS.map((z) => (
|
||||||
|
<div key={z} className="li">
|
||||||
|
<div className="ls" style={{ background: `${ZONE_META[z].color}33`, border: `1px solid ${ZONE_META[z].color}` }} />
|
||||||
|
{ZONE_META[z].name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="lt" style={{ marginTop: 8 }}>
|
||||||
|
AIS 선박(속도)
|
||||||
|
</div>
|
||||||
|
<div className="li">
|
||||||
|
<div className="ls" style={{ background: "#3B82F6", borderRadius: 999 }} />
|
||||||
|
SOG ≥ 10 kt
|
||||||
|
</div>
|
||||||
|
<div className="li">
|
||||||
|
<div className="ls" style={{ background: "#22C55E", borderRadius: 999 }} />
|
||||||
|
1 ≤ SOG < 10 kt
|
||||||
|
</div>
|
||||||
|
<div className="li">
|
||||||
|
<div className="ls" style={{ background: "#64748B", borderRadius: 999 }} />
|
||||||
|
SOG < 1 kt (or unknown)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lt" style={{ marginTop: 8 }}>
|
||||||
|
CN Permit(업종)
|
||||||
|
</div>
|
||||||
|
<div className="li">
|
||||||
|
<div className="ls" style={{ background: "#1E40AF", borderRadius: 999 }} />
|
||||||
|
PT 본선 (ring + 색상)
|
||||||
|
</div>
|
||||||
|
<div className="li">
|
||||||
|
<div className="ls" style={{ background: "#EA580C", borderRadius: 999 }} />
|
||||||
|
PT-S 부속선
|
||||||
|
</div>
|
||||||
|
<div className="li">
|
||||||
|
<div className="ls" style={{ background: "#10B981", borderRadius: 999 }} />
|
||||||
|
GN 유망
|
||||||
|
</div>
|
||||||
|
<div className="li">
|
||||||
|
<div className="ls" style={{ background: "#8B5CF6", borderRadius: 999 }} />
|
||||||
|
OT 1척식
|
||||||
|
</div>
|
||||||
|
<div className="li">
|
||||||
|
<div className="ls" style={{ background: "#EF4444", borderRadius: 999 }} />
|
||||||
|
PS 위망
|
||||||
|
</div>
|
||||||
|
<div className="li">
|
||||||
|
<div className="ls" style={{ background: "#F59E0B", borderRadius: 999 }} />
|
||||||
|
FC 운반선
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lt" style={{ marginTop: 8 }}>
|
||||||
|
밀도(3D)
|
||||||
|
</div>
|
||||||
|
<div className="li" style={{ color: "var(--muted)" }}>
|
||||||
|
Hexagon: 화면 내 AIS 포인트 집계
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lt" style={{ marginTop: 8 }}>
|
||||||
|
연결선
|
||||||
|
</div>
|
||||||
|
<div className="li">
|
||||||
|
<div style={{ width: 20, height: 2, background: "rgba(59,130,246,.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>
|
||||||
|
<div className="li">
|
||||||
|
<div style={{ width: 20, height: 2, background: "#F59E0B", borderRadius: 1 }} />
|
||||||
|
쌍 이격 경고 (>3NM)
|
||||||
|
</div>
|
||||||
|
<div className="li">
|
||||||
|
<div style={{ width: 20, height: 2, background: "#D97706", 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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
988
apps/web/src/widgets/map3d/Map3D.tsx
Normal file
988
apps/web/src/widgets/map3d/Map3D.tsx
Normal file
@ -0,0 +1,988 @@
|
|||||||
|
import { HexagonLayer } from "@deck.gl/aggregation-layers";
|
||||||
|
import { IconLayer, GeoJsonLayer, LineLayer, ScatterplotLayer } from "@deck.gl/layers";
|
||||||
|
import { MapboxOverlay } from "@deck.gl/mapbox";
|
||||||
|
import { MapView, _GlobeView as GlobeView, type PickingInfo } from "@deck.gl/core";
|
||||||
|
import maplibregl, {
|
||||||
|
type LayerSpecification,
|
||||||
|
type RasterDEMSourceSpecification,
|
||||||
|
type StyleSpecification,
|
||||||
|
type VectorSourceSpecification,
|
||||||
|
} from "maplibre-gl";
|
||||||
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import type { AisTarget } from "../../entities/aisTarget/model/types";
|
||||||
|
import type { LegacyVesselInfo } from "../../entities/legacyVessel/model/types";
|
||||||
|
import type { ZonesGeoJson } from "../../entities/zone/api/useZones";
|
||||||
|
import type { ZoneId } from "../../entities/zone/model/meta";
|
||||||
|
import { ZONE_META } from "../../entities/zone/model/meta";
|
||||||
|
import type { MapToggleState } from "../../features/mapToggles/MapToggles";
|
||||||
|
import type { FcLink, FleetCircle, PairLink } from "../../features/legacyDashboard/model/types";
|
||||||
|
import { hexToRgb } from "../../shared/lib/color/hexToRgb";
|
||||||
|
|
||||||
|
export type Map3DSettings = {
|
||||||
|
showSeamark: boolean;
|
||||||
|
showShips: boolean;
|
||||||
|
showDensity: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BaseMapId = "enhanced" | "legacy";
|
||||||
|
export type MapProjectionId = "mercator" | "globe";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
targets: AisTarget[];
|
||||||
|
zones: ZonesGeoJson | null;
|
||||||
|
selectedMmsi: number | null;
|
||||||
|
settings: Map3DSettings;
|
||||||
|
baseMap: BaseMapId;
|
||||||
|
projection: MapProjectionId;
|
||||||
|
overlays: MapToggleState;
|
||||||
|
onSelectMmsi: (mmsi: number | null) => void;
|
||||||
|
onViewBboxChange?: (bbox: [number, number, number, number]) => void;
|
||||||
|
legacyHits?: Map<number, LegacyVesselInfo> | null;
|
||||||
|
pairLinks?: PairLink[];
|
||||||
|
fcLinks?: FcLink[];
|
||||||
|
fleetCircles?: FleetCircle[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const SHIP_ICON_MAPPING = {
|
||||||
|
ship: {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 128,
|
||||||
|
height: 128,
|
||||||
|
anchorX: 64,
|
||||||
|
anchorY: 64,
|
||||||
|
mask: true,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function isFiniteNumber(x: unknown): x is number {
|
||||||
|
return typeof x === "number" && Number.isFinite(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEGACY_CODE_COLORS: Record<string, [number, number, number]> = {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEPTH_DISABLED_PARAMS = {
|
||||||
|
// In MapLibre+Deck (interleaved), the map often leaves a depth buffer populated.
|
||||||
|
// For 2D overlays like zones/icons/halos we want stable painter's-order rendering
|
||||||
|
// to avoid z-fighting flicker when layers overlap at (or near) the same z.
|
||||||
|
depthCompare: "always",
|
||||||
|
depthWriteEnabled: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const GLOBE_OVERLAY_PARAMS = {
|
||||||
|
// In globe mode we want depth-testing against the globe so features on the far side don't draw through.
|
||||||
|
// Still disable depth writes so our overlays don't interfere with each other.
|
||||||
|
depthCompare: "less-equal",
|
||||||
|
depthWriteEnabled: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function getMapTilerKey(): string | null {
|
||||||
|
const k = import.meta.env.VITE_MAPTILER_KEY;
|
||||||
|
if (typeof k !== "string") return null;
|
||||||
|
const v = k.trim();
|
||||||
|
return v ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSeamarkOverlay(map: maplibregl.Map, beforeLayerId?: string) {
|
||||||
|
const srcId = "seamark";
|
||||||
|
const layerId = "seamark";
|
||||||
|
|
||||||
|
if (!map.getSource(srcId)) {
|
||||||
|
map.addSource(srcId, {
|
||||||
|
type: "raster",
|
||||||
|
tiles: ["https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png"],
|
||||||
|
tileSize: 256,
|
||||||
|
attribution: "© OpenSeaMap contributors",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getLayer(layerId)) {
|
||||||
|
const layer: LayerSpecification = {
|
||||||
|
id: layerId,
|
||||||
|
type: "raster",
|
||||||
|
source: srcId,
|
||||||
|
paint: { "raster-opacity": 0.85 },
|
||||||
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
|
// By default, MapLibre adds new layers to the top.
|
||||||
|
// For readability we want seamarks above bathymetry fill, but below bathymetry lines/labels.
|
||||||
|
const before = beforeLayerId && map.getLayer(beforeLayerId) ? beforeLayerId : undefined;
|
||||||
|
map.addLayer(layer, before);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: string) {
|
||||||
|
const oceanSourceId = "maptiler-ocean";
|
||||||
|
const terrainSourceId = "maptiler-terrain";
|
||||||
|
|
||||||
|
if (!style.sources) style.sources = {} as StyleSpecification["sources"];
|
||||||
|
if (!style.layers) style.layers = [];
|
||||||
|
|
||||||
|
if (!style.sources[oceanSourceId]) {
|
||||||
|
style.sources[oceanSourceId] = {
|
||||||
|
type: "vector",
|
||||||
|
url: `https://api.maptiler.com/tiles/ocean/tiles.json?key=${encodeURIComponent(maptilerKey)}`,
|
||||||
|
} satisfies VectorSourceSpecification as unknown as StyleSpecification["sources"][string];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!style.sources[terrainSourceId]) {
|
||||||
|
style.sources[terrainSourceId] = {
|
||||||
|
type: "raster-dem",
|
||||||
|
url: `https://api.maptiler.com/tiles/terrain-rgb/tiles.json?key=${encodeURIComponent(maptilerKey)}`,
|
||||||
|
tileSize: 512,
|
||||||
|
encoding: "mapbox",
|
||||||
|
} satisfies RasterDEMSourceSpecification as unknown as StyleSpecification["sources"][string];
|
||||||
|
}
|
||||||
|
|
||||||
|
const depth = ["to-number", ["get", "depth"]] as unknown as number[];
|
||||||
|
const depthLabel = ["concat", ["to-string", ["*", depth, -1]], "m"] as unknown as string[];
|
||||||
|
|
||||||
|
const bathyFillColor = [
|
||||||
|
"interpolate",
|
||||||
|
["linear"],
|
||||||
|
depth,
|
||||||
|
-11000,
|
||||||
|
"#00040b",
|
||||||
|
-8000,
|
||||||
|
"#010610",
|
||||||
|
-6000,
|
||||||
|
"#020816",
|
||||||
|
-4000,
|
||||||
|
"#030c1c",
|
||||||
|
-2000,
|
||||||
|
"#041022",
|
||||||
|
-1000,
|
||||||
|
"#051529",
|
||||||
|
-500,
|
||||||
|
"#061a30",
|
||||||
|
-200,
|
||||||
|
"#071f36",
|
||||||
|
-100,
|
||||||
|
"#08263d",
|
||||||
|
-50,
|
||||||
|
"#092c44",
|
||||||
|
-20,
|
||||||
|
"#0a334b",
|
||||||
|
0,
|
||||||
|
"#0b3a53",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const bathyHillshade: LayerSpecification = {
|
||||||
|
id: "bathymetry-hillshade",
|
||||||
|
type: "hillshade",
|
||||||
|
source: terrainSourceId,
|
||||||
|
paint: {
|
||||||
|
"hillshade-illumination-anchor": "viewport",
|
||||||
|
"hillshade-illumination-direction": 315,
|
||||||
|
"hillshade-illumination-altitude": 45,
|
||||||
|
"hillshade-exaggeration": ["interpolate", ["linear"], ["zoom"], 0, 0.15, 6, 0.25, 10, 0.32],
|
||||||
|
// Dark-mode tuned shading. Alpha is baked into the colors.
|
||||||
|
"hillshade-shadow-color": "rgba(0,0,0,0.45)",
|
||||||
|
"hillshade-highlight-color": "rgba(255,255,255,0.18)",
|
||||||
|
"hillshade-accent-color": "rgba(255,255,255,0.06)",
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
|
const bathyFill: LayerSpecification = {
|
||||||
|
id: "bathymetry-fill",
|
||||||
|
type: "fill",
|
||||||
|
source: oceanSourceId,
|
||||||
|
"source-layer": "contour",
|
||||||
|
paint: {
|
||||||
|
// Dark-mode friendly palette (shallow = slightly brighter; deep = near-black).
|
||||||
|
"fill-color": bathyFillColor,
|
||||||
|
"fill-opacity": ["interpolate", ["linear"], ["zoom"], 0, 0.9, 6, 0.86, 10, 0.78],
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
|
const bathyExtrusion: LayerSpecification = {
|
||||||
|
id: "bathymetry-extrusion",
|
||||||
|
type: "fill-extrusion",
|
||||||
|
source: oceanSourceId,
|
||||||
|
"source-layer": "contour",
|
||||||
|
minzoom: 6,
|
||||||
|
paint: {
|
||||||
|
"fill-extrusion-color": bathyFillColor,
|
||||||
|
// MapLibre fill-extrusion cannot go below 0m, so we exaggerate the "relative seabed height"
|
||||||
|
// (shallow areas higher, deep areas lower) to create a stepped relief.
|
||||||
|
"fill-extrusion-base": 0,
|
||||||
|
// NOTE: `zoom` can only appear as the input to a top-level `step`/`interpolate`.
|
||||||
|
"fill-extrusion-height": [
|
||||||
|
"interpolate",
|
||||||
|
["linear"],
|
||||||
|
["zoom"],
|
||||||
|
6,
|
||||||
|
["*", ["+", depth, 12000], 0.002], // depth is negative; -> range [0..12000]
|
||||||
|
10,
|
||||||
|
["*", ["+", depth, 12000], 0.01],
|
||||||
|
],
|
||||||
|
"fill-extrusion-opacity": ["interpolate", ["linear"], ["zoom"], 6, 0.0, 7, 0.25, 10, 0.55],
|
||||||
|
"fill-extrusion-vertical-gradient": true,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
|
const bathyBandBorders: LayerSpecification = {
|
||||||
|
id: "bathymetry-borders",
|
||||||
|
type: "line",
|
||||||
|
source: oceanSourceId,
|
||||||
|
"source-layer": "contour",
|
||||||
|
minzoom: 4,
|
||||||
|
paint: {
|
||||||
|
"line-color": "rgba(255,255,255,0.06)",
|
||||||
|
"line-opacity": ["interpolate", ["linear"], ["zoom"], 4, 0.12, 8, 0.18, 12, 0.22],
|
||||||
|
"line-blur": ["interpolate", ["linear"], ["zoom"], 4, 0.8, 10, 0.2],
|
||||||
|
"line-width": ["interpolate", ["linear"], ["zoom"], 4, 0.2, 8, 0.35, 12, 0.6],
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
|
const bathyLinesMinor: LayerSpecification = {
|
||||||
|
id: "bathymetry-lines",
|
||||||
|
type: "line",
|
||||||
|
source: oceanSourceId,
|
||||||
|
"source-layer": "contour_line",
|
||||||
|
minzoom: 8,
|
||||||
|
paint: {
|
||||||
|
"line-color": [
|
||||||
|
"interpolate",
|
||||||
|
["linear"],
|
||||||
|
depth,
|
||||||
|
-11000,
|
||||||
|
"rgba(255,255,255,0.04)",
|
||||||
|
-6000,
|
||||||
|
"rgba(255,255,255,0.05)",
|
||||||
|
-2000,
|
||||||
|
"rgba(255,255,255,0.07)",
|
||||||
|
0,
|
||||||
|
"rgba(255,255,255,0.10)",
|
||||||
|
],
|
||||||
|
"line-opacity": ["interpolate", ["linear"], ["zoom"], 8, 0.18, 10, 0.22, 12, 0.28],
|
||||||
|
"line-blur": ["interpolate", ["linear"], ["zoom"], 8, 0.8, 11, 0.3],
|
||||||
|
"line-width": ["interpolate", ["linear"], ["zoom"], 8, 0.35, 10, 0.55, 12, 0.85],
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
|
const majorDepths = [-50, -100, -200, -500, -1000, -2000, -4000, -6000, -8000, -9500];
|
||||||
|
const bathyLinesMajor: LayerSpecification = {
|
||||||
|
id: "bathymetry-lines-major",
|
||||||
|
type: "line",
|
||||||
|
source: oceanSourceId,
|
||||||
|
"source-layer": "contour_line",
|
||||||
|
minzoom: 8,
|
||||||
|
// Use legacy filter syntax here (not expression "in"), so we can pass multiple values.
|
||||||
|
filter: ["in", "depth", ...majorDepths] as unknown as unknown[],
|
||||||
|
paint: {
|
||||||
|
"line-color": "rgba(255,255,255,0.16)",
|
||||||
|
"line-opacity": ["interpolate", ["linear"], ["zoom"], 8, 0.22, 10, 0.28, 12, 0.34],
|
||||||
|
"line-blur": ["interpolate", ["linear"], ["zoom"], 8, 0.4, 11, 0.2],
|
||||||
|
"line-width": ["interpolate", ["linear"], ["zoom"], 8, 0.6, 10, 0.95, 12, 1.3],
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
|
const bathyBandBordersMajor: LayerSpecification = {
|
||||||
|
id: "bathymetry-borders-major",
|
||||||
|
type: "line",
|
||||||
|
source: oceanSourceId,
|
||||||
|
"source-layer": "contour",
|
||||||
|
minzoom: 4,
|
||||||
|
// Use legacy filter syntax here (not expression "in"), so we can pass multiple values.
|
||||||
|
filter: ["in", "depth", ...majorDepths] as unknown as unknown[],
|
||||||
|
paint: {
|
||||||
|
"line-color": "rgba(255,255,255,0.14)",
|
||||||
|
"line-opacity": ["interpolate", ["linear"], ["zoom"], 4, 0.14, 8, 0.2, 12, 0.26],
|
||||||
|
"line-blur": ["interpolate", ["linear"], ["zoom"], 4, 0.3, 10, 0.15],
|
||||||
|
"line-width": ["interpolate", ["linear"], ["zoom"], 4, 0.35, 8, 0.55, 12, 0.85],
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
|
const bathyLabels: LayerSpecification = {
|
||||||
|
id: "bathymetry-labels",
|
||||||
|
type: "symbol",
|
||||||
|
source: oceanSourceId,
|
||||||
|
"source-layer": "contour_line",
|
||||||
|
minzoom: 10,
|
||||||
|
// Use legacy filter syntax here (not expression "in"), so we can pass multiple values.
|
||||||
|
filter: ["in", "depth", ...majorDepths] as unknown as unknown[],
|
||||||
|
layout: {
|
||||||
|
"symbol-placement": "line",
|
||||||
|
"text-field": depthLabel,
|
||||||
|
"text-font": ["Noto Sans Regular", "Open Sans Regular"],
|
||||||
|
"text-size": ["interpolate", ["linear"], ["zoom"], 10, 10, 12, 12],
|
||||||
|
"text-allow-overlap": false,
|
||||||
|
"text-padding": 2,
|
||||||
|
"text-rotation-alignment": "map",
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
"text-color": "rgba(226,232,240,0.72)",
|
||||||
|
"text-halo-color": "rgba(2,6,23,0.82)",
|
||||||
|
"text-halo-width": 1.0,
|
||||||
|
"text-halo-blur": 0.6,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
|
const landformLabels: LayerSpecification = {
|
||||||
|
id: "bathymetry-landforms",
|
||||||
|
type: "symbol",
|
||||||
|
source: oceanSourceId,
|
||||||
|
"source-layer": "landform",
|
||||||
|
minzoom: 8,
|
||||||
|
filter: ["has", "name"] as unknown as unknown[],
|
||||||
|
layout: {
|
||||||
|
"text-field": ["get", "name"] as unknown as unknown[],
|
||||||
|
"text-font": ["Noto Sans Italic", "Noto Sans Regular", "Open Sans Italic", "Open Sans Regular"],
|
||||||
|
"text-size": ["interpolate", ["linear"], ["zoom"], 8, 11, 10, 12, 12, 13],
|
||||||
|
"text-allow-overlap": false,
|
||||||
|
"text-anchor": "center",
|
||||||
|
"text-offset": [0, 0.0],
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
"text-color": "rgba(148,163,184,0.70)",
|
||||||
|
"text-halo-color": "rgba(2,6,23,0.85)",
|
||||||
|
"text-halo-width": 1.0,
|
||||||
|
"text-halo-blur": 0.7,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
|
// Insert before the first symbol layer (keep labels on top), otherwise append.
|
||||||
|
const layers = style.layers as LayerSpecification[];
|
||||||
|
const symbolIndex = layers.findIndex((l) => l.type === "symbol");
|
||||||
|
const insertAt = symbolIndex >= 0 ? symbolIndex : layers.length;
|
||||||
|
|
||||||
|
const toInsert = [
|
||||||
|
bathyFill,
|
||||||
|
bathyHillshade,
|
||||||
|
bathyExtrusion,
|
||||||
|
bathyBandBorders,
|
||||||
|
bathyBandBordersMajor,
|
||||||
|
bathyLinesMinor,
|
||||||
|
bathyLinesMajor,
|
||||||
|
bathyLabels,
|
||||||
|
landformLabels,
|
||||||
|
].filter(
|
||||||
|
(l) => !layers.some((x) => x.id === l.id),
|
||||||
|
);
|
||||||
|
if (toInsert.length > 0) layers.splice(insertAt, 0, ...toInsert);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveInitialMapStyle(signal: AbortSignal): Promise<string | StyleSpecification> {
|
||||||
|
const key = getMapTilerKey();
|
||||||
|
if (!key) return "/map/styles/osm-seamark.json";
|
||||||
|
|
||||||
|
const baseMapId = (import.meta.env.VITE_MAPTILER_BASE_MAP_ID || "dataviz-dark").trim();
|
||||||
|
const styleUrl = `https://api.maptiler.com/maps/${encodeURIComponent(baseMapId)}/style.json?key=${encodeURIComponent(key)}`;
|
||||||
|
|
||||||
|
const res = await fetch(styleUrl, { signal, headers: { accept: "application/json" } });
|
||||||
|
if (!res.ok) throw new Error(`MapTiler style fetch failed: ${res.status} ${res.statusText}`);
|
||||||
|
const json = (await res.json()) as StyleSpecification;
|
||||||
|
injectOceanBathymetryLayers(json, key);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveMapStyle(baseMap: BaseMapId, signal: AbortSignal): Promise<string | StyleSpecification> {
|
||||||
|
if (baseMap === "legacy") return "/map/styles/carto-dark.json";
|
||||||
|
return resolveInitialMapStyle(signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getShipColor(
|
||||||
|
t: AisTarget,
|
||||||
|
selectedMmsi: number | null,
|
||||||
|
legacyShipCode: string | null,
|
||||||
|
): [number, number, number, number] {
|
||||||
|
if (selectedMmsi && t.mmsi === selectedMmsi) return [255, 255, 255, 255];
|
||||||
|
if (legacyShipCode) {
|
||||||
|
const rgb = LEGACY_CODE_COLORS[legacyShipCode];
|
||||||
|
if (rgb) return [rgb[0], rgb[1], rgb[2], 235];
|
||||||
|
return [245, 158, 11, 235];
|
||||||
|
}
|
||||||
|
if (!isFiniteNumber(t.sog)) return [100, 116, 139, 160];
|
||||||
|
if (t.sog >= 10) return [59, 130, 246, 220];
|
||||||
|
if (t.sog >= 1) return [34, 197, 94, 210];
|
||||||
|
return [100, 116, 139, 160];
|
||||||
|
}
|
||||||
|
|
||||||
|
type DashSeg = { from: [number, number]; to: [number, number]; suspicious: boolean };
|
||||||
|
|
||||||
|
function dashifyLine(from: [number, number], to: [number, number], suspicious: boolean): DashSeg[] {
|
||||||
|
// Simple dashed effect: split into segments and render every other one.
|
||||||
|
const segs: DashSeg[] = [];
|
||||||
|
const steps = 14;
|
||||||
|
for (let i = 0; i < steps; i++) {
|
||||||
|
if (i % 2 === 1) continue;
|
||||||
|
const a0 = i / steps;
|
||||||
|
const a1 = (i + 1) / steps;
|
||||||
|
const lon0 = from[0] + (to[0] - from[0]) * a0;
|
||||||
|
const lat0 = from[1] + (to[1] - from[1]) * a0;
|
||||||
|
const lon1 = from[0] + (to[0] - from[0]) * a1;
|
||||||
|
const lat1 = from[1] + (to[1] - from[1]) * a1;
|
||||||
|
segs.push({ from: [lon0, lat0], to: [lon1, lat1], suspicious });
|
||||||
|
}
|
||||||
|
return segs;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PairRangeCircle = {
|
||||||
|
center: [number, number]; // [lon, lat]
|
||||||
|
radiusNm: number;
|
||||||
|
warn: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DECK_VIEW_ID = "mapbox";
|
||||||
|
|
||||||
|
function getDeckView(projection: MapProjectionId) {
|
||||||
|
return projection === "globe" ? new GlobeView({ id: DECK_VIEW_ID }) : new MapView({ id: DECK_VIEW_ID });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Map3D({
|
||||||
|
targets,
|
||||||
|
zones,
|
||||||
|
selectedMmsi,
|
||||||
|
settings,
|
||||||
|
baseMap,
|
||||||
|
projection,
|
||||||
|
overlays,
|
||||||
|
onSelectMmsi,
|
||||||
|
onViewBboxChange,
|
||||||
|
legacyHits,
|
||||||
|
pairLinks,
|
||||||
|
fcLinks,
|
||||||
|
fleetCircles,
|
||||||
|
}: Props) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||||
|
const overlayRef = useRef<MapboxOverlay | null>(null);
|
||||||
|
const showSeamarkRef = useRef(settings.showSeamark);
|
||||||
|
const baseMapRef = useRef<BaseMapId>(baseMap);
|
||||||
|
const projectionRef = useRef<MapProjectionId>(projection);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
showSeamarkRef.current = settings.showSeamark;
|
||||||
|
}, [settings.showSeamark]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
baseMapRef.current = baseMap;
|
||||||
|
}, [baseMap]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
projectionRef.current = projection;
|
||||||
|
}, [projection]);
|
||||||
|
|
||||||
|
// Init MapLibre + Deck.gl (single WebGL context via MapboxOverlay)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current || mapRef.current) return;
|
||||||
|
|
||||||
|
let map: maplibregl.Map | null = null;
|
||||||
|
let overlay: MapboxOverlay | null = null;
|
||||||
|
let cancelled = false;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
let style: string | StyleSpecification = "/map/styles/osm-seamark.json";
|
||||||
|
try {
|
||||||
|
style = await resolveMapStyle(baseMapRef.current, controller.signal);
|
||||||
|
} catch (e) {
|
||||||
|
// Don't block the app if MapTiler isn't configured yet.
|
||||||
|
// This is expected in early dev environments without `VITE_MAPTILER_KEY`.
|
||||||
|
console.warn("Map style init failed, falling back to local raster style:", e);
|
||||||
|
style = "/map/styles/osm-seamark.json";
|
||||||
|
}
|
||||||
|
if (cancelled || !containerRef.current) return;
|
||||||
|
|
||||||
|
map = new maplibregl.Map({
|
||||||
|
container: containerRef.current,
|
||||||
|
style,
|
||||||
|
center: [126.5, 34.2],
|
||||||
|
zoom: 7,
|
||||||
|
pitch: 45,
|
||||||
|
bearing: 0,
|
||||||
|
maxPitch: 85,
|
||||||
|
dragRotate: true,
|
||||||
|
pitchWithRotate: true,
|
||||||
|
touchPitch: true,
|
||||||
|
scrollZoom: { around: "center" },
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addControl(new maplibregl.NavigationControl({ showZoom: true, showCompass: true }), "top-left");
|
||||||
|
map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: "metric" }), "bottom-left");
|
||||||
|
|
||||||
|
// NOTE: `MapboxOverlayProps`'s TS typing pins `DeckProps` generics to `null`,
|
||||||
|
// which makes `views` type `null`. Runtime supports `views`; cast to keep TS happy.
|
||||||
|
overlay = new MapboxOverlay({ interleaved: true, layers: [], views: getDeckView(projectionRef.current) } as unknown as never);
|
||||||
|
map.addControl(overlay);
|
||||||
|
|
||||||
|
mapRef.current = map;
|
||||||
|
overlayRef.current = overlay;
|
||||||
|
|
||||||
|
function applyProjection() {
|
||||||
|
if (!map) return;
|
||||||
|
const next = projectionRef.current;
|
||||||
|
if (next === "mercator") return;
|
||||||
|
try {
|
||||||
|
map.setProjection({ type: next });
|
||||||
|
// Globe mode renders a single world; copies can look odd and aren't needed for KR region.
|
||||||
|
map.setRenderWorldCopies(next !== "globe");
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Projection apply failed:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the seamark raster overlay exists even when using MapTiler vector styles.
|
||||||
|
map.on("style.load", () => {
|
||||||
|
applyProjection();
|
||||||
|
if (!showSeamarkRef.current) return;
|
||||||
|
try {
|
||||||
|
ensureSeamarkOverlay(map!, "bathymetry-lines");
|
||||||
|
} catch {
|
||||||
|
// ignore (style not ready / already has it)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send initial bbox and update on move end (useful for lists / debug)
|
||||||
|
const emitBbox = () => {
|
||||||
|
const cb = onViewBboxChange;
|
||||||
|
if (!cb || !map) return;
|
||||||
|
const b = map.getBounds();
|
||||||
|
cb([b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]);
|
||||||
|
};
|
||||||
|
map.on("load", emitBbox);
|
||||||
|
map.on("moveend", emitBbox);
|
||||||
|
|
||||||
|
function applySeamarkOpacity() {
|
||||||
|
if (!map) return;
|
||||||
|
const opacity = settings.showSeamark ? 0.85 : 0;
|
||||||
|
try {
|
||||||
|
map.setPaintProperty("seamark", "raster-opacity", opacity);
|
||||||
|
} catch {
|
||||||
|
// style not ready yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
map.once("load", () => {
|
||||||
|
if (showSeamarkRef.current) {
|
||||||
|
try {
|
||||||
|
ensureSeamarkOverlay(map!, "bathymetry-lines");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
applySeamarkOpacity();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
if (map) {
|
||||||
|
map.remove();
|
||||||
|
map = null;
|
||||||
|
}
|
||||||
|
if (overlay) {
|
||||||
|
overlay.finalize();
|
||||||
|
overlay = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
overlayRef.current = null;
|
||||||
|
mapRef.current = null;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Projection toggle (mercator <-> globe)
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
// Recreate the Deck overlay so the Deck instance uses the correct View type
|
||||||
|
// (MapView vs GlobeView) and stays in sync with MapLibre.
|
||||||
|
try {
|
||||||
|
const old = overlayRef.current;
|
||||||
|
if (old) map.removeControl(old);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const next = new MapboxOverlay({ interleaved: true, layers: [], views: getDeckView(projection) } as unknown as never);
|
||||||
|
map.addControl(next);
|
||||||
|
overlayRef.current = next;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Deck overlay re-create failed:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyProjection = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
try {
|
||||||
|
map.setProjection({ type: projection });
|
||||||
|
map.setRenderWorldCopies(projection !== "globe");
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Projection switch failed:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (map.isStyleLoaded()) applyProjection();
|
||||||
|
else map.once("style.load", applyProjection);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
try {
|
||||||
|
map.off("style.load", applyProjection);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [projection]);
|
||||||
|
|
||||||
|
// Base map toggle
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const style = await resolveMapStyle(baseMap, controller.signal);
|
||||||
|
if (cancelled) return;
|
||||||
|
// Disable style diff to avoid warnings with custom layers (Deck MapboxOverlay) and
|
||||||
|
// to ensure a clean rebuild when switching between very different styles.
|
||||||
|
map.setStyle(style, { diff: false });
|
||||||
|
} catch (e) {
|
||||||
|
if (cancelled) return;
|
||||||
|
console.warn("Base map switch failed:", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [baseMap]);
|
||||||
|
|
||||||
|
// seamark toggle
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
if (settings.showSeamark) {
|
||||||
|
try {
|
||||||
|
ensureSeamarkOverlay(map, "bathymetry-lines");
|
||||||
|
map.setPaintProperty("seamark", "raster-opacity", 0.85);
|
||||||
|
} catch {
|
||||||
|
// ignore until style is ready
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If seamark is off, remove the layer+source to avoid unnecessary network tile requests.
|
||||||
|
try {
|
||||||
|
if (map.getLayer("seamark")) map.removeLayer("seamark");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (map.getSource("seamark")) map.removeSource("seamark");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [settings.showSeamark]);
|
||||||
|
|
||||||
|
const shipData = useMemo(() => {
|
||||||
|
return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon));
|
||||||
|
}, [targets]);
|
||||||
|
|
||||||
|
const legacyTargets = useMemo(() => {
|
||||||
|
if (!legacyHits) return [];
|
||||||
|
return shipData.filter((t) => legacyHits.has(t.mmsi));
|
||||||
|
}, [shipData, legacyHits]);
|
||||||
|
|
||||||
|
const fcDashed = useMemo(() => {
|
||||||
|
const segs: DashSeg[] = [];
|
||||||
|
for (const l of fcLinks || []) segs.push(...dashifyLine(l.from, l.to, l.suspicious));
|
||||||
|
return segs;
|
||||||
|
}, [fcLinks]);
|
||||||
|
|
||||||
|
const pairRanges = useMemo(() => {
|
||||||
|
const out: PairRangeCircle[] = [];
|
||||||
|
for (const p of pairLinks || []) {
|
||||||
|
const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2];
|
||||||
|
out.push({ center, radiusNm: Math.max(0.05, p.distanceNm / 2), warn: p.warn });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [pairLinks]);
|
||||||
|
|
||||||
|
// When the selected MMSI changes due to external UI (e.g., list click), fly to it.
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
if (!selectedMmsi) return;
|
||||||
|
const t = shipData.find((x) => x.mmsi === selectedMmsi);
|
||||||
|
if (!t) return;
|
||||||
|
map.easeTo({ center: [t.lon, t.lat], zoom: Math.max(map.getZoom(), 10), duration: 600 });
|
||||||
|
}, [selectedMmsi, shipData]);
|
||||||
|
|
||||||
|
// Update Deck.gl layers
|
||||||
|
useEffect(() => {
|
||||||
|
const overlay = overlayRef.current;
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!overlay || !map) return;
|
||||||
|
|
||||||
|
const overlayParams = projection === "globe" ? GLOBE_OVERLAY_PARAMS : DEPTH_DISABLED_PARAMS;
|
||||||
|
const layers = [];
|
||||||
|
|
||||||
|
if (settings.showDensity) {
|
||||||
|
layers.push(
|
||||||
|
new HexagonLayer<AisTarget>({
|
||||||
|
id: "density",
|
||||||
|
data: shipData,
|
||||||
|
pickable: true,
|
||||||
|
extruded: true,
|
||||||
|
radius: 2500,
|
||||||
|
elevationScale: 35,
|
||||||
|
coverage: 0.92,
|
||||||
|
opacity: 0.35,
|
||||||
|
getPosition: (d) => [d.lon, d.lat],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlays.zones && zones) {
|
||||||
|
layers.push(
|
||||||
|
new GeoJsonLayer({
|
||||||
|
id: "zones",
|
||||||
|
data: zones,
|
||||||
|
pickable: true,
|
||||||
|
// Avoid z-fighting flicker with other layers in the shared MapLibre depth buffer.
|
||||||
|
parameters: overlayParams,
|
||||||
|
stroked: true,
|
||||||
|
filled: true,
|
||||||
|
getFillColor: (f) => {
|
||||||
|
const zoneId = (f.properties as { zoneId?: string } | undefined)?.zoneId as ZoneId | undefined;
|
||||||
|
const col = zoneId ? ZONE_META[zoneId]?.color : "#3B82F6";
|
||||||
|
const [r, g, b] = hexToRgb(col);
|
||||||
|
return [r, g, b, 22];
|
||||||
|
},
|
||||||
|
getLineColor: (f) => {
|
||||||
|
const zoneId = (f.properties as { zoneId?: string } | undefined)?.zoneId as ZoneId | undefined;
|
||||||
|
const col = zoneId ? ZONE_META[zoneId]?.color : "#3B82F6";
|
||||||
|
const [r, g, b] = hexToRgb(col);
|
||||||
|
return [r, g, b, 200];
|
||||||
|
},
|
||||||
|
lineWidthMinPixels: 1,
|
||||||
|
lineWidthMaxPixels: 2,
|
||||||
|
getLineWidth: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) {
|
||||||
|
layers.push(
|
||||||
|
new ScatterplotLayer<FleetCircle>({
|
||||||
|
id: "fleet-circles",
|
||||||
|
data: fleetCircles,
|
||||||
|
pickable: false,
|
||||||
|
billboard: projection === "globe",
|
||||||
|
parameters: overlayParams,
|
||||||
|
filled: false,
|
||||||
|
stroked: true,
|
||||||
|
radiusUnits: "meters",
|
||||||
|
getRadius: (d) => d.radiusNm * 1852,
|
||||||
|
lineWidthUnits: "pixels",
|
||||||
|
getLineWidth: 1,
|
||||||
|
getLineColor: () => [245, 158, 11, 140],
|
||||||
|
getPosition: (d) => d.center,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlays.pairRange && pairRanges.length > 0) {
|
||||||
|
layers.push(
|
||||||
|
new ScatterplotLayer<PairRangeCircle>({
|
||||||
|
id: "pair-range",
|
||||||
|
data: pairRanges,
|
||||||
|
pickable: false,
|
||||||
|
billboard: projection === "globe",
|
||||||
|
parameters: overlayParams,
|
||||||
|
filled: false,
|
||||||
|
stroked: true,
|
||||||
|
radiusUnits: "meters",
|
||||||
|
getRadius: (d) => d.radiusNm * 1852,
|
||||||
|
radiusMinPixels: 10,
|
||||||
|
lineWidthUnits: "pixels",
|
||||||
|
getLineWidth: () => 1,
|
||||||
|
getLineColor: (d) => (d.warn ? [245, 158, 11, 170] : [59, 130, 246, 110]),
|
||||||
|
getPosition: (d) => d.center,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) {
|
||||||
|
layers.push(
|
||||||
|
new LineLayer<PairLink>({
|
||||||
|
id: "pair-lines",
|
||||||
|
data: pairLinks,
|
||||||
|
pickable: false,
|
||||||
|
parameters: overlayParams,
|
||||||
|
getSourcePosition: (d) => d.from,
|
||||||
|
getTargetPosition: (d) => d.to,
|
||||||
|
getColor: (d) => (d.warn ? [245, 158, 11, 220] : [59, 130, 246, 85]),
|
||||||
|
getWidth: (d) => (d.warn ? 2.2 : 1.4),
|
||||||
|
widthUnits: "pixels",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlays.fcLines && fcDashed.length > 0) {
|
||||||
|
layers.push(
|
||||||
|
new LineLayer<DashSeg>({
|
||||||
|
id: "fc-lines",
|
||||||
|
data: fcDashed,
|
||||||
|
pickable: false,
|
||||||
|
parameters: overlayParams,
|
||||||
|
getSourcePosition: (d) => d.from,
|
||||||
|
getTargetPosition: (d) => d.to,
|
||||||
|
getColor: (d) => (d.suspicious ? [239, 68, 68, 220] : [217, 119, 6, 200]),
|
||||||
|
getWidth: () => 1.3,
|
||||||
|
widthUnits: "pixels",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.showShips && legacyTargets.length > 0) {
|
||||||
|
layers.push(
|
||||||
|
new ScatterplotLayer<AisTarget>({
|
||||||
|
id: "legacy-halo",
|
||||||
|
data: legacyTargets,
|
||||||
|
pickable: false,
|
||||||
|
billboard: projection === "globe",
|
||||||
|
// This ring is most prone to z-fighting, so force it into pure painter's-order rendering.
|
||||||
|
parameters: overlayParams,
|
||||||
|
filled: false,
|
||||||
|
stroked: true,
|
||||||
|
radiusUnits: "pixels",
|
||||||
|
getRadius: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 22 : 16),
|
||||||
|
lineWidthUnits: "pixels",
|
||||||
|
getLineWidth: 2,
|
||||||
|
getLineColor: (d) => {
|
||||||
|
const l = legacyHits?.get(d.mmsi);
|
||||||
|
const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null;
|
||||||
|
if (!rgb) return [245, 158, 11, 200];
|
||||||
|
return [rgb[0], rgb[1], rgb[2], 200];
|
||||||
|
},
|
||||||
|
getPosition: (d) => [d.lon, d.lat],
|
||||||
|
updateTriggers: {
|
||||||
|
getRadius: [selectedMmsi],
|
||||||
|
getLineColor: [legacyHits],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.showShips) {
|
||||||
|
layers.push(
|
||||||
|
new IconLayer<AisTarget>({
|
||||||
|
id: "ships",
|
||||||
|
data: shipData,
|
||||||
|
pickable: true,
|
||||||
|
// Mercator: keep icons horizontal on the sea surface when view is pitched/rotated.
|
||||||
|
// Globe: billboard to keep the icon visible and glued to the globe.
|
||||||
|
billboard: projection === "globe",
|
||||||
|
parameters: projection === "globe" ? ({ ...overlayParams, cullMode: "none" } as const) : overlayParams,
|
||||||
|
iconAtlas: "/assets/ship.svg",
|
||||||
|
iconMapping: SHIP_ICON_MAPPING,
|
||||||
|
getIcon: () => "ship",
|
||||||
|
getPosition: (d) => [d.lon, d.lat],
|
||||||
|
getAngle: (d) => (isFiniteNumber(d.cog) ? d.cog : 0),
|
||||||
|
sizeUnits: "pixels",
|
||||||
|
getSize: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 34 : 22),
|
||||||
|
getColor: (d) => getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null),
|
||||||
|
alphaCutoff: 0.05,
|
||||||
|
updateTriggers: {
|
||||||
|
getSize: [selectedMmsi],
|
||||||
|
getColor: [selectedMmsi, legacyHits],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay.setProps({
|
||||||
|
layers,
|
||||||
|
getTooltip: (info: PickingInfo) => {
|
||||||
|
if (!info.object) return null;
|
||||||
|
if (info.layer && info.layer.id === "density") {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const o: any = info.object;
|
||||||
|
const n = Array.isArray(o?.points) ? o.points.length : 0;
|
||||||
|
return { text: `AIS density: ${n}` };
|
||||||
|
}
|
||||||
|
// zones
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const obj: any = info.object;
|
||||||
|
if (typeof obj.mmsi === "number") {
|
||||||
|
const t = obj as AisTarget;
|
||||||
|
const name = (t.name || "").trim() || "(no name)";
|
||||||
|
const legacy = legacyHits?.get(t.mmsi);
|
||||||
|
const legacyHtml = legacy
|
||||||
|
? `<div style="margin-top: 6px; padding-top: 6px; border-top: 1px solid rgba(255,255,255,.08)">
|
||||||
|
<div><b>CN Permit</b> · <b>${legacy.shipCode}</b> · ${legacy.permitNo}</div>
|
||||||
|
</div>`
|
||||||
|
: "";
|
||||||
|
return {
|
||||||
|
html: `<div style="font-family: system-ui; font-size: 12px;">
|
||||||
|
<div style="font-weight: 700; margin-bottom: 4px;">${name}</div>
|
||||||
|
<div>MMSI: <b>${t.mmsi}</b> · ${t.vesselType || "Unknown"}</div>
|
||||||
|
<div>SOG: <b>${t.sog ?? "?"}</b> kt · COG: <b>${t.cog ?? "?"}</b>°</div>
|
||||||
|
<div style="opacity:.8">${t.status || ""}</div>
|
||||||
|
<div style="opacity:.7; font-size: 11px; margin-top: 4px;">${t.messageTimestamp || ""}</div>
|
||||||
|
${legacyHtml}
|
||||||
|
</div>`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = obj.properties as { zoneName?: string; zoneLabel?: string } | undefined;
|
||||||
|
const label = p?.zoneName ?? p?.zoneLabel;
|
||||||
|
if (label) return { text: label };
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onClick: (info: PickingInfo) => {
|
||||||
|
if (!info.object) {
|
||||||
|
onSelectMmsi(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (info.layer && info.layer.id === "density") return;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const obj: any = info.object;
|
||||||
|
if (typeof obj.mmsi === "number") {
|
||||||
|
const t = obj as AisTarget;
|
||||||
|
onSelectMmsi(t.mmsi);
|
||||||
|
map.easeTo({ center: [t.lon, t.lat], zoom: Math.max(map.getZoom(), 10), duration: 600 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
projection,
|
||||||
|
shipData,
|
||||||
|
zones,
|
||||||
|
selectedMmsi,
|
||||||
|
overlays.zones,
|
||||||
|
settings.showShips,
|
||||||
|
settings.showDensity,
|
||||||
|
onSelectMmsi,
|
||||||
|
legacyHits,
|
||||||
|
legacyTargets,
|
||||||
|
overlays.pairLines,
|
||||||
|
overlays.pairRange,
|
||||||
|
overlays.fcLines,
|
||||||
|
overlays.fleetCircles,
|
||||||
|
pairLinks,
|
||||||
|
pairRanges,
|
||||||
|
fcDashed,
|
||||||
|
fleetCircles,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return <div ref={containerRef} style={{ width: "100%", height: "100%" }} />;
|
||||||
|
}
|
||||||
270
apps/web/src/widgets/relations/RelationsPanel.tsx
Normal file
270
apps/web/src/widgets/relations/RelationsPanel.tsx
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
import { VESSEL_TYPES } from "../../entities/vessel/model/meta";
|
||||||
|
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
|
||||||
|
import { haversineNm } from "../../shared/lib/geo/haversineNm";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
selectedVessel: DerivedLegacyVessel | null;
|
||||||
|
vessels: DerivedLegacyVessel[];
|
||||||
|
fleetVessels: DerivedLegacyVessel[];
|
||||||
|
onSelectMmsi: (mmsi: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RelationsPanel({ selectedVessel, vessels, fleetVessels, onSelectMmsi }: Props) {
|
||||||
|
if (selectedVessel) {
|
||||||
|
const v = selectedVessel;
|
||||||
|
const meta = VESSEL_TYPES[v.shipCode];
|
||||||
|
const ownerLabel = v.ownerCn || v.ownerRoman || v.ownerKey || "-";
|
||||||
|
const sameOwner = v.ownerKey ? vessels.filter((v2) => v2.ownerKey === v.ownerKey && v2.mmsi !== v.mmsi) : [];
|
||||||
|
const pair = v.pairPermitNo ? vessels.find((v2) => v2.permitNo === v.pairPermitNo) ?? null : null;
|
||||||
|
const fcNearby = vessels.filter((fc) => fc.shipCode === "FC" && fc.mmsi !== v.mmsi && haversineNm(fc.lat, fc.lon, v.lat, v.lon) < 5);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rel-panel">
|
||||||
|
<div className="rel-header">
|
||||||
|
<span style={{ fontSize: 14 }}>{meta.icon}</span>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 800, color: meta.color }}>{v.permitNo}</span>
|
||||||
|
<span className="rel-badge" style={{ background: `${meta.color}22`, color: meta.color }}>
|
||||||
|
{v.shipCode}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 9, color: "var(--muted)", marginBottom: 6 }}>
|
||||||
|
소유주:{" "}
|
||||||
|
<b
|
||||||
|
style={{
|
||||||
|
color: "var(--text)",
|
||||||
|
display: "inline-block",
|
||||||
|
maxWidth: 230,
|
||||||
|
verticalAlign: "bottom",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
title={ownerLabel}
|
||||||
|
>
|
||||||
|
{ownerLabel}
|
||||||
|
</b>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pair ? (
|
||||||
|
<>
|
||||||
|
<div style={{ fontSize: 8, fontWeight: 700, color: "var(--muted)", margin: "6px 0 3px", letterSpacing: 1 }}>
|
||||||
|
⛓ 쌍끌이 쌍
|
||||||
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const dist = haversineNm(v.lat, v.lon, pair.lat, pair.lon);
|
||||||
|
const warn = dist > 3;
|
||||||
|
const pairMeta = VESSEL_TYPES[pair.shipCode];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="rel-line">
|
||||||
|
<div className="dot" style={{ background: meta.color }} />
|
||||||
|
<span style={{ fontSize: 9, fontWeight: 600, cursor: "pointer" }} onClick={() => onSelectMmsi(v.mmsi)}>
|
||||||
|
{v.permitNo}
|
||||||
|
</span>
|
||||||
|
<div className="rel-link">{warn ? "⚠" : "⟷"}</div>
|
||||||
|
<div className="dot" style={{ background: pairMeta.color }} />
|
||||||
|
<span style={{ fontSize: 9, fontWeight: 600, cursor: "pointer" }} onClick={() => onSelectMmsi(pair.mmsi)}>
|
||||||
|
{pair.permitNo}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="rel-dist"
|
||||||
|
style={{
|
||||||
|
background: warn ? "#F59E0B22" : "#22C55E22",
|
||||||
|
color: warn ? "#F59E0B" : "#22C55E",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dist.toFixed(2)}NM
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 8, color: "var(--muted)", marginLeft: 10 }}>
|
||||||
|
정상 범위: 0.3~1.0NM | {warn ? "⚠ 이격 경고" : "✓ 정상 동기화"}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{fcNearby.length && v.shipCode !== "FC" ? (
|
||||||
|
<>
|
||||||
|
<div style={{ fontSize: 8, fontWeight: 700, color: "var(--muted)", margin: "6px 0 3px", letterSpacing: 1 }}>
|
||||||
|
🚛 근접 운반선
|
||||||
|
</div>
|
||||||
|
{fcNearby.slice(0, 6).map((fc) => {
|
||||||
|
const dist = haversineNm(v.lat, v.lon, fc.lat, fc.lon);
|
||||||
|
const isSameOwner = !!v.ownerKey && v.ownerKey === fc.ownerKey;
|
||||||
|
const warn = dist < 0.5;
|
||||||
|
return (
|
||||||
|
<div key={fc.mmsi} className="rel-line">
|
||||||
|
<div className="dot" style={{ background: VESSEL_TYPES.FC.color }} />
|
||||||
|
<span style={{ fontSize: 9, fontWeight: 600, cursor: "pointer" }} onClick={() => onSelectMmsi(fc.mmsi)}>
|
||||||
|
{fc.permitNo}
|
||||||
|
</span>
|
||||||
|
<span className="rel-dist" style={{ background: "#D9770622", color: "#D97706" }}>
|
||||||
|
{dist.toFixed(1)}NM
|
||||||
|
</span>
|
||||||
|
{isSameOwner ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 7,
|
||||||
|
background: "#F59E0B22",
|
||||||
|
color: "#F59E0B",
|
||||||
|
padding: "1px 3px",
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
동일소유주
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{warn ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 7,
|
||||||
|
background: "#EF444422",
|
||||||
|
color: "#EF4444",
|
||||||
|
padding: "1px 3px",
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
환적의심
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{sameOwner.length ? (
|
||||||
|
<>
|
||||||
|
<div style={{ fontSize: 8, fontWeight: 700, color: "var(--muted)", margin: "6px 0 3px", letterSpacing: 1 }}>
|
||||||
|
🏢 동일 소유주 선단 ({sameOwner.length + 1}척)
|
||||||
|
</div>
|
||||||
|
{sameOwner.slice(0, 8).map((sv) => {
|
||||||
|
const m = VESSEL_TYPES[sv.shipCode];
|
||||||
|
return (
|
||||||
|
<div key={sv.mmsi} className="fleet-vessel" onClick={() => onSelectMmsi(sv.mmsi)} style={{ cursor: "pointer" }}>
|
||||||
|
<div className="dot" style={{ background: m.color }} />
|
||||||
|
<span style={{ color: m.color, fontWeight: 600 }}>{sv.shipCode}</span> {sv.permitNo}
|
||||||
|
<span style={{ color: "var(--muted)" }}>
|
||||||
|
{sv.sog ?? "?"}kt {sv.state.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{sameOwner.length > 8 ? <div style={{ fontSize: 8, color: "var(--muted)" }}>... +{sameOwner.length - 8}척</div> : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No vessel selected: show top fleets
|
||||||
|
if (fleetVessels.length === 0) {
|
||||||
|
return <div style={{ fontSize: 11, color: "var(--muted)" }}>(현재 지도에 표시중인 대상 선박이 없습니다)</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = new Map<string, DerivedLegacyVessel[]>();
|
||||||
|
for (const v of fleetVessels) {
|
||||||
|
if (!v.ownerKey) continue;
|
||||||
|
const list = group.get(v.ownerKey);
|
||||||
|
if (list) list.push(v);
|
||||||
|
else group.set(v.ownerKey, [v]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const topFleets = Array.from(group.entries())
|
||||||
|
.map(([ownerKey, vs]) => ({ ownerKey, vs }))
|
||||||
|
.filter((x) => x.vs.length >= 3)
|
||||||
|
.sort((a, b) => b.vs.length - a.vs.length)
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
if (topFleets.length === 0) {
|
||||||
|
return <div style={{ fontSize: 11, color: "var(--muted)" }}>(표시 중인 선단(3척 이상) 없음)</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{topFleets.map(({ ownerKey, vs }) => {
|
||||||
|
const displayOwner = vs.find((v) => v.ownerCn)?.ownerCn || vs.find((v) => v.ownerRoman)?.ownerRoman || ownerKey;
|
||||||
|
const displayTitle = ownerKey && displayOwner !== ownerKey ? `${displayOwner} (${ownerKey})` : displayOwner;
|
||||||
|
const codes: Record<string, number> = {};
|
||||||
|
for (const v of vs) codes[v.shipCode] = (codes[v.shipCode] ?? 0) + 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={ownerKey} className="fleet-card">
|
||||||
|
<div className="fleet-owner">
|
||||||
|
🏢{" "}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
maxWidth: 240,
|
||||||
|
verticalAlign: "bottom",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
title={displayTitle}
|
||||||
|
>
|
||||||
|
{displayOwner}
|
||||||
|
</span>{" "}
|
||||||
|
<span style={{ fontSize: 8, color: "var(--muted)" }}>{vs.length}척</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 4, flexWrap: "wrap", marginBottom: 3 }}>
|
||||||
|
{Object.entries(codes).map(([c, n]) => {
|
||||||
|
const meta = VESSEL_TYPES[c as keyof typeof VESSEL_TYPES];
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={c}
|
||||||
|
style={{
|
||||||
|
fontSize: 8,
|
||||||
|
background: `${meta.color}22`,
|
||||||
|
color: meta.color,
|
||||||
|
padding: "1px 4px",
|
||||||
|
borderRadius: 2,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c}×{n}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
||||||
|
{vs.slice(0, 18).map((v) => {
|
||||||
|
const m = VESSEL_TYPES[v.shipCode];
|
||||||
|
const text = v.shipCode === "FC" ? "F" : v.shipCode === "PT" ? "M" : v.shipCode === "PT-S" ? "S" : v.shipCode[0];
|
||||||
|
return (
|
||||||
|
<span key={v.mmsi} style={{ display: "inline-flex", alignItems: "center", gap: 2 }}>
|
||||||
|
<div
|
||||||
|
onClick={() => onSelectMmsi(v.mmsi)}
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
borderRadius: v.shipCode === "FC" ? 2 : "50%",
|
||||||
|
background: m.color,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: 6,
|
||||||
|
color: "#fff",
|
||||||
|
border: "1px solid rgba(255,255,255,.2)",
|
||||||
|
}}
|
||||||
|
title={`${v.permitNo} ${v.shipCode} ${v.sog ?? "?"}kt`}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
{v.pairPermitNo && (v.shipCode === "PT" || v.shipCode === "PT-S") ? <span style={{ fontSize: 8, color: "var(--muted)" }}>⟷</span> : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{vs.length > 18 ? <span style={{ fontSize: 8, color: "var(--muted)" }}>+{vs.length - 18}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
apps/web/src/widgets/speed/SpeedProfilePanel.tsx
Normal file
62
apps/web/src/widgets/speed/SpeedProfilePanel.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { ZONE_META } from "../../entities/zone/model/meta";
|
||||||
|
import { SPEED_MAX, VESSEL_TYPES } from "../../entities/vessel/model/meta";
|
||||||
|
import type { VesselTypeCode } from "../../entities/vessel/model/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
selectedType: VesselTypeCode | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SpeedProfilePanel({ selectedType }: Props) {
|
||||||
|
const code = selectedType ?? "PT";
|
||||||
|
const t = VESSEL_TYPES[code];
|
||||||
|
|
||||||
|
const segs = t.speedProfile.map((s) => {
|
||||||
|
const left = (s.range[0] / SPEED_MAX) * 100;
|
||||||
|
const width = ((s.range[1] - s.range[0]) / SPEED_MAX) * 100;
|
||||||
|
const showText = width > 8;
|
||||||
|
const text = showText ? `${s.label}${s.primary ? ` ${s.typicalSpeed}kt` : ""}` : "";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${s.label}-${s.range[0]}`}
|
||||||
|
className={`sseg ${s.primary ? "p" : ""}`}
|
||||||
|
style={{ left: `${left}%`, width: `${width}%`, background: s.color }}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const primary = t.speedProfile.filter((s) => s.primary);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 10, fontWeight: 700, color: t.color, marginBottom: 3 }}>
|
||||||
|
{t.icon} {code} {t.name}
|
||||||
|
</div>
|
||||||
|
<div className="sbar">{segs}</div>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||||
|
{[0, 3, 5, 7, 10, 15].map((k) => (
|
||||||
|
<span key={k} style={{ fontSize: 7, color: "rgba(255,255,255,.2)" }}>
|
||||||
|
{k}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 2 }}>
|
||||||
|
{primary.map((s) => (
|
||||||
|
<span key={s.label} style={{ fontSize: 8, color: s.color, marginRight: 8 }}>
|
||||||
|
★{s.label} {s.range[0]}~{s.range[1]}kt
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 9, color: "var(--muted)", marginTop: 3 }}>
|
||||||
|
궤적: <b style={{ color: "var(--text)" }}>{t.trajectory}</b> | 수역:{" "}
|
||||||
|
{t.allowedZones.map((z) => (
|
||||||
|
<span key={z} style={{ color: ZONE_META[z].color, marginRight: 6 }}>
|
||||||
|
{ZONE_META[z].label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
52
apps/web/src/widgets/topbar/Topbar.tsx
Normal file
52
apps/web/src/widgets/topbar/Topbar.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
type Props = {
|
||||||
|
total: number;
|
||||||
|
fishing: number;
|
||||||
|
transit: number;
|
||||||
|
pairLinks: number;
|
||||||
|
alarms: number;
|
||||||
|
pollingStatus: "idle" | "loading" | "ready" | "error";
|
||||||
|
lastFetchMinutes: number | null;
|
||||||
|
clock: string;
|
||||||
|
adminMode?: boolean;
|
||||||
|
onLogoClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Topbar({ total, fishing, transit, pairLinks, alarms, pollingStatus, lastFetchMinutes, clock, adminMode, onLogoClick }: Props) {
|
||||||
|
const statusColor =
|
||||||
|
pollingStatus === "ready" ? "#22C55E" : pollingStatus === "loading" ? "#F59E0B" : pollingStatus === "error" ? "#EF4444" : "var(--muted)";
|
||||||
|
return (
|
||||||
|
<div className="topbar">
|
||||||
|
<div className="logo" onClick={onLogoClick} style={{ cursor: onLogoClick ? "pointer" : undefined }} title={adminMode ? "ADMIN" : undefined}>
|
||||||
|
🛰 <span>WING</span> 조업감시·선단연관 {adminMode ? <span style={{ fontSize: 10, color: "#F59E0B" }}>(ADMIN)</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="stats">
|
||||||
|
<div className="stat">
|
||||||
|
DATA <b style={{ color: "#22C55E" }}>API</b>
|
||||||
|
</div>
|
||||||
|
<div className="stat">
|
||||||
|
POLL{" "}
|
||||||
|
<b style={{ color: statusColor }}>
|
||||||
|
{pollingStatus.toUpperCase()}
|
||||||
|
{lastFetchMinutes ? `(${lastFetchMinutes}m)` : ""}
|
||||||
|
</b>
|
||||||
|
</div>
|
||||||
|
<div className="stat">
|
||||||
|
전체 <b>{total}</b>척
|
||||||
|
</div>
|
||||||
|
<div className="stat">
|
||||||
|
조업 <b style={{ color: "#22C55E" }}>{fishing}</b>
|
||||||
|
</div>
|
||||||
|
<div className="stat">
|
||||||
|
항해 <b style={{ color: "#3B82F6" }}>{transit}</b>
|
||||||
|
</div>
|
||||||
|
<div className="stat">
|
||||||
|
쌍연결 <b style={{ color: "#F59E0B" }}>{pairLinks}</b>
|
||||||
|
</div>
|
||||||
|
<div className="stat">
|
||||||
|
경고 <b style={{ color: "#EF4444" }}>{alarms}</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="time">{clock}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
apps/web/src/widgets/vesselList/VesselList.tsx
Normal file
59
apps/web/src/widgets/vesselList/VesselList.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { VESSEL_TYPES } from "../../entities/vessel/model/meta";
|
||||||
|
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
vessels: DerivedLegacyVessel[];
|
||||||
|
selectedMmsi: number | null;
|
||||||
|
onSelectMmsi: (mmsi: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isFiniteNumber(x: unknown): x is number {
|
||||||
|
return typeof x === "number" && Number.isFinite(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VesselList({ vessels, selectedMmsi, onSelectMmsi }: Props) {
|
||||||
|
const sorted = vessels
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => (isFiniteNumber(b.sog) ? b.sog : -1) - (isFiniteNumber(a.sog) ? a.sog : -1))
|
||||||
|
.slice(0, 80);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="vlist">
|
||||||
|
{sorted.map((v) => {
|
||||||
|
const meta = VESSEL_TYPES[v.shipCode];
|
||||||
|
const primarySegs = meta.speedProfile.filter((s) => s.primary);
|
||||||
|
const inRange =
|
||||||
|
v.sog !== null && primarySegs.length ? primarySegs.some((s) => v.sog! >= s.range[0] && v.sog! <= s.range[1]) : false;
|
||||||
|
|
||||||
|
const sc = v.state.isFishing ? "#22C55E" : (v.sog ?? 0) > 3 ? "#3B82F6" : "#64748B";
|
||||||
|
const speedColor = inRange ? "#22C55E" : (v.sog ?? 0) > 5 ? "#3B82F6" : "var(--muted)";
|
||||||
|
const hasPair = v.pairPermitNo ? "⛓" : "";
|
||||||
|
const sel = selectedMmsi === v.mmsi;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={v.mmsi}
|
||||||
|
className="vi"
|
||||||
|
onClick={() => onSelectMmsi(v.mmsi)}
|
||||||
|
style={sel ? { background: "rgba(59,130,246,.12)", border: "1px solid rgba(59,130,246,.45)" } : undefined}
|
||||||
|
title={v.name}
|
||||||
|
>
|
||||||
|
<div className="dot" style={{ background: meta.color, boxShadow: v.state.isFishing ? `0 0 3px ${meta.color}` : undefined }} />
|
||||||
|
<div className="nm">
|
||||||
|
{hasPair}
|
||||||
|
{v.permitNo}
|
||||||
|
</div>
|
||||||
|
<div className="sp" style={{ color: speedColor }}>
|
||||||
|
{v.sog !== null ? v.sog.toFixed(1) : "?"}kt
|
||||||
|
</div>
|
||||||
|
<div className="st" style={{ background: `${sc}22`, color: sc }}>
|
||||||
|
{v.state.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{sorted.length === 0 ? <div style={{ fontSize: 11, color: "var(--muted)" }}>(표시할 대상 선박 없음)</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
28
apps/web/tsconfig.app.json
Normal file
28
apps/web/tsconfig.app.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
apps/web/tsconfig.json
Normal file
7
apps/web/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
apps/web/tsconfig.node.json
Normal file
26
apps/web/tsconfig.node.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
46
apps/web/vite.config.ts
Normal file
46
apps/web/vite.config.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { defineConfig, loadEnv } from "vite";
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), "");
|
||||||
|
|
||||||
|
const webPort = Number(env.WEB_PORT || process.env.WEB_PORT || 5175);
|
||||||
|
const apiPort = Number(env.API_PORT || process.env.API_PORT || 5174);
|
||||||
|
|
||||||
|
// Same proxy pattern as the "dark" project:
|
||||||
|
// - dev: use Vite proxy (/snp-api -> upstream host)
|
||||||
|
// - prod: set VITE_API_URL to absolute base if needed
|
||||||
|
const snpApiTarget = env.VITE_SNP_API_TARGET || process.env.VITE_SNP_API_TARGET || "http://211.208.115.83:8041";
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
// deck.gl (via loaders.gl) contains a few Node-only helper modules.
|
||||||
|
// In browser builds, they should be tree-shaken, but Vite/Rollup may still warn.
|
||||||
|
child_process: fileURLToPath(new URL("./src/shared/shims/child_process.ts", import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
// IMPORTANT: keep the web dev server port fixed so it doesn't collide with the API server port.
|
||||||
|
// If the port is already taken, fail fast instead of auto-falling-back to the API port (proxy loop -> 500).
|
||||||
|
port: webPort,
|
||||||
|
strictPort: true,
|
||||||
|
proxy: {
|
||||||
|
// Optional local API server (future DB-backed APIs etc).
|
||||||
|
"/api": {
|
||||||
|
target: `http://127.0.0.1:${apiPort}`,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
// SNP-Batch AIS upstream (ship positions)
|
||||||
|
"/snp-api": {
|
||||||
|
target: snpApiTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
1
data/raw/zones/특정어업수역Ⅰ.json
Normal file
1
data/raw/zones/특정어업수역Ⅰ.json
Normal file
File diff suppressed because one or more lines are too long
1
data/raw/zones/특정어업수역Ⅱ.json
Normal file
1
data/raw/zones/특정어업수역Ⅱ.json
Normal file
File diff suppressed because one or more lines are too long
1
data/raw/zones/특정어업수역Ⅲ.json
Normal file
1
data/raw/zones/특정어업수역Ⅲ.json
Normal file
File diff suppressed because one or more lines are too long
1
data/raw/zones/특정어업수역Ⅳ.json
Normal file
1
data/raw/zones/특정어업수역Ⅳ.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"type": "FeatureCollection", "name": "\ud2b9\uc815\uc5b4\uc5c5\uc218\uc5ed4", "crs": {"type": "name", "properties": {"name": "urn:ogc:def:crs:OGC:1.3:CRS84"}}, "features": [{"type": "Feature", "properties": {"fid": 0, "GML_ID": null, "OBJECTID": null, "ZONE_NM": null, "MNCT_NO": null, "MNCT_SCALE": null, "MNCT_NM": null, "RELREGLTN": null, "RELGOAG": null, "REVIYR": null, "ZONE_DESC": null, "PHOTO1_PAT": null, "ID": -2147483647, "CATE_CD": null, "ADR_CD": null, "ADR_KNM": null, "ORIGIN": null, "ORIYR": null, "ORIORG": null, "NAME": "\ud2b9\uc815\uc5b4\uc5c5\uc218\uc5ed\u2163", "WARD_NM": null, "WARD_ID": null, "GISID": null, "FID_2": null, "NAME_2": null, "FID_3": null, "NAME_3": null, "GID": null, "NAME_4": null, "FID_4": null, "NAME_5": null, "FID_5": null, "NAME_6": null}, "geometry": {"type": "MultiPolygon", "coordinates": [[[[13859276.603817873, 4232038.462456921], [13859276.603762543, 4321218.244482412], [13859276.603710985, 4404317.064005076], [13840719.645028654, 4439106.786523586], [13884632.712472571, 4439106.787250583], [13884632.712472571, 4439504.084564682], [13940418.269436067, 4439504.375880923], [13969123.924724836, 4439504.525783945], [13968718.329494288, 4438626.439593866], [13962623.599395147, 4425543.915710401], [13960437.31344761, 4420657.3891166765], [13958238.813611617, 4416093.569832627], [13958143.094601436, 4415900.994484875], [13958143.094601437, 4415900.994484875], [13957298.344237303, 4414201.456484755], [13953878.455604602, 4406316.186534493], [13949652.450365951, 4397019.979821594], [13948553.200448176, 4393395.13065616], [13947612.731073817, 4389132.176741289], [13947612.731072996, 4387549.226905922], [13947466.164417507, 4385829.556682826], [13947783.725505754, 4381721.729468383], [13948260.06713652, 4379835.70012994], [13949359.317054221, 4375897.403884492], [13951093.689146286, 4371808.582233328], [13954867.780530114, 4365670.678186072], [13964809.885341855, 4351190.629491161], [13978342.873219142, 4331838.456925102], [13980382.592510404, 4329007.496874151], [13981728.043604897, 4327079.749205159], [13985775.34591557, 4321280.81855131], [13997066.763484716, 4305102.598482491], [13999424.043863578, 4300225.286038025], [14003039.354703771, 4290447.064438686], [14005091.287883686, 4284626.561498255], [14006520.312777169, 4279426.932176922], [14007631.77658257, 4275178.643476352], [14008242.470981453, 4271549.325573796], [14009378.362562515, 4262248.123573576], [14009427.990871342, 4261704.85208626], [14009708.137538105, 4258638.140769343], [14009854.704193696, 4257224.555715567], [14009378.362562606, 4254698.603440943], [14005347.779531531, 4240996.452433007], [14002367.590864772, 4231511.1380338315], [14001280.554835469, 4227266.412716273], [14000486.652116666, 4225212.134400094], [13998047.81589918, 4222926.459154359], [13991387.305576058, 4216684.234498038], [13970721.407121927, 4197120.494488488], [13958654.085803084, 4185745.4565721145], [13956602.15262321, 4184012.5742896623], [13944065.033685392, 4171984.566055202], [13940467.606607554, 4168533.224265296], [13935619.01320107, 4163881.1438622964], [13935718.55954324, 4163976.6556012244], [13817590.293393573, 4163976.6556012244], [13859276.603817873, 4232038.462456921]]]]}}]}
|
||||||
BIN
legacy/AIS_permitted_Chinese_2025_정리완료_최종.xlsx
Normal file
BIN
legacy/AIS_permitted_Chinese_2025_정리완료_최종.xlsx
Normal file
Binary file not shown.
828
legacy/선단구성_906척_어업수역 (1).jsx
Normal file
828
legacy/선단구성_906척_어업수역 (1).jsx
Normal file
@ -0,0 +1,828 @@
|
|||||||
|
|
||||||
|
import React, { useState, useMemo } from "react";
|
||||||
|
|
||||||
|
const D=[
|
||||||
|
["沧州渤海新区广建水产品有限公司", "cangzhoubohaixinquguangjianshuichanpinyouxiangongsi", [[["C21-13558", "冀黄港渔05001", "jihuanggangyu05001", 106], ["C21-13559", "冀黄港渔05002", "jihuanggangyu05002", 106]], [["C21-13560", "冀黄港渔05777", "jihuanggangyu05777", 99], ["C21-13561", "冀黄港渔05888", "jihuanggangyu05888", 99]], [["C21-13562", "冀黄港渔06957", "jihuanggangyu06957", 117], ["C21-13563", "冀黄港渔06958", "jihuanggangyu06958", 117]], [["C21-13564", "冀黄港渔50777", "jihuanggangyu50777", 121], ["C21-13565", "冀黄港渔50888", "jihuanggangyu50888", 121]]], [["C25-9732", "冀黄港渔05005", "jihuanggangyu05005", 101], ["C25-9733", "冀黄港渔05006", "jihuanggangyu05006", 101], ["C25-16527", "冀黄港渔06055", "jihuanggangyu06055", 121], ["C25-16528", "冀黄港渔06056", "jihuanggangyu06056", 121], ["C25-16531", "冀黄港渔06587", "jihuanggangyu06587", 101], ["C25-16532", "冀黄港渔06588", "jihuanggangyu06588", 101], ["C25-16533", "冀黄港渔06788", "jihuanggangyu06788", 130], ["C25-16812", "冀黄港渔06787", "jihuanggangyu06787", 130], ["C25-16905", "冀黄港渔05003", "jihuanggangyu05003", 101], ["C25-16906", "冀黄港渔05004", "jihuanggangyu05004", 101], ["C25-17083", "冀黄港渔06897", "jihuanggangyu06897", 130], ["C25-17084", "冀黄港渔06898", "jihuanggangyu06898", 130]], [], [], [["C40-8489", "冀黄港渔运05027", "jihuanggangyu05027", 143]], [], []],
|
||||||
|
["宁波海裕渔业有限公司", "ningbohaiyuyuyeyouxiangongsi", [], [], [], [["C23-0105", "浙甬渔60651", "zheyongyu60651", 258], ["C23-0106", "浙甬渔60451", "zheyongyu60451", 154], ["C23-0107", "宁渔459", "ningyu459", 115], ["C23-0108", "宁渔22", "ningyu22", 490], ["C23-0110", "浙甬渔60652", "zheyongyu60652", 258], ["C23-0111", "宁渔458", "ningyu458", 115], ["C23-0112", "宁渔460", "ningyu460", 115], ["C23-0113", "宁渔23", "ningyu23", 541], ["C23-0115", "浙甬渔60656", "zheyongyu60656", 257], ["C23-0116", "浙甬渔60453", "zheyongyu60453", 154], ["C23-0117", "宁渔456", "ningyu456", 113], ["C23-0118", "宁渔24", "ningyu24", 541], ["C23-0120", "浙甬渔60655", "zheyongyu60655", 257], ["C23-0121", "宁渔454", "ningyu454", 113], ["C23-0122", "浙甬渔60457", "zheyongyu60457", 121], ["C23-8137", "浙甬渔60025", "zheyongyu60025", 498]], [], [], []],
|
||||||
|
["张长祥", "zhangchangxiang", [], [["C25-16688", "津塘渔03886", "jintangyu03886", 148], ["C25-16698", "津塘渔03666", "jintangyu03666", 148], ["C25-16769", "津塘渔03887", "jintangyu03887", 148], ["C25-16770", "津塘渔03889", "jintangyu03889", 148], ["C25-16923", "津塘渔03778", "jintangyu03778", 148], ["C25-16924", "津塘渔03779", "jintangyu03779", 148], ["C25-16926", "津塘渔03989", "jintangyu03989", 148], ["C25-17235", "津塘渔03299", "jintangyu03299", 148], ["C25-17236", "津塘渔03398", "jintangyu03398", 148], ["C25-17237", "津塘渔03399", "jintangyu03399", 148], ["C25-17238", "津塘渔03599", "jintangyu03599", 148], ["C25-17239", "津塘渔03699", "jintangyu03699", 148], ["C25-17241", "津塘渔03899", "jintangyu03899", 148], ["C25-17242", "津塘渔03969", "jintangyu03969", 148]], [], [], [], [], []],
|
||||||
|
["岱山县昌达渔业有限公司", "daishanxianchangdayuyeyouxiangongsi", [[["C21-14531", "浙岱渔02856", "zhedaiyu02856", 219], ["C21-14532", "浙岱渔02855", "zhedaiyu02855", 219]], [["C21-14533", "浙岱渔02858", "zhedaiyu02858", 219], ["C21-14534", "浙岱渔02857", "zhedaiyu02857", 219]], [["C21-14535", "浙岱渔02859", "zhedaiyu02859", 219], ["C21-14536", "浙岱渔02860", "zhedaiyu02860", 219]], [["C21-14537", "浙岱渔02861", "zhedaiyu02861", 219], ["C21-14538", "浙岱渔02862", "zhedaiyu02862", 219]]], [], [], [], [], [], []],
|
||||||
|
["赵凤春", "zhaofengchun", [], [["C25-16920", "津塘渔03766", "jintangyu03766", 97], ["C25-16921", "津塘渔03768", "jintangyu03768", 97], ["C25-16922", "津塘渔03776", "jintangyu03776", 97], ["C25-16925", "津塘渔03799", "jintangyu03799", 97], ["C25-17244", "津塘渔03566", "jintangyu03566", 93], ["C25-17246", "津塘渔03080", "jintangyu03080", 93], ["C25-17248", "津塘渔03688", "jintangyu03688", 93], ["C25-17249", "津塘渔03068", "jintangyu03068", 93]], [], [], [], [], []],
|
||||||
|
["荣成市祥宇渔业有限公司", "rongchengshixiangyuyuyeyouxiangongsi", [[["C21-13175", "鲁荣渔56555", "lurongyu56555", 127], ["C21-13176", "鲁荣渔56556", "lurongyu56556", 127]], [["C21-13179", "鲁荣渔56789", "lurongyu56789", 127], ["C21-13180", "鲁荣渔56790", "lurongyu56790", 127]], [["C21-14005", "鲁荣渔50085", "lurongyu50085", 117], ["C21-14006", "鲁荣渔50086", "lurongyu50086", 117]]], [], [], [], [], [], []],
|
||||||
|
["大连南北众安渔业有限公司", "daliannanbeizhonganyuyeyouxiangongsi", [], [["C25-17263", "辽庄渔55567", "liaozhuangyu55567", 144], ["C25-17264", "辽庄渔55678", "liaozhuangyu55678", 144], ["C25-17265", "辽庄渔55777", "liaozhuangyu55777", 144], ["C25-17266", "辽庄渔55789", "liaozhuangyu55789", 144]], [], [], [["C40-8495", "辽庄渔运55898", "liaozhuangyuyun55898", 249], ["C40-8496", "辽庄渔运55899", "liaozhuangyuyun55899", 249]], [], []],
|
||||||
|
["天津凯祥船舶服务有限公司", "tianjinkaixiangchuanbofuwuyouxiangongsi", [], [["C25-16830", "津塘渔03838", "jintangyu03838", 145], ["C25-17278", "津塘渔03833", "jintangyu03833", 145], ["C25-17279", "津塘渔03839", "jintangyu03839", 145], ["C25-17280", "津塘渔03836", "jintangyu03836", 145], ["C25-17281", "津塘渔03837", "jintangyu03837", 145]], [], [], [], [], []],
|
||||||
|
["烟台市海达渔业捕捞有限公司", "yantaishihaidayuyebulaoyouxiangongsi", [[["C21-12026", "鲁牟渔60009", "lumuyu60009", 127], ["C21-12027", "鲁牟渔60010", "lumuyu60010", 127]], [["C21-13588", "鲁牟渔60005", "lumuyu60005", 106], ["C21-13589", "鲁牟渔60006", "lumuyu60006", 106]]], [], [], [], [], [], []],
|
||||||
|
["荣成市平远渔业有限公司", "rongchengshipingyuanyuyeyouxiangongsi", [[["C21-12418", "鲁荣渔51277", "lurongyu51277", 126], ["C21-12419", "鲁荣渔51278", "lurongyu51278", 126]], [["C21-13354", "鲁荣渔52027", "lurongyu52027", 126], ["C21-13355", "鲁荣渔52028", "lurongyu52028", 126]]], [], [], [], [], [], []],
|
||||||
|
["荣成市汇尊水产有限公司", "rongchengshihuizunshuichanyouxiangongsi", [[["C21-12476", "鲁荣渔52803", "lurongyu52803", 107], ["C21-12477", "鲁荣渔52804", "lurongyu52804", 107]], [["C21-13614", "鲁荣渔55955", "lurongyu55955", 111], ["C21-13615", "鲁荣渔55956", "lurongyu55956", 111]]], [], [], [], [], [], []],
|
||||||
|
["王信静", "wangxinjing", [[["C21-12484", "鲁荣渔52911", "lurongyu52911", 99], ["C21-12485", "鲁荣渔52912", "lurongyu52912", 99]], [["C21-13866", "鲁荣渔52913", "lurongyu52913", 99], ["C21-13867", "鲁荣渔52914", "lurongyu52914", 99]]], [], [], [], [], [], []],
|
||||||
|
["荣成市佳汇渔业有限公司", "rongchengshijiahuiyuyeyouxiangongsi", [[["C21-13169", "鲁荣渔55509", "lurongyu55509", 96], ["C21-13170", "鲁荣渔55510", "lurongyu55510", 96]], [["C21-13604", "鲁荣渔55299", "lurongyu55299", 106], ["C21-13605", "鲁荣渔55300", "lurongyu55300", 106]]], [], [], [], [], [], []],
|
||||||
|
["乳山市圣海渔业有限公司", "rushanshishenghaiyuyeyouxiangongsi", [[["C21-13215", "鲁乳渔54931", "luruyu54931", 127], ["C21-13216", "鲁乳渔54932", "luruyu54932", 127]], [["C21-13217", "鲁乳渔54937", "luruyu54937", 106], ["C21-13218", "鲁乳渔54938", "luruyu54938", 106]]], [], [], [], [], [], []],
|
||||||
|
["周向辉", "zhouxianghui", [[["C21-13366", "鲁荣渔55179", "lurongyu55179", 106], ["C21-13367", "鲁荣渔55180", "lurongyu55180", 106]], [["C21-14349", "鲁荣渔55919", "lurongyu55919", 106], ["C21-14350", "鲁荣渔55920", "lurongyu55920", 106]]], [], [], [], [], [], []],
|
||||||
|
["荣成市振江渔业有限公司", "rongchengshizhenjiangyuyeyouxiangongsi", [[["C21-13380", "鲁荣渔55769", "lurongyu55769", 98], ["C21-13381", "鲁荣渔55770", "lurongyu55770", 98]], [["C21-13880", "鲁荣渔55769", "lurongyu55769", 98], ["C21-13881", "鲁荣渔55770", "lurongyu55770", 98]]], [], [], [], [], [], []],
|
||||||
|
["荣成市德海海水捕捞有限公司", "rongchengshidehaihaishuibulaoyouxiangongsi", [[["C21-13414", "鲁荣渔59115", "lurongyu59115", 123], ["C21-13415", "鲁荣渔59116", "lurongyu59116", 123]], [["C21-13606", "鲁荣渔55387", "lurongyu55387", 109], ["C21-13607", "鲁荣渔55388", "lurongyu55388", 109]]], [], [], [], [], [], []],
|
||||||
|
["荣成市华海渔业有限公司", "rongchengshihuahaiyuyeyouxiangongsi", [[["C21-13420", "鲁荣渔59507", "lurongyu59507", 133], ["C21-13421", "鲁荣渔59508", "lurongyu59508", 133]], [["C21-14107", "鲁荣渔58905", "lurongyu58905", 117], ["C21-14108", "鲁荣渔58906", "lurongyu58906", 117]]], [], [], [], [], [], []],
|
||||||
|
["陶进红", "taojinhong", [[["C21-13444", "鲁威渔60865", "luweiyu60865", 106], ["C21-13445", "鲁威渔60866", "luweiyu60866", 106]], [["C21-13446", "鲁威渔60867", "luweiyu60867", 106], ["C21-13447", "鲁威渔60868", "luweiyu60868", 106]]], [], [], [], [], [], []],
|
||||||
|
["大连福峰水产有限公司", "dalianfufengshuichanyouxiangongsi", [[["C21-13706", "辽大花渔15287", "liaodahuayu15287", 106], ["C21-13707", "辽大花渔15288", "liaodahuayu15288", 106]]], [["C25-17116", "辽庄渔75001", "liaozhuangyu75001", 148], ["C25-17117", "辽庄渔75002", "liaozhuangyu75002", 148]], [], [], [], [], []],
|
||||||
|
["杨顺兵", "yangshunbing", [], [], [], [], [], [["C21-13821", "浙岭渔23366", "zhelingyu23366", 215], ["C21-14399", "浙岭渔23335", "zhelingyu23335", 215]], [["C21-13986", "浙岭渔23367", "zhelingyu23367", 218], ["C21-14265", "浙岭渔23336", "zhelingyu23336", 214]]],
|
||||||
|
["李依蓉", "liyirong", [[["C21-13856", "鲁荣渔51915", "lurongyu51915", 106], ["C21-13857", "鲁荣渔51916", "lurongyu51916", 106]], [["C21-14339", "鲁荣渔55007", "lurongyu55007", 106], ["C21-14340", "鲁荣渔55008", "lurongyu55008", 106]]], [], [], [], [], [], []],
|
||||||
|
["郭伟华", "guoweihua", [[["C21-13892", "鲁荣渔56267", "lurongyu56267", 126], ["C21-13893", "鲁荣渔56268", "lurongyu56268", 126]], [["C21-14347", "鲁荣渔55717", "lurongyu55717", 98], ["C21-14348", "鲁荣渔55718", "lurongyu55718", 98]]], [], [], [], [], [], []],
|
||||||
|
["荣成润沣捕捞有限公司", "rongchengrunfengbulaoyouxiangongsi", [[["C21-13894", "鲁荣渔56787", "lurongyu56787", 117], ["C21-13895", "鲁荣渔56788", "lurongyu56788", 117]], [["C21-13908", "鲁荣渔59181", "lurongyu59181", 169], ["C21-13909", "鲁荣渔59182", "lurongyu59182", 169]]], [], [], [], [], [], []],
|
||||||
|
["肖富军", "xiaofujun", [[["C21-13904", "鲁荣渔59087", "lurongyu59087", 106], ["C21-13905", "鲁荣渔59088", "lurongyu59088", 106]], [["C21-14309", "鲁荣渔59017", "lurongyu59017", 106], ["C21-14310", "鲁荣渔59018", "lurongyu59018", 106]]], [], [], [], [], [], []],
|
||||||
|
["郑新友", "zhengxinyou", [[["C21-13910", "鲁荣渔59189", "lurongyu59189", 101], ["C21-13911", "鲁荣渔59190", "lurongyu59190", 101]], [["C21-14319", "鲁荣渔55961", "lurongyu55961", 106], ["C21-14320", "鲁荣渔55962", "lurongyu55962", 106]]], [], [], [], [], [], []],
|
||||||
|
["温州洞头中旭渔业有限公司", "wenzhoudongtouzhongxuyuyeyouxiangongsi", [[["C21-14243", "浙洞渔30067", "zhedongyu30067", 218], ["C21-14244", "浙洞渔30068", "zhedongyu30068", 214]], [["C21-14325", "浙洞渔30098", "zhedongyu30098", 214], ["C21-14326", "浙洞渔30099", "zhedongyu30099", 216]]], [], [], [], [], [], []],
|
||||||
|
["荣成市顺洋渔业有限公司", "rongchengshishunyangyuyeyouxiangongsi", [[["C21-14299", "鲁荣渔56047", "lurongyu56047", 297], ["C21-14300", "鲁荣渔56048", "lurongyu56048", 297]], [["C21-14454", "鲁荣渔58387", "lurongyu58387", 297], ["C21-14455", "鲁荣渔58388", "lurongyu58388", 297]]], [], [], [], [], [], []],
|
||||||
|
["荣成市晨曦渔业有限公司", "rongchengshichenxiyuyeyouxiangongsi", [[["C21-14547", "鲁荣渔52083", "lurongyu52083", 129], ["C21-14548", "鲁荣渔52084", "lurongyu52084", 129]], [["C21-14553", "鲁荣渔52435", "lurongyu52435", 90], ["C21-14554", "鲁荣渔52436", "lurongyu52436", 90]]], [], [], [], [], [], []],
|
||||||
|
["大连南北众合渔业有限公司", "daliannanbeizhongheyuyeyouxiangongsi", [], [["C25-17177", "辽庄渔55555", "liaozhuangyu55555", 149], ["C25-17178", "辽庄渔55666", "liaozhuangyu55666", 149], ["C25-17179", "辽庄渔55888", "liaozhuangyu55888", 149], ["C25-17180", "辽庄渔55999", "liaozhuangyu55999", 149]], [], [], [], [], []],
|
||||||
|
["张欣强", "zhangxinqiang", [], [["C25-17231", "津塘渔03355", "jintangyu03355", 148], ["C25-17232", "津塘渔03559", "jintangyu03559", 148], ["C25-17233", "津塘渔03885", "jintangyu03885", 148]], [], [], [["C40-8478", "津塘渔运03195", "jintangyuyun03195", 246]], [], []],
|
||||||
|
["象山东鸿延绳钓渔业专业合作社", "xiangshandonghongyanshengdiaoyuyezhuanyehezuoshe", [], [["C25-17261", "浙象渔55009", "zhexiangyu55009", 149], ["C25-17262", "浙象渔55005", "zhexiangyu55005", 149], ["C25-17299", "浙象渔55008", "zhexiangyu55008", 149], ["C25-17300", "浙象渔55006", "zhexiangyu55006", 149]], [], [], [], [], []],
|
||||||
|
["荣成市保顺水产捕捞有限公司", "rongchengshibaoshunshuichanbulaoyouxiangongsi", [[["C21-13906", "鲁荣渔59157", "lurongyu59157", 72], ["C21-13907", "鲁荣渔59158", "lurongyu59158", 72]]], [], [], [], [], [["C21-13089", "鲁荣渔51627", "lurongyu51627", 114]], []],
|
||||||
|
["林应刚", "linyinggang", [[["C21-14387", "浙岭渔23357", "zhelingyu23357", 218], ["C21-14388", "浙岭渔23358", "zhelingyu23358", 218]]], [], [], [], [], [], [["C21-14386", "浙岭渔23332", "zhelingyu23332", 217]]],
|
||||||
|
["任丘市丰顺远洋渔业有限公司", "renqiushifengshunyuanyangyuyeyouxiangongsi", [], [["C25-16575", "冀任渔00988", "jirenyu00988", 144], ["C25-16576", "冀任渔00999", "jirenyu00999", 144], ["C25-16577", "冀任渔00555", "jirenyu00555", 150]], [], [], [], [], []],
|
||||||
|
["陈红喜", "chenhongxi", [], [["C25-16825", "津塘渔03966", "jintangyu03966", 42], ["C25-17243", "津塘渔03009", "jintangyu03009", 148]], [], [], [["C40-8476", "津塘渔运03889", "jintangyuyun03889", 246]], [], []],
|
||||||
|
["大连泰保水产品有限公司", "daliantaibaoshuichanpinyouxiangongsi", [], [["C25-16874", "辽大中渔25111", "liaodazhongyu25111", 148], ["C25-16875", "辽大中渔25112", "liaodazhongyu25112", 148], ["C25-17111", "辽大中渔25113", "liaodazhongyu25113", 148]], [], [], [], [], []],
|
||||||
|
["大连聚鲜水产品有限公司", "dalianjuxianshuichanpinyouxiangongsi", [], [["C25-17255", "辽大中渔25203", "liaodazhongyu25203", 148]], [], [], [["C40-8461", "辽大中渔运15138", "liaodazhongyuyun15138", 169], ["C40-8493", "辽大中渔运15159", "liaodazhongyuyun15159", 178]], [], []],
|
||||||
|
["陈祥金", "chenxiangjin", [[["C21-5323", "浙岭渔20851", "zhelingyu20851", 216], ["C21-5324", "浙岭渔20852", "zhelingyu20852", 216]]], [], [], [], [], [], []],
|
||||||
|
["林大鹏", "lindapeng", [], [], [], [], [], [["C21-8049", "辽大甘渔15395", "liaodaganyu15395", 277]], [["C21-12347", "辽大甘渔15396", "liaodaganyu15396", 277]]],
|
||||||
|
["王梦祥", "wangmengxiang", [[["C21-10119", "浙岭渔20617", "zhelingyu20617", 216], ["C21-10120", "浙岭渔20618", "zhelingyu20618", 216]]], [], [], [], [], [], []],
|
||||||
|
["章会波", "zhanghuibo", [[["C21-10143", "浙象渔49003", "zhexiangyu49003", 216], ["C21-10144", "浙象渔49004", "zhexiangyu49004", 216]]], [], [], [], [], [], []],
|
||||||
|
["柯红军", "kehongjun", [[["C21-10190", "浙象渔49021", "zhexiangyu49021", 216], ["C21-10191", "浙象渔49022", "zhexiangyu49022", 216]]], [], [], [], [], [], []],
|
||||||
|
["吴冬", "wudong", [[["C21-10279", "辽大中渔25087", "liaodazhongyu25087", 175], ["C21-10280", "辽大中渔25088", "liaodazhongyu25088", 175]]], [], [], [], [], [], []],
|
||||||
|
["郭献法", "guoxianfa", [[["C21-10727", "浙岭渔22529", "zhelingyu22529", 218], ["C21-10728", "浙岭渔22530", "zhelingyu22530", 218]]], [], [], [], [], [], []],
|
||||||
|
["颜可贵", "yankegui", [[["C21-10731", "浙岭渔23897", "zhelingyu23897", 218], ["C21-10732", "浙岭渔23898", "zhelingyu23898", 218]]], [], [], [], [], [], []],
|
||||||
|
["江罗斌", "jiangluobin", [[["C21-10748", "浙岭渔28891", "zhelingyu28891", 218], ["C21-10749", "浙岭渔28892", "zhelingyu28892", 218]]], [], [], [], [], [], []],
|
||||||
|
["荣成市广泽渔业有限公司", "rongchengshiguangzeyuyeyouxiangongsi", [[["C21-11028", "鲁荣渔55237", "lurongyu55237", 106], ["C21-11029", "鲁荣渔55238", "lurongyu55238", 106]]], [], [], [], [], [], []],
|
||||||
|
["蔡华勤", "caihuaqin", [[["C21-11206", "浙岭渔29825", "zhelingyu29825", 217], ["C21-11207", "浙岭渔29826", "zhelingyu29826", 217]]], [], [], [], [], [], []],
|
||||||
|
["陈夏友", "chenxiayou", [[["C21-11255", "浙岭渔27835", "zhelingyu27835", 216], ["C21-11256", "浙岭渔27836", "zhelingyu27836", 216]]], [], [], [], [], [], []],
|
||||||
|
["郑小方", "zhengxiaofang", [[["C21-11257", "浙岭渔29959", "zhelingyu29959", 218], ["C21-11258", "浙岭渔29960", "zhelingyu29960", 218]]], [], [], [], [], [], []],
|
||||||
|
["林恩云", "linenyun", [[["C21-11263", "浙岭渔26938", "zhelingyu26938", 218], ["C21-11264", "浙岭渔26939", "zhelingyu26939", 218]]], [], [], [], [], [], []],
|
||||||
|
["颜可荣", "yankerong", [[["C21-11547", "浙岭渔23712", "zhelingyu23712", 218], ["C21-11548", "浙岭渔23713", "zhelingyu23713", 218]]], [], [], [], [], [], []],
|
||||||
|
["李爱斌", "liaibin", [[["C21-11869", "浙象渔42131", "zhexiangyu42131", 165], ["C21-11870", "浙象渔42132", "zhexiangyu42132", 165]]], [], [], [], [], [], []],
|
||||||
|
["刘斌", "liubin", [[["C21-11871", "浙象渔42107", "zhexiangyu42107", 214], ["C21-11872", "浙象渔42108", "zhexiangyu42108", 214]]], [], [], [], [], [], []],
|
||||||
|
["庄启蒙", "zhuangqimeng", [[["C21-11899", "浙岭渔22825", "zhelingyu22825", 218], ["C21-11900", "浙岭渔22823", "zhelingyu22823", 212]]], [], [], [], [], [], []],
|
||||||
|
["庄德金", "zhuangdejin", [[["C21-11905", "浙岭渔23347", "zhelingyu23347", 272], ["C21-11906", "浙岭渔23348", "zhelingyu23348", 272]]], [], [], [], [], [], []],
|
||||||
|
["宋万里", "songwanli", [[["C21-12164", "鲁乳渔54955", "luruyu54955", 116], ["C21-12165", "鲁乳渔54956", "luruyu54956", 116]]], [], [], [], [], [], []],
|
||||||
|
["林俭国", "linjianguo", [[["C21-12222", "浙象渔48027", "zhexiangyu48027", 198], ["C21-12223", "浙象渔48028", "zhexiangyu48028", 198]]], [], [], [], [], [], []],
|
||||||
|
["王建宏", "wangjianhong", [[["C21-12226", "浙象渔48095", "zhexiangyu48095", 198], ["C21-12227", "浙象渔48096", "zhexiangyu48096", 198]]], [], [], [], [], [], []],
|
||||||
|
["马庆华", "maqinghua", [[["C21-12271", "浙岭渔15011", "zhelingyu15011", 218], ["C21-12272", "浙岭渔15012", "zhelingyu15012", 218]]], [], [], [], [], [], []],
|
||||||
|
["黄美生", "huangmeisheng", [[["C21-12273", "浙岭渔15007", "zhelingyu15007", 218], ["C21-12274", "浙岭渔15008", "zhelingyu15008", 218]]], [], [], [], [], [], []],
|
||||||
|
["陈招德", "chenzhaode", [[["C21-12280", "浙岭渔29977", "zhelingyu29977", 218], ["C21-12281", "浙岭渔29978", "zhelingyu29978", 218]]], [], [], [], [], [], []],
|
||||||
|
["林应龙", "linyinglong", [[["C21-12290", "浙岭渔20355", "zhelingyu20355", 218], ["C21-12291", "浙岭渔20356", "zhelingyu20356", 218]]], [], [], [], [], [], []],
|
||||||
|
["陈坚辉", "chenjianhui", [[["C21-12298", "浙象渔66091", "zhexiangyu66091", 216], ["C21-12299", "浙象渔66092", "zhexiangyu66092", 216]]], [], [], [], [], [], []],
|
||||||
|
["耿俊玲", "gengjunling", [[["C21-12334", "辽庄渔85136", "liaozhuangyu85136", 237], ["C21-12335", "辽庄渔85137", "liaozhuangyu85137", 237]]], [], [], [], [], [], []],
|
||||||
|
["威海海晟渔业有限公司", "weihaihaishengyuyeyouxiangongsi", [[["C21-12430", "鲁荣渔51469", "lurongyu51469", 117], ["C21-12431", "鲁荣渔51470", "lurongyu51470", 117]]], [], [], [], [], [], []],
|
||||||
|
["荣成市骏马渔业有限公司", "rongchengshijunmayuyeyouxiangongsi", [[["C21-12518", "鲁荣渔55581", "lurongyu55581", 106], ["C21-12519", "鲁荣渔55582", "lurongyu55582", 106]]], [], [], [], [], [], []],
|
||||||
|
["姜远春", "jiangyuanchun", [[["C21-12520", "鲁荣渔55587", "lurongyu55587", 106], ["C21-12521", "鲁荣渔55588", "lurongyu55588", 106]]], [], [], [], [], [], []],
|
||||||
|
["许德欣", "xudexin", [[["C21-12542", "鲁荣渔55859", "lurongyu55859", 107], ["C21-12543", "鲁荣渔55860", "lurongyu55860", 107]]], [], [], [], [], [], []],
|
||||||
|
["王行凯", "wanghangkai", [[["C21-12552", "鲁荣渔56755", "lurongyu56755", 106], ["C21-12553", "鲁荣渔56756", "lurongyu56756", 106]]], [], [], [], [], [], []],
|
||||||
|
["王珊", "wangshan", [[["C21-12650", "鲁荣渔55571", "lurongyu55571", 117], ["C21-12651", "鲁荣渔55572", "lurongyu55572", 117]]], [], [], [], [], [], []],
|
||||||
|
["栾治民", "luanzhimin", [[["C21-12686", "辽大甘渔15387", "liaodaganyu15387", 297], ["C21-12687", "辽大甘渔15388", "liaodaganyu15388", 297]]], [], [], [], [], [], []],
|
||||||
|
["郑夕伟", "zhengxiwei", [[["C21-12748", "鲁荣渔50787", "lurongyu50787", 95], ["C21-12749", "鲁荣渔50788", "lurongyu50788", 95]]], [], [], [], [], [], []],
|
||||||
|
["王建中", "wangjianzhong", [[["C21-12804", "鲁荣渔55135", "lurongyu55135", 111], ["C21-12805", "鲁荣渔55136", "lurongyu55136", 111]]], [], [], [], [], [], []],
|
||||||
|
["李婷婷", "litingting", [[["C21-12808", "鲁荣渔55177", "lurongyu55177", 106], ["C21-12809", "鲁荣渔55178", "lurongyu55178", 106]]], [], [], [], [], [], []],
|
||||||
|
["荣成市吉泉渔业有限公司", "rongchengshijiquanyuyeyouxiangongsi", [[["C21-12862", "鲁荣渔56757", "lurongyu56757", 106], ["C21-12863", "鲁荣渔56758", "lurongyu56758", 106]]], [], [], [], [], [], []],
|
||||||
|
["张术珊", "zhangshushan", [[["C21-12864", "鲁荣渔56777", "lurongyu56777", 117], ["C21-12865", "鲁荣渔56778", "lurongyu56778", 117]]], [], [], [], [], [], []],
|
||||||
|
["宁兰江", "ninglanjiang", [[["C21-12868", "鲁荣渔56867", "lurongyu56867", 106], ["C21-12869", "鲁荣渔56868", "lurongyu56868", 106]]], [], [], [], [], [], []],
|
||||||
|
["毕崇方", "bichongfang", [[["C21-12872", "鲁荣渔56899", "lurongyu56899", 106], ["C21-12873", "鲁荣渔56900", "lurongyu56900", 106]]], [], [], [], [], [], []],
|
||||||
|
["张德洪", "zhangdehong", [[["C21-12894", "鲁荣渔58707", "lurongyu58707", 99], ["C21-12895", "鲁荣渔58708", "lurongyu58708", 99]]], [], [], [], [], [], []],
|
||||||
|
["乳山明强渔业有限公司", "rushanmingqiangyuyeyouxiangongsi", [[["C21-12928", "鲁乳渔51065", "luruyu51065", 99], ["C21-12929", "鲁乳渔51066", "luruyu51066", 99]]], [], [], [], [], [], []],
|
||||||
|
["陈亦富", "chenyifu", [[["C21-12978", "浙岭渔25811", "zhelingyu25811", 218], ["C21-12979", "浙岭渔25812", "zhelingyu25812", 218]]], [], [], [], [], [], []],
|
||||||
|
["许积毅", "xujiyi", [[["C21-13008", "鲁威经渔51127", "luweijingyu51127", 117], ["C21-13009", "鲁威经渔51128", "luweijingyu51128", 117]]], [], [], [], [], [], []],
|
||||||
|
["姜明全", "jiangmingquan", [[["C21-13081", "鲁荣渔50945", "lurongyu50945", 106], ["C21-13082", "鲁荣渔50946", "lurongyu50946", 106]]], [], [], [], [], [], []],
|
||||||
|
["曲波", "qubo", [[["C21-13087", "鲁荣渔51615", "lurongyu51615", 99], ["C21-13088", "鲁荣渔51616", "lurongyu51616", 99]]], [], [], [], [], [], []],
|
||||||
|
["刘丽", "liuli", [[["C21-13093", "鲁荣渔51773", "lurongyu51773", 98], ["C21-13094", "鲁荣渔51774", "lurongyu51774", 98]]], [], [], [], [], [], []],
|
||||||
|
["迟明和", "chiminghe", [[["C21-13099", "鲁荣渔51935", "lurongyu51935", 98], ["C21-13100", "鲁荣渔51936", "lurongyu51936", 98]]], [], [], [], [], [], []],
|
||||||
|
["吕海兵", "lvhaibing", [[["C21-13101", "鲁荣渔52131", "lurongyu52131", 98], ["C21-13102", "鲁荣渔52132", "lurongyu52132", 98]]], [], [], [], [], [], []],
|
||||||
|
["荣成市佳鸿渔业有限公司", "rongchengshijiahongyuyeyouxiangongsi", [[["C21-13107", "鲁荣渔52421", "lurongyu52421", 98], ["C21-13108", "鲁荣渔52422", "lurongyu52422", 98]]], [], [], [], [], [], []],
|
||||||
|
["周文松", "zhouwensong", [[["C21-13109", "鲁荣渔52521", "lurongyu52521", 97], ["C21-13110", "鲁荣渔52522", "lurongyu52522", 97]]], [], [], [], [], [], []],
|
||||||
|
["宋存波", "songcunbo", [[["C21-13123", "鲁荣渔55935", "lurongyu55935", 111], ["C21-13124", "鲁荣渔55936", "lurongyu55936", 111]]], [], [], [], [], [], []],
|
||||||
|
["王科杰", "wangkejie", [[["C21-13133", "鲁荣渔55377", "lurongyu55377", 126], ["C21-13134", "鲁荣渔55378", "lurongyu55378", 126]]], [], [], [], [], [], []],
|
||||||
|
["苏军科", "sujunke", [[["C21-13139", "鲁荣渔55519", "lurongyu55519", 98], ["C21-13140", "鲁荣渔55520", "lurongyu55520", 98]]], [], [], [], [], [], []],
|
||||||
|
["杨洪光", "yanghongguang", [[["C21-13147", "鲁荣渔55679", "lurongyu55679", 107], ["C21-13148", "鲁荣渔55680", "lurongyu55680", 107]]], [], [], [], [], [], []],
|
||||||
|
["荣成市丰兴渔业有限公司", "rongchengshifengxingyuyeyouxiangongsi", [[["C21-13149", "鲁荣渔55697", "lurongyu55697", 98], ["C21-13150", "鲁荣渔55698", "lurongyu55698", 98]]], [], [], [], [], [], []],
|
||||||
|
["荣成市昌合渔业有限公司", "rongchengshichangheyuyeyouxiangongsi", [[["C21-13177", "鲁荣渔56587", "lurongyu56587", 106], ["C21-13178", "鲁荣渔56588", "lurongyu56588", 106]]], [], [], [], [], [], []],
|
||||||
|
["荣成市泰达渔业有限公司", "rongchengshitaidayuyeyouxiangongsi", [[["C21-13183", "鲁荣渔57169", "lurongyu57169", 106], ["C21-13184", "鲁荣渔57170", "lurongyu57170", 106]]], [], [], [], [], [], []],
|
||||||
|
["荣成鑫汪渔业有限公司", "rongchengxinwangyuyeyouxiangongsi", [[["C21-13185", "鲁荣渔57177", "lurongyu57177", 106], ["C21-13186", "鲁荣渔57178", "lurongyu57178", 106]]], [], [], [], [], [], []],
|
||||||
|
["连亚豪", "lianyahao", [[["C21-13187", "鲁荣渔57185", "lurongyu57185", 106], ["C21-13188", "鲁荣渔57186", "lurongyu57186", 106]]], [], [], [], [], [], []],
|
||||||
|
["荣成市禾丰水产有限公司", "rongchengshihefengshuichanyouxiangongsi", [[["C21-13201", "鲁荣渔59029", "lurongyu59029", 143], ["C21-13202", "鲁荣渔59030", "lurongyu59030", 143]]], [], [], [], [], [], []],
|
||||||
|
["殷路遥", "yinluyao", [[["C21-13203", "鲁荣渔59061", "lurongyu59061", 121], ["C21-13204", "鲁荣渔59062", "lurongyu59062", 121]]], [], [], [], [], [], []],
|
||||||
|
["王新生", "wangxinsheng", [[["C21-13213", "鲁荣渔59387", "lurongyu59387", 106], ["C21-13214", "鲁荣渔59388", "lurongyu59388", 106]]], [], [], [], [], [], []],
|
||||||
|
["刘铁军", "liutiejun", [[["C21-13219", "鲁乳渔54957", "luruyu54957", 106], ["C21-13220", "鲁乳渔54958", "luruyu54958", 106]]], [], [], [], [], [], []],
|
||||||
|
["王新才", "wangxincai", [[["C21-13350", "鲁荣渔51865", "lurongyu51865", 97], ["C21-13351", "鲁荣渔51866", "lurongyu51866", 97]]], [], [], [], [], [], []],
|
||||||
|
["张红玉", "zhanghongyu", [[["C21-13360", "鲁荣渔52967", "lurongyu52967", 129], ["C21-13361", "鲁荣渔52968", "lurongyu52968", 129]]], [], [], [], [], [], []],
|
||||||
|
["王进春", "wangjinchun", [[["C21-13368", "鲁荣渔55551", "lurongyu55551", 106], ["C21-13369", "鲁荣渔55552", "lurongyu55552", 106]]], [], [], [], [], [], []],
|
||||||
|
["王祖壮", "wangzuzhuang", [[["C21-13374", "鲁荣渔55655", "lurongyu55655", 114], ["C21-13375", "鲁荣渔55656", "lurongyu55656", 114]]], [], [], [], [], [], []],
|
||||||
|
["戚荣平", "qirongping", [[["C21-13382", "鲁荣渔55921", "lurongyu55921", 106], ["C21-13383", "鲁荣渔55922", "lurongyu55922", 106]]], [], [], [], [], [], []],
|
||||||
|
["荣成市蓝琦渔业有限公司", "rongchengshilanqiyuyeyouxiangongsi", [[["C21-13386", "鲁荣渔55967", "lurongyu55967", 111], ["C21-13387", "鲁荣渔55968", "lurongyu55968", 111]]], [], [], [], [], [], []],
|
||||||
|
["王荣新", "wangrongxin", [[["C21-13396", "鲁荣渔57617", "lurongyu57617", 130], ["C21-13397", "鲁荣渔57618", "lurongyu57618", 130]]], [], [], [], [], [], []],
|
||||||
|
["荣成市裕耀渔业有限公司", "rongchengshiyuyaoyuyeyouxiangongsi", [[["C21-13400", "鲁荣渔57889", "lurongyu57889", 106], ["C21-13401", "鲁荣渔57890", "lurongyu57890", 106]]], [], [], [], [], [], []],
|
||||||
|
["荣成市立发渔业有限公司", "rongchengshilifayuyeyouxiangongsi", [[["C21-13408", "鲁荣渔59019", "lurongyu59019", 121], ["C21-13409", "鲁荣渔59020", "lurongyu59020", 121]]], [], [], [], [], [], []],
|
||||||
|
["李海波", "lihaibo", [[["C21-13416", "鲁荣渔59179", "lurongyu59179", 122], ["C21-13417", "鲁荣渔59180", "lurongyu59180", 122]]], [], [], [], [], [], []],
|
||||||
|
["威海天祥水产有限公司", "weihaitianxiangshuichanyouxiangongsi", [[["C21-13424", "鲁威经渔51005", "luweijingyu51005", 118], ["C21-13425", "鲁威经渔51006", "luweijingyu51006", 118]]], [], [], [], [], [], []],
|
||||||
|
["陶俊军", "taojunjun", [[["C21-13448", "鲁威渔60967", "luweiyu60967", 117], ["C21-13449", "鲁威渔60968", "luweiyu60968", 117]]], [], [], [], [], [], []],
|
||||||
|
["柯根法", "kegenfa", [[["C21-13530", "浙象渔45071", "zhexiangyu45071", 196], ["C21-13531", "浙象渔45072", "zhexiangyu45072", 196]]], [], [], [], [], [], []],
|
||||||
|
["任丘市荣发顺达远洋渔业有限公司", "renqiushirongfashundayuanyangyuyeyouxiangongsi", [[["C21-13566", "冀任渔00768", "jirenyu00768", 178], ["C21-13567", "冀任渔00769", "jirenyu00769", 178]]], [], [], [], [], [], []],
|
||||||
|
["荣成启丽渔业有限公司", "rongchengqiliyuyeyouxiangongsi", [[["C21-13608", "鲁荣渔55685", "lurongyu55685", 99], ["C21-13609", "鲁荣渔55686", "lurongyu55686", 99]]], [], [], [], [], [], []],
|
||||||
|
["荣成市天和渔业有限公司", "rongchengshitianheyuyeyouxiangongsi", [[["C21-13612", "鲁荣渔55867", "lurongyu55867", 107], ["C21-13613", "鲁荣渔55868", "lurongyu55868", 107]]], [], [], [], [], [], []],
|
||||||
|
["姜泽丽", "jiangzeli", [[["C21-13622", "鲁荣渔57175", "lurongyu57175", 106], ["C21-13623", "鲁荣渔57176", "lurongyu57176", 106]]], [], [], [], [], [], []],
|
||||||
|
["孟昭生", "mengzhaosheng", [[["C21-13628", "鲁荣渔58677", "lurongyu58677", 68], ["C21-13629", "鲁荣渔58678", "lurongyu58678", 68]]], [], [], [], [], [], []],
|
||||||
|
["于德品", "yudepin", [[["C21-13640", "鲁乳渔51157", "luruyu51157", 106], ["C21-13641", "鲁乳渔51158", "luruyu51158", 106]]], [], [], [], [], [], []],
|
||||||
|
["许后法", "xuhoufa", [[["C21-13787", "浙岭渔23798", "zhelingyu23798", 216], ["C21-13788", "浙岭渔23799", "zhelingyu23799", 216]]], [], [], [], [], [], []],
|
||||||
|
["荣成市杰逢捕捞有限公司", "rongchengshijiefengbulaoyouxiangongsi", [[["C21-13816", "鲁荣渔56889", "lurongyu56889", 106], ["C21-13817", "鲁荣渔56890", "lurongyu56890", 106]]], [], [], [], [], [], []],
|
||||||
|
["程士庆", "chengshiqing", [], [], [], [], [], [["C21-13982", "浙岭渔29801", "zhelingyu29801", 218]], [["C21-13819", "浙岭渔23788", "zhelingyu23788", 216]]],
|
||||||
|
["张文英", "zhangwenying", [[["C21-13830", "鲁荣渔50875", "lurongyu50875", 98], ["C21-13831", "鲁荣渔50876", "lurongyu50876", 98]]], [], [], [], [], [], []],
|
||||||
|
["李际方", "lijifang", [[["C21-13838", "鲁荣渔51147", "lurongyu51147", 134], ["C21-13839", "鲁荣渔51148", "lurongyu51148", 134]]], [], [], [], [], [], []],
|
||||||
|
["蔡继政", "caijizheng", [[["C21-13848", "鲁荣渔51505", "lurongyu51505", 97], ["C21-13849", "鲁荣渔51506", "lurongyu51506", 97]]], [], [], [], [], [], []],
|
||||||
|
["曲军伟", "qujunwei", [[["C21-13860", "鲁荣渔52283", "lurongyu52283", 91], ["C21-13861", "鲁荣渔52284", "lurongyu52284", 91]]], [], [], [], [], [], []],
|
||||||
|
["荣成市朝阳渔业有限公司", "rongchengshichaoyangyuyeyouxiangongsi", [[["C21-13874", "鲁荣渔55597", "lurongyu55597", 111], ["C21-13875", "鲁荣渔55598", "lurongyu55598", 111]]], [], [], [], [], [], []],
|
||||||
|
["汤华海", "tanghuahai", [[["C21-13876", "鲁荣渔55601", "lurongyu55601", 111], ["C21-13877", "鲁荣渔55602", "lurongyu55602", 111]]], [], [], [], [], [], []],
|
||||||
|
["唐音", "tangyin", [[["C21-13878", "鲁荣渔55715", "lurongyu55715", 143], ["C21-13879", "鲁荣渔55716", "lurongyu55716", 143]]], [], [], [], [], [], []],
|
||||||
|
["荣成市盛源渔业有限公司", "rongchengshishengyuanyuyeyouxiangongsi", [[["C21-13912", "鲁荣渔59275", "lurongyu59275", 133], ["C21-13913", "鲁荣渔59276", "lurongyu59276", 133]]], [], [], [], [], [], []],
|
||||||
|
["邓兰红", "denglanhong", [[["C21-13936", "鲁威渔60957", "luweiyu60957", 96], ["C21-13937", "鲁威渔60958", "luweiyu60958", 96]]], [], [], [], [], [], []],
|
||||||
|
["林霞", "linxia", [[["C21-13938", "鲁文渔53097", "luwenyu53097", 68], ["C21-13939", "鲁文渔53098", "luwenyu53098", 68]]], [], [], [], [], [], []],
|
||||||
|
["林君斌", "linjunbin", [[["C21-13952", "浙奉渔18087", "zhefengyu18087", 198], ["C21-13953", "浙奉渔18088", "zhefengyu18088", 198]]], [], [], [], [], [], []],
|
||||||
|
["陈冬方", "chendongfang", [[["C21-13983", "浙岭渔27851", "zhelingyu27851", 218], ["C21-13984", "浙岭渔27852", "zhelingyu27852", 218]]], [], [], [], [], [], []],
|
||||||
|
["杜军明", "dujunming", [[["C21-14001", "浙岭渔23352", "zhelingyu23352", 218], ["C21-14002", "浙岭渔23353", "zhelingyu23353", 218]]], [], [], [], [], [], []],
|
||||||
|
["荣成市福生渔业有限公司", "rongchengshifushengyuyeyouxiangongsi", [[["C21-14007", "鲁荣渔50905", "lurongyu50905", 101], ["C21-14008", "鲁荣渔50906", "lurongyu50906", 101]]], [], [], [], [], [], []],
|
||||||
|
["田宝卫", "tianbaowei", [[["C21-14009", "鲁荣渔50927", "lurongyu50927", 129], ["C21-14010", "鲁荣渔50928", "lurongyu50928", 129]]], [], [], [], [], [], []],
|
||||||
|
["刘昌革", "liuchangge", [[["C21-14011", "鲁荣渔51079", "lurongyu51079", 95], ["C21-14012", "鲁荣渔51080", "lurongyu51080", 95]]], [], [], [], [], [], []],
|
||||||
|
["刘佳", "liujia", [[["C21-14017", "鲁荣渔51333", "lurongyu51333", 97], ["C21-14018", "鲁荣渔51334", "lurongyu51334", 97]]], [], [], [], [], [], []],
|
||||||
|
["周华隆", "zhouhualong", [[["C21-14025", "鲁荣渔51923", "lurongyu51923", 97], ["C21-14026", "鲁荣渔51924", "lurongyu51924", 97]]], [], [], [], [], [], []],
|
||||||
|
["王连娜", "wanglianna", [[["C21-14027", "鲁荣渔51957", "lurongyu51957", 109], ["C21-14028", "鲁荣渔51958", "lurongyu51958", 109]]], [], [], [], [], [], []],
|
||||||
|
["鞠世柏", "jushibai", [[["C21-14035", "鲁荣渔52771", "lurongyu52771", 106], ["C21-14036", "鲁荣渔52772", "lurongyu52772", 106]]], [], [], [], [], [], []],
|
||||||
|
["李加鹏", "lijiapeng", [[["C21-14039", "鲁荣渔55037", "lurongyu55037", 126], ["C21-14040", "鲁荣渔55038", "lurongyu55038", 126]]], [], [], [], [], [], []],
|
||||||
|
["王海云", "wanghaiyun", [[["C21-14059", "鲁荣渔55379", "lurongyu55379", 96], ["C21-14060", "鲁荣渔55380", "lurongyu55380", 96]]], [], [], [], [], [], []],
|
||||||
|
["荣成鸿安渔业有限公司", "rongchenghonganyuyeyouxiangongsi", [[["C21-14061", "鲁荣渔55727", "lurongyu55727", 96], ["C21-14062", "鲁荣渔55728", "lurongyu55728", 96]]], [], [], [], [], [], []],
|
||||||
|
["荣成市明海捕捞有限公司", "rongchengshiminghaibulaoyouxiangongsi", [[["C21-14065", "鲁荣渔55821", "lurongyu55821", 117], ["C21-14066", "鲁荣渔55822", "lurongyu55822", 117]]], [], [], [], [], [], []],
|
||||||
|
["毕远朋", "biyuanpeng", [[["C21-14067", "鲁荣渔55831", "lurongyu55831", 126], ["C21-14068", "鲁荣渔55832", "lurongyu55832", 126]]], [], [], [], [], [], []],
|
||||||
|
["郇日强", "huanriqiang", [[["C21-14071", "鲁荣渔55971", "lurongyu55971", 117], ["C21-14072", "鲁荣渔55972", "lurongyu55972", 117]]], [], [], [], [], [], []],
|
||||||
|
["荣成市福强捕捞有限公司", "rongchengshifuqiangbulaoyouxiangongsi", [[["C21-14075", "鲁荣渔56357", "lurongyu56357", 163], ["C21-14076", "鲁荣渔56358", "lurongyu56358", 163]]], [], [], [], [], [], []],
|
||||||
|
["李吉钊", "lijizhao", [[["C21-14077", "鲁荣渔56577", "lurongyu56577", 106], ["C21-14078", "鲁荣渔56578", "lurongyu56578", 106]]], [], [], [], [], [], []],
|
||||||
|
["田明杰", "tianmingjie", [[["C21-14081", "鲁荣渔56667", "lurongyu56667", 106], ["C21-14082", "鲁荣渔56668", "lurongyu56668", 106]]], [], [], [], [], [], []],
|
||||||
|
["周本润", "zhoubenrun", [[["C21-14083", "鲁荣渔56669", "lurongyu56669", 106], ["C21-14084", "鲁荣渔56670", "lurongyu56670", 106]]], [], [], [], [], [], []],
|
||||||
|
["张炳承", "zhangbingcheng", [[["C21-14085", "鲁荣渔56679", "lurongyu56679", 106], ["C21-14086", "鲁荣渔56680", "lurongyu56680", 106]]], [], [], [], [], [], []],
|
||||||
|
["荣成市真鑫渔业有限公司", "rongchengshizhenxinyuyeyouxiangongsi", [[["C21-14093", "鲁荣渔57087", "lurongyu57087", 121], ["C21-14094", "鲁荣渔57088", "lurongyu57088", 121]]], [], [], [], [], [], []],
|
||||||
|
["荣坤", "rongkun", [[["C21-14095", "鲁荣渔57097", "lurongyu57097", 117], ["C21-14096", "鲁荣渔57098", "lurongyu57098", 117]]], [], [], [], [], [], []],
|
||||||
|
["田义", "tianyi", [[["C21-14105", "鲁荣渔58797", "lurongyu58797", 117], ["C21-14106", "鲁荣渔58798", "lurongyu58798", 117]]], [], [], [], [], [], []],
|
||||||
|
["李昕", "lixin", [[["C21-14113", "鲁荣渔59065", "lurongyu59065", 122], ["C21-14114", "鲁荣渔59066", "lurongyu59066", 122]]], [], [], [], [], [], []],
|
||||||
|
["袁嵩琦", "yuansongqi", [[["C21-14117", "鲁荣渔59197", "lurongyu59197", 106], ["C21-14118", "鲁荣渔59198", "lurongyu59198", 106]]], [], [], [], [], [], []],
|
||||||
|
["马千里", "maqianli", [[["C21-14119", "鲁荣渔59385", "lurongyu59385", 106], ["C21-14120", "鲁荣渔59386", "lurongyu59386", 106]]], [], [], [], [], [], []],
|
||||||
|
["李世凯", "lishikai", [[["C21-14121", "鲁威高渔60185", "luweigaoyu60185", 117], ["C21-14122", "鲁威高渔60186", "luweigaoyu60186", 117]]], [], [], [], [], [], []],
|
||||||
|
["毕礼承", "bilicheng", [[["C21-14123", "鲁威高渔60889", "luweigaoyu60889", 117], ["C21-14124", "鲁威高渔60890", "luweigaoyu60890", 117]]], [], [], [], [], [], []],
|
||||||
|
["威海顺泰水产食品有限公司", "weihaishuntaishuichuanshipinyouxiangongsi", [[["C21-14125", "鲁威高渔60967", "luweigaoyu60967", 102], ["C21-14126", "鲁威高渔60968", "luweigaoyu60968", 102]]], [], [], [], [], [], []],
|
||||||
|
["威海英涵渔业有限公司", "weihaiyinghanyuyeyouxiangongsi", [[["C21-14127", "鲁威经渔51019", "luweijingyu51019", 136], ["C21-14128", "鲁威经渔51020", "luweijingyu51020", 136]]], [], [], [], [], [], []],
|
||||||
|
["威海合顺渔业有限公司", "weihaiheshunyuyeyouxiangongsi", [[["C21-14129", "鲁威经渔51085", "luweijingyu51085", 290], ["C21-14130", "鲁威经渔51086", "luweijingyu51086", 290]]], [], [], [], [], [], []],
|
||||||
|
["威海润恒渔业有限公司", "weihairunhengyuyeyouxiangongsi", [[["C21-14139", "鲁文渔51007", "luwenyu51007", 107], ["C21-14140", "鲁文渔51008", "luwenyu51008", 107]]], [], [], [], [], [], []],
|
||||||
|
["王春红", "wangchunhong", [[["C21-14145", "鲁文渔53857", "luwenyu53857", 101], ["C21-14146", "鲁文渔53858", "luwenyu53858", 101]]], [], [], [], [], [], []],
|
||||||
|
["大连悦启鑫晟捕捞有限公司", "dalianyueqixinchengbulaoyouxiangongsi", [[["C21-14155", "辽大金渔15211", "liaodajinyu15211", 175], ["C21-14156", "辽大金渔15212", "liaodajinyu15212", 175]]], [], [], [], [], [], []],
|
||||||
|
["庄礼兵", "zhuanglibing", [[["C21-14177", "浙岭渔20508", "zhelingyu20508", 214], ["C21-14178", "浙岭渔20535", "zhelingyu20535", 214]]], [], [], [], [], [], []],
|
||||||
|
["陈鹏", "chenpeng", [[["C21-14179", "浙岭渔41077", "zhelingyu41077", 216], ["C21-14180", "浙岭渔41078", "zhelingyu41078", 216]]], [], [], [], [], [], []],
|
||||||
|
["史纪兆", "shijizhao", [[["C21-14185", "浙象渔66035", "zhexiangyu66035", 217], ["C21-14186", "浙象渔66036", "zhexiangyu66036", 217]]], [], [], [], [], [], []],
|
||||||
|
["林含密", "linhanmi", [[["C21-14191", "浙奉渔16081", "zhefengyu16081", 217], ["C21-14192", "浙奉渔16082", "zhefengyu16082", 217]]], [], [], [], [], [], []],
|
||||||
|
["林建明", "linjianming", [[["C21-14193", "浙奉渔12037", "zhefengyu12037", 198], ["C21-14194", "浙奉渔12038", "zhefengyu12038", 198]]], [], [], [], [], [], []],
|
||||||
|
["林松盛", "linsongsheng", [[["C21-14195", "浙奉渔18001", "zhefengyu18001", 219], ["C21-14196", "浙奉渔18002", "zhefengyu18002", 219]]], [], [], [], [], [], []],
|
||||||
|
["章毕科", "zhangbike", [[["C21-14197", "浙奉渔10071", "zhefengyu10071", 217], ["C21-14198", "浙奉渔10072", "zhefengyu10072", 217]]], [], [], [], [], [], []],
|
||||||
|
["樊新春", "fanxinchun", [[["C21-14201", "鲁荣渔55055", "lurongyu55055", 99], ["C21-14202", "鲁荣渔55056", "lurongyu55056", 99]]], [], [], [], [], [], []],
|
||||||
|
["向仁毅", "xiangrenyi", [[["C21-14203", "鲁荣渔56697", "lurongyu56697", 106], ["C21-14204", "鲁荣渔56698", "lurongyu56698", 106]]], [], [], [], [], [], []],
|
||||||
|
["荣成市航程渔业有限公司", "rongchengshihangchengyuyeyouxiangongsi", [[["C21-14205", "鲁荣渔58759", "lurongyu58759", 106], ["C21-14206", "鲁荣渔58760", "lurongyu58760", 106]]], [], [], [], [], [], []],
|
||||||
|
["金校辰", "jinxiaochen", [[["C21-14211", "浙岭渔22981", "zhelingyu22981", 218], ["C21-14212", "浙岭渔22982", "zhelingyu22982", 218]]], [], [], [], [], [], []],
|
||||||
|
["郑凯伦", "zhengkailun", [[["C21-14213", "浙岭渔22527", "zhelingyu22527", 214], ["C21-14214", "浙岭渔22528", "zhelingyu22528", 214]]], [], [], [], [], [], []],
|
||||||
|
["陈其忠", "chenqizhong", [[["C21-14215", "浙岭渔22565", "zhelingyu22565", 218], ["C21-14216", "浙岭渔22566", "zhelingyu22566", 218]]], [], [], [], [], [], []],
|
||||||
|
["林武燕", "linwuyan", [[["C21-14217", "浙岭渔23765", "zhelingyu23765", 218], ["C21-14218", "浙岭渔23766", "zhelingyu23766", 218]]], [], [], [], [], [], []],
|
||||||
|
["章海刚", "zhanghaigang", [[["C21-14221", "浙岭渔18135", "zhelingyu18135", 218], ["C21-14222", "浙岭渔18136", "zhelingyu18136", 218]]], [], [], [], [], [], []],
|
||||||
|
["邬旭雷", "wuxulei", [[["C21-14223", "浙奉渔13085", "zhefengyu13085", 198], ["C21-14224", "浙奉渔13086", "zhefengyu13086", 198]]], [], [], [], [], [], []],
|
||||||
|
["周飞云", "zhoufeiyun", [[["C21-14225", "浙奉渔29001", "zhefengyu29001", 219], ["C21-14226", "浙奉渔29002", "zhefengyu29002", 219]]], [], [], [], [], [], []],
|
||||||
|
["施明立", "shimingli", [[["C21-14233", "浙岭渔20538", "zhelingyu20538", 219], ["C21-14234", "浙岭渔20539", "zhelingyu20539", 219]]], [], [], [], [], [], []],
|
||||||
|
["包照好", "baozhaohao", [[["C21-14235", "浙三渔00518", "zhesanyu00518", 218], ["C21-14236", "浙三渔00519", "zhesanyu00519", 218]]], [], [], [], [], [], []],
|
||||||
|
["包判好", "baopanhao", [[["C21-14237", "浙三渔00785", "zhesanyu00785", 218], ["C21-14238", "浙三渔00786", "zhesanyu00786", 216]]], [], [], [], [], [], []],
|
||||||
|
["汪方艇", "wangfangting", [[["C21-14239", "浙象渔45001", "zhexiangyu45001", 218], ["C21-14240", "浙象渔45002", "zhexiangyu45002", 218]]], [], [], [], [], [], []],
|
||||||
|
["丁从蔡", "dingcongcai", [[["C21-14263", "浙岭渔29915", "zhelingyu29915", 218], ["C21-14264", "浙岭渔29916", "zhelingyu29916", 218]]], [], [], [], [], [], []],
|
||||||
|
["段敏康", "duanminkang", [[["C21-14269", "浙象渔40191", "zhexiangyu40191", 182], ["C21-14270", "浙象渔40192", "zhexiangyu40192", 182]]], [], [], [], [], [], []],
|
||||||
|
["鞠建松", "jujiansong", [[["C21-14277", "鲁荣渔52739", "lurongyu52739", 106], ["C21-14278", "鲁荣渔52740", "lurongyu52740", 106]]], [], [], [], [], [], []],
|
||||||
|
["刘大航", "liudahang", [[["C21-14279", "鲁荣渔55579", "lurongyu55579", 92], ["C21-14280", "鲁荣渔55580", "lurongyu55580", 92]]], [], [], [], [], [], []],
|
||||||
|
["荣成市常丰渔业有限公司", "rongchengshichangfengyuyeyouxiangongsi", [[["C21-14283", "鲁荣渔55687", "lurongyu55687", 99], ["C21-14284", "鲁荣渔55688", "lurongyu55688", 99]]], [], [], [], [], [], []],
|
||||||
|
["王彩玲", "wangcailing", [[["C21-14285", "鲁荣渔55689", "lurongyu55689", 99], ["C21-14286", "鲁荣渔55690", "lurongyu55690", 99]]], [], [], [], [], [], []],
|
||||||
|
["伯绍霞", "baishaoxia", [[["C21-14287", "鲁荣渔55701", "lurongyu55701", 101], ["C21-14288", "鲁荣渔55702", "lurongyu55702", 101]]], [], [], [], [], [], []],
|
||||||
|
["蔡晓辉", "caixiaohui", [[["C21-14289", "鲁荣渔55819", "lurongyu55819", 106], ["C21-14290", "鲁荣渔55820", "lurongyu55820", 106]]], [], [], [], [], [], []],
|
||||||
|
["张海涛", "zhanghaitao", [[["C21-14291", "鲁荣渔55855", "lurongyu55855", 99], ["C21-14292", "鲁荣渔55856", "lurongyu55856", 99]]], [], [], [], [], [], []],
|
||||||
|
["袁彩荣", "yuancairong", [[["C21-14295", "鲁荣渔55885", "lurongyu55885", 106], ["C21-14296", "鲁荣渔55886", "lurongyu55886", 106]]], [], [], [], [], [], []],
|
||||||
|
["马晓红", "maxiaohong", [[["C21-14301", "鲁荣渔57597", "lurongyu57597", 130], ["C21-14302", "鲁荣渔57598", "lurongyu57598", 130]]], [], [], [], [], [], []],
|
||||||
|
["鞠海军", "juhaijun", [[["C21-14303", "鲁荣渔58475", "lurongyu58475", 106], ["C21-14304", "鲁荣渔58476", "lurongyu58476", 106]]], [], [], [], [], [], []],
|
||||||
|
["曲向阳", "quxiangyang", [[["C21-14315", "鲁荣渔55165", "lurongyu55165", 106], ["C21-14316", "鲁荣渔55166", "lurongyu55166", 106]]], [], [], [], [], [], []],
|
||||||
|
["王春景", "wangchunjing", [[["C21-14317", "鲁荣渔55657", "lurongyu55657", 117], ["C21-14318", "鲁荣渔55658", "lurongyu55658", 117]]], [], [], [], [], [], []],
|
||||||
|
["程兴虎", "chengxinghu", [[["C21-14329", "鲁荣渔50801", "lurongyu50801", 98], ["C21-14330", "鲁荣渔50802", "lurongyu50802", 98]]], [], [], [], [], [], []],
|
||||||
|
["袁卫斌", "yuanweibin", [[["C21-14333", "鲁荣渔51211", "lurongyu51211", 106], ["C21-14334", "鲁荣渔51212", "lurongyu51212", 106]]], [], [], [], [], [], []],
|
||||||
|
["张晓明", "zhangxiaoming", [[["C21-14337", "鲁荣渔55001", "lurongyu55001", 111], ["C21-14338", "鲁荣渔55002", "lurongyu55002", 111]]], [], [], [], [], [], []],
|
||||||
|
["蔡继光", "caijiguang", [[["C21-14345", "鲁荣渔55635", "lurongyu55635", 106], ["C21-14346", "鲁荣渔55636", "lurongyu55636", 106]]], [], [], [], [], [], []],
|
||||||
|
["王国旗", "wangguoqi", [[["C21-14351", "鲁荣渔55951", "lurongyu55951", 90], ["C21-14352", "鲁荣渔55952", "lurongyu55952", 90]]], [], [], [], [], [], []],
|
||||||
|
["张强", "zhangqiang", [[["C21-14353", "鲁荣渔55981", "lurongyu55981", 114], ["C21-14354", "鲁荣渔55982", "lurongyu55982", 114]]], [], [], [], [], [], []],
|
||||||
|
["毕金龙", "bijinlong", [[["C21-14357", "鲁荣渔57589", "lurongyu57589", 117], ["C21-14358", "鲁荣渔57590", "lurongyu57590", 117]]], [], [], [], [], [], []],
|
||||||
|
["荣成市学文海水捕捞有限公司", "rongchengshixuewenhaishuibulaoyouxiangongsi", [[["C21-14359", "鲁荣渔58735", "lurongyu58735", 122], ["C21-14360", "鲁荣渔58736", "lurongyu58736", 122]]], [], [], [], [], [], []],
|
||||||
|
["荣成市宏鑫水产有限公司", "rongchengshihongxinshuichanyouxiangongsi", [[["C21-14363", "鲁荣渔59119", "lurongyu59119", 127], ["C21-14364", "鲁荣渔59120", "lurongyu59120", 127]]], [], [], [], [], [], []],
|
||||||
|
["沈明贤", "shenmingxian", [[["C21-14373", "浙奉渔19055", "zhefengyu19055", 218], ["C21-14374", "浙奉渔19056", "zhefengyu19056", 218]]], [], [], [], [], [], []],
|
||||||
|
["林岳华", "linyuehua", [[["C21-14375", "浙奉渔18091", "zhefengyu18091", 218], ["C21-14376", "浙奉渔18092", "zhefengyu18092", 218]]], [], [], [], [], [], []],
|
||||||
|
["麦海舰", "maihaijian", [[["C21-14379", "浙象渔47107", "zhexiangyu47107", 198], ["C21-14380", "浙象渔47108", "zhexinagyu47108", 198]]], [], [], [], [], [], []],
|
||||||
|
["徐再良", "xuzailiang", [[["C21-14391", "浙岭渔23836", "zhelingyu23836", 218], ["C21-14392", "浙岭渔23837", "zhelingyu23837", 218]]], [], [], [], [], [], []],
|
||||||
|
["刘祖国", "liuzuguo", [[["C21-14393", "浙岭渔29862", "zhelingyu29862", 218], ["C21-14394", "浙岭渔29863", "zhelingyu29863", 218]]], [], [], [], [], [], []],
|
||||||
|
["黄定兵", "huangdingbing", [[["C21-14395", "浙岭渔23508", "zhelingyu23508", 218], ["C21-14396", "浙岭渔23509", "zhelingyu23509", 216]]], [], [], [], [], [], []],
|
||||||
|
["张定方", "zhangdingfang", [[["C21-14397", "浙路渔82405", "zheluyu82405", 218], ["C21-14398", "浙路渔82406", "zheluyu82406", 218]]], [], [], [], [], [], []],
|
||||||
|
["林君杰", "linjunjie", [[["C21-14410", "浙奉渔18071", "zhefengyu18071", 218], ["C21-14411", "浙奉渔18072", "zhefengyu18072", 217]]], [], [], [], [], [], []],
|
||||||
|
["周丽华", "zhoulihua", [[["C21-14412", "鲁荣渔50871", "lurongyu50871", 98], ["C21-14413", "鲁荣渔50872", "lurongyu50872", 98]]], [], [], [], [], [], []],
|
||||||
|
["田娜", "tianna", [[["C21-14414", "鲁荣渔50907", "lurongyu50907", 98], ["C21-14415", "鲁荣渔50908", "lurongyu50908", 98]]], [], [], [], [], [], []],
|
||||||
|
["荣成市泽远渔业有限公司", "rongchengshizeyuanyuyeyouxiangongsi", [[["C21-14418", "鲁荣渔51631", "lurongyu51631", 297], ["C21-14419", "鲁荣渔51632", "lurongyu51632", 297]]], [], [], [], [], [], []],
|
||||||
|
["夏金松", "xiajinsong", [[["C21-14420", "鲁荣渔52655", "lurongyu52655", 97], ["C21-14421", "鲁荣渔52656", "lurongyu52656", 97]]], [], [], [], [], [], []],
|
||||||
|
["范秀珍", "fanxiuzhen", [[["C21-14426", "鲁荣渔55309", "lurongyu55309", 99], ["C21-14427", "鲁荣渔55310", "lurongyu55310", 99]]], [], [], [], [], [], []],
|
||||||
|
["李荣国", "lirongguo", [[["C21-14428", "鲁荣渔55367", "lurongyu55367", 116], ["C21-14429", "鲁荣渔55368", "lurongyu55368", 116]]], [], [], [], [], [], []],
|
||||||
|
["王建文", "wangjianwen", [[["C21-14430", "鲁荣渔55527", "lurongyu55527", 117], ["C21-14431", "鲁荣渔55528", "lurongyu55528", 117]]], [], [], [], [], [], []],
|
||||||
|
["郭远军", "guoyuanjun", [[["C21-14432", "鲁荣渔55617", "lurongyu55617", 96], ["C21-14433", "鲁荣渔55618", "lurongyu55618", 96]]], [], [], [], [], [], []],
|
||||||
|
["孙华艳", "sunhuayan", [[["C21-14434", "鲁荣渔55947", "lurongyu55947", 116], ["C21-14435", "鲁荣渔55948", "lurongyu55948", 116]]], [], [], [], [], [], []],
|
||||||
|
["王忠言", "wangzhongyan", [[["C21-14436", "鲁荣渔56377", "lurongyu56377", 107], ["C21-14437", "鲁荣渔56378", "lurongyu56378", 107]]], [], [], [], [], [], []],
|
||||||
|
["蔡传亭", "caichuanting", [[["C21-14442", "鲁荣渔56877", "lurongyu56877", 122], ["C21-14443", "鲁荣渔56878", "lurongyu56878", 122]]], [], [], [], [], [], []],
|
||||||
|
["威海昇晟渔业有限公司", "weihaishengshengyuyeyouxiangongsi", [[["C21-14448", "鲁荣渔57695", "lurongyu57695", 212], ["C21-14449", "鲁荣渔57696", "lurongyu57696", 212]]], [], [], [], [], [], []],
|
||||||
|
["曲海", "quhai", [[["C21-14450", "鲁荣渔57767", "lurongyu57767", 123], ["C21-14451", "鲁荣渔57768", "lurongyu57768", 123]]], [], [], [], [], [], []],
|
||||||
|
["蔡继贤", "caijixian", [[["C21-14452", "鲁荣渔58051", "lurongyu58051", 116], ["C21-14453", "鲁荣渔58052", "lurongyu58052", 116]]], [], [], [], [], [], []],
|
||||||
|
["王宁", "wangning", [[["C21-14456", "鲁荣渔58989", "lurongyu58989", 127], ["C21-14457", "鲁荣渔58990", "lurongyu58990", 127]]], [], [], [], [], [], []],
|
||||||
|
["威海华达捕捞有限公司", "weihaihuadabulaoyouxiangongsi", [[["C21-14462", "鲁威渔59997", "luweiyu59997", 277], ["C21-14463", "鲁威渔59998", "luweiyu59998", 277]]], [], [], [], [], [], []],
|
||||||
|
["邹文军", "zouwenjun", [[["C21-14464", "鲁威渔61787", "luweiyu61787", 117], ["C21-14465", "鲁威渔61788", "luweiyu61788", 117]]], [], [], [], [], [], []],
|
||||||
|
["威海力庆渔业捕捞有限公司", "weihailiqingyuyebulaoyouxiangongsi", [[["C21-14466", "鲁威经渔51015", "luweijingyu51015", 127], ["C21-14467", "鲁威经渔51016", "luweijingyu51016", 127]]], [], [], [], [], [], []],
|
||||||
|
["邱献云", "qiuxianyun", [[["C21-14470", "鲁文渔53255", "luwenyu53255", 84], ["C21-14471", "鲁文渔53256", "luwenyu53256", 84]]], [], [], [], [], [], []],
|
||||||
|
["大连悦纳捕捞有限公司", "dalianyuenabulaoyouxiangongsi", [[["C21-14480", "辽大金渔15676", "liaodajinyu15676", 122], ["C21-14481", "辽大金渔15677", "liaodajinyu15677", 122]]], [], [], [], [], [], []],
|
||||||
|
["大连仙丰渔业有限公司", "dalianxianfengyuyeyouxiangongsi", [[["C21-14482", "辽大金渔85779", "liaodajinyu85779", 290], ["C21-14483", "辽大金渔85780", "liaodajinyu85780", 290]]], [], [], [], [], [], []],
|
||||||
|
["郭丽燕", "guoliyan", [[["C21-14498", "鲁荣渔55185", "lurongyu55185", 96], ["C21-14499", "鲁荣渔55186", "lurongyu55186", 96]]], [], [], [], [], [], []],
|
||||||
|
["荣成市福盛德渔业有限公司", "rongchengshifushengdeyuyeyouxiangongsi", [[["C21-14500", "鲁荣渔55287", "lurongyu55287", 117], ["C21-14501", "鲁荣渔55288", "lurongyu55288", 117]]], [], [], [], [], [], []],
|
||||||
|
["柯崇浩", "kechonghao", [[["C21-14508", "浙岭渔23718", "zhelingyu23718", 217], ["C21-14509", "浙岭渔23719", "zhelingyu23719", 217]]], [], [], [], [], [], []],
|
||||||
|
["田焱", "tianyan", [[["C21-14511", "鲁荣渔58191", "lurongyu58191", 106], ["C21-14512", "鲁荣渔58192", "lurongyu58192", 106]]], [], [], [], [], [], []],
|
||||||
|
["王磊", "wanglei", [[["C21-14513", "鲁荣渔58761", "lurongyu58761", 101], ["C21-14514", "鲁荣渔58762", "lurongyu58762", 101]]], [], [], [], [], [], []],
|
||||||
|
["谷晓东", "guxiaodong", [[["C21-14515", "鲁荣渔58867", "lurongyu58867", 297], ["C21-14516", "鲁荣渔58868", "lurongyu58868", 297]]], [], [], [], [], [], []],
|
||||||
|
["荣成市航海捕捞有限公司", "rongchengshihanghaibulaoyouxiangongsi", [[["C21-14517", "鲁荣渔58891", "lurongyu58891", 296], ["C21-14518", "鲁荣渔58892", "lurongyu58892", 296]]], [], [], [], [], [], []],
|
||||||
|
["威海市东升实业公司", "weihaishidongshengshiyegongsi", [[["C21-14521", "鲁威渔52017", "luweiyu52017", 126], ["C21-14522", "鲁威渔52018", "luweiyu52018", 126]]], [], [], [], [], [], []],
|
||||||
|
["王增康", "wangzengkang", [[["C21-14523", "浙象渔33057", "zhexiangyu33057", 219], ["C21-14524", "浙象渔33058", "zhexiangyu33058", 219]]], [], [], [], [], [], []],
|
||||||
|
["许位夫", "xuweifu", [[["C21-14525", "浙象渔42081", "zhexiangyu42081", 219], ["C21-14526", "浙象渔42082", "zhexinagyu42082", 219]]], [], [], [], [], [], []],
|
||||||
|
["林康", "linkang", [[["C21-14529", "浙奉渔17015", "zhefengyu17015", 219], ["C21-14530", "浙奉渔17016", "zhefengyu17016", 219]]], [], [], [], [], [], []],
|
||||||
|
["项修法", "xiangxiufa", [[["C21-14539", "浙临渔12801", "zhelinyu12801", 218], ["C21-14540", "浙临渔12802", "zhelinyu12802", 218]]], [], [], [], [], [], []],
|
||||||
|
["王小丽", "wangxiaoli", [[["C21-14541", "鲁荣渔50913", "lurongyu50913", 95], ["C21-14542", "鲁荣渔50914", "lurongyu50914", 95]]], [], [], [], [], [], []],
|
||||||
|
["威海嘉明渔业有限公司", "weihaijiamingyuyeyouxiangongsi", [[["C21-14543", "鲁荣渔51447", "lurongyu51447", 90], ["C21-14544", "鲁荣渔51448", "lurongyu51448", 90]]], [], [], [], [], [], []],
|
||||||
|
["张巧兰", "zhangqiaolan", [[["C21-14545", "鲁荣渔51897", "lurongyu51897", 127], ["C21-14546", "鲁荣渔51898", "lurongyu51898", 127]]], [], [], [], [], [], []],
|
||||||
|
["王翠玲", "wangcuiling", [[["C21-14549", "鲁荣渔52093", "lurongyu52093", 96], ["C21-14550", "鲁荣渔52094", "lurongyu52094", 96]]], [], [], [], [], [], []],
|
||||||
|
["荣成顺伟达渔业有限公司", "rongchengshunweidayuyeyouxiangongsi", [[["C21-14551", "鲁荣渔52429", "lurongyu52429", 96], ["C21-14552", "鲁荣渔52430", "lurongyu52430", 96]]], [], [], [], [], [], []],
|
||||||
|
["威海满诚渔业有限公司", "weihaimanchengyuyeyouxiangongsi", [[["C21-14555", "鲁荣渔55029", "lurongyu55029", 118], ["C21-14556", "鲁荣渔55030", "lurongyu55030", 118]]], [], [], [], [], [], []],
|
||||||
|
["李艳钊", "liyanzhao", [[["C21-14557", "鲁荣渔55167", "lurongyu55167", 106], ["C21-14558", "鲁荣渔55168", "lurongyu55168", 106]]], [], [], [], [], [], []],
|
||||||
|
["马永辉", "mayonghui", [[["C21-14559", "鲁荣渔55217", "lurongyu55217", 106], ["C21-14560", "鲁荣渔55218", "lurongyu55218", 106]]], [], [], [], [], [], []],
|
||||||
|
["张芹", "zhangqin", [[["C21-14561", "鲁荣渔55699", "lurongyu55699", 92], ["C21-14562", "鲁荣渔55700", "lurongyu55700", 92]]], [], [], [], [], [], []],
|
||||||
|
["田丰政", "tianfengzheng", [[["C21-14563", "鲁荣渔55737", "lurongyu55737", 96], ["C21-14564", "鲁荣渔55738", "lurongyu55738", 96]]], [], [], [], [], [], []],
|
||||||
|
["许日", "xuri", [[["C21-14565", "鲁荣渔56827", "lurongyu56827", 106], ["C21-14566", "鲁荣渔56828", "lurongyu56828", 106]]], [], [], [], [], [], []],
|
||||||
|
["王祚堂", "wangzuotang", [], [["C25-10441", "苏射渔02222", "susheyu02222", 149], ["C25-15732", "苏射渔08777", "susheyu08777", 149]], [], [], [], [], []],
|
||||||
|
["鲁长霞", "luchangxia", [], [["C25-16332", "津汉渔04618", "jinhanyu04618", 145], ["C25-17282", "津汉渔04608", "jinhanyu04608", 145]], [], [], [], [], []],
|
||||||
|
["任月通", "renyuetong", [], [["C25-16547", "冀黄渔06758", "jihuangyu06758", 134], ["C25-16548", "冀黄渔06759", "jihuangyu06759", 134]], [], [], [], [], []],
|
||||||
|
["张妍", "zhangyan", [], [["C25-16621", "津塘渔03689", "jintangyu03689", 45], ["C25-16918", "津塘渔03186", "jintangyu03186", 98]], [], [], [], [], []],
|
||||||
|
["任东亮", "rendongliang", [], [["C25-17086", "冀黄渔01777", "jihuangyu01777", 149], ["C25-17089", "冀黄渔06776", "jihuangyu06776", 149]], [], [], [], [], []],
|
||||||
|
["黄骅市宏安海水捕捞有限公司", "huanghuashihonganhaishuibulaoyouxiangongsi", [], [["C25-17087", "冀黄渔02996", "jihuangyu02996", 98], ["C25-17088", "冀黄渔02997", "jihuangyu02997", 98]], [], [], [], [], []],
|
||||||
|
["大连启嘉渔业有限公司", "dalianqijiayuyeyouxiangongsi", [], [["C25-17113", "辽庄渔75777", "liaozhuangyu75777", 149], ["C25-17114", "辽庄渔75555", "liaozhuangyu75555", 149]], [], [], [], [], []],
|
||||||
|
["潘金玉", "panjinyu", [], [["C25-17119", "辽庄渔85888", "liaozhuangyu85888", 149]], [], [], [["C40-8474", "辽庄渔运85778", "liaozhuangyuyun85778", 178]], [], []],
|
||||||
|
["宋惠生", "songhuisheng", [], [["C25-17228", "津塘渔03187", "jintangyu03187", 148], ["C25-17245", "津塘渔03188", "jintangyu03188", 148]], [], [], [], [], []],
|
||||||
|
["张丽", "zhangli", [], [["C25-17229", "津塘渔03116", "jintangyu03116", 147], ["C25-17230", "津塘渔03185", "jintangyu03185", 147]], [], [], [], [], []],
|
||||||
|
["大连铸航水产销售有限公司", "dalianzhuhangshuichanxiaoshouyouxiangongsi", [], [["C25-17257", "辽大金渔35988", "liaodajinyu35988", 148], ["C25-17258", "辽大金渔35989", "liaodajinyu35989", 148]], [], [], [], [], []],
|
||||||
|
["大连金多洋渔业有限公司", "dalianjinduoyangyuyeyouxiangongsi", [], [["C25-17268", "辽庄渔85678", "liaozhuangyu85678", 144], ["C25-17269", "辽庄渔85999", "liaozhuangyu85999", 144]], [], [], [], [], []],
|
||||||
|
["大连贺程渔业有限公司", "dalianhechengyuyeyouxiangongsi", [], [["C25-17271", "辽大金渔15688", "liaodajinyu15688", 144], ["C25-17272", "辽大金渔15828", "liaodajinyu15828", 144]], [], [], [], [], []],
|
||||||
|
["庄河市八方聚财渔业有限公司", "zhuangheshibafangjucaiyuyeyouxiangongsi", [], [["C25-17274", "辽庄渔75678", "liaozhuangyu75678", 98], ["C25-17275", "辽庄渔75789", "liaozhuangyu75789", 98]], [], [], [], [], []],
|
||||||
|
["陈欢", "chenhuan", [], [["C25-17283", "津汉渔04567", "jinhanyu04567", 144], ["C25-17284", "津汉渔04678", "jinhanyu04678", 144]], [], [], [], [], []],
|
||||||
|
["孙红昌", "sunhongchang", [], [["C25-17291", "苏射渔01555", "susheyu01555", 147], ["C25-17292", "苏射渔01666", "susheyu01666", 147]], [], [], [], [], []],
|
||||||
|
["大连世景源水产品有限公司", "dalianshijingyuanshuichanpinyouxiangongsi", [], [["C25-17307", "辽大中渔25176", "liaodazhongyu25176", 148], ["C25-17308", "辽大中渔25177", "liaodazhongyu25177", 148]], [], [], [], [], []],
|
||||||
|
["大连盛庆渔业有限公司", "dalianshengqingyuyeyouxiangongsi", [], [["C25-17309", "辽庄渔65787", "liaozhuangyu65787", 144], ["C25-17310", "辽庄渔65786", "liaozhuangyu65786", 144]], [], [], [], [], []],
|
||||||
|
["大连国友捕捞有限公司", "dalianguoyoubulaoyouxiangongsi", [], [], [], [], [["C40-8428", "辽庄渔运25266", "liaozhuangyuyun25266", 161], ["C40-8441", "辽庄渔运25267", "liaozhuangyuyun25267", 161]], [], []],
|
||||||
|
["天津荣祥水产品有限公司", "tianjinrongxiangshuichanpinyouxiangongsi", [], [], [], [], [["C40-8477", "津塘渔运03181", "jintangyuyun03181", 238], ["C40-8479", "津塘渔运03186", "jintangyuyun03186", 287]], [], []],
|
||||||
|
["朱国清", "zhuguoqing", [], [], [], [], [], [["C21-9112", "浙岭渔22928", "zhelingyu22928", 218]], []],
|
||||||
|
["庄彩芹", "zhuangcaiqin", [], [], [], [], [], [["C21-9631", "浙岭渔27801", "zhelingyu27801", 217]], []],
|
||||||
|
["庄如军", "zhuangrujun", [], [], [], [], [], [], [["C21-9632", "浙岭渔27802", "zhelingyu27802", 216]]],
|
||||||
|
["郑文英", "zhengwenying", [], [], [], [], [], [], [["C21-9681", "浙岭渔23888", "zhelingyu23888", 218]]],
|
||||||
|
["薛金娥", "xuejine", [], [], [], [], [], [["C21-10087", "浙岭渔20865", "zhelingyu20865", 217]], []],
|
||||||
|
["王蒙洪", "wangmenghong", [], [], [], [], [], [["C21-10155", "浙岭渔23887", "zhelingyu23887", 218]], []],
|
||||||
|
["郭文玉", "guowenyu", [], [], [], [], [], [["C21-10194", "浙岭渔28881", "zhelingyu28881", 216]], []],
|
||||||
|
["江维生", "jiangweisheng", [], [], [], [], [], [], [["C21-10195", "浙岭渔28882", "zhelingyu28882", 216]]],
|
||||||
|
["林武庆", "linwuqing", [], [], [], [], [], [["C21-10205", "浙岭渔23855", "zhelingyu23855", 218]], []],
|
||||||
|
["林武强", "linwuqiang", [], [], [], [], [], [], [["C21-10206", "浙岭渔23856", "zhelingyu23856", 218]]],
|
||||||
|
["陈清华", "chenqinghua", [], [], [], [], [], [], [["C21-10621", "浙岭渔20866", "zhelingyu20866", 217]]],
|
||||||
|
["郭祥", "guoxiang", [], [], [], [], [], [["C21-10640", "浙岭渔20398", "zhelingyu20398", 218]], []],
|
||||||
|
["郭修柏", "guoxiubai", [], [], [], [], [], [], [["C21-10641", "浙岭渔20399", "zhelingyu20399", 218]]],
|
||||||
|
["林仁营", "linrenying", [], [], [], [], [], [["C21-10729", "浙岭渔20597", "zhelingyu20597", 218]], []],
|
||||||
|
["林仁青", "linrenqing", [], [], [], [], [], [], [["C21-10730", "浙岭渔20598", "zhelingyu20598", 218]]],
|
||||||
|
["郑念德", "zhengniande", [], [], [], [], [], [], [["C21-10740", "浙岭渔29808", "zhelingyu29808", 218]]],
|
||||||
|
["陈灵忠", "chenlingzhong", [], [], [], [], [], [["C21-11214", "浙岭渔26918", "zhelingyu26918", 218]], []],
|
||||||
|
["林都云", "linduyun", [], [], [], [], [], [], [["C21-11215", "浙岭渔26919", "zhelingyu26919", 218]]],
|
||||||
|
["刘祖德", "liuzude", [], [], [], [], [], [["C21-11895", "浙岭渔23829", "zhelingyu23829", 218]], []],
|
||||||
|
["林武明", "linwuming", [], [], [], [], [], [], [["C21-11896", "浙岭渔23830", "zhelingyu23830", 218]]],
|
||||||
|
["陈兆喜", "chenzhaoxi", [], [], [], [], [], [["C21-12218", "浙岭渔23725", "zhelingyu23725", 218]], []],
|
||||||
|
["庄念军", "zhuangnianjun", [], [], [], [], [], [], [["C21-12219", "浙岭渔23726", "zhelingyu23726", 218]]],
|
||||||
|
["郑志方", "zhengzhifang", [], [], [], [], [], [["C21-12258", "浙岭渔23876", "zhelingyu23876", 218]], []],
|
||||||
|
["林泽", "linze", [], [], [], [], [], [], [["C21-12276", "浙岭渔20366", "zhelingyu20366", 216]]],
|
||||||
|
["林应元", "linyingyuan", [], [], [], [], [], [], [["C21-12277", "浙岭渔23737", "zhelingyu23737", 218]]],
|
||||||
|
["黄宝林", "huangbaolin", [], [], [], [], [], [["C21-12301", "浙岭渔28906", "zhelingyu28906", 218]], []],
|
||||||
|
["林武英", "linwuying", [], [], [], [], [], [["C21-12306", "浙岭渔23757", "zhelingyu23757", 218]], []],
|
||||||
|
["郑达传", "zhengdachuan", [], [], [], [], [], [], [["C21-12307", "浙岭渔23758", "zhelingyu23758", 218]]],
|
||||||
|
["薛良华", "xuelianghua", [], [], [], [], [], [["C21-12308", "浙岭渔23872", "zhelingyu23872", 218]], []],
|
||||||
|
["陈建华", "chenjianhua", [], [], [], [], [], [], [["C21-12309", "浙岭渔23871", "zhelingyu23871", 218]]],
|
||||||
|
["林维聪", "linweicong", [], [], [], [], [], [["C21-12974", "浙岭渔23701", "zhelingyu23701", 212]], []],
|
||||||
|
["陈理清", "chenliqing", [], [], [], [], [], [], [["C21-12975", "浙岭渔23703", "zhelingyu23703", 212]]],
|
||||||
|
["闫亮", "yanliang", [], [], [], [], [], [], [["C21-13090", "鲁荣渔51628", "lurongyu51628", 114]]],
|
||||||
|
["林维春", "linweichun", [], [], [], [], [], [["C21-13317", "浙岭渔20367", "zhelingyu20367", 216]], []],
|
||||||
|
["赵彩芸", "zhaocaiyun", [], [], [], [], [], [["C21-13528", "浙象渔40031", "zhexiangyu40031", 212]], []],
|
||||||
|
["吴明其", "wumingqi", [], [], [], [], [], [], [["C21-13529", "浙象渔40032", "zhexiangyu40032", 212]]],
|
||||||
|
["李峰", "lifeng", [], [], [], [], [], [["C21-13532", "浙象渔47007", "zhexiangyu47007", 219]], []],
|
||||||
|
["陈仕兵", "chenshibing", [], [], [], [], [], [], [["C21-13533", "浙象渔47058", "zhexiangyu47058", 219]]],
|
||||||
|
["戴安亮", "daianliang", [], [], [], [], [], [["C21-13546", "浙岭渔29715", "zhelingyu29715", 217]], []],
|
||||||
|
["张云妙", "zhangyunmiao", [], [], [], [], [], [], [["C21-13547", "浙岭渔29716", "zhelingyu29716", 217]]],
|
||||||
|
["章士伟", "zhangshiwei", [], [], [], [], [], [["C21-13682", "浙象渔49011", "zhexiangyu49011", 218]], []],
|
||||||
|
["章伟平", "zhangweiping", [], [], [], [], [], [], [["C21-13683", "浙象渔49012", "zhexiangyu49012", 218]]],
|
||||||
|
["陈达祥", "chendaxiang", [], [], [], [], [], [], [["C21-13786", "浙岭渔22929", "zhelingyu22929", 218]]],
|
||||||
|
["林应文", "linyingwen", [], [], [], [], [], [], [["C21-13789", "浙岭渔23859", "zhelingyu23859", 218]]],
|
||||||
|
["孙中明", "sunzhongming", [], [], [], [], [], [["C21-13818", "浙岭渔23789", "zhelingyu23789", 216]], []],
|
||||||
|
["贾义明", "jiayiming", [], [], [], [], [], [["C21-13822", "浙象渔67107", "zhexiangyu67107", 212]], []],
|
||||||
|
["贾敏锋", "jiaminfeng", [], [], [], [], [], [], [["C21-13823", "浙象渔67108", "zhexiangyu67108", 212]]],
|
||||||
|
["徐刚", "xugang", [], [], [], [], [], [["C21-13956", "浙象渔50107", "zhexiangyu50107", 202]], []],
|
||||||
|
["徐永辉", "xuyonghui", [], [], [], [], [], [], [["C21-13957", "浙象渔50108", "zhexiangyu50108", 202]]],
|
||||||
|
["张磊", "zhanglei", [], [], [], [], [], [["C21-13987", "鲁荣渔57327", "lurongyu57327", 117]], []],
|
||||||
|
["张平平", "zhangpingping", [], [], [], [], [], [], [["C21-13988", "鲁荣渔57328", "lurongyu57328", 127]]],
|
||||||
|
["赖达进", "laidajin", [], [], [], [], [], [["C21-14181", "浙三渔00787", "zhesanyu00787", 216]], []],
|
||||||
|
["赖达撑", "laidacheng", [], [], [], [], [], [], [["C21-14182", "浙三渔00788", "zhesanyu00788", 216]]],
|
||||||
|
["李兴定", "lixingding", [], [], [], [], [], [["C21-14183", "浙岭渔69391", "zhelingyu69391", 210]], []],
|
||||||
|
["石永国", "shiyongguo", [], [], [], [], [], [["C21-14187", "浙象渔67091", "zhexiangyu67091", 194]], []],
|
||||||
|
["黄振德", "huangzhende", [], [], [], [], [], [], [["C21-14188", "浙象渔67092", "zhexiangyu67092", 218]]],
|
||||||
|
["戴叶玲", "daiyeling", [], [], [], [], [], [["C21-14189", "浙象渔48057", "zhexiangyu48057", 219]], []],
|
||||||
|
["戴小林", "daixiaolin", [], [], [], [], [], [], [["C21-14190", "浙象渔48058", "zhexiangyu48058", 219]]],
|
||||||
|
["杜星霖", "duxinglin", [], [], [], [], [], [["C21-14369", "浙岭渔23305", "zhelingyu23305", 218]], []],
|
||||||
|
["庄道兵", "zhuangdaobing", [], [], [], [], [], [], [["C21-14370", "浙岭渔23306", "zhelingyu23306", 218]]],
|
||||||
|
["尚鹏飞", "shangpengfei", [], [], [], [], [], [["C21-14371", "浙岭渔23381", "zhelingyu23381", 218]], []],
|
||||||
|
["尚作卫", "shangzuowei", [], [], [], [], [], [], [["C21-14372", "浙岭渔23382", "zhelingyu23382", 218]]],
|
||||||
|
["王期", "wangqi", [], [], [], [], [], [], [["C21-14381", "浙象渔40046", "zhexiangyu40046", 219]]],
|
||||||
|
["吴跃翔", "wuyuexiang", [], [], [], [], [], [["C21-14382", "浙象渔40045", "zhexiangyu40045", 219]], []],
|
||||||
|
["黄星", "huangxing", [], [], [], [], [], [["C21-14385", "浙岭渔23331", "zhelingyu23331", 217]], []],
|
||||||
|
["朱智桥", "zhuzhiqiao", [], [], [], [], [], [["C21-14406", "浙象渔42025", "zhexiangyu42025", 192]], []],
|
||||||
|
["朱志励", "zhuzhili", [], [], [], [], [], [], [["C21-14407", "浙象渔42026", "zhexiangyu42026", 192]]],
|
||||||
|
["肖金凤", "xiaojinfeng", [], [], [], [], [], [["C21-14422", "鲁荣渔52977", "lurongyu52977", 106]], []],
|
||||||
|
["王秋红", "wangqiuhong", [], [], [], [], [], [], [["C21-14423", "鲁荣渔52978", "lurongyu52978", 106]]],
|
||||||
|
["刘东伟", "liudongwei", [], [], [], [], [], [["C21-14458", "鲁荣渔59105", "lurongyu59105", 107]], []],
|
||||||
|
["赵军阳", "zhaojunyang", [], [], [], [], [], [], [["C21-14459", "鲁荣渔59106", "lurongyu59106", 107]]],
|
||||||
|
["丁伟", "dingwei", [], [], [], [], [], [["C21-14506", "浙象渔30069", "zhexiangyu30069", 202]], []],
|
||||||
|
["应浩", "yinghao", [], [], [], [], [], [], [["C21-14507", "浙象渔30070", "zhexiangyu30070", 202]]],
|
||||||
|
["曾国财", "zengguocai", [], [], [], [], [], [], [["C21-14510", "浙岭渔69390", "zhelingyu69390", 218]]],
|
||||||
|
["焦玉龙", "jiaoyulong", [], [], [], [], [], [["C21-14519", "鲁荣渔59161", "lurongyu59161", 97]], []],
|
||||||
|
["姜治海", "jiangzhihai", [], [], [], [], [], [], [["C21-14520", "鲁荣渔59162", "lurongyu59162", 97]]],
|
||||||
|
["金盼盼", "jinpanpan", [], [], [], [], [], [["C21-14527", "浙象渔50199", "zhexiangyu50199", 205]], []],
|
||||||
|
["王喜维", "wangxiwei", [], [], [], [], [], [], [["C21-14528", "浙象渔50198", "zhexiangyu50198", 186]]],
|
||||||
|
["江志荣", "jiangzhirong", [], [], [["C22-8061", "浙岭渔23502", "zhelingyu23502", 216]], [], [], [], []],
|
||||||
|
["梁明军", "liangmingjun", [], [], [["C22-8062", "浙岭渔23519", "zhelingyu23519", 218]], [], [], [], []],
|
||||||
|
["赵忠明", "zhaozhongming", [], [], [["C22-8063", "浙岭渔23593", "zhelingyu23593", 211]], [], [], [], []],
|
||||||
|
["江于中", "jiangyuzhong", [], [], [["C22-8074", "浙岭渔28850", "zhelingyu28850", 218]], [], [], [], []],
|
||||||
|
["许海金", "xuhaijin", [], [], [["C22-8077", "浙象渔30360", "zhexiangyu30360", 190]], [], [], [], []],
|
||||||
|
["王青富", "wangqingfu", [], [], [["C22-8079", "浙岭渔23555", "zhelingyu23555", 212]], [], [], [], []],
|
||||||
|
["赵美何", "zhaomeihe", [], [], [["C22-8083", "浙岭渔26878", "zhelingyu26878", 218]], [], [], [], []],
|
||||||
|
["杜志旺", "duzhiwang", [], [], [["C22-8091", "浙岭渔28909", "zhelingyu28909", 218]], [], [], [], []],
|
||||||
|
["陈金玉", "chenjinyu", [], [], [["C22-8092", "浙岭渔23568", "zhelingyu23568", 218]], [], [], [], []],
|
||||||
|
["杨小德", "yangxiaode", [], [], [["C22-8094", "浙岭渔23596", "zhelingyu23596", 218]], [], [], [], []],
|
||||||
|
["包纯增", "baochunzeng", [], [], [["C22-8095", "浙岭渔23639", "zhelingyu23639", 210]], [], [], [], []],
|
||||||
|
["赵云方", "zhaoyunfang", [], [], [["C22-8098", "浙岭渔28855", "zhelingyu28855", 218]], [], [], [], []],
|
||||||
|
["杜军慧", "dujunhui", [], [], [["C22-8099", "浙岭渔23476", "zhelingyu23476", 218]], [], [], [], []],
|
||||||
|
["王东东", "wangdongdong", [], [["C25-9362", "苏赣渔05788", "suganyu05788", 145]], [], [], [], [], []],
|
||||||
|
["张桂云", "zhangguiyun", [], [["C25-9425", "冀黄渔06789", "jihuangyu06789", 144]], [], [], [], [], []],
|
||||||
|
["贾友钱", "jiayouqian", [], [["C25-10144", "浙瑞渔12102", "zheruiyu12102", 99]], [], [], [], [], []],
|
||||||
|
["刘必军", "liubijun", [], [["C25-15401", "苏阜渔06663", "sufuyu06663", 149]], [], [], [], [], []],
|
||||||
|
["薛迪平", "xuediping", [], [["C25-15455", "浙瑞渔01688", "zheruiyu01688", 148]], [], [], [], [], []],
|
||||||
|
["杨定余", "yangdingyu", [], [["C25-15733", "苏射渔06699", "susheyu06699", 149]], [], [], [], [], []],
|
||||||
|
["瑞安市连喜渔业捕捞有限公司", "ruianshilianxiyuyebolaoyouxiangongsi", [], [["C25-15747", "浙瑞渔12181", "zheruiyu12181", 141]], [], [], [], [], []],
|
||||||
|
["方文松", "fangwensong", [], [["C25-15756", "浙瑞渔01651", "zheruiyu01651", 143]], [], [], [], [], []],
|
||||||
|
["林帮火", "linbanghuo", [], [["C25-16225", "浙瑞渔12188", "zheruiyu12188", 143]], [], [], [], [], []],
|
||||||
|
["谢秀钗", "xiexiuzuo", [], [["C25-16228", "浙瑞渔12238", "zheruiyu12238", 148]], [], [], [], [], []],
|
||||||
|
["王贞", "wangzhen", [], [["C25-16394", "苏赣渔04887", "suganyu04887", 145]], [], [], [], [], []],
|
||||||
|
["侯祥林", "houxianglin", [], [["C25-16406", "苏射渔01169", "susheyu01169", 149]], [], [], [], [], []],
|
||||||
|
["陈新明", "chenxinming", [], [["C25-16412", "苏射渔08588", "susheyu08588", 149]], [], [], [], [], []],
|
||||||
|
["虞冠迪", "yuguandi", [], [["C25-16413", "浙瑞渔12132", "zheruiyu12132", 148]], [], [], [], [], []],
|
||||||
|
["辛祥玉", "xinxiangyu", [], [["C25-16538", "冀黄渔01518", "jihuangyu01518", 135]], [], [], [], [], []],
|
||||||
|
["李清森", "liqingsen", [], [["C25-16541", "冀黄渔05279", "jihuangyu05279", 135]], [], [], [], [], []],
|
||||||
|
["高长佑", "gaochangyou", [], [["C25-16552", "冀黄渔06887", "jihuangyu06887", 149]], [], [], [], [], []],
|
||||||
|
["胡金发", "hujinfa", [], [["C25-16555", "冀黄渔07615", "jihuangyu07615", 137]], [], [], [], [], []],
|
||||||
|
["邵世伟", "shaoshiwei", [], [["C25-16660", "苏赣渔05676", "suganyu05676", 145]], [], [], [], [], []],
|
||||||
|
["周克伟", "zhoukewei", [], [["C25-16664", "苏射渔03288", "susheyu03288", 148]], [], [], [], [], []],
|
||||||
|
["阮臣楷", "ruanchenkai", [], [["C25-16665", "浙瑞渔01208", "zheruiyu01208", 146]], [], [], [], [], []],
|
||||||
|
["缪德才", "miaodecai", [], [["C25-16773", "苏射渔07388", "susheyu07388", 149]], [], [], [], [], []],
|
||||||
|
["苑猛", "yuanmeng", [], [["C25-16803", "冀黄渔05988", "jihuangyu05988", 137]], [], [], [], [], []],
|
||||||
|
["张桂峰", "zhangguifeng", [], [["C25-16910", "冀黄渔06727", "jihuangyu06727", 137]], [], [], [], [], []],
|
||||||
|
["王运", "wangyun", [], [["C25-16927", "辽大甘渔15399", "liaodaganyu15399", 149]], [], [], [], [], []],
|
||||||
|
["田德学", "tiandexue", [], [["C25-16933", "辽锦渔15615", "liaojinyu15615", 148]], [], [], [], [], []],
|
||||||
|
["王路路", "wanglulu", [], [["C25-17001", "苏赣渔05619", "suganyu05619", 145]], [], [], [], [], []],
|
||||||
|
["蔡光进", "caiguangjin", [], [["C25-17069", "浙瑞渔12068", "zheruiyu12068", 143]], [], [], [], [], []],
|
||||||
|
["张存香", "zhangcunxiang", [], [["C25-17070", "浙瑞渔12115", "zheruiyu12115", 143]], [], [], [], [], []],
|
||||||
|
["张玉洪", "zhangyuhong", [], [["C25-17085", "冀黄渔00130", "jihuangyu00130", 144]], [], [], [], [], []],
|
||||||
|
["李玲", "liling", [], [["C25-17101", "苏赣渔09679", "suganyu09679", 146]], [], [], [], [], []],
|
||||||
|
["李金业", "lijinye", [], [["C25-17104", "苏赣渔05988", "suganyu05988", 146]], [], [], [], [], []],
|
||||||
|
["胡文涵", "huwenhan", [], [["C25-17115", "辽庄渔85555", "liaozhuangyu85555", 149]], [], [], [], [], []],
|
||||||
|
["潘德宁", "pandening", [], [["C25-17118", "辽庄渔85558", "liaozhuangyu85558", 149]], [], [], [], [], []],
|
||||||
|
["张良良", "zhangliangliang", [], [["C25-17120", "辽庄渔65999", "liaozhuangyu65999", 149]], [], [], [], [], []],
|
||||||
|
["贺丹", "hedan", [], [["C25-17121", "辽庄渔65888", "liaozhuangyu65888", 149]], [], [], [], [], []],
|
||||||
|
["阮建飞", "ruanjianfei", [], [["C25-17139", "浙瑞渔12110", "zheruiyu12110", 130]], [], [], [], [], []],
|
||||||
|
["林丰", "linfeng", [], [["C25-17141", "浙瑞渔12161", "zheruiyu12161", 148]], [], [], [], [], []],
|
||||||
|
["薛威格", "xueweige", [], [["C25-17144", "浙瑞渔01240", "zheruiyu01240", 98]], [], [], [], [], []],
|
||||||
|
["潘时虎", "panshihu", [], [["C25-17145", "浙瑞渔01731", "zheruiyu01731", 95]], [], [], [], [], []],
|
||||||
|
["汪集龙", "wangjilong", [], [["C25-17155", "辽大金渔75039", "liaodajinyu75039", 149]], [], [], [], [], []],
|
||||||
|
["初杰", "chujie", [], [["C25-17156", "辽庄渔85777", "liaozhuangyu85777", 147]], [], [], [], [], []],
|
||||||
|
["张绍全", "zhangshaoquan", [], [["C25-17158", "辽锦渔15111", "liaojinyu15111", 148]], [], [], [], [], []],
|
||||||
|
["蔡丰华", "caifenghua", [], [["C25-17159", "浙瑞渔01227", "zheruiyu01227", 143]], [], [], [], [], []],
|
||||||
|
["虞冠呈", "yuguancheng", [], [["C25-17160", "浙瑞渔02951", "zheruiyu02951", 148]], [], [], [], [], []],
|
||||||
|
["阮臣弟", "ruanchendi", [], [["C25-17161", "浙瑞渔12168", "zheruiyu12168", 148]], [], [], [], [], []],
|
||||||
|
["蔡建挺", "caijianting", [], [["C25-17162", "浙瑞渔12191", "zheruiyu12191", 145]], [], [], [], [], []],
|
||||||
|
["虞冠仕", "yuguanshi", [], [["C25-17163", "浙瑞渔12192", "zheruiyu12192", 148]], [], [], [], [], []],
|
||||||
|
["蔡钱荣", "caiqianrong", [], [["C25-17166", "浙瑞渔12158", "zheruiyu12158", 98]], [], [], [], [], []],
|
||||||
|
["缪明武", "zuomingwu", [], [["C25-17172", "浙瑞渔01657", "zheruiyu01657", 99]], [], [], [], [], []],
|
||||||
|
["大连福鑫渔业有限公司", "dalianfuxinyuyeyouxiangongsi", [], [["C25-17175", "辽庄渔65688", "liaozhuangyu65688", 149]], [], [], [], [], []],
|
||||||
|
["秦海涛", "qinhaitao", [], [["C25-17188", "苏赣渔09677", "suganyu09677", 146]], [], [], [], [], []],
|
||||||
|
["施小菊", "shixiaoju", [], [["C25-17194", "浙瑞渔01518", "zheruiyu01518", 143]], [], [], [], [], []],
|
||||||
|
["陈积敏", "chenjimin", [], [["C25-17195", "浙瑞渔13089", "zheruiyu13089", 140]], [], [], [], [], []],
|
||||||
|
["林候池", "linhouchi", [], [["C25-17198", "浙瑞渔12128", "zheruiyu12128", 147]], [], [], [], [], []],
|
||||||
|
["林候式", "linhoushi", [], [["C25-17199", "浙瑞渔12189", "zheruiyu12189", 143]], [], [], [], [], []],
|
||||||
|
["蔡兰海", "cailanhai", [], [["C25-17200", "浙瑞渔12286", "zheruiyu12286", 149]], [], [], [], [], []],
|
||||||
|
["唐雪", "tangxue", [], [["C25-17201", "辽庄渔65599", "liaozhuangyu65599", 126]], [], [], [], [], []],
|
||||||
|
["孟向坤", "mengxiangkun", [], [["C25-17204", "冀黄渔06578", "jihuangyu06578", 135]], [], [], [], [], []],
|
||||||
|
["邵常华", "shaochanghua", [], [["C25-17205", "冀黄渔07659", "jihuangyu07659", 135]], [], [], [], [], []],
|
||||||
|
["白金祥", "baijinxiang", [], [["C25-17206", "冀黄渔07698", "jihuangyu07698", 135]], [], [], [], [], []],
|
||||||
|
["林友光", "linyouguang", [], [["C25-17208", "浙瑞渔12196", "zheruiyu12196", 148]], [], [], [], [], []],
|
||||||
|
["薛建设", "xuejianshe", [], [["C25-17209", "浙瑞渔12018", "zheruiyu12018", 149]], [], [], [], [], []],
|
||||||
|
["李秀绿", "lixiulv", [], [["C25-17210", "浙瑞渔12288", "zheruiyu12288", 143]], [], [], [], [], []],
|
||||||
|
["徐秀雷", "xuxiulei", [], [["C25-17211", "苏阜渔06399", "sufuyu06399", 148]], [], [], [], [], []],
|
||||||
|
["王霞光", "wangxiaguang", [], [["C25-17212", "苏阜渔08689", "sufuyu08689", 148]], [], [], [], [], []],
|
||||||
|
["秦绪燕", "qinxuyan", [], [["C25-17213", "苏赣渔04339", "suganyu04339", 149]], [], [], [], [], []],
|
||||||
|
["盛长青", "shengchangqing", [], [["C25-17214", "苏赣渔04368", "suganyu04368", 149]], [], [], [], [], []],
|
||||||
|
["侯学波", "houxuebo", [], [["C25-17215", "苏赣渔04382", "suganyu04382", 149]], [], [], [], [], []],
|
||||||
|
["李秀志", "lixiuzhi", [], [["C25-17216", "苏赣渔04338", "suganyu04338", 149]], [], [], [], [], []],
|
||||||
|
["李静", "lijing", [], [["C25-17217", "苏赣渔04399", "suganyu04399", 149]], [], [], [], [], []],
|
||||||
|
["孙成杰", "sunchengjie", [], [["C25-17218", "苏赣渔04081", "suganyu04081", 149]], [], [], [], [], []],
|
||||||
|
["庄广立", "zhuangguangli", [], [["C25-17219", "苏赣渔05328", "suganyu05328", 149]], [], [], [], [], []],
|
||||||
|
["陈恩亮", "chenenliang", [], [["C25-17220", "苏赣渔05333", "suganyu05333", 149]], [], [], [], [], []],
|
||||||
|
["李新余", "lixinyu", [], [["C25-17221", "苏赣渔09591", "suganyu09591", 148]], [], [], [], [], []],
|
||||||
|
["王祥栋", "wangxiangdong", [], [["C25-17224", "苏赣渔04879", "suganyu04879", 146]], [], [], [], [], []],
|
||||||
|
["邵春明", "shaochunming", [], [["C25-17227", "津塘渔03156", "jintangyu03156", 122]], [], [], [], [], []],
|
||||||
|
["李波", "libo", [], [["C25-17247", "津塘渔03805", "jintangyu03805", 40]], [], [], [], [], []],
|
||||||
|
["胡乃龙", "hunailong", [], [["C25-17259", "辽瓦渔75777", "liaowayu75777", 149]], [], [], [], [], []],
|
||||||
|
["蔡晓坤", "caixiaokun", [], [["C25-17267", "辽庄渔65577", "liaozhuangyu65577", 144]], [], [], [], [], []],
|
||||||
|
["张忠本", "zhangzhongben", [], [["C25-17276", "辽庄渔75888", "liaozhuangyu75888", 98]], [], [], [], [], []],
|
||||||
|
["刘学德", "liuxuede", [], [["C25-17277", "辽庄渔75999", "liaozhuangyu75999", 98]], [], [], [], [], []],
|
||||||
|
["王柱盛", "wangzhusheng", [], [["C25-17285", "冀黄渔00908", "jihuangyu00908", 149]], [], [], [], [], []],
|
||||||
|
["许志强", "xuzhiqiang", [], [["C25-17286", "冀黄渔01279", "jihuangyu01279", 103]], [], [], [], [], []],
|
||||||
|
["黄骅市鑫洋船舶租赁服务有限公司", "huanghuashixinyangchuanbozulinfuwuyouxiangongsi", [], [["C25-17287", "冀黄渔02697", "jihuangyu02697", 143]], [], [], [], [], []],
|
||||||
|
["张宝广", "zhangbaoguang", [], [["C25-17288", "冀黄渔06498", "jihuangyu06498", 121]], [], [], [], [], []],
|
||||||
|
["高路敏", "gaolumin", [], [["C25-17289", "冀黄渔06533", "jihuangyu06533", 148]], [], [], [], [], []],
|
||||||
|
["张金岭", "zhangjinling", [], [["C25-17290", "冀黄渔05627", "jihuangyu05627", 144]], [], [], [], [], []],
|
||||||
|
["姜楠", "jiangnan", [], [["C25-17293", "苏射渔06789", "susheyu06789", 147]], [], [], [], [], []],
|
||||||
|
["王刚", "wanggang", [], [["C25-17294", "苏射渔01679", "susheyu01679", 149]], [], [], [], [], []],
|
||||||
|
["卢凤", "lufeng", [], [["C25-17295", "苏赣渔05218", "suganyu05218", 145]], [], [], [], [], []],
|
||||||
|
["王彦文", "wangyanwen", [], [["C25-17296", "苏赣渔02396", "suganyu02396", 145]], [], [], [], [], []],
|
||||||
|
["胡英勉", "huyingmian", [], [["C25-17297", "苏赣渔02506", "suganyu02506", 145]], [], [], [], [], []],
|
||||||
|
["王为成", "wangweicheng", [], [["C25-17298", "苏赣渔05667", "suganyu05667", 145]], [], [], [], [], []],
|
||||||
|
["瑞安市琪霖渔业捕捞有限公司", "ruianshizuolinyuyebolaoyouxiangongsi", [], [["C25-17301", "浙瑞渔12666", "zheruiyu12666", 146]], [], [], [], [], []],
|
||||||
|
["瑞安市鑫航渔业捕捞有限公司", "ruianshizuohangyuyebolaoyouxiangongsi", [], [["C25-17302", "浙瑞渔12345", "zheruiyu12345", 146]], [], [], [], [], []],
|
||||||
|
["温州汇满丰渔业有限责任公司", "wenzhouhuimanfengyuyeyouxianzherengongsi", [], [["C25-17303", "浙平渔80248", "zhepingyu80248", 130]], [], [], [], [], []],
|
||||||
|
["苏棉美", "sumianmei", [], [["C25-17304", "浙平渔82168", "zhepingyu82168", 123]], [], [], [], [], []],
|
||||||
|
["邱兰妹", "qiulanmei", [], [["C25-17305", "浙平渔82052", "zhepingyu82052", 140]], [], [], [], [], []],
|
||||||
|
["苏意武", "suyiwu", [], [["C25-17306", "浙平渔82160", "zhepingyu82160", 139]], [], [], [], [], []],
|
||||||
|
["吴良勇", "wuliangyong", [], [], [], [], [["C40-8341", "浙岭渔运31066", "zhelingyuyun31066", 248]], [], []],
|
||||||
|
["蔡显雄", "caixianxiong", [], [], [], [], [["C40-8408", "浙岭渔运10078", "zhelingyuyun10078", 287]], [], []],
|
||||||
|
["房秋云", "fangqiuyun", [], [], [], [], [["C40-8415", "浙岭渔运31009", "zhelingyuyun31009", 243]], [], []],
|
||||||
|
["王成江", "wangchengjiang", [], [], [], [], [["C40-8423", "辽大金渔运15888", "liaodajinyuyun15888", 136]], [], []],
|
||||||
|
["国向治", "guoxiangzhi", [], [], [], [], [["C40-8434", "辽大中渔运15033", "liaodazhongyuyun15033", 63]], [], []],
|
||||||
|
["王蒙能", "wangmengneng", [], [], [], [], [["C40-8448", "浙岭渔运31079", "zhelingyuyun31079", 281]], [], []],
|
||||||
|
["余奶富", "yunaifu", [], [], [], [], [["C40-8454", "浙岭渔运10028", "zhelingyuyun10028", 248]], [], []],
|
||||||
|
["陈清国", "chenqingguo", [], [], [], [], [["C40-8458", "浙岭渔运30088", "zhelingyuyun30088", 258]], [], []],
|
||||||
|
["秦保光", "qinbaoguang", [], [], [], [], [["C40-8464", "苏赣渔运05566", "suganyuyun05566", 253]], [], []],
|
||||||
|
["杨宝利", "yangbaoli", [], [], [], [], [["C40-8466", "津塘渔运03666", "jintangyuyun03666", 62]], [], []],
|
||||||
|
["方瑞瑞", "fangruirui", [], [], [], [], [["C40-8471", "浙象渔运02209", "zhexiangyuyun02209", 259]], [], []],
|
||||||
|
["吴侠", "wuxia", [], [], [], [], [["C40-8475", "浙象渔运02103", "zhexiangyuyun02103", 248]], [], []],
|
||||||
|
["刘坤", "liukun", [], [], [], [], [["C40-8484", "辽大中渔运15158", "liaodazhongyuyun15158", 130]], [], []],
|
||||||
|
["王赞", "wangzan", [], [], [], [], [["C40-8485", "辽大中渔运15157", "liaodazhongyuyun15157", 187]], [], []],
|
||||||
|
["黄骅市长荣渔业捕捞有限公司", "huanghuashichangrongyuyebulaoyouxiangongsi", [], [], [], [], [["C40-8488", "冀黄渔运05155", "jihuangyuyun05155", 156]], [], []],
|
||||||
|
["王从营", "wangcongying", [], [], [], [], [["C40-8490", "苏赣渔运05601", "suganyuyun05601", 297]], [], []],
|
||||||
|
["林立丰", "linlifeng", [], [], [], [], [["C40-8491", "浙岭渔运31195", "zhelingyuyun31195", 270]], [], []],
|
||||||
|
["赵恩军", "zhaoenjun", [], [], [], [], [["C40-8492", "浙岭渔运31171", "zhelingyuyun31171", 240]], [], []],
|
||||||
|
["迟晓颖", "chixiaoying", [], [], [], [], [["C40-8494", "辽瓦渔运55032", "liaowayuyun55032", 172]], [], []]
|
||||||
|
];
|
||||||
|
|
||||||
|
const ZI=[[131.265,36.1666],[129.7162,35.65],[129.7275,35.692],[129.7654,35.7956],[129.8298,35.9931],[129.8323,36.0444],[129.7208,36.2417],[129.6737,36.2681],[129.6906,36.4539],[129.7282,36.7868],[129.6711,37.0095],[129.6223,37.2561],[129.4768,37.4744],[129.3255,37.6935],[129.1762,37.8767],[128.9936,38.068],[128.8559,38.2544],[130.1643,38.0028],[131.6327,37.3261],[131.6277,37.3163],[131.6156,37.2834],[131.4273,37.0406],[130.375,36.1666],[131.265,36.1666]];
|
||||||
|
const ZII=[[126.0005,32.1833],[126.0141,33.104],[126.0646,33.0051],[126.1417,32.9416],[126.2317,32.9132],[126.3279,32.9173],[126.4691,33.0037],[126.5577,33.0178],[126.6491,33.0193],[126.7439,33.0327],[126.852,33.0983],[126.9682,33.1401],[127.0508,33.2096],[127.1211,33.2964],[127.1685,33.3742],[127.2041,33.4496],[127.214,33.5279],[127.0126,33.7734],[127.26,33.7988],[128.0,34.1187],[128.8883,34.344],[127.86,33.2283],[126.0005,32.1833]];
|
||||||
|
const ZIII=[[124.1255,35.0007],[124.9773,34.8191],[124.9046,33.9378],[125.9272,33.7231],[126.0401,33.7034],[126.059,33.6762],[126.0738,33.6023],[126.0562,33.5638],[126.0067,33.5056],[125.9855,33.4803],[125.9427,33.433],[125.9209,33.386],[125.9048,33.3233],[125.9048,33.2998],[125.9159,33.2501],[125.9335,33.2153],[125.9646,33.1745],[124.1704,33.3003],[124.1255,35.0007]];
|
||||||
|
const ZIV=[[124.5,35.5],[124.5,36.75],[124.3333,37.0],[125.2289,37.0029],[125.4284,36.9026],[125.3881,36.8333],[125.3498,36.7644],[125.2935,36.6406],[125.2951,36.5872],[125.3248,36.5157],[125.5696,36.2265],[125.6364,36.15],[125.7914,35.926],[125.8327,35.8149],[125.8488,35.7167],[125.8484,35.6655],[125.7756,35.4651],[125.6868,35.3876],[125.3743,35.148],[125.1858,35.0],[124.1255,35.0007],[124.5,35.5]];
|
||||||
|
|
||||||
|
|
||||||
|
const ZONES=[
|
||||||
|
{id:"I",name:"수역Ⅰ",pts:ZI,color:"#ef4444",fill:"rgba(239,68,68,0.08)",center:[130.3,37.0]},
|
||||||
|
{id:"II",name:"수역Ⅱ",pts:ZII,color:"#3b82f6",fill:"rgba(59,130,246,0.08)",center:[127.4,33.3]},
|
||||||
|
{id:"III",name:"수역Ⅲ",pts:ZIII,color:"#10b981",fill:"rgba(16,185,129,0.08)",center:[125.0,33.9]},
|
||||||
|
{id:"IV",name:"수역Ⅳ",pts:ZIV,color:"#f59e0b",fill:"rgba(245,158,11,0.08)",center:[125.0,36.0]},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Map projection
|
||||||
|
const MAP={lonMin:123.5,lonMax:132.2,latMin:31.8,latMax:38.8,w:440,h:400};
|
||||||
|
function toSvg(lon,lat){
|
||||||
|
return [((lon-MAP.lonMin)/(MAP.lonMax-MAP.lonMin))*MAP.w, (1-(lat-MAP.latMin)/(MAP.latMax-MAP.latMin))*MAP.h];
|
||||||
|
}
|
||||||
|
function polyPath(pts){
|
||||||
|
return "M"+pts.map(p=>{const[x,y]=toSvg(p[0],p[1]);return `${x.toFixed(1)},${y.toFixed(1)}`;}).join("L")+"Z";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(d){
|
||||||
|
return d.map(r=>{
|
||||||
|
const [own,ownEn,pairs,gn,ot,ps,fc,upt,upts]=r;
|
||||||
|
const s=(a,code)=>a.map(x=>({pn:x[0],cn:x[1],en:x[2],ton:x[3],code}));
|
||||||
|
const pp=pairs.map(p=>({m:{pn:p[0][0],cn:p[0][1],en:p[0][2],ton:p[0][3],code:"PT"},s:{pn:p[1][0],cn:p[1][1],en:p[1][2],ton:p[1][3],code:"PT-S"}}));
|
||||||
|
const total=pp.length*2+gn.length+ot.length+ps.length+fc.length+upt.length+upts.length;
|
||||||
|
return {own,ownEn,pairs:pp,gn:s(gn,"GN"),ot:s(ot,"OT"),ps:s(ps,"PS"),fc:s(fc,"FC"),upt:s(upt,"PT"),upts:s(upts,"PT-S"),total};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const CC={PT:"#1e40af","PT-S":"#ea580c",GN:"#10b981",OT:"#8b5cf6",PS:"#ef4444",FC:"#f59e0b"};
|
||||||
|
|
||||||
|
function Ship({type, sz=28}){
|
||||||
|
const c={main:["#1e40af","#3b82f6","#60a5fa"],sub:["#c2410c","#ea580c","#fb923c"],gn:["#047857","#10b981","#6ee7b7"],ot:["#6d28d9","#8b5cf6","#c4b5fd"],ps:["#b91c1c","#ef4444","#fca5a5"],fc:["#92400e","#f59e0b","#fcd34d"]}[type]||["#6b7280","#94a3b8","#d1d5db"];
|
||||||
|
return (
|
||||||
|
<svg width={sz} height={sz} viewBox="0 0 36 36">
|
||||||
|
<path d="M6,20 L4,27 Q4,30 7,30 L29,30 Q32,30 32,27 L30,20 Z" fill={c[0]}/>
|
||||||
|
<rect x="8" y="16" width="20" height="5" rx="1.5" fill={c[1]}/>
|
||||||
|
<rect x="12" y="9" width="12" height="8" rx="1.5" fill={c[2]} stroke={c[0]} strokeWidth="0.5"/>
|
||||||
|
<rect x="14" y="11" width="3" height="3.5" rx="0.5" fill="#fff" opacity="0.7"/>
|
||||||
|
<rect x="19" y="11" width="3" height="3.5" rx="0.5" fill="#fff" opacity="0.7"/>
|
||||||
|
<line x1="18" y1="9" x2="18" y2="4" stroke={c[0]} strokeWidth="1.5"/>
|
||||||
|
{type==="fc" && <g><rect x="7" y="19" width="5" height="7" rx="1" fill="#fbbf24" stroke="#92400e" strokeWidth="0.4"/><rect x="14" y="19" width="5" height="7" rx="1" fill="#fbbf24" stroke="#92400e" strokeWidth="0.4"/><rect x="21" y="19" width="5" height="7" rx="1" fill="#fbbf24" stroke="#92400e" strokeWidth="0.4"/></g>}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VC({s, label}){
|
||||||
|
const bc=CC[s.code]||"#6b7280";
|
||||||
|
const bg={PT:"#eff6ff","PT-S":"#fff7ed",GN:"#f0fdf4",OT:"#f5f3ff",PS:"#fef2f2",FC:"#fffbeb"}[s.code]||"#f9fafb";
|
||||||
|
const tp=s.code==="PT"?"main":s.code==="PT-S"?"sub":s.code==="FC"?"fc":s.code==="GN"?"gn":s.code==="OT"?"ot":"ps";
|
||||||
|
return (
|
||||||
|
<div style={{display:"flex",alignItems:"center",gap:6,padding:"5px 8px",borderRadius:8,background:bg,border:`2px solid ${bc}`,position:"relative"}}>
|
||||||
|
{label && <div style={{position:"absolute",top:-7,left:8,fontSize:8,fontWeight:800,color:"#fff",background:bc,padding:"0 6px",borderRadius:8}}>{label}</div>}
|
||||||
|
<Ship type={tp} sz={26}/>
|
||||||
|
<div style={{flex:1,minWidth:0}}>
|
||||||
|
<div style={{fontSize:11,fontWeight:700,color:"#1e293b"}}>{s.cn} <span style={{fontWeight:400,color:bc,fontStyle:"italic",fontSize:10}}>{s.en}</span></div>
|
||||||
|
<div style={{fontSize:9,color:"#94a3b8"}}>{s.pn} · {s.ton}t</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ZoneMap(){
|
||||||
|
return (
|
||||||
|
<svg viewBox={`0 0 ${MAP.w} ${MAP.h}`} style={{width:"100%",height:"100%",background:"linear-gradient(180deg,#e0f2fe,#bfdbfe)"}}>
|
||||||
|
<defs>{ZONES.map(z=><pattern key={z.id} id={`p${z.id}`} patternUnits="userSpaceOnUse" width="8" height="8"><line x1="0" y1="0" x2="8" y2="8" stroke={z.color} strokeWidth="0.5" opacity="0.3"/></pattern>)}</defs>
|
||||||
|
{ZONES.map(z=>{
|
||||||
|
const [cx,cy]=toSvg(z.center[0],z.center[1]);
|
||||||
|
return (
|
||||||
|
<g key={z.id}>
|
||||||
|
<path d={polyPath(z.pts)} fill={z.fill} stroke={z.color} strokeWidth="1.5" strokeDasharray="4 2"/>
|
||||||
|
<path d={polyPath(z.pts)} fill={`url(#p${z.id})`}/>
|
||||||
|
<text x={cx} y={cy} textAnchor="middle" fontSize="14" fontWeight="800" fill={z.color} opacity="0.7">{z.name}</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<text x={toSvg(127.5,36.5)[0]} y={toSvg(127.5,36.5)[1]} textAnchor="middle" fontSize="11" fill="#475569" opacity="0.5">한국 Korea</text>
|
||||||
|
<text x={toSvg(124.5,35.5)[0]} y={toSvg(124.5,35.5)[1]} textAnchor="middle" fontSize="9" fill="#64748b" opacity="0.4">서해</text>
|
||||||
|
<text x={toSvg(129.5,34.5)[0]} y={toSvg(129.5,34.5)[1]} textAnchor="middle" fontSize="9" fill="#64748b" opacity="0.4">남해</text>
|
||||||
|
<text x={toSvg(130.5,37.5)[0]} y={toSvg(130.5,37.5)[1]} textAnchor="middle" fontSize="9" fill="#64748b" opacity="0.4">동해</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Detail({o}){
|
||||||
|
return (
|
||||||
|
<div style={{padding:14}}>
|
||||||
|
<div style={{display:"flex",gap:12,alignItems:"flex-start"}}>
|
||||||
|
<div style={{flex:1}}>
|
||||||
|
{o.pairs.length>0 && <div style={{marginBottom:10}}>
|
||||||
|
<div style={{fontSize:11,fontWeight:600,color:"#475569",marginBottom:6}}>🔗 2척식 저인망 Pair Trawl — {o.pairs.length}쌍 | 허가수역: Ⅱ,Ⅲ</div>
|
||||||
|
<div style={{display:"flex",flexDirection:"column",gap:10}}>
|
||||||
|
{o.pairs.map((p,i)=>(
|
||||||
|
<div key={i} style={{border:"1.5px dashed #94a3b8",borderRadius:10,padding:"10px 8px 6px",background:"#f8fafc",position:"relative"}}>
|
||||||
|
<div style={{position:"absolute",top:-8,left:"50%",transform:"translateX(-50%)",fontSize:9,fontWeight:700,color:"#475569",background:"#f8fafc",padding:"0 6px",border:"1px solid #cbd5e1",borderRadius:6,whiteSpace:"nowrap"}}>⚓ 제{i+1}쌍</div>
|
||||||
|
<div style={{display:"flex",gap:4,marginTop:2}}>
|
||||||
|
<div style={{flex:1}}><VC s={p.m} label="본선 MAIN"/></div>
|
||||||
|
<div style={{flex:1}}><VC s={p.s} label="부속선 SUB"/></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
{o.upt.length>0 && <div style={{marginBottom:8}}>
|
||||||
|
<div style={{fontSize:11,fontWeight:600,color:"#1e40af",marginBottom:4}}>🚢 본선(미매칭) — {o.upt.length}척 | 수역 Ⅱ,Ⅲ</div>
|
||||||
|
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:4}}>{o.upt.map(s=><VC key={s.pn} s={s} label="본선"/>)}</div>
|
||||||
|
</div>}
|
||||||
|
{o.upts.length>0 && <div style={{marginBottom:8}}>
|
||||||
|
<div style={{fontSize:11,fontWeight:600,color:"#ea580c",marginBottom:4}}>🛥️ 부속선(미매칭) — {o.upts.length}척 | 수역 Ⅱ,Ⅲ</div>
|
||||||
|
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:4}}>{o.upts.map(s=><VC key={s.pn} s={s} label="부속선"/>)}</div>
|
||||||
|
</div>}
|
||||||
|
{o.gn.length>0 && <div style={{marginBottom:8}}>
|
||||||
|
<div style={{fontSize:11,fontWeight:600,color:"#047857",marginBottom:4}}>🎣 유망 Gill Net — {o.gn.length}척 | 수역 Ⅱ,Ⅲ,Ⅳ</div>
|
||||||
|
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:4}}>{o.gn.map(s=><VC key={s.pn} s={s}/>)}</div>
|
||||||
|
</div>}
|
||||||
|
{o.ot.length>0 && <div style={{marginBottom:8}}>
|
||||||
|
<div style={{fontSize:11,fontWeight:600,color:"#6d28d9",marginBottom:4}}>⛵ 1척식 Otter Trawl — {o.ot.length}척 | 수역 Ⅱ,Ⅲ</div>
|
||||||
|
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:4}}>{o.ot.map(s=><VC key={s.pn} s={s}/>)}</div>
|
||||||
|
</div>}
|
||||||
|
{o.ps.length>0 && <div style={{marginBottom:8}}>
|
||||||
|
<div style={{fontSize:11,fontWeight:600,color:"#b91c1c",marginBottom:4}}>🐟 위망 Purse Seine — {o.ps.length}척 | 수역 Ⅰ,Ⅱ,Ⅲ,Ⅳ</div>
|
||||||
|
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:4}}>{o.ps.map(s=><VC key={s.pn} s={s}/>)}</div>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
{o.fc.length>0 && <div style={{display:"flex",alignItems:"flex-start",gap:0}}>
|
||||||
|
<div style={{display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center",flexShrink:0,padding:"20px 0"}}>
|
||||||
|
<svg width="50" height="60" viewBox="0 0 50 60"><line x1="5" y1="20" x2="38" y2="28" stroke="#f59e0b" strokeWidth="1.5" strokeDasharray="4 2" opacity="0.6"/><line x1="5" y1="35" x2="38" y2="30" stroke="#f59e0b" strokeWidth="2" strokeDasharray="4 2"/><line x1="5" y1="45" x2="38" y2="35" stroke="#f59e0b" strokeWidth="1.5" strokeDasharray="4 2" opacity="0.6"/><polygon points="38,24 48,31 38,38" fill="#f59e0b"/><text x="25" y="12" textAnchor="middle" fontSize="7" fill="#b45309" fontWeight="700">어획물 이적</text></svg>
|
||||||
|
</div>
|
||||||
|
<div style={{width:180,flexShrink:0}}>
|
||||||
|
<div style={{fontSize:11,fontWeight:700,color:"#92400e",marginBottom:6}}>🚛 운반선 Carrier ({o.fc.length}) | 수역 Ⅰ~Ⅳ</div>
|
||||||
|
<div style={{display:"flex",flexDirection:"column",gap:6}}>{o.fc.map(s=><VC key={s.pn} s={s} label="운반 FC"/>)}</div>
|
||||||
|
<div style={{marginTop:10,textAlign:"center",fontSize:20}}>🏭<div style={{fontSize:9,color:"#64748b"}}>→ 항구</div></div>
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App(){
|
||||||
|
const owners=useMemo(()=>parse(D),[]);
|
||||||
|
const [sel,setSel]=useState(null);
|
||||||
|
const [search,setSearch]=useState("");
|
||||||
|
const [filterCode,setFilterCode]=useState("ALL");
|
||||||
|
const [page,setPage]=useState(0);
|
||||||
|
const [tab,setTab]=useState("map");
|
||||||
|
const PER=30;
|
||||||
|
|
||||||
|
const filtered=useMemo(()=>{
|
||||||
|
let list=owners;
|
||||||
|
if(search.trim()){const q=search.toLowerCase();list=list.filter(o=>o.own.includes(q)||o.ownEn.toLowerCase().includes(q));}
|
||||||
|
if(filterCode!=="ALL"){list=list.filter(o=>{
|
||||||
|
if(filterCode==="PT") return o.pairs.length>0||o.upt.length>0||o.upts.length>0;
|
||||||
|
if(filterCode==="GN") return o.gn.length>0;
|
||||||
|
if(filterCode==="OT") return o.ot.length>0;
|
||||||
|
if(filterCode==="PS") return o.ps.length>0;
|
||||||
|
if(filterCode==="FC") return o.fc.length>0;
|
||||||
|
if(filterCode==="MULTI"){const t=new Set();if(o.pairs.length||o.upt.length||o.upts.length)t.add("PT");if(o.gn.length)t.add("GN");if(o.ot.length)t.add("OT");if(o.ps.length)t.add("PS");if(o.fc.length)t.add("FC");return t.size>=2;}
|
||||||
|
return true;
|
||||||
|
});}
|
||||||
|
return list;
|
||||||
|
},[owners,search,filterCode]);
|
||||||
|
|
||||||
|
const totalPages=Math.ceil(filtered.length/PER);
|
||||||
|
const paged=filtered.slice(page*PER,(page+1)*PER);
|
||||||
|
|
||||||
|
const stats=useMemo(()=>{
|
||||||
|
let pt=0,pts=0,gn=0,ot=0,ps=0,fc=0;
|
||||||
|
owners.forEach(o=>{pt+=o.pairs.length+o.upt.length;pts+=o.pairs.length+o.upts.length;gn+=o.gn.length;ot+=o.ot.length;ps+=o.ps.length;fc+=o.fc.length;});
|
||||||
|
return {pt,pts,gn,ot,ps,fc,total:pt+pts+gn+ot+ps+fc};
|
||||||
|
},[owners]);
|
||||||
|
|
||||||
|
const zoneInfos=[
|
||||||
|
{id:"I",types:["PS(위망)","FC(운반)"],counts:"PS 16척 · FC 31척"},
|
||||||
|
{id:"II",types:["PT(저인망)","GN(유망)","OT(1척식)","PS(위망)","FC(운반)"],counts:"PT 646척 · GN 200척 · OT 13척 · PS 16척 · FC 31척"},
|
||||||
|
{id:"III",types:["PT(저인망)","GN(유망)","OT(1척식)","PS(위망)","FC(운반)"],counts:"PT 646척 · GN 200척 · OT 13척 · PS 16척 · FC 31척"},
|
||||||
|
{id:"IV",types:["GN(유망)","PS(위망)","FC(운반)"],counts:"GN 200척 · PS 16척 · FC 31척"},
|
||||||
|
];
|
||||||
|
|
||||||
|
const permitRows=[
|
||||||
|
{code:"PT(본선)",zone:"Ⅱ,Ⅲ",n:"323",c:"#1e40af",note:"본선+부속선 쌍 운용"},
|
||||||
|
{code:"PT-S(부속)",zone:"Ⅱ,Ⅲ",n:"323",c:"#ea580c",note:"본선에 종속"},
|
||||||
|
{code:"GN(유망)",zone:"Ⅱ,Ⅲ,Ⅳ",n:"200",c:"#10b981",note:"수역Ⅳ 추가 허용"},
|
||||||
|
{code:"OT(1척식)",zone:"Ⅱ,Ⅲ",n:"13",c:"#8b5cf6",note:"단독 저인망"},
|
||||||
|
{code:"PS(위망)",zone:"Ⅰ,Ⅱ,Ⅲ,Ⅳ",n:"16",c:"#ef4444",note:"전 수역 허가"},
|
||||||
|
{code:"FC(운반)",zone:"Ⅰ,Ⅱ,Ⅲ,Ⅳ",n:"31",c:"#f59e0b",note:"전 수역 허가"},
|
||||||
|
];
|
||||||
|
|
||||||
|
function getZoneStr(o){
|
||||||
|
const zs=new Set();
|
||||||
|
const ptN=o.pairs.length+o.upt.length;
|
||||||
|
const ptsN=o.pairs.length+o.upts.length;
|
||||||
|
if(ptN||ptsN){zs.add("Ⅱ");zs.add("Ⅲ");}
|
||||||
|
if(o.gn.length){zs.add("Ⅱ");zs.add("Ⅲ");zs.add("Ⅳ");}
|
||||||
|
if(o.ot.length){zs.add("Ⅱ");zs.add("Ⅲ");}
|
||||||
|
if(o.ps.length){zs.add("Ⅰ");zs.add("Ⅱ");zs.add("Ⅲ");zs.add("Ⅳ");}
|
||||||
|
if(o.fc.length){zs.add("Ⅰ");zs.add("Ⅱ");zs.add("Ⅲ");zs.add("Ⅳ");}
|
||||||
|
return [...zs].sort().join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{minHeight:"100vh",background:"#f0f4f8",fontFamily:"'Malgun Gothic','맑은 고딕',sans-serif"}}>
|
||||||
|
<div style={{background:"linear-gradient(135deg,#0f172a,#1e3a5f)",padding:"16px 20px",color:"#fff"}}>
|
||||||
|
<h1 style={{fontSize:18,fontWeight:700,margin:0}}>선단 구성 네트워크 — 906척 · 어업수역 매핑</h1>
|
||||||
|
<p style={{fontSize:11,color:"#93c5fd",margin:"4px 0 0"}}>{owners.length}개 소유주 · {stats.total}척 · 특정어업수역 Ⅰ~Ⅳ · 한자/영문 병기</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{display:"flex",gap:0,background:"#fff",borderBottom:"1px solid #e2e8f0"}}>
|
||||||
|
{[["map","🗺️ 어업수역 지도"],["list","📋 소유주별 선단 (906척)"]].map(([k,l])=>(
|
||||||
|
<button key={k} onClick={()=>setTab(k)} style={{padding:"10px 20px",border:"none",cursor:"pointer",fontSize:13,fontWeight:600,borderBottom:tab===k?"3px solid #1e40af":"3px solid transparent",background:tab===k?"#eff6ff":"transparent",color:tab===k?"#1e40af":"#64748b"}}>{l}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{maxWidth:960,margin:"0 auto",padding:"12px 16px"}}>
|
||||||
|
|
||||||
|
{tab==="map" && (
|
||||||
|
<div style={{display:"grid",gridTemplateColumns:"440px 1fr",gap:16}}>
|
||||||
|
<div style={{background:"#fff",borderRadius:12,border:"1px solid #e2e8f0",overflow:"hidden"}}>
|
||||||
|
<div style={{padding:"8px 12px",borderBottom:"1px solid #e2e8f0",fontSize:13,fontWeight:700,color:"#1e293b"}}>특정어업수역 Ⅰ~Ⅳ</div>
|
||||||
|
<div style={{height:400}}><ZoneMap/></div>
|
||||||
|
</div>
|
||||||
|
<div style={{display:"flex",flexDirection:"column",gap:10}}>
|
||||||
|
{ZONES.map((z,zi)=>(
|
||||||
|
<div key={z.id} style={{background:"#fff",borderRadius:10,border:`2px solid ${z.color}30`,padding:12}}>
|
||||||
|
<div style={{display:"flex",alignItems:"center",gap:6,marginBottom:6}}>
|
||||||
|
<div style={{width:28,height:28,borderRadius:8,background:z.color,color:"#fff",display:"flex",alignItems:"center",justifyContent:"center",fontSize:12,fontWeight:800}}>{z.id}</div>
|
||||||
|
<div style={{fontSize:13,fontWeight:700,color:z.color}}>특정어업수역 {z.name.replace("수역","")}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{fontSize:11,color:"#475569",lineHeight:1.6}}>
|
||||||
|
<b>허가 업종:</b> {zoneInfos[zi].types.join(", ")}<br/>
|
||||||
|
<b>허가 선박:</b> {zoneInfos[zi].counts}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{background:"#fff",borderRadius:10,border:"1px solid #e2e8f0",padding:12}}>
|
||||||
|
<div style={{fontSize:12,fontWeight:700,color:"#1e293b",marginBottom:6}}>업종별 허가수역</div>
|
||||||
|
<table style={{width:"100%",borderCollapse:"collapse",fontSize:11}}>
|
||||||
|
<thead><tr style={{background:"#f8fafc"}}>
|
||||||
|
{["업종","수역","척수","비고"].map(h=><th key={h} style={{padding:"4px 6px",fontWeight:700,color:"#64748b",borderBottom:"1px solid #e2e8f0",textAlign:"left"}}>{h}</th>)}
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{permitRows.map(r=>(
|
||||||
|
<tr key={r.code} style={{borderBottom:"1px solid #f1f5f9"}}>
|
||||||
|
<td style={{padding:"4px 6px",fontWeight:700,color:r.c}}>{r.code}</td>
|
||||||
|
<td style={{padding:"4px 6px",fontWeight:600}}>{r.zone}</td>
|
||||||
|
<td style={{padding:"4px 6px"}}>{r.n}척</td>
|
||||||
|
<td style={{padding:"4px 6px",color:"#64748b"}}>{r.note}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab==="list" && (
|
||||||
|
<div>
|
||||||
|
<div style={{display:"flex",gap:10,flexWrap:"wrap",alignItems:"center",marginBottom:8}}>
|
||||||
|
{[{t:"main",l:"🔵 본선 PT",n:stats.pt},{t:"sub",l:"🟠 부속 PT-S",n:stats.pts},{t:"gn",l:"🟢 유망 GN",n:stats.gn},{t:"ot",l:"🟣 1척식 OT",n:stats.ot},{t:"ps",l:"🔴 위망 PS",n:stats.ps},{t:"fc",l:"🟡 운반 FC",n:stats.fc}].map(x=>(
|
||||||
|
<div key={x.t} style={{display:"flex",alignItems:"center",gap:4}}><Ship type={x.t} sz={20}/><span style={{fontSize:10,fontWeight:600,color:"#475569"}}>{x.l}({x.n})</span></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{display:"flex",gap:6,marginBottom:10,flexWrap:"wrap",alignItems:"center"}}>
|
||||||
|
<input value={search} onChange={e=>{setSearch(e.target.value);setPage(0);setSel(null);}} placeholder="소유주 검색 (한자/영문)" style={{padding:"6px 10px",border:"1px solid #d1d5db",borderRadius:6,fontSize:12,width:200}}/>
|
||||||
|
{[["ALL","전체"],["PT","저인망"],["GN","유망"],["OT","1척식"],["PS","위망"],["FC","운반선"],["MULTI","다중업종"]].map(([k,l])=>(
|
||||||
|
<button key={k} onClick={()=>{setFilterCode(k);setPage(0);setSel(null);}} style={{padding:"4px 10px",borderRadius:6,border:"none",fontSize:11,fontWeight:600,cursor:"pointer",background:filterCode===k?"#1e40af":"#e2e8f0",color:filterCode===k?"#fff":"#475569"}}>{l}</button>
|
||||||
|
))}
|
||||||
|
<span style={{fontSize:11,color:"#94a3b8",marginLeft:"auto"}}>{filtered.length}개 소유주 / {page+1}/{totalPages||1}p</span>
|
||||||
|
</div>
|
||||||
|
<div style={{background:"#fff",borderRadius:10,border:"1px solid #e2e8f0",overflow:"hidden"}}>
|
||||||
|
<table style={{width:"100%",borderCollapse:"collapse",fontSize:11}}>
|
||||||
|
<thead><tr style={{background:"#f8fafc"}}>
|
||||||
|
{["No","소유주 Owner","척수","PT","PT-S","GN","OT","PS","FC","수역"].map(h=><th key={h} style={{padding:"7px 6px",fontWeight:700,color:"#64748b",borderBottom:"2px solid #e2e8f0",textAlign:"left"}}>{h}</th>)}
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{paged.map((o,i)=>{
|
||||||
|
const idx=page*PER+i+1;
|
||||||
|
const isOpen=sel===o.own;
|
||||||
|
const ptN=o.pairs.length+o.upt.length;
|
||||||
|
const ptsN=o.pairs.length+o.upts.length;
|
||||||
|
const zoneStr=getZoneStr(o);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={o.own+idx}>
|
||||||
|
<tr onClick={()=>setSel(isOpen?null:o.own)} style={{cursor:"pointer",borderBottom:"1px solid #f1f5f9",background:isOpen?"#eff6ff":"transparent"}}>
|
||||||
|
<td style={{padding:"5px 6px",color:"#94a3b8",width:32}}>{idx}</td>
|
||||||
|
<td style={{padding:"5px 6px"}}><div style={{fontWeight:700,color:"#1e293b",fontSize:12}}>{o.own}</div><div style={{fontSize:9,color:"#64748b",fontStyle:"italic"}}>{o.ownEn}</div></td>
|
||||||
|
<td style={{padding:"5px 6px",fontWeight:700,color:"#1e40af"}}>{o.total}</td>
|
||||||
|
<td style={{padding:"5px 6px",color:ptN?"#1e40af":"#e2e8f0",fontWeight:ptN?700:400}}>{ptN||"-"}</td>
|
||||||
|
<td style={{padding:"5px 6px",color:ptsN?"#ea580c":"#e2e8f0",fontWeight:ptsN?700:400}}>{ptsN||"-"}</td>
|
||||||
|
<td style={{padding:"5px 6px",color:o.gn.length?"#10b981":"#e2e8f0",fontWeight:o.gn.length?700:400}}>{o.gn.length||"-"}</td>
|
||||||
|
<td style={{padding:"5px 6px",color:o.ot.length?"#8b5cf6":"#e2e8f0",fontWeight:o.ot.length?700:400}}>{o.ot.length||"-"}</td>
|
||||||
|
<td style={{padding:"5px 6px",color:o.ps.length?"#ef4444":"#e2e8f0",fontWeight:o.ps.length?700:400}}>{o.ps.length||"-"}</td>
|
||||||
|
<td style={{padding:"5px 6px",color:o.fc.length?"#f59e0b":"#e2e8f0",fontWeight:o.fc.length?700:400}}>{o.fc.length||"-"}</td>
|
||||||
|
<td style={{padding:"5px 6px",fontSize:10,fontWeight:600,color:"#475569"}}>{zoneStr}</td>
|
||||||
|
</tr>
|
||||||
|
{isOpen && <tr><td colSpan={10} style={{padding:0,background:"#f8fafc",borderBottom:"2px solid #1e40af"}}><Detail o={o}/></td></tr>}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{totalPages>1 && (
|
||||||
|
<div style={{display:"flex",gap:4,justifyContent:"center",marginTop:10}}>
|
||||||
|
<button onClick={()=>setPage(Math.max(0,page-1))} disabled={page===0} style={{padding:"4px 10px",borderRadius:6,border:"1px solid #d1d5db",fontSize:11,cursor:"pointer",background:"#fff"}}>◀</button>
|
||||||
|
{Array.from({length:Math.min(10,totalPages)},(_,k)=>{
|
||||||
|
let p=page<5?k:page>=totalPages-5?totalPages-10+k:page-5+k;
|
||||||
|
if(p<0) p=0;
|
||||||
|
if(p>=totalPages) return null;
|
||||||
|
return (<button key={p} onClick={()=>setPage(p)} style={{padding:"4px 8px",borderRadius:6,border:"none",fontSize:11,cursor:"pointer",background:page===p?"#1e40af":"#e2e8f0",color:page===p?"#fff":"#475569",fontWeight:page===p?700:400}}>{p+1}</button>);
|
||||||
|
})}
|
||||||
|
<button onClick={()=>setPage(Math.min(totalPages-1,page+1))} disabled={page>=totalPages-1} style={{padding:"4px 10px",borderRadius:6,border:"1px solid #d1d5db",fontSize:11,cursor:"pointer",background:"#fff"}}>▶</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
639
legacy/조업감시_선단연관_대시보드.html
Normal file
639
legacy/조업감시_선단연관_대시보드.html
Normal file
@ -0,0 +1,639 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>906척 실시간 조업 감시 — 선단 연관관계</title>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css"/>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700;800;900&display=swap');
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
:root{--bg:#020617;--panel:#0F172A;--card:#1E293B;--border:#1E3A5F;--text:#E2E8F0;--muted:#64748B;--accent:#3B82F6;
|
||||||
|
--pt:#2563EB;--pts:#3B82F6;--gn:#059669;--ot:#7C3AED;--ps:#DC2626;--fc:#D97706;--crit:#EF4444;--high:#F59E0B}
|
||||||
|
body{font-family:'Noto Sans KR',sans-serif;background:var(--bg);color:var(--text);overflow:hidden;height:100vh}
|
||||||
|
.app{display:grid;grid-template-columns:310px 1fr;grid-template-rows:44px 1fr;height:100vh}
|
||||||
|
.topbar{grid-column:1/-1;background:var(--panel);border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 14px;gap:10px;z-index:1000}
|
||||||
|
.topbar .logo{font-size:14px;font-weight:800;display:flex;align-items:center;gap:5px}
|
||||||
|
.topbar .logo span{color:var(--accent)}
|
||||||
|
.topbar .stats{display:flex;gap:14px;margin-left:auto}
|
||||||
|
.topbar .stat{font-size:10px;color:var(--muted);display:flex;align-items:center;gap:3px}
|
||||||
|
.topbar .stat b{color:var(--text);font-size:12px}
|
||||||
|
.topbar .time{font-size:10px;color:var(--accent);font-weight:600}
|
||||||
|
.sidebar{background:var(--panel);border-right:1px solid var(--border);overflow-y:auto;display:flex;flex-direction:column}
|
||||||
|
.map-area{position:relative}
|
||||||
|
.sb{padding:10px 12px;border-bottom:1px solid var(--border)}
|
||||||
|
.sb-t{font-size:9px;font-weight:700;color:var(--muted);letter-spacing:1.5px;text-transform:uppercase;margin-bottom:6px}
|
||||||
|
/* Type grid */
|
||||||
|
.tg{display:grid;grid-template-columns:repeat(3,1fr);gap:3px}
|
||||||
|
.tb{background:var(--card);border:1px solid transparent;border-radius:5px;padding:4px;cursor:pointer;text-align:center;transition:all .15s}
|
||||||
|
.tb:hover{border-color:var(--border)}
|
||||||
|
.tb.on{border-color:var(--accent);background:rgba(59,130,246,.1)}
|
||||||
|
.tb .c{font-size:11px;font-weight:800}
|
||||||
|
.tb .n{font-size:8px;color:var(--muted)}
|
||||||
|
/* Speed bar */
|
||||||
|
.sbar{position:relative;height:24px;background:var(--bg);border-radius:5px;overflow:hidden;margin:4px 0}
|
||||||
|
.sseg{position:absolute;border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:7px;color:#fff;font-weight:600}
|
||||||
|
.sseg.p{height:24px;top:0;border:1.5px solid rgba(255,255,255,.25);box-shadow:0 0 6px rgba(0,0,0,.3)}
|
||||||
|
.sseg:not(.p){height:16px;top:4px;opacity:.6}
|
||||||
|
/* Vessel list */
|
||||||
|
.vlist{max-height:180px;overflow-y:auto}
|
||||||
|
.vi{display:flex;align-items:center;gap:6px;padding:4px 6px;border-radius:3px;cursor:pointer;font-size:10px;transition:background .1s}
|
||||||
|
.vi:hover{background:var(--card)}
|
||||||
|
.vi .dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
|
||||||
|
.vi .nm{flex:1;font-weight:500}
|
||||||
|
.vi .sp{font-weight:700;font-size:9px}
|
||||||
|
.vi .st{font-size:7px;padding:1px 3px;border-radius:2px;font-weight:600}
|
||||||
|
/* Alarm */
|
||||||
|
.ai{display:flex;gap:6px;padding:4px 6px;border-radius:3px;margin-bottom:2px;font-size:9px;border-left:3px solid}
|
||||||
|
.ai.cr{border-color:var(--crit);background:rgba(239,68,68,.07)}
|
||||||
|
.ai.hi{border-color:var(--high);background:rgba(245,158,11,.05)}
|
||||||
|
.ai .at{color:var(--muted);font-size:8px;white-space:nowrap}
|
||||||
|
/* Relation panel */
|
||||||
|
.rel-panel{background:var(--card);border-radius:6px;padding:8px;margin-top:4px}
|
||||||
|
.rel-header{display:flex;align-items:center;gap:6px;margin-bottom:6px}
|
||||||
|
.rel-badge{font-size:9px;padding:1px 5px;border-radius:3px;font-weight:700}
|
||||||
|
.rel-line{display:flex;align-items:center;gap:4px;font-size:10px;padding:2px 0}
|
||||||
|
.rel-line .dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}
|
||||||
|
.rel-link{width:20px;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:10px}
|
||||||
|
.rel-dist{font-size:8px;padding:1px 4px;border-radius:2px;font-weight:600}
|
||||||
|
/* Fleet network */
|
||||||
|
.fleet-card{background:rgba(30,41,59,.8);border:1px solid var(--border);border-radius:6px;padding:8px;margin-bottom:4px}
|
||||||
|
.fleet-owner{font-size:10px;font-weight:700;color:var(--accent);margin-bottom:4px}
|
||||||
|
.fleet-vessel{display:flex;align-items:center;gap:4px;font-size:9px;padding:1px 0}
|
||||||
|
/* Toggles */
|
||||||
|
.tog{display:flex;gap:3px;flex-wrap:wrap;margin-bottom:6px}
|
||||||
|
.tog-btn{font-size:8px;padding:2px 6px;border-radius:3px;border:1px solid var(--border);background:var(--card);color:var(--muted);cursor:pointer;transition:all .15s}
|
||||||
|
.tog-btn.on{background:var(--accent);color:#fff;border-color:var(--accent)}
|
||||||
|
/* Map panels */
|
||||||
|
.map-legend{position:absolute;bottom:12px;right:12px;z-index:800;background:rgba(15,23,42,.92);backdrop-filter:blur(8px);border:1px solid var(--border);border-radius:8px;padding:10px;font-size:9px;min-width:180px}
|
||||||
|
.map-legend .lt{font-size:8px;font-weight:700;color:var(--muted);margin-bottom:4px;letter-spacing:1px}
|
||||||
|
.map-legend .li{display:flex;align-items:center;gap:5px;margin-bottom:2px}
|
||||||
|
.map-legend .ls{width:12px;height:12px;border-radius:3px;flex-shrink:0}
|
||||||
|
.map-info{position:absolute;top:12px;right:12px;z-index:800;background:rgba(15,23,42,.95);backdrop-filter:blur(8px);border:1px solid var(--border);border-radius:8px;padding:12px;width:270px}
|
||||||
|
.map-info .ir{display:flex;justify-content:space-between;font-size:10px;padding:2px 0;border-bottom:1px solid rgba(255,255,255,.03)}
|
||||||
|
.map-info .il{color:var(--muted)}
|
||||||
|
.map-info .iv{font-weight:600}
|
||||||
|
.close-btn{position:absolute;top:6px;right:8px;background:none;border:none;color:var(--muted);cursor:pointer;font-size:13px}
|
||||||
|
.month-row{display:flex;gap:1px}
|
||||||
|
.month-cell{flex:1;height:12px;border-radius:2px;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:600}
|
||||||
|
::-webkit-scrollbar{width:3px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}
|
||||||
|
.leaflet-control-zoom{border:1px solid var(--border)!important}
|
||||||
|
.leaflet-control-zoom a{background:var(--panel)!important;color:var(--text)!important;border-color:var(--border)!important}
|
||||||
|
.leaflet-control-attribution{display:none!important}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="logo">🛰 <span>WING</span> 조업감시·선단연관</div>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat">전체 <b id="sTotal">906</b>척</div>
|
||||||
|
<div class="stat">조업 <b id="sFish" style="color:#22C55E">0</b></div>
|
||||||
|
<div class="stat">항해 <b id="sTran" style="color:#3B82F6">0</b></div>
|
||||||
|
<div class="stat">쌍연결 <b id="sPair" style="color:#F59E0B">0</b></div>
|
||||||
|
<div class="stat">경고 <b id="sAlarm" style="color:#EF4444">0</b></div>
|
||||||
|
</div>
|
||||||
|
<div class="time" id="clock"></div>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="sb">
|
||||||
|
<div class="sb-t">업종 필터</div>
|
||||||
|
<div class="tg" id="typeGrid"></div>
|
||||||
|
</div>
|
||||||
|
<div class="sb">
|
||||||
|
<div class="sb-t">지도 표시 설정</div>
|
||||||
|
<div class="tog" id="toggles"></div>
|
||||||
|
</div>
|
||||||
|
<div class="sb">
|
||||||
|
<div class="sb-t">속도 프로파일</div>
|
||||||
|
<div id="speedPanel"></div>
|
||||||
|
</div>
|
||||||
|
<div class="sb">
|
||||||
|
<div class="sb-t">선단 연관관계 <span id="relInfo" style="color:var(--accent);font-size:8px"></span></div>
|
||||||
|
<div id="relPanel"></div>
|
||||||
|
</div>
|
||||||
|
<div class="sb" style="flex:1;min-height:0;">
|
||||||
|
<div class="sb-t">선박 목록 <span id="vlInfo" style="color:var(--accent);font-size:8px"></span></div>
|
||||||
|
<div class="vlist" id="vlist"></div>
|
||||||
|
</div>
|
||||||
|
<div class="sb" style="max-height:130px;overflow-y:auto;">
|
||||||
|
<div class="sb-t">실시간 경고</div>
|
||||||
|
<div id="alarms"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="map-area">
|
||||||
|
<div id="map" style="width:100%;height:100%"></div>
|
||||||
|
<div class="map-legend" id="legend"></div>
|
||||||
|
<div class="map-info" id="info" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ═══ DATA ═══
|
||||||
|
const ZP={
|
||||||
|
"Ⅰ":[[131.265,36.1666],[129.7162,35.65],[129.7275,35.692],[129.7654,35.7956],[129.8298,35.9931],[129.8323,36.0444],[129.7208,36.2417],[129.6737,36.2681],[129.6906,36.4539],[129.7282,36.7868],[129.6711,37.0095],[129.6223,37.2561],[129.4768,37.4744],[129.3255,37.6935],[129.1762,37.8767],[128.9936,38.068],[128.8559,38.2544],[130.1643,38.0028],[131.6327,37.3261],[131.6277,37.3163],[131.6156,37.2834],[131.4273,37.0406],[130.375,36.1666],[131.265,36.1666]],
|
||||||
|
"Ⅱ":[[126.0005,32.1833],[126.0141,33.104],[126.0646,33.0051],[126.1417,32.9416],[126.2317,32.9132],[126.3279,32.9173],[126.4691,33.0037],[126.5577,33.0178],[126.6491,33.0193],[126.7439,33.0327],[126.852,33.0983],[126.9682,33.1401],[127.0508,33.2096],[127.1211,33.2964],[127.1685,33.3742],[127.2041,33.4496],[127.214,33.5279],[127.0126,33.7734],[127.26,33.7988],[128.0,34.1187],[128.8883,34.344],[127.86,33.2283],[126.0005,32.1833]],
|
||||||
|
"Ⅲ":[[124.1255,35.0007],[124.9773,34.8191],[124.9046,33.9378],[125.9272,33.7231],[126.0401,33.7034],[126.059,33.6762],[126.0738,33.6023],[126.0562,33.5638],[126.0067,33.5056],[125.9855,33.4803],[125.9427,33.433],[125.9209,33.386],[125.9048,33.3233],[125.9048,33.2998],[125.9159,33.2501],[125.9335,33.2153],[125.9646,33.1745],[124.1704,33.3003],[124.1255,35.0007]],
|
||||||
|
"Ⅳ":[[124.5,35.5],[124.5,36.75],[124.3333,37.0],[125.2289,37.0029],[125.4284,36.9026],[125.3881,36.8333],[125.3498,36.7644],[125.2935,36.6406],[125.2951,36.5872],[125.3248,36.5157],[125.5696,36.2265],[125.6364,36.15],[125.7914,35.926],[125.8327,35.8149],[125.8488,35.7167],[125.8484,35.6655],[125.7756,35.4651],[125.6868,35.3876],[125.3743,35.148],[125.1858,35.0],[124.1255,35.0007],[124.5,35.5]],
|
||||||
|
};
|
||||||
|
const ZC={"Ⅰ":"#3B82F6","Ⅱ":"#22C55E","Ⅲ":"#F59E0B","Ⅳ":"#A855F7"};
|
||||||
|
const ZN={"Ⅰ":"수역I(동해)","Ⅱ":"수역II(제주남방)","Ⅲ":"수역III(서해남부)","Ⅳ":"수역IV(서해중간)"};
|
||||||
|
|
||||||
|
const T={
|
||||||
|
PT:{nm:"2척식 저인망(본선)",cnt:323,z:["Ⅱ","Ⅲ"],col:"#2563EB",ic:"⛵",
|
||||||
|
sp:[{s:"정박",r:[0,.5],v:.3,c:"#64748B"},{s:"투양망",r:[1,2.5],v:1.5,c:"#F59E0B"},{s:"예인조업",r:[2.5,4.5],v:3.3,c:"#2563EB",p:1},{s:"저속",r:[4.5,7],v:5.5,c:"#8B5CF6"},{s:"고속",r:[7,15],v:8.3,c:"#475569"}],
|
||||||
|
mo:[.5,.5,.7,.8,.7,.1,0,0,.8,1,.9,.6],tj:"직선예인(쌍동기화)"},
|
||||||
|
"PT-S":{nm:"2척식 저인망(부속선)",cnt:323,z:["Ⅱ","Ⅲ"],col:"#3B82F6",ic:"⛵",
|
||||||
|
sp:[{s:"정박",r:[0,.5],v:.3,c:"#64748B"},{s:"보조",r:[1,2.5],v:1.5,c:"#F59E0B"},{s:"동기예인",r:[2.5,4.5],v:3.3,c:"#3B82F6",p:1},{s:"추종",r:[4.5,7],v:5.5,c:"#8B5CF6"},{s:"고속",r:[7,15],v:8.3,c:"#475569"}],
|
||||||
|
mo:[.5,.5,.7,.8,.7,.1,0,0,.8,1,.9,.6],tj:"본선거울상"},
|
||||||
|
GN:{nm:"유망",cnt:200,z:["Ⅱ","Ⅲ","Ⅳ"],col:"#059669",ic:"🪢",
|
||||||
|
sp:[{s:"정박",r:[0,.3],v:.2,c:"#64748B"},{s:"표류",r:[.5,2],v:1,c:"#059669",p:1},{s:"양망",r:[1,3],v:2,c:"#F59E0B"},{s:"투망",r:[2,4],v:3,c:"#EF4444"},{s:"항해",r:[5,15],v:7.5,c:"#475569"}],
|
||||||
|
mo:[.6,.5,.7,.7,.6,.3,.2,.3,.9,1,.9,.7],tj:"투망→표류→양망"},
|
||||||
|
OT:{nm:"1척식 저인망",cnt:13,z:["Ⅱ","Ⅲ"],col:"#7C3AED",ic:"🚢",
|
||||||
|
sp:[{s:"정박",r:[0,.5],v:.3,c:"#64748B"},{s:"투양망",r:[1,2],v:1.5,c:"#F59E0B"},{s:"단독예인",r:[2.5,5],v:3.5,c:"#7C3AED",p:1},{s:"항해",r:[5,15],v:8,c:"#475569"}],
|
||||||
|
mo:[.5,.5,.7,.7,.6,.1,0,0,.8,1,.8,.5],tj:"단독레이스트랙"},
|
||||||
|
PS:{nm:"위망/채낚기",cnt:16,z:["Ⅰ","Ⅱ","Ⅲ","Ⅳ"],col:"#DC2626",ic:"🦑",
|
||||||
|
sp:[{s:"정박",r:[0,.3],v:.2,c:"#64748B"},{s:"위망",r:[.3,1.5],v:.5,c:"#DC2626",p:1},{s:"채낚기",r:[.3,2],v:.8,c:"#F97316",p:1},{s:"투양",r:[1.5,3],v:2,c:"#F59E0B"},{s:"항해",r:[5,15],v:8,c:"#475569"}],
|
||||||
|
mo:[.6,.7,.8,.7,.4,.3,.5,.7,.9,1,.8,.6],tj:"점군집/야간표류"},
|
||||||
|
FC:{nm:"운반선",cnt:31,z:["Ⅰ","Ⅱ","Ⅲ","Ⅳ"],col:"#D97706",ic:"🚛",
|
||||||
|
sp:[{s:"정박",r:[0,.3],v:.2,c:"#64748B"},{s:"환적",r:[.5,2],v:1,c:"#D97706",p:1},{s:"저속",r:[3,6],v:4.5,c:"#8B5CF6"},{s:"고속",r:[6,15],v:9,c:"#475569"}],
|
||||||
|
mo:[.4,.4,.6,.7,.5,.2,.1,.1,.8,1,.9,.5],tj:"허브스포크순회"},
|
||||||
|
};
|
||||||
|
const MO=["1월","2월","3월","4월","5월","6월","7월","8월","9월","10월","11월","12월"];
|
||||||
|
|
||||||
|
// ═══ OWNERS & FLEET GENERATION ═══
|
||||||
|
const SURNAMES=["张","王","李","刘","陈","杨","黄","赵","周","吴","徐","孙","马","朱","胡","郭","林","何","高","罗"];
|
||||||
|
const REGIONS=["荣成","石岛","烟台","威海","日照","青岛","连云港","舟山","象山","大连"];
|
||||||
|
|
||||||
|
function rnd(a,b){return a+Math.random()*(b-a)}
|
||||||
|
function pick(a){return a[Math.floor(Math.random()*a.length)]}
|
||||||
|
|
||||||
|
// Generate owners with fleets
|
||||||
|
function genFleet(){
|
||||||
|
const owners=[];
|
||||||
|
let vid=1, ptPairs=[], fcList=[];
|
||||||
|
|
||||||
|
// Generate PT pairs: 40 pairs shown (of 311)
|
||||||
|
for(let i=0;i<40;i++){
|
||||||
|
const owner = pick(SURNAMES)+pick(SURNAMES)+pick(["渔业","海产","水产","船务"])+pick(["有限公司","合作社",""]);
|
||||||
|
const region = pick(REGIONS);
|
||||||
|
const baseId = 10000+vid;
|
||||||
|
const zone = pick(["Ⅱ","Ⅲ"]);
|
||||||
|
const poly=ZP[zone]; const lats=poly.map(p=>p[1]),lons=poly.map(p=>p[0]);
|
||||||
|
const lat=rnd(Math.min(...lats)+.1,Math.max(...lats)-.1);
|
||||||
|
const lon=rnd(Math.min(...lons)+.1,Math.max(...lons)-.1);
|
||||||
|
const isFishing=Math.random()<.55;
|
||||||
|
const sp=isFishing?rnd(2.5,4.5):rnd(6,11);
|
||||||
|
const crs=rnd(0,360);
|
||||||
|
const pairDist=isFishing?rnd(.2,1.2):rnd(0,.3);
|
||||||
|
const pairAngle=rnd(0,360);
|
||||||
|
const lat2=lat+pairDist/60*Math.cos(pairAngle*Math.PI/180);
|
||||||
|
const lon2=lon+pairDist/60*Math.sin(pairAngle*Math.PI/180)/Math.cos(lat*Math.PI/180);
|
||||||
|
|
||||||
|
const pt={id:vid++,permit:`C21-${baseId}A`,code:"PT",lat,lon,speed:+sp.toFixed(1),course:+crs.toFixed(0),
|
||||||
|
state:isFishing?"조업중":(sp<1?"정박":"항해중"),zone,isFishing,color:"#2563EB",owner,region,pairId:null};
|
||||||
|
const pts={id:vid++,permit:`C21-${baseId}B`,code:"PT-S",lat:+lat2.toFixed(4),lon:+lon2.toFixed(4),
|
||||||
|
speed:+(sp+rnd(-.3,.3)).toFixed(1),course:+(crs+rnd(-10,10)).toFixed(0),
|
||||||
|
state:isFishing?"조업중":(sp<1?"정박":"항해중"),zone,isFishing,color:"#3B82F6",owner,region,pairId:null};
|
||||||
|
pt.pairId=pts.id; pts.pairId=pt.id;
|
||||||
|
pt.pairDist=+pairDist.toFixed(2);pts.pairDist=pt.pairDist;
|
||||||
|
ptPairs.push({pt,pts,owner,region});
|
||||||
|
owners.push({name:owner,region,vessels:[pt,pts],type:"trawl"});
|
||||||
|
}
|
||||||
|
|
||||||
|
// GN vessels
|
||||||
|
for(let i=0;i<30;i++){
|
||||||
|
const oi=Math.random()<.3?owners[Math.floor(Math.random()*owners.length)]:null;
|
||||||
|
const owner=oi?oi.name:pick(SURNAMES)+pick(SURNAMES)+pick(["渔业","水产"])+"有限公司";
|
||||||
|
const region=oi?oi.region:pick(REGIONS);
|
||||||
|
const zone=pick(["Ⅱ","Ⅲ","Ⅳ"]);
|
||||||
|
const poly=ZP[zone];const lats=poly.map(p=>p[1]),lons=poly.map(p=>p[0]);
|
||||||
|
const lat=rnd(Math.min(...lats)+.1,Math.max(...lats)-.1);
|
||||||
|
const lon=rnd(Math.min(...lons)+.1,Math.max(...lons)-.1);
|
||||||
|
const isF=Math.random()<.5;
|
||||||
|
const sp=isF?rnd(.5,2):rnd(5,10);
|
||||||
|
const v={id:vid++,permit:`C21-${10000+vid}A`,code:"GN",lat,lon,speed:+sp.toFixed(1),course:+rnd(0,360).toFixed(0),
|
||||||
|
state:isF?pick(["표류","투망","양망"]):(sp<1?"정박":"항해중"),zone,isFishing:isF,color:"#059669",owner,region,pairId:null,pairDist:null};
|
||||||
|
if(oi)oi.vessels.push(v); else owners.push({name:owner,region,vessels:[v],type:"gn"});
|
||||||
|
}
|
||||||
|
|
||||||
|
// OT
|
||||||
|
for(let i=0;i<13;i++){
|
||||||
|
const owner=pick(SURNAMES)+pick(SURNAMES)+"远洋渔业";const region=pick(REGIONS);
|
||||||
|
const zone=pick(["Ⅱ","Ⅲ"]);const poly=ZP[zone];const lats=poly.map(p=>p[1]),lons=poly.map(p=>p[0]);
|
||||||
|
const lat=rnd(Math.min(...lats)+.1,Math.max(...lats)-.1);
|
||||||
|
const lon=rnd(Math.min(...lons)+.1,Math.max(...lons)-.1);
|
||||||
|
const isF=Math.random()<.5;const sp=isF?rnd(2.5,5):rnd(5,10);
|
||||||
|
const v={id:vid++,permit:`C21-${10000+vid}A`,code:"OT",lat,lon,speed:+sp.toFixed(1),course:+rnd(0,360).toFixed(0),
|
||||||
|
state:isF?"조업중":"항해중",zone,isFishing:isF,color:"#7C3AED",owner,region,pairId:null,pairDist:null};
|
||||||
|
owners.push({name:owner,region,vessels:[v],type:"ot"});
|
||||||
|
}
|
||||||
|
|
||||||
|
// PS
|
||||||
|
for(let i=0;i<16;i++){
|
||||||
|
const owner=pick(SURNAMES)+pick(SURNAMES)+"水产";const region=pick(REGIONS);
|
||||||
|
const zone=pick(["Ⅰ","Ⅱ","Ⅲ","Ⅳ"]);const poly=ZP[zone];const lats=poly.map(p=>p[1]),lons=poly.map(p=>p[0]);
|
||||||
|
const lat=rnd(Math.min(...lats)+.1,Math.max(...lats)-.1);
|
||||||
|
const lon=rnd(Math.min(...lons)+.1,Math.max(...lons)-.1);
|
||||||
|
const isF=Math.random()<.5;const sp=isF?rnd(.3,1.5):rnd(5,9);
|
||||||
|
const v={id:vid++,permit:`C21-${10000+vid}A`,code:"PS",lat,lon,speed:+sp.toFixed(1),course:+rnd(0,360).toFixed(0),
|
||||||
|
state:isF?pick(["위망","채낚기"]):"항해중",zone,isFishing:isF,color:"#DC2626",owner,region,pairId:null,pairDist:null};
|
||||||
|
owners.push({name:owner,region,vessels:[v],type:"ps"});
|
||||||
|
}
|
||||||
|
|
||||||
|
// FC — assigned to existing trawl owners
|
||||||
|
const trawlOwners=owners.filter(o=>o.type==="trawl");
|
||||||
|
for(let i=0;i<31;i++){
|
||||||
|
const oi=i<trawlOwners.length?trawlOwners[i]:pick(trawlOwners);
|
||||||
|
const zone=pick(["Ⅱ","Ⅲ"]);const poly=ZP[zone];const lats=poly.map(p=>p[1]),lons=poly.map(p=>p[0]);
|
||||||
|
// Position near owner's PT vessels
|
||||||
|
const ref=oi.vessels.find(v=>v.code==="PT")||oi.vessels[0];
|
||||||
|
const lat=ref.lat+rnd(-.2,.2);const lon=ref.lon+rnd(-.2,.2);
|
||||||
|
const isNear=Math.random()<.4;
|
||||||
|
const sp=isNear?rnd(.5,1.5):rnd(5,9);
|
||||||
|
const v={id:vid++,permit:`C21-${10000+vid}A`,code:"FC",lat,lon,speed:+sp.toFixed(1),course:+rnd(0,360).toFixed(0),
|
||||||
|
state:isNear?"환적":"항해중",zone,isFishing:isNear,color:"#D97706",owner:oi.name,region:oi.region,pairId:null,pairDist:null,
|
||||||
|
nearVessels:isNear?oi.vessels.filter(v2=>v2.code!=="FC").slice(0,2).map(v2=>v2.id):[]};
|
||||||
|
oi.vessels.push(v);
|
||||||
|
fcList.push(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allV=[];
|
||||||
|
owners.forEach(o=>o.vessels.forEach(v=>allV.push(v)));
|
||||||
|
return {allV,owners,ptPairs,fcList};
|
||||||
|
}
|
||||||
|
|
||||||
|
let {allV:allVessels,owners:allOwners,ptPairs,fcList}=genFleet();
|
||||||
|
let selType=null, selVessel=null;
|
||||||
|
let showPairLines=true, showFCLines=true, showZones=true, showFleetCircles=false;
|
||||||
|
|
||||||
|
// ═══ MAP ═══
|
||||||
|
const map=L.map('map',{center:[34.2,126.5],zoom:7,attributionControl:false});
|
||||||
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',{maxZoom:18,subdomains:'abcd'}).addTo(map);
|
||||||
|
|
||||||
|
const zoneLayers={};
|
||||||
|
for(const[n,coords] of Object.entries(ZP)){
|
||||||
|
zoneLayers[n]=L.polygon(coords.map(c=>[c[1],c[0]]),{color:ZC[n],weight:1.5,fillColor:ZC[n],fillOpacity:.08,dashArray:'6 3'}).addTo(map);
|
||||||
|
zoneLayers[n].bindTooltip(ZN[n],{permanent:false,direction:'center'});
|
||||||
|
}
|
||||||
|
|
||||||
|
const markerGroup=L.layerGroup().addTo(map);
|
||||||
|
const lineGroup=L.layerGroup().addTo(map);
|
||||||
|
const fleetGroup=L.layerGroup().addTo(map);
|
||||||
|
|
||||||
|
function mkIcon(v,highlight){
|
||||||
|
const sz=v.isFishing?11:7;
|
||||||
|
const isFC=v.code==="FC";
|
||||||
|
const shape=isFC?'border-radius:2px;':'border-radius:50%;';
|
||||||
|
const border=highlight?'3px solid #FFF':v.isFishing?'2px solid rgba(255,255,255,.4)':'1px solid rgba(255,255,255,.15)';
|
||||||
|
const shadow=v.isFishing||highlight?`box-shadow:0 0 ${highlight?12:6}px ${v.color};`:'';
|
||||||
|
const html=`<div style="width:${sz}px;height:${sz}px;${shape}background:${v.color};border:${border};${shadow}opacity:${v.isFishing?1:.55}"></div>`;
|
||||||
|
return L.divIcon({html,className:'',iconSize:[sz,sz],iconAnchor:[sz/2,sz/2]});
|
||||||
|
}
|
||||||
|
|
||||||
|
function haversine(lat1,lon1,lat2,lon2){
|
||||||
|
const R=3440.065;const dLat=(lat2-lat1)*Math.PI/180;const dLon=(lon2-lon1)*Math.PI/180;
|
||||||
|
const a=Math.sin(dLat/2)**2+Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*Math.sin(dLon/2)**2;
|
||||||
|
return R*2*Math.atan2(Math.sqrt(a),Math.sqrt(1-a));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMap(){
|
||||||
|
markerGroup.clearLayers();
|
||||||
|
lineGroup.clearLayers();
|
||||||
|
fleetGroup.clearLayers();
|
||||||
|
|
||||||
|
const filtered=selType?allVessels.filter(v=>v.code===selType||
|
||||||
|
(selType==="PT"&&v.code==="PT-S")||(selType==="PT-S"&&v.code==="PT")||
|
||||||
|
(selType==="FC"&&(v.code==="PT"||v.code==="PT-S"))||
|
||||||
|
((selType==="PT"||selType==="PT-S")&&v.code==="FC")
|
||||||
|
):allVessels;
|
||||||
|
|
||||||
|
// Vessels
|
||||||
|
filtered.forEach(v=>{
|
||||||
|
const hl=selVessel&&selVessel.id===v.id;
|
||||||
|
L.marker([v.lat,v.lon],{icon:mkIcon(v,hl)}).on('click',()=>selectVessel(v)).addTo(markerGroup);
|
||||||
|
});
|
||||||
|
|
||||||
|
// PT ↔ PT-S pair lines
|
||||||
|
if(showPairLines){
|
||||||
|
ptPairs.forEach(p=>{
|
||||||
|
if(selType&&selType!=="PT"&&selType!=="PT-S"&&selType!=="FC")return;
|
||||||
|
const dist=haversine(p.pt.lat,p.pt.lon,p.pts.lat,p.pts.lon);
|
||||||
|
const warn=dist>3;const crit=dist>10;
|
||||||
|
const color=crit?'#EF4444':warn?'#F59E0B':'#3B82F644';
|
||||||
|
const weight=crit?3:warn?2:1;
|
||||||
|
const dash=warn?'':'4 4';
|
||||||
|
const line=L.polyline([[p.pt.lat,p.pt.lon],[p.pts.lat,p.pts.lon]],{color,weight,dashArray:dash,opacity:warn?1:.4});
|
||||||
|
line.bindTooltip(`${p.pt.permit} ↔ ${p.pts.permit}<br>이격: ${dist.toFixed(2)} NM ${warn?'⚠ 경고':'✓ 정상'}<br>소유주: ${p.owner}`,{sticky:true});
|
||||||
|
line.addTo(lineGroup);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// FC ↔ nearby vessel lines (transshipment)
|
||||||
|
if(showFCLines){
|
||||||
|
fcList.forEach(fc=>{
|
||||||
|
if(selType&&selType!=="FC"&&selType!=="PT"&&selType!=="PT-S")return;
|
||||||
|
if(!fc.nearVessels||!fc.nearVessels.length)return;
|
||||||
|
fc.nearVessels.forEach(nid=>{
|
||||||
|
const nv=allVessels.find(v=>v.id===nid);
|
||||||
|
if(!nv)return;
|
||||||
|
const dist=haversine(fc.lat,fc.lon,nv.lat,nv.lon);
|
||||||
|
const line=L.polyline([[fc.lat,fc.lon],[nv.lat,nv.lon]],{color:'#D97706',weight:2,dashArray:'3 5',opacity:.7});
|
||||||
|
line.bindTooltip(`🚛 ${fc.permit} → ${nv.permit}<br>환적 거리: ${dist.toFixed(2)} NM<br>소유주: ${fc.owner}`,{sticky:true});
|
||||||
|
line.addTo(lineGroup);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fleet circles
|
||||||
|
if(showFleetCircles){
|
||||||
|
allOwners.filter(o=>o.vessels.length>=3).forEach(o=>{
|
||||||
|
const lats=o.vessels.map(v=>v.lat),lons=o.vessels.map(v=>v.lon);
|
||||||
|
const cLat=lats.reduce((a,b)=>a+b)/lats.length;
|
||||||
|
const cLon=lons.reduce((a,b)=>a+b)/lons.length;
|
||||||
|
const maxDist=Math.max(...o.vessels.map(v=>haversine(cLat,cLon,v.lat,v.lon)));
|
||||||
|
const circle=L.circle([cLat,cLon],{radius:maxDist*1852+2000,color:'#F59E0B',weight:1,fillColor:'#F59E0B',fillOpacity:.04,dashArray:'8 4'});
|
||||||
|
circle.bindTooltip(`🏢 ${o.name}<br>${o.region}<br>${o.vessels.length}척 (${o.vessels.map(v=>v.code).filter((c,i,a)=>a.indexOf(c)===i).join('+')})`,{sticky:true});
|
||||||
|
circle.addTo(fleetGroup);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zone highlighting
|
||||||
|
for(const[n,layer]of Object.entries(zoneLayers)){
|
||||||
|
const t=selType?T[selType]:null;
|
||||||
|
const ok=!t||t.z.includes(n);
|
||||||
|
layer.setStyle({fillOpacity:showZones?(ok?.12:.02):.01,weight:showZones?(ok?2:.5):.3,opacity:showZones?1:.2});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ SIDEBAR ═══
|
||||||
|
function renderTypeGrid(){
|
||||||
|
const g=document.getElementById('typeGrid');
|
||||||
|
let h=`<div class="tb ${!selType?'on':''}" onclick="selTypeF(null)" style="grid-column:1/-1"><div class="c" style="color:var(--accent)">전체</div><div class="n">906척</div></div>`;
|
||||||
|
for(const[c,t]of Object.entries(T))h+=`<div class="tb ${selType===c?'on':''}" onclick="selTypeF('${c}')"><div class="c" style="color:${t.col}">${c}</div><div class="n">${t.cnt}</div></div>`;
|
||||||
|
g.innerHTML=h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderToggles(){
|
||||||
|
const el=document.getElementById('toggles');
|
||||||
|
const togs=[
|
||||||
|
{id:'pairLines',label:'쌍 연결선',val:showPairLines},
|
||||||
|
{id:'fcLines',label:'환적 연결선',val:showFCLines},
|
||||||
|
{id:'zones',label:'수역 표시',val:showZones},
|
||||||
|
{id:'fleetCirc',label:'선단 범위',val:showFleetCircles},
|
||||||
|
];
|
||||||
|
el.innerHTML=togs.map(t=>`<div class="tog-btn ${t.val?'on':''}" onclick="toggleOpt('${t.id}')">${t.label}</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSpeed(){
|
||||||
|
const el=document.getElementById('speedPanel');
|
||||||
|
const t=selType?T[selType]:T.PT;const code=selType||'PT';
|
||||||
|
let segs='';
|
||||||
|
t.sp.forEach(s=>{const l=(s.r[0]/15)*100,w=((s.r[1]-s.r[0])/15)*100;
|
||||||
|
segs+=`<div class="sseg ${s.p?'p':''}" style="left:${l}%;width:${w}%;background:${s.c}">${w>8?s.s+(s.p?` ${s.v}kt`:''):''}</div>`;});
|
||||||
|
const leg=t.sp.filter(s=>s.p).map(s=>`<span style="font-size:8px;color:${s.c}">★${s.s} ${s.r[0]}~${s.r[1]}kt</span>`).join(' ');
|
||||||
|
el.innerHTML=`<div style="font-size:10px;font-weight:700;color:${t.col};margin-bottom:3px">${t.ic} ${code} ${t.nm}</div>
|
||||||
|
<div class="sbar">${segs}</div><div style="display:flex;justify-content:space-between">${[0,3,5,7,10,15].map(k=>`<span style="font-size:7px;color:rgba(255,255,255,.2)">${k}</span>`).join('')}</div>
|
||||||
|
<div style="margin-top:2px">${leg}</div>
|
||||||
|
<div style="font-size:9px;color:var(--muted);margin-top:3px">궤적: <b style="color:var(--text)">${t.tj}</b> | 수역: ${t.z.map(z=>`<span style="color:${ZC[z]}">${z}</span>`).join(' ')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRelations(){
|
||||||
|
const el=document.getElementById('relPanel');
|
||||||
|
const info=document.getElementById('relInfo');
|
||||||
|
let h='';
|
||||||
|
|
||||||
|
if(selVessel){
|
||||||
|
const v=selVessel;
|
||||||
|
// Find same owner
|
||||||
|
const sameOwner=allVessels.filter(v2=>v2.owner===v.owner&&v2.id!==v.id);
|
||||||
|
const pair=v.pairId?allVessels.find(v2=>v2.id===v.pairId):null;
|
||||||
|
const fcNearby=fcList.filter(fc=>haversine(fc.lat,fc.lon,v.lat,v.lon)<5);
|
||||||
|
info.textContent=`${v.permit} 연관`;
|
||||||
|
|
||||||
|
h+=`<div class="rel-panel">`;
|
||||||
|
h+=`<div class="rel-header"><span style="font-size:14px">${T[v.code].ic}</span><span style="font-size:11px;font-weight:800;color:${v.color}">${v.permit}</span><span class="rel-badge" style="background:${v.color}22;color:${v.color}">${v.code}</span></div>`;
|
||||||
|
h+=`<div style="font-size:9px;color:var(--muted);margin-bottom:6px">소유주: <b style="color:var(--text)">${v.owner}</b> (${v.region})</div>`;
|
||||||
|
|
||||||
|
// Pair relationship
|
||||||
|
if(pair){
|
||||||
|
const dist=haversine(v.lat,v.lon,pair.lat,pair.lon);
|
||||||
|
const warn=dist>3;
|
||||||
|
h+=`<div style="font-size:8px;font-weight:700;color:var(--muted);margin:6px 0 3px;letter-spacing:1px">⛓ 쌍끌이 쌍</div>`;
|
||||||
|
h+=`<div class="rel-line">
|
||||||
|
<div class="dot" style="background:${v.color}"></div><span style="font-size:9px;font-weight:600">${v.permit}</span>
|
||||||
|
<div class="rel-link">${warn?'⚠':'⟷'}</div>
|
||||||
|
<div class="dot" style="background:${pair.color}"></div><span style="font-size:9px;font-weight:600">${pair.permit}</span>
|
||||||
|
<span class="rel-dist" style="background:${warn?'#F59E0B22':'#22C55E22'};color:${warn?'#F59E0B':'#22C55E'}">${dist.toFixed(2)}NM</span>
|
||||||
|
</div>`;
|
||||||
|
h+=`<div style="font-size:8px;color:var(--muted);margin-left:10px">정상 범위: 0.3~1.0NM | ${warn?'⚠ 이격 경고':'✓ 정상 동기화'}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FC relationships
|
||||||
|
if(fcNearby.length&&v.code!=="FC"){
|
||||||
|
h+=`<div style="font-size:8px;font-weight:700;color:var(--muted);margin:6px 0 3px;letter-spacing:1px">🚛 근접 운반선</div>`;
|
||||||
|
fcNearby.forEach(fc=>{
|
||||||
|
const dist=haversine(v.lat,v.lon,fc.lat,fc.lon);
|
||||||
|
const isSameOwner=fc.owner===v.owner;
|
||||||
|
h+=`<div class="rel-line">
|
||||||
|
<div class="dot" style="background:#D97706"></div><span style="font-size:9px;font-weight:600">${fc.permit}</span>
|
||||||
|
<span class="rel-dist" style="background:#D9770622;color:#D97706">${dist.toFixed(1)}NM</span>
|
||||||
|
${isSameOwner?'<span style="font-size:7px;background:#F59E0B22;color:#F59E0B;padding:1px 3px;border-radius:2px">동일소유주</span>':''}
|
||||||
|
${dist<0.5?'<span style="font-size:7px;background:#EF444422;color:#EF4444;padding:1px 3px;border-radius:2px">환적의심</span>':''}
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same owner fleet
|
||||||
|
if(sameOwner.length){
|
||||||
|
h+=`<div style="font-size:8px;font-weight:700;color:var(--muted);margin:6px 0 3px;letter-spacing:1px">🏢 동일 소유주 선단 (${sameOwner.length+1}척)</div>`;
|
||||||
|
sameOwner.slice(0,8).forEach(sv=>{
|
||||||
|
h+=`<div class="fleet-vessel" onclick="selectVessel(allVessels.find(v=>v.id===${sv.id}))" style="cursor:pointer">
|
||||||
|
<div class="dot" style="background:${sv.color}"></div>
|
||||||
|
<span style="color:${sv.color};font-weight:600">${sv.code}</span> ${sv.permit}
|
||||||
|
<span style="color:var(--muted)">${sv.speed}kt ${sv.state}</span>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
if(sameOwner.length>8)h+=`<div style="font-size:8px;color:var(--muted)">... +${sameOwner.length-8}척</div>`;
|
||||||
|
}
|
||||||
|
h+=`</div>`;
|
||||||
|
} else {
|
||||||
|
info.textContent=`(선박 클릭 시 표시)`;
|
||||||
|
// Show top fleets
|
||||||
|
const topFleets=allOwners.filter(o=>o.vessels.length>=3).sort((a,b)=>b.vessels.length-a.vessels.length).slice(0,5);
|
||||||
|
topFleets.forEach(o=>{
|
||||||
|
const codes={};o.vessels.forEach(v=>{codes[v.code]=(codes[v.code]||0)+1;});
|
||||||
|
const hasPair=o.vessels.some(v=>v.code==="PT");
|
||||||
|
const hasFC=o.vessels.some(v=>v.code==="FC");
|
||||||
|
h+=`<div class="fleet-card">
|
||||||
|
<div class="fleet-owner">🏢 ${o.name} <span style="font-size:8px;color:var(--muted)">${o.region}</span></div>
|
||||||
|
<div style="display:flex;gap:4px;flex-wrap:wrap;margin-bottom:3px">
|
||||||
|
${Object.entries(codes).map(([c,n])=>`<span style="font-size:8px;background:${T[c].col}22;color:${T[c].col};padding:1px 4px;border-radius:2px;font-weight:600">${c}×${n}</span>`).join('')}
|
||||||
|
</div>`;
|
||||||
|
// Mini fleet diagram
|
||||||
|
if(hasPair||hasFC){
|
||||||
|
h+=`<div style="display:flex;align-items:center;gap:2px;flex-wrap:wrap">`;
|
||||||
|
o.vessels.forEach(v=>{
|
||||||
|
h+=`<div onclick="selectVessel(allVessels.find(x=>x.id===${v.id}))" style="cursor:pointer;width:16px;height:16px;border-radius:${v.code==='FC'?'2px':'50%'};background:${v.color};display:flex;align-items:center;justify-content:center;font-size:6px;color:#fff;border:1px solid rgba(255,255,255,.2)" title="${v.permit} ${v.code} ${v.speed}kt">${v.code==='FC'?'F':v.code==='PT'?'M':v.code==='PT-S'?'S':v.code[0]}</div>`;
|
||||||
|
if(v.pairId){const p=o.vessels.find(v2=>v2.id===v.pairId);if(p&&v.code==="PT")h+=`<span style="font-size:8px;color:var(--muted)">⟷</span>`;}
|
||||||
|
});
|
||||||
|
h+=`</div>`;
|
||||||
|
}
|
||||||
|
h+=`</div>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
el.innerHTML=h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVList(){
|
||||||
|
const el=document.getElementById('vlist');
|
||||||
|
const info=document.getElementById('vlInfo');
|
||||||
|
const f=selType?allVessels.filter(v=>v.code===selType||(selType==="PT"&&v.code==="PT-S")||(selType==="PT-S"&&v.code==="PT")):allVessels;
|
||||||
|
const sorted=[...f].sort((a,b)=>b.speed-a.speed).slice(0,50);
|
||||||
|
info.textContent=`(${f.length}척)`;
|
||||||
|
el.innerHTML=sorted.map(v=>{
|
||||||
|
const t=T[v.code];const ps=t.sp.find(s=>s.p);const inR=ps&&v.speed>=ps.r[0]&&v.speed<=ps.r[1];
|
||||||
|
const sc=v.isFishing?'#22C55E':v.speed>3?'#3B82F6':'#64748B';
|
||||||
|
const hasPair=v.pairId?'⛓':'';
|
||||||
|
return`<div class="vi" onclick="selectVessel(allVessels.find(x=>x.id===${v.id}))">
|
||||||
|
<div class="dot" style="background:${v.color};${v.isFishing?'box-shadow:0 0 3px '+v.color:''}"></div>
|
||||||
|
<div class="nm">${hasPair}${v.permit}</div>
|
||||||
|
<div class="sp" style="color:${inR?'#22C55E':v.speed>5?'#3B82F6':'var(--muted)'}">${v.speed}kt</div>
|
||||||
|
<div class="st" style="background:${sc}22;color:${sc}">${v.state}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAlarms(){
|
||||||
|
const el=document.getElementById('alarms');
|
||||||
|
const alarms=[];
|
||||||
|
ptPairs.forEach(p=>{
|
||||||
|
const d=haversine(p.pt.lat,p.pt.lon,p.pts.lat,p.pts.lon);
|
||||||
|
if(d>3)alarms.push({sv:'hi',msg:`<b style="color:#2563EB">${p.pt.permit}</b>↔<b style="color:#3B82F6">${p.pts.permit}</b> 쌍분리 ${d.toFixed(1)}NM`,t:'-3분'});
|
||||||
|
});
|
||||||
|
fcList.forEach(fc=>{
|
||||||
|
if(fc.nearVessels&&fc.nearVessels.length){
|
||||||
|
const nv=allVessels.find(v=>v.id===fc.nearVessels[0]);
|
||||||
|
if(nv)alarms.push({sv:'hi',msg:`🚛<b style="color:#D97706">${fc.permit}</b>→<b style="color:${nv.color}">${nv.permit}</b> 환적의심`,t:'-5분'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const mo=new Date().getMonth();
|
||||||
|
allVessels.filter(v=>T[v.code].mo[mo]===0&&v.isFishing).forEach(v=>{
|
||||||
|
alarms.push({sv:'cr',msg:`<b style="color:${v.color}">${v.permit}</b> [${v.code}] 휴어기조업 ${v.speed}kt`,t:'-1분'});
|
||||||
|
});
|
||||||
|
alarms.push({sv:'cr',msg:'<b style="color:#DC2626">C21-10887A</b> [PS] AIS소실 45분',t:'-12분'});
|
||||||
|
const shown=alarms.slice(0,6);
|
||||||
|
el.innerHTML=shown.map(a=>`<div class="ai ${a.sv}"><span class="at">${a.t}</span><span style="flex:1">${a.msg}</span></div>`).join('');
|
||||||
|
document.getElementById('sAlarm').textContent=alarms.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLegend(){
|
||||||
|
const el=document.getElementById('legend');
|
||||||
|
let h='<div class="lt">수역</div>';
|
||||||
|
for(const[n,c]of Object.entries(ZC))h+=`<div class="li"><div class="ls" style="background:${c}33;border:1px solid ${c}"></div>${ZN[n]}</div>`;
|
||||||
|
h+='<div class="lt" style="margin-top:8px">선박</div>';
|
||||||
|
for(const[c,t]of Object.entries(T))h+=`<div class="li"><div class="ls" style="background:${t.col};border-radius:${c==='FC'?'2px':'50%'};width:10px;height:10px"></div>${c} ${t.nm}</div>`;
|
||||||
|
h+='<div class="lt" style="margin-top:8px">연결선</div>';
|
||||||
|
h+=`<div class="li"><div style="width:20px;height:2px;background:#3B82F644;border-radius:1px"></div>PT↔PT-S 쌍 (정상)</div>`;
|
||||||
|
h+=`<div class="li"><div style="width:20px;height:2px;background:#F59E0B;border-radius:1px"></div>쌍 이격 경고 (>3NM)</div>`;
|
||||||
|
h+=`<div class="li"><div style="width:20px;height:2px;background:#D97706;border-radius:1px;border:1px dashed #D97706"></div>FC 환적 연결</div>`;
|
||||||
|
h+=`<div class="li"><div style="width:14px;height:14px;border-radius:50%;border:1px dashed #F59E0B;opacity:.5"></div>선단 범위</div>`;
|
||||||
|
el.innerHTML=h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showInfo(v){
|
||||||
|
const el=document.getElementById('info');
|
||||||
|
const t=T[v.code];const ps=t.sp.find(s=>s.p);const inR=ps&&v.speed>=ps.r[0]&&v.speed<=ps.r[1];
|
||||||
|
const pair=v.pairId?allVessels.find(v2=>v2.id===v.pairId):null;
|
||||||
|
const pairDist=pair?haversine(v.lat,v.lon,pair.lat,pair.lon):null;
|
||||||
|
const mo=new Date().getMonth();
|
||||||
|
|
||||||
|
let miniBar='';
|
||||||
|
t.sp.forEach(s=>{const l=(s.r[0]/15)*100,w=((s.r[1]-s.r[0])/15)*100;
|
||||||
|
miniBar+=`<div style="position:absolute;top:${s.p?0:2}px;height:${s.p?14:8}px;left:${l}%;width:${w}%;background:${s.c};border-radius:2px;opacity:${s.p?.9:.4}"></div>`;});
|
||||||
|
miniBar+=`<div style="position:absolute;top:-2px;left:${Math.min(v.speed/15*100,100)}%;width:2px;height:18px;background:#FFF;border-radius:1px;box-shadow:0 0 4px #FFF"></div>`;
|
||||||
|
|
||||||
|
el.style.display='block';
|
||||||
|
el.innerHTML=`<button class="close-btn" onclick="document.getElementById('info').style.display='none'">✕</button>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||||
|
<span style="font-size:20px">${t.ic}</span>
|
||||||
|
<div><div style="font-size:16px;font-weight:900;color:${t.col}">${v.permit}</div>
|
||||||
|
<div style="font-size:10px;color:var(--muted)">${v.code} · ${t.nm}</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ir"><span class="il">속도</span><span class="iv" style="color:${inR?'#22C55E':'var(--muted)'}">${v.speed}kt ${inR?'(조업범위)':'(범위외)'}</span></div>
|
||||||
|
<div class="ir"><span class="il">상태</span><span class="iv">${v.state}</span></div>
|
||||||
|
<div class="ir"><span class="il">수역</span><span class="iv" style="color:${ZC[v.zone]||'var(--text)'}">${v.zone} ${t.z.includes(v.zone)?'✓허가':'⚠이탈'}</span></div>
|
||||||
|
<div class="ir"><span class="il">위치</span><span class="iv">${v.lat.toFixed(3)}°N ${v.lon.toFixed(3)}°E</span></div>
|
||||||
|
<div class="ir"><span class="il">소유주</span><span class="iv">${v.owner}</span></div>
|
||||||
|
<div class="ir"><span class="il">지역</span><span class="iv">${v.region}</span></div>
|
||||||
|
${pair?`<div class="ir"><span class="il">쌍 선박</span><span class="iv" style="color:${pair.color}">${pair.permit} (${pair.code})</span></div>
|
||||||
|
<div class="ir"><span class="il">쌍 이격</span><span class="iv" style="color:${pairDist>3?'#F59E0B':'#22C55E'}">${pairDist.toFixed(2)}NM ${pairDist>3?'⚠':'✓'}</span></div>`:''}
|
||||||
|
|
||||||
|
<div style="margin-top:8px">
|
||||||
|
<div style="font-size:8px;font-weight:700;color:var(--muted);margin-bottom:2px">속도 vs 조업범위</div>
|
||||||
|
<div style="position:relative;height:14px;background:var(--bg);border-radius:3px;overflow:visible">${miniBar}</div>
|
||||||
|
<div style="display:flex;justify-content:space-between;margin-top:1px">${[0,5,10,15].map(k=>`<span style="font-size:6px;color:rgba(255,255,255,.2)">${k}</span>`).join('')}</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:6px">
|
||||||
|
<div style="font-size:8px;font-weight:700;color:var(--muted);margin-bottom:2px">월별 강도</div>
|
||||||
|
<div class="month-row">${t.mo.map((val,i)=>{
|
||||||
|
const cur=i===mo;const bg=val===0?'#EF444433':`${t.col}${Math.round(val*200).toString(16).padStart(2,'0')}`;
|
||||||
|
return`<div class="month-cell" style="background:${bg};${cur?'border:1.5px solid #FFF;':''}color:${val===0?'#EF4444':cur?'#FFF':'transparent'}">${val===0?'✗':cur?'◉':''}</div>`;
|
||||||
|
}).join('')}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ INTERACTIONS ═══
|
||||||
|
window.selTypeF=function(type){
|
||||||
|
selType=type;selVessel=null;
|
||||||
|
renderTypeGrid();renderToggles();renderSpeed();renderRelations();renderVList();renderMap();renderAlarms();
|
||||||
|
if(type){
|
||||||
|
const f=allVessels.filter(v=>v.code===type);
|
||||||
|
if(f.length){const b=L.latLngBounds(f.map(v=>[v.lat,v.lon]));map.flyToBounds(b.pad(.3),{duration:.6});}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.selectVessel=function(v){
|
||||||
|
if(!v)return;
|
||||||
|
selVessel=v;
|
||||||
|
renderRelations();renderMap();showInfo(v);
|
||||||
|
map.flyTo([v.lat,v.lon],10,{duration:.6});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.toggleOpt=function(id){
|
||||||
|
if(id==='pairLines')showPairLines=!showPairLines;
|
||||||
|
if(id==='fcLines')showFCLines=!showFCLines;
|
||||||
|
if(id==='zones')showZones=!showZones;
|
||||||
|
if(id==='fleetCirc')showFleetCircles=!showFleetCircles;
|
||||||
|
renderToggles();renderMap();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ═══ SIMULATION ═══
|
||||||
|
function tick(){
|
||||||
|
allVessels.forEach(v=>{
|
||||||
|
v.lat+=(.5-Math.random())*.003;v.lon+=(.5-Math.random())*.003;
|
||||||
|
v.speed=Math.max(0,+(v.speed+(.5-Math.random())*.4).toFixed(1));
|
||||||
|
v.course=+((v.course+(.5-Math.random())*6+360)%360).toFixed(0);
|
||||||
|
});
|
||||||
|
// update pair distances
|
||||||
|
ptPairs.forEach(p=>{p.pt.pairDist=+haversine(p.pt.lat,p.pt.lon,p.pts.lat,p.pts.lon).toFixed(2);p.pts.pairDist=p.pt.pairDist;});
|
||||||
|
|
||||||
|
renderMap();renderVList();
|
||||||
|
document.getElementById('sFish').textContent=allVessels.filter(v=>v.isFishing).length;
|
||||||
|
document.getElementById('sTran').textContent=allVessels.filter(v=>!v.isFishing&&v.speed>3).length;
|
||||||
|
document.getElementById('sPair').textContent=ptPairs.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ INIT ═══
|
||||||
|
renderTypeGrid();renderToggles();renderSpeed();renderRelations();renderVList();renderMap();renderAlarms();renderLegend();
|
||||||
|
document.getElementById('sFish').textContent=allVessels.filter(v=>v.isFishing).length;
|
||||||
|
document.getElementById('sTran').textContent=allVessels.filter(v=>!v.isFishing&&v.speed>3).length;
|
||||||
|
document.getElementById('sPair').textContent=ptPairs.length;
|
||||||
|
setInterval(()=>{document.getElementById('clock').textContent=new Date().toLocaleString('ko-KR',{hour12:false})},1000);
|
||||||
|
setInterval(tick,3000);
|
||||||
|
setInterval(renderAlarms,8000);
|
||||||
|
document.getElementById('clock').textContent=new Date().toLocaleString('ko-KR',{hour12:false});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
legacy/항적점검표_본선_부속선_운반선.xlsx
Normal file
BIN
legacy/항적점검표_본선_부속선_운반선.xlsx
Normal file
Binary file not shown.
15
package.json
Normal file
15
package.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "wing-fleet-dashboard",
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "pnpm@10.29.3",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "pnpm -r --parallel dev",
|
||||||
|
"dev:web": "pnpm --filter @wing/web dev",
|
||||||
|
"dev:api": "pnpm --filter @wing/api dev",
|
||||||
|
"build": "pnpm -r build",
|
||||||
|
"prepare:data": "node scripts/prepare-zones.mjs && node scripts/prepare-legacy.mjs"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
3001
pnpm-lock.yaml
generated
Normal file
3001
pnpm-lock.yaml
generated
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
4
pnpm-workspace.yaml
Normal file
4
pnpm-workspace.yaml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
packages:
|
||||||
|
- "apps/*"
|
||||||
|
- "packages/*"
|
||||||
|
|
||||||
288
scripts/prepare-legacy.mjs
Normal file
288
scripts/prepare-legacy.mjs
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import vm from "node:vm";
|
||||||
|
import xlsx from "xlsx";
|
||||||
|
|
||||||
|
const ROOT = process.cwd();
|
||||||
|
|
||||||
|
const LEGACY_DIR = path.join(ROOT, "legacy");
|
||||||
|
const OUT_DIR = path.join(ROOT, "apps", "web", "public", "data", "legacy");
|
||||||
|
|
||||||
|
const PERMITTED_XLSX = path.join(LEGACY_DIR, "AIS_permitted_Chinese_2025_정리완료_최종.xlsx");
|
||||||
|
const CHECKLIST_XLSX = path.join(LEGACY_DIR, "항적점검표_본선_부속선_운반선.xlsx");
|
||||||
|
const FLEET_906_JSX = path.join(LEGACY_DIR, "선단구성_906척_어업수역 (1).jsx");
|
||||||
|
|
||||||
|
function toStr(x) {
|
||||||
|
return (x ?? "").toString().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumOrNull(x) {
|
||||||
|
const s = toStr(x);
|
||||||
|
if (!s) return null;
|
||||||
|
const n = Number(s);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shipCodeFromChecklistSheet(sheetName) {
|
||||||
|
if (sheetName.includes("본선")) return "PT";
|
||||||
|
if (sheetName.includes("부속선")) return "PT-S";
|
||||||
|
if (sheetName.includes("운반선")) return "FC";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractArrayLiteral(txt, marker) {
|
||||||
|
const markerIdx = txt.indexOf(marker);
|
||||||
|
if (markerIdx < 0) throw new Error(`Marker not found: ${marker}`);
|
||||||
|
const start = txt.indexOf("[", markerIdx);
|
||||||
|
if (start < 0) throw new Error(`'[' not found after marker: ${marker}`);
|
||||||
|
|
||||||
|
let depth = 0;
|
||||||
|
let inString = false;
|
||||||
|
let esc = false;
|
||||||
|
for (let i = start; i < txt.length; i += 1) {
|
||||||
|
const ch = txt[i];
|
||||||
|
if (inString) {
|
||||||
|
if (esc) {
|
||||||
|
esc = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "\\") {
|
||||||
|
esc = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "\"") {
|
||||||
|
inString = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === "\"") {
|
||||||
|
inString = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === "[") depth += 1;
|
||||||
|
if (ch === "]") depth -= 1;
|
||||||
|
if (depth === 0) return txt.slice(start, i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unterminated array literal for marker: ${marker}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPermittedListXlsx(filePath) {
|
||||||
|
const wb = xlsx.readFile(filePath, { cellDates: true });
|
||||||
|
const sheetName = wb.SheetNames[0];
|
||||||
|
const ws = wb.Sheets[sheetName];
|
||||||
|
const rows = xlsx.utils.sheet_to_json(ws, { defval: "", raw: false });
|
||||||
|
|
||||||
|
const byPermitNo = new Map();
|
||||||
|
for (const r of rows) {
|
||||||
|
const permitNo = toStr(r.permit_no);
|
||||||
|
if (!permitNo) continue;
|
||||||
|
const mmsi = toNumOrNull(r.target_id);
|
||||||
|
|
||||||
|
const prev = byPermitNo.get(permitNo);
|
||||||
|
if (prev) {
|
||||||
|
if (mmsi && !prev.mmsiList.includes(mmsi)) prev.mmsiList.push(mmsi);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
byPermitNo.set(permitNo, {
|
||||||
|
permitNo,
|
||||||
|
shipNameRoman: toStr(r.ship_name),
|
||||||
|
ton: toNumOrNull(r.ton),
|
||||||
|
callSign: toStr(r.call_sign),
|
||||||
|
shipPower: toNumOrNull(r.ship_power),
|
||||||
|
shipLen: toNumOrNull(r.ship_len),
|
||||||
|
shipWidth: toNumOrNull(r.ship_width),
|
||||||
|
shipDept: toNumOrNull(r.ship_dept),
|
||||||
|
workSeaArea: toStr(r.work_sea_area),
|
||||||
|
workTerm1: toStr(r.work_term1),
|
||||||
|
workTerm2: toStr(r.work_term2),
|
||||||
|
quota: toStr(r.quota),
|
||||||
|
shipCode: toStr(r.ship_code),
|
||||||
|
mmsiList: mmsi ? [mmsi] : [],
|
||||||
|
sources: { permittedList: true, checklist: false, fleet906: false },
|
||||||
|
ownerCn: null,
|
||||||
|
ownerRoman: null,
|
||||||
|
shipNameCn: null,
|
||||||
|
pairPermitNo: null,
|
||||||
|
pairShipNameCn: null,
|
||||||
|
checklistSheet: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(byPermitNo.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
function readChecklistXlsx(filePath) {
|
||||||
|
const wb = xlsx.readFile(filePath, { cellDates: true });
|
||||||
|
const out = [];
|
||||||
|
|
||||||
|
for (const sheetName of wb.SheetNames) {
|
||||||
|
const code = shipCodeFromChecklistSheet(sheetName);
|
||||||
|
if (!code) continue;
|
||||||
|
const ws = wb.Sheets[sheetName];
|
||||||
|
const rows = xlsx.utils.sheet_to_json(ws, { defval: "", raw: false, range: 2 });
|
||||||
|
|
||||||
|
for (const r of rows) {
|
||||||
|
const permitNo = toStr(r["허가번호"]);
|
||||||
|
if (!permitNo) continue;
|
||||||
|
out.push({
|
||||||
|
permitNo,
|
||||||
|
shipCode: code,
|
||||||
|
shipNameCn: toStr(r["선박명\n(중국)"]),
|
||||||
|
shipNameRoman: toStr(r["선박명\n(로마)"]),
|
||||||
|
ton: toNumOrNull(r["톤수"]),
|
||||||
|
ownerCn: toStr(r["소유주\n(중국)"]),
|
||||||
|
ownerRoman: toStr(r["소유주\n(로마)"]),
|
||||||
|
pairPermitNo: toStr(r["짝 허가번호"]),
|
||||||
|
pairShipNameCn: toStr(r["짝 선박명"]),
|
||||||
|
workSeaArea: toStr(r["허가수역"]),
|
||||||
|
checklistSheet: sheetName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readFleet906(filePath) {
|
||||||
|
const txt = await fs.readFile(filePath, "utf-8");
|
||||||
|
const arrLit = extractArrayLiteral(txt, "const D=");
|
||||||
|
const D = vm.runInNewContext(`(${arrLit})`, {}, { timeout: 10_000 });
|
||||||
|
if (!Array.isArray(D)) throw new Error("Fleet906 D is not an array");
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
|
||||||
|
function pushVessel(v, shipCode, own, ownEn, extra = {}) {
|
||||||
|
if (!Array.isArray(v) || v.length < 4) return;
|
||||||
|
out.push({
|
||||||
|
permitNo: toStr(v[0]),
|
||||||
|
shipNameCn: toStr(v[1]),
|
||||||
|
shipNameRoman: toStr(v[2]),
|
||||||
|
ton: toNumOrNull(v[3]),
|
||||||
|
shipCode,
|
||||||
|
ownerCn: toStr(own),
|
||||||
|
ownerRoman: toStr(ownEn),
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const r of D) {
|
||||||
|
if (!Array.isArray(r) || r.length < 9) continue;
|
||||||
|
const [own, ownEn, pairs, gn, ot, ps, fc, upt, upts] = r;
|
||||||
|
|
||||||
|
if (Array.isArray(pairs)) {
|
||||||
|
for (const p of pairs) {
|
||||||
|
if (!Array.isArray(p) || p.length < 2) continue;
|
||||||
|
const main = p[0];
|
||||||
|
const sub = p[1];
|
||||||
|
const mainPermitNo = Array.isArray(main) ? toStr(main[0]) : "";
|
||||||
|
const subPermitNo = Array.isArray(sub) ? toStr(sub[0]) : "";
|
||||||
|
|
||||||
|
pushVessel(main, "PT", own, ownEn, { pairPermitNo: subPermitNo });
|
||||||
|
pushVessel(sub, "PT-S", own, ownEn, { pairPermitNo: mainPermitNo });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(gn)) for (const v of gn) pushVessel(v, "GN", own, ownEn);
|
||||||
|
if (Array.isArray(ot)) for (const v of ot) pushVessel(v, "OT", own, ownEn);
|
||||||
|
if (Array.isArray(ps)) for (const v of ps) pushVessel(v, "PS", own, ownEn);
|
||||||
|
if (Array.isArray(fc)) for (const v of fc) pushVessel(v, "FC", own, ownEn);
|
||||||
|
if (Array.isArray(upt)) for (const v of upt) pushVessel(v, "PT", own, ownEn);
|
||||||
|
if (Array.isArray(upts)) for (const v of upts) pushVessel(v, "PT-S", own, ownEn);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const permitted = readPermittedListXlsx(PERMITTED_XLSX);
|
||||||
|
const checklist = readChecklistXlsx(CHECKLIST_XLSX);
|
||||||
|
const fleet906 = await readFleet906(FLEET_906_JSX);
|
||||||
|
|
||||||
|
const byPermitNo = new Map();
|
||||||
|
for (const v of permitted) byPermitNo.set(v.permitNo, v);
|
||||||
|
|
||||||
|
for (const c of checklist) {
|
||||||
|
const prev = byPermitNo.get(c.permitNo);
|
||||||
|
if (!prev) {
|
||||||
|
byPermitNo.set(c.permitNo, {
|
||||||
|
permitNo: c.permitNo,
|
||||||
|
shipNameRoman: c.shipNameRoman,
|
||||||
|
ton: c.ton,
|
||||||
|
callSign: "",
|
||||||
|
shipPower: null,
|
||||||
|
shipLen: null,
|
||||||
|
shipWidth: null,
|
||||||
|
shipDept: null,
|
||||||
|
workSeaArea: c.workSeaArea,
|
||||||
|
workTerm1: "",
|
||||||
|
workTerm2: "",
|
||||||
|
quota: "",
|
||||||
|
shipCode: c.shipCode,
|
||||||
|
mmsiList: [],
|
||||||
|
sources: { permittedList: false, checklist: true, fleet906: false },
|
||||||
|
ownerCn: c.ownerCn || null,
|
||||||
|
ownerRoman: c.ownerRoman || null,
|
||||||
|
shipNameCn: c.shipNameCn || null,
|
||||||
|
pairPermitNo: c.pairPermitNo || null,
|
||||||
|
pairShipNameCn: c.pairShipNameCn || null,
|
||||||
|
checklistSheet: c.checklistSheet || null,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
prev.sources.checklist = true;
|
||||||
|
prev.shipCode = prev.shipCode || c.shipCode;
|
||||||
|
prev.shipNameCn = prev.shipNameCn || c.shipNameCn || null;
|
||||||
|
prev.shipNameRoman = prev.shipNameRoman || c.shipNameRoman || "";
|
||||||
|
prev.ton = prev.ton ?? c.ton ?? null;
|
||||||
|
prev.ownerCn = prev.ownerCn || c.ownerCn || null;
|
||||||
|
prev.ownerRoman = prev.ownerRoman || c.ownerRoman || null;
|
||||||
|
prev.pairPermitNo = prev.pairPermitNo || c.pairPermitNo || null;
|
||||||
|
prev.pairShipNameCn = prev.pairShipNameCn || c.pairShipNameCn || null;
|
||||||
|
prev.workSeaArea = prev.workSeaArea || c.workSeaArea || "";
|
||||||
|
prev.checklistSheet = prev.checklistSheet || c.checklistSheet || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const f of fleet906) {
|
||||||
|
const prev = byPermitNo.get(f.permitNo);
|
||||||
|
if (!prev) continue;
|
||||||
|
prev.sources.fleet906 = true;
|
||||||
|
prev.shipNameCn = prev.shipNameCn || f.shipNameCn || null;
|
||||||
|
prev.shipNameRoman = prev.shipNameRoman || f.shipNameRoman || "";
|
||||||
|
prev.ton = prev.ton ?? f.ton ?? null;
|
||||||
|
prev.shipCode = prev.shipCode || f.shipCode || "";
|
||||||
|
prev.ownerCn = prev.ownerCn || f.ownerCn || null;
|
||||||
|
prev.ownerRoman = prev.ownerRoman || f.ownerRoman || null;
|
||||||
|
prev.pairPermitNo = prev.pairPermitNo || f.pairPermitNo || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vessels = Array.from(byPermitNo.values()).filter((v) => v && v.permitNo);
|
||||||
|
|
||||||
|
const out = {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
counts: {
|
||||||
|
permittedList: permitted.length,
|
||||||
|
checklist: checklist.length,
|
||||||
|
fleet906: fleet906.length,
|
||||||
|
merged: vessels.length,
|
||||||
|
},
|
||||||
|
vessels,
|
||||||
|
};
|
||||||
|
|
||||||
|
const outPath = path.join(OUT_DIR, "chinese-permitted.v1.json");
|
||||||
|
await fs.writeFile(outPath, JSON.stringify(out), "utf-8");
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`Wrote ${vessels.length} vessels -> ${path.relative(ROOT, outPath)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
96
scripts/prepare-zones.mjs
Normal file
96
scripts/prepare-zones.mjs
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const ROOT = process.cwd();
|
||||||
|
|
||||||
|
const ZONES = [
|
||||||
|
{ zoneId: "1", file: "특정어업수역Ⅰ.json", label: "Ⅰ", name: "수역I(동해)" },
|
||||||
|
{ zoneId: "2", file: "특정어업수역Ⅱ.json", label: "Ⅱ", name: "수역II(제주남방)" },
|
||||||
|
{ zoneId: "3", file: "특정어업수역Ⅲ.json", label: "Ⅲ", name: "수역III(서해남부)" },
|
||||||
|
{ zoneId: "4", file: "특정어업수역Ⅳ.json", label: "Ⅳ", name: "수역IV(서해중간)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Inverse Web Mercator (EPSG:3857) -> WGS84 lon/lat
|
||||||
|
function mercatorToLonLat(x, y) {
|
||||||
|
const R = 6378137;
|
||||||
|
const lon = (x / R) * (180 / Math.PI);
|
||||||
|
const lat = (2 * Math.atan(Math.exp(y / R)) - Math.PI / 2) * (180 / Math.PI);
|
||||||
|
return [lon, lat];
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeLonLat(x, y) {
|
||||||
|
return Math.abs(x) <= 180 && Math.abs(y) <= 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertPoint(pt) {
|
||||||
|
if (!Array.isArray(pt) || pt.length < 2) return pt;
|
||||||
|
const x = pt[0];
|
||||||
|
const y = pt[1];
|
||||||
|
if (typeof x !== "number" || typeof y !== "number") return pt;
|
||||||
|
if (looksLikeLonLat(x, y)) return [x, y];
|
||||||
|
return mercatorToLonLat(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertCoords(coords) {
|
||||||
|
if (!Array.isArray(coords)) return coords;
|
||||||
|
if (coords.length === 0) return coords;
|
||||||
|
|
||||||
|
// Point: [x, y]
|
||||||
|
if (typeof coords[0] === "number") return convertPoint(coords);
|
||||||
|
|
||||||
|
// Nested arrays
|
||||||
|
return coords.map(convertCoords);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const rawDir = path.join(ROOT, "data", "raw", "zones");
|
||||||
|
const outDir = path.join(ROOT, "apps", "web", "public", "data", "zones");
|
||||||
|
await fs.mkdir(outDir, { recursive: true });
|
||||||
|
|
||||||
|
const features = [];
|
||||||
|
for (const z of ZONES) {
|
||||||
|
const rawPath = path.join(rawDir, z.file);
|
||||||
|
const txt = await fs.readFile(rawPath, "utf-8");
|
||||||
|
const fc = JSON.parse(txt);
|
||||||
|
if (!fc || fc.type !== "FeatureCollection" || !Array.isArray(fc.features)) {
|
||||||
|
throw new Error(`Unexpected GeoJSON in ${rawPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const f of fc.features) {
|
||||||
|
if (!f?.geometry?.coordinates) continue;
|
||||||
|
const geometry = {
|
||||||
|
...f.geometry,
|
||||||
|
coordinates: convertCoords(f.geometry.coordinates),
|
||||||
|
};
|
||||||
|
|
||||||
|
features.push({
|
||||||
|
...f,
|
||||||
|
properties: {
|
||||||
|
...(f.properties || {}),
|
||||||
|
zoneId: z.zoneId,
|
||||||
|
zoneLabel: z.label,
|
||||||
|
zoneName: z.name,
|
||||||
|
},
|
||||||
|
geometry,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
name: "zones.wgs84",
|
||||||
|
features,
|
||||||
|
};
|
||||||
|
|
||||||
|
const outPath = path.join(outDir, "zones.wgs84.geojson");
|
||||||
|
await fs.writeFile(outPath, JSON.stringify(out), "utf-8");
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`Wrote ${features.length} features -> ${path.relative(ROOT, outPath)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
불러오는 중...
Reference in New Issue
Block a user