gc-wing/legacy/조업감시_선단연관_대시보드.html

640 lines
38 KiB
HTML
Raw Normal View 히스토리

2026-02-15 11:22:38 +09:00
<!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>