965 lines
44 KiB
HTML
965 lines
44 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>装箱区信息看板</title>
|
||
<style>
|
||
:root {
|
||
color-scheme: dark;
|
||
--bg: #0d0d0d;
|
||
--panel: rgba(255, 255, 255, .03);
|
||
--panel-2: rgba(255, 255, 255, .025);
|
||
--line: rgba(255, 255, 255, .07);
|
||
--cyan: #31d7ed;
|
||
--blue: #2a91ff;
|
||
--green: #42df91;
|
||
--yellow: #ffd35a;
|
||
--red: #ff6574;
|
||
--purple: #b28cff;
|
||
--text: #eef8ff;
|
||
--muted: #898781;
|
||
--agv: rgba(242, 250, 255, .92);
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
|
||
body {
|
||
color: var(--text);
|
||
background: #0d0d0d;
|
||
font-family: "Microsoft YaHei", "PingFang SC", system-ui, sans-serif;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
button, input { font: inherit; }
|
||
|
||
#stage {
|
||
position: relative;
|
||
width: 1920px;
|
||
height: 1080px;
|
||
flex: 0 0 auto;
|
||
overflow: hidden;
|
||
padding: 20px 24px 16px;
|
||
transform-origin: center center;
|
||
background:
|
||
radial-gradient(1200px 500px at 50% -10%, rgba(25, 110, 161, .28), transparent 60%),
|
||
linear-gradient(180deg, #0a1424 0%, #0d1424 45%, #0d1220 100%);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
#stage::before {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
background-image:
|
||
linear-gradient(rgba(90, 140, 190, .05) 1px, transparent 1px),
|
||
linear-gradient(90deg, rgba(90, 140, 190, .05) 1px, transparent 1px);
|
||
background-size: 48px 48px;
|
||
}
|
||
#stage::after {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
box-shadow: inset 0 0 100px rgba(0, 0, 0, .28);
|
||
}
|
||
#stage > * { position: relative; z-index: 1; }
|
||
|
||
.header {
|
||
height: 58px;
|
||
display: grid;
|
||
grid-template-columns: 350px 1fr 350px;
|
||
align-items: center;
|
||
}
|
||
.brand { display: flex; align-items: center; gap: 12px; }
|
||
.brand-mark {
|
||
position: relative;
|
||
width: 40px;
|
||
height: 40px;
|
||
border: 0;
|
||
border-radius: 10px;
|
||
background: linear-gradient(135deg, #10aeff, #007aff);
|
||
box-shadow: none;
|
||
}
|
||
.brand-mark::before, .brand-mark::after {
|
||
content: "箱";
|
||
position: absolute;
|
||
inset: 0;
|
||
display: grid;
|
||
place-items: center;
|
||
border: 0;
|
||
color: #fff;
|
||
font-size: 18px;
|
||
font-weight: 800;
|
||
}
|
||
.brand-mark::after { display: none; }
|
||
.brand-cn { font-size: 17px; font-weight: 700; letter-spacing: 1px; }
|
||
.brand-en { margin-top: 2px; color: #898781; font-size: 10px; letter-spacing: 2.4px; }
|
||
h1 {
|
||
margin: 0;
|
||
text-align: center;
|
||
font-size: 30px;
|
||
font-weight: 700;
|
||
letter-spacing: 8px;
|
||
background: linear-gradient(180deg, #ffffff, #9ed0ff);
|
||
-webkit-background-clip: text;
|
||
background-clip: text;
|
||
color: transparent;
|
||
text-shadow: none;
|
||
}
|
||
.clock { text-align: right; font-variant-numeric: tabular-nums; }
|
||
#clockTime { font-size: 25px; font-weight: 750; letter-spacing: 2px; }
|
||
#clockDate { margin-top: 2px; color: var(--muted); font-size: 12px; letter-spacing: 1px; }
|
||
|
||
.kpis { height: 70px; display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; }
|
||
.kpi, .toolbar, .map-panel, .side-panel {
|
||
border: 1px solid rgba(255, 255, 255, .07);
|
||
background: rgba(255, 255, 255, .03);
|
||
box-shadow: none;
|
||
}
|
||
.kpi {
|
||
position: relative;
|
||
overflow: hidden;
|
||
padding: 10px 18px;
|
||
border-radius: 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
}
|
||
.kpi::before { display: none; }
|
||
.kpi-icon {
|
||
width: 10px;
|
||
height: 42px;
|
||
display: block;
|
||
flex: none;
|
||
overflow: hidden;
|
||
border: 0;
|
||
border-radius: 5px;
|
||
color: transparent;
|
||
background: var(--accent);
|
||
font-size: 0;
|
||
box-shadow: none;
|
||
}
|
||
.kpi-value { font-size: 25px; font-weight: 800; line-height: 1; font-variant-numeric: tabular-nums; }
|
||
.kpi-value small { margin-left: 3px; color: var(--muted); font-size: 12px; font-weight: 400; }
|
||
.kpi-label { margin-top: 5px; color: #9eb8ca; font-size: 12px; }
|
||
|
||
.toolbar {
|
||
min-height: 55px;
|
||
padding: 8px 12px;
|
||
border-radius: 9px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 18px;
|
||
}
|
||
.legend { display: flex; align-items: center; gap: 12px; flex-wrap: nowrap; }
|
||
.legend-title { color: #b7d6e9; font-size: 12px; font-weight: 700; }
|
||
.legend-item { display: inline-flex; align-items: center; gap: 5px; color: #aac2d4; font-size: 11px; white-space: nowrap; }
|
||
.legend-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--c); }
|
||
.legend-dot.alert { box-shadow: 0 0 8px var(--c); }
|
||
.toolbar-spacer { flex: 1; }
|
||
.control { display: flex; align-items: center; gap: 7px; color: #9db8cb; font-size: 12px; }
|
||
.control input {
|
||
height: 31px;
|
||
padding: 0 9px;
|
||
border: 1px solid #315a79;
|
||
border-radius: 5px;
|
||
outline: none;
|
||
color: #eaf9ff;
|
||
background: rgba(3, 14, 27, .72);
|
||
}
|
||
.control input:focus { border-color: var(--cyan); }
|
||
#searchInput { width: 190px; }
|
||
#thresholdInput, #zoomInput { width: 62px; text-align: center; }
|
||
.btn {
|
||
height: 31px;
|
||
padding: 0 13px;
|
||
border: 1px solid #287ca8;
|
||
border-radius: 5px;
|
||
color: #def8ff;
|
||
background: linear-gradient(180deg, #11618a, #0a4669);
|
||
cursor: pointer;
|
||
transition: .18s ease;
|
||
}
|
||
.btn:hover { border-color: #50dcf2; filter: brightness(1.13); }
|
||
.btn.secondary { border-color: #365b78; background: #102c45; }
|
||
#searchMessage { min-width: 105px; font-size: 11px; }
|
||
#searchMessage.ok { color: #55e89a; }
|
||
#searchMessage.error { color: #ff7c89; }
|
||
|
||
.content { flex: 1; min-height: 0; display: grid; grid-template-columns: 1fr 342px; gap: 12px; }
|
||
.map-panel { min-width: 0; padding: 10px; border-radius: 10px; display: flex; flex-direction: column; }
|
||
.panel-head { height: 28px; display: flex; align-items: center; justify-content: space-between; padding: 0 4px 8px; }
|
||
.panel-title { color: #bcecff; font-size: 14px; font-weight: 700; letter-spacing: 1px; }
|
||
.panel-hint { color: #668aa3; font-size: 10px; }
|
||
.map-viewport {
|
||
position: relative;
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
cursor: grab;
|
||
touch-action: none;
|
||
border: 1px solid rgba(255, 255, 255, .06);
|
||
border-radius: 10px;
|
||
background:
|
||
linear-gradient(rgba(90, 140, 190, .04) 1px, transparent 1px),
|
||
linear-gradient(90deg, rgba(90, 140, 190, .04) 1px, transparent 1px),
|
||
rgba(4, 13, 25, .68);
|
||
background-size: 36px 36px;
|
||
}
|
||
.map-viewport.dragging { cursor: grabbing; }
|
||
.map-canvas {
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
width: 1450px;
|
||
height: 1590px;
|
||
transform-origin: top left;
|
||
will-change: transform;
|
||
user-select: none;
|
||
}
|
||
.map-canvas::before {
|
||
content: "装箱车间 / PACKING WORKSHOP";
|
||
position: absolute;
|
||
left: 18px;
|
||
top: 415px;
|
||
color: rgba(90, 206, 238, .3);
|
||
font-size: 10px;
|
||
letter-spacing: 3px;
|
||
writing-mode: vertical-rl;
|
||
}
|
||
|
||
.north-arrow {
|
||
position: absolute;
|
||
z-index: 8;
|
||
right: 18px;
|
||
top: 16px;
|
||
width: 58px;
|
||
height: 72px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
color: #c8f6ff;
|
||
font-size: 10px;
|
||
letter-spacing: 1px;
|
||
text-shadow: none;
|
||
}
|
||
.north-arrow b { font-size: 32px; line-height: 32px; color: #ecfdff; }
|
||
|
||
.floor-zone {
|
||
position: absolute;
|
||
left: 65px;
|
||
width: 1320px;
|
||
padding: 14px 12px 10px;
|
||
border: 2px solid var(--zone-color);
|
||
border-radius: 8px;
|
||
background: color-mix(in srgb, var(--zone-color) 4%, rgba(3, 13, 25, .7));
|
||
box-shadow: inset 0 0 16px rgba(24, 118, 174, .06);
|
||
}
|
||
.floor-zone::before, .floor-zone::after {
|
||
content: "";
|
||
position: absolute;
|
||
width: 13px;
|
||
height: 13px;
|
||
border: 2px solid var(--zone-color);
|
||
background: #06111f;
|
||
}
|
||
.floor-zone::before { left: -8px; top: -8px; border-right: 0; border-bottom: 0; }
|
||
.floor-zone::after { right: -8px; bottom: -8px; border-left: 0; border-top: 0; }
|
||
.zone-label {
|
||
position: absolute;
|
||
z-index: 5;
|
||
left: 14px;
|
||
top: -13px;
|
||
height: 24px;
|
||
padding: 3px 12px;
|
||
border: 1px solid var(--zone-color);
|
||
border-radius: 12px;
|
||
color: #effcff;
|
||
background: color-mix(in srgb, var(--zone-color) 38%, #071526);
|
||
box-shadow: 0 3px 10px rgba(0, 0, 0, .4);
|
||
font-size: 11px;
|
||
font-weight: 800;
|
||
letter-spacing: 1px;
|
||
}
|
||
.zone-count { margin-left: 7px; color: rgba(255, 255, 255, .68); font-weight: 400; }
|
||
.zone-north { top: 28px; height: 182px; --zone-color: #2ca9c2; }
|
||
.zone-center { top: 265px; height: 950px; --zone-color: #3578b7; }
|
||
.zone-south { top: 1270px; height: 290px; --zone-color: #367e9d; }
|
||
|
||
.slot-grid {
|
||
height: 100%;
|
||
display: grid;
|
||
grid-template-columns: 150px 150px 54px 150px 150px 54px 150px 150px;
|
||
gap: 8px;
|
||
justify-content: center;
|
||
}
|
||
.zone-center .slot-grid { grid-template-rows: repeat(7, 124px); row-gap: 8px; }
|
||
.zone-north .slot-grid { grid-template-columns: repeat(3, 150px) 54px repeat(3, 150px); grid-template-rows: 150px; }
|
||
.agv-column {
|
||
position: relative;
|
||
grid-row: 1 / -1;
|
||
border-left: 2px solid var(--agv);
|
||
border-right: 2px solid var(--agv);
|
||
background: repeating-linear-gradient(180deg, transparent 0 14px, rgba(236, 250, 255, .08) 14px 28px);
|
||
box-shadow: inset 0 0 8px rgba(238, 252, 255, .05);
|
||
}
|
||
.agv-column::after {
|
||
content: "↑ AGV ↓";
|
||
position: absolute;
|
||
left: 50%;
|
||
top: 50%;
|
||
transform: translate(-50%, -50%);
|
||
color: rgba(235, 250, 255, .75);
|
||
font-size: 9px;
|
||
letter-spacing: 4px;
|
||
writing-mode: vertical-rl;
|
||
}
|
||
.agv-main {
|
||
position: absolute;
|
||
left: 65px;
|
||
width: 1320px;
|
||
height: 30px;
|
||
border-top: 2px solid var(--agv);
|
||
border-bottom: 2px solid var(--agv);
|
||
background: repeating-linear-gradient(90deg, transparent 0 22px, rgba(236, 250, 255, .07) 22px 44px);
|
||
box-shadow: none;
|
||
color: rgba(238, 251, 255, .72);
|
||
font-size: 9px;
|
||
line-height: 26px;
|
||
text-align: center;
|
||
letter-spacing: 14px;
|
||
}
|
||
.agv-main.one { top: 220px; }
|
||
.agv-main.two { top: 1225px; }
|
||
|
||
.south-grid {
|
||
height: 100%;
|
||
display: grid;
|
||
grid-template-columns: repeat(8, 125px) 54px 158px;
|
||
grid-template-rows: repeat(2, 124px);
|
||
gap: 10px 8px;
|
||
justify-content: center;
|
||
}
|
||
.south-grid .agv-column { grid-column: 9; grid-row: 1 / 3; }
|
||
|
||
.slot {
|
||
position: relative;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
padding: 8px 9px 7px;
|
||
border: 1px solid var(--slot-border);
|
||
border-radius: 5px;
|
||
color: #eaf7ff;
|
||
background: linear-gradient(150deg, var(--slot-top), var(--slot-bottom));
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .06), 0 3px 7px rgba(0, 0, 0, .25);
|
||
cursor: pointer;
|
||
transition: transform .16s ease, filter .16s ease, box-shadow .16s ease;
|
||
}
|
||
.slot::after {
|
||
content: "";
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
height: 3px;
|
||
background: var(--state-color);
|
||
box-shadow: none;
|
||
}
|
||
.slot:hover { z-index: 5; transform: translateY(-2px); filter: brightness(1.12); }
|
||
.slot.selected {
|
||
z-index: 6;
|
||
border-color: #fff;
|
||
outline: 2px solid rgba(221, 246, 255, .8);
|
||
outline-offset: 1px;
|
||
}
|
||
.slot.search-hit { animation: search-pulse .7s ease-in-out infinite alternate; }
|
||
@keyframes search-pulse {
|
||
from { outline-color: rgba(178, 225, 245, .65); }
|
||
to { outline-color: #ffffff; filter: brightness(1.12); }
|
||
}
|
||
.slot-head { min-height: 16px; padding-right: 14px; display: flex; align-items: center; }
|
||
.slot-code { overflow: hidden; color: #fff; font-size: 12px; font-weight: 800; letter-spacing: .3px; text-overflow: ellipsis; white-space: nowrap; }
|
||
.slot-state-dot {
|
||
position: absolute;
|
||
right: 8px;
|
||
top: 8px;
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
background: var(--state-color);
|
||
box-shadow: none;
|
||
}
|
||
.slot-name {
|
||
height: 17px;
|
||
margin-top: 3px;
|
||
padding: 2px 5px;
|
||
overflow: hidden;
|
||
border-radius: 3px;
|
||
color: #b8d8ed;
|
||
background: rgba(0, 0, 0, .2);
|
||
font-size: 8px;
|
||
line-height: 13px;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.slot-box-panel {
|
||
min-height: 28px;
|
||
margin-top: 4px;
|
||
padding: 3px 5px;
|
||
overflow: hidden;
|
||
border: 1px solid rgba(142, 179, 205, .28);
|
||
border-radius: 4px;
|
||
background: rgba(0, 0, 0, .22);
|
||
}
|
||
.slot-box-panel.has-box { border-color: rgba(70, 151, 190, .7); background: rgba(20, 68, 94, .45); }
|
||
.slot-box-label { display: block; color: #8faec4; font-size: 7px; line-height: 9px; }
|
||
.slot-box-panel.has-box .slot-box-label { color: #87c9e5; }
|
||
.slot-box-value { display: block; margin-top: 1px; overflow: hidden; color: #dbeaf4; font-size: 9px; font-weight: 700; line-height: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||
.slot-box-panel.has-box .slot-box-value { color: #dcf4ff; }
|
||
.slot-info-row { min-height: 15px; display: flex; align-items: center; justify-content: space-between; gap: 4px; color: #91adc1; font-size: 8px; }
|
||
.slot-info-label { flex: none; }
|
||
.slot-info-value { min-width: 0; overflow: hidden; color: #f1f8fc; font-weight: 700; text-align: right; text-overflow: ellipsis; white-space: nowrap; }
|
||
.slot-info-value.roll-no { color: #b9dff7; }
|
||
.slot-stock {
|
||
flex: none;
|
||
padding: 1px 5px;
|
||
border: 1px solid var(--state-color);
|
||
border-radius: 8px;
|
||
color: var(--state-color);
|
||
background: color-mix(in srgb, var(--state-color) 10%, transparent);
|
||
font-size: 7px;
|
||
font-weight: 700;
|
||
line-height: 11px;
|
||
white-space: nowrap;
|
||
}
|
||
.slot-stock::before { content: ""; display: inline-block; width: 5px; height: 5px; margin-right: 3px; border-radius: 50%; background: currentColor; vertical-align: 0; }
|
||
.slot.empty { --slot-top: rgba(23, 54, 79, .63); --slot-bottom: rgba(8, 28, 46, .62); --slot-border: #345974; opacity: .78; border-style: dashed; }
|
||
.slot.empty .slot-info-value.roll-no { color: #b9dff7; }
|
||
.slot.carrier { --slot-top: #153c51; --slot-bottom: #0b2636; --slot-border: #356b85; }
|
||
.slot.roll { --slot-top: #183d63; --slot-bottom: #0c2844; --slot-border: #426f9b; }
|
||
.slot.qualified { --slot-top: #16444d; --slot-bottom: #0b2b34; --slot-border: #3c747d; }
|
||
.slot.controlled { --slot-top: #762536; --slot-bottom: #3b111c; --slot-border: #f05a70; box-shadow: 0 0 12px rgba(255, 83, 100, .45); }
|
||
.slot.overdue { --slot-top: #8a2935; --slot-bottom: #430f18; --slot-border: #ff6574; animation: overdue-pulse 1.8s ease-in-out infinite; }
|
||
.slot.controlled::after, .slot.overdue::after { box-shadow: 0 0 8px #ff6574; }
|
||
.slot.controlled .slot-state-dot, .slot.overdue .slot-state-dot { box-shadow: 0 0 7px #ff6574; }
|
||
.slot.controlled .slot-stock::before, .slot.overdue .slot-stock::before { box-shadow: 0 0 4px #ff6574; }
|
||
@keyframes overdue-pulse { 50% { filter: brightness(1.18); box-shadow: 0 0 13px rgba(255, 83, 100, .55); } }
|
||
.dock-slot { padding: 8px 9px; border-width: 2px; }
|
||
.dock-slot .slot-code { color: #fff2c2; font-size: 11px; }
|
||
|
||
.side-panel { min-height: 0; padding: 12px; border-radius: 10px; display: flex; flex-direction: column; gap: 10px; }
|
||
.side-title { display: flex; align-items: center; justify-content: space-between; color: #c0ecff; font-size: 14px; font-weight: 750; }
|
||
.demo-tag { padding: 3px 8px; border: 1px solid #3f6f95; border-radius: 10px; color: #9fc9e5; background: rgba(45, 101, 142, .14); font-size: 9px; letter-spacing: 1px; }
|
||
.detail-card {
|
||
padding: 12px;
|
||
border: 1px solid rgba(74, 150, 204, .35);
|
||
border-radius: 8px;
|
||
background: rgba(5, 20, 36, .72);
|
||
}
|
||
.detail-code { color: #fff; font-size: 18px; font-weight: 800; letter-spacing: 1px; }
|
||
.detail-name { margin-top: 3px; color: #75b9df; font-size: 11px; }
|
||
.detail-status { margin: 10px 0 5px; display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; border: 1px solid var(--detail-color); border-radius: 11px; color: var(--detail-color); font-size: 10px; }
|
||
.detail-status i { width: 7px; height: 7px; border-radius: 50%; background: var(--detail-color); }
|
||
.detail-card.alert .detail-status i { box-shadow: 0 0 7px #ff6574; }
|
||
.detail-rows { margin-top: 7px; border-top: 1px solid rgba(87, 143, 182, .2); }
|
||
.detail-row { min-height: 31px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid rgba(87, 143, 182, .13); font-size: 11px; }
|
||
.detail-row span:first-child { flex: none; color: #779ab3; }
|
||
.detail-row b { min-width: 0; color: #e9f7ff; text-align: right; overflow-wrap: anywhere; }
|
||
.detail-row b.em { color: #ffe594; }
|
||
|
||
.queue-wrap { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 8px; }
|
||
.queue-group { flex: 1; min-height: 0; padding: 7px 8px 5px; border: 1px solid rgba(67, 131, 172, .28); border-radius: 7px; background: rgba(5, 20, 36, .5); display: flex; flex-direction: column; }
|
||
.queue-group.dock-02 { border-color: rgba(54, 126, 157, .34); }
|
||
.queue-head { margin: 0 0 6px; display: flex; justify-content: space-between; color: #91cce9; font-size: 11px; }
|
||
.queue-head b { color: #d9f2ff; font-weight: 750; }
|
||
.queue-head small { margin-left: 5px; color: #638ba5; font-size: 8px; font-weight: 400; }
|
||
.queue-list { flex: 1; min-height: 0; margin: 0; padding: 0; overflow: auto; list-style: none; }
|
||
.queue-list::-webkit-scrollbar { width: 5px; }
|
||
.queue-list::-webkit-scrollbar-thumb { border-radius: 3px; background: #27516d; }
|
||
.queue-item {
|
||
margin-bottom: 6px;
|
||
padding: 7px 8px;
|
||
border: 1px solid rgba(83, 137, 175, .25);
|
||
border-radius: 6px;
|
||
background: rgba(8, 27, 46, .72);
|
||
display: grid;
|
||
grid-template-columns: 25px 1fr auto;
|
||
align-items: center;
|
||
gap: 7px;
|
||
}
|
||
.queue-no { width: 22px; height: 22px; display: grid; place-items: center; border-radius: 5px; color: #77d7eb; background: rgba(35, 213, 232, .11); font-size: 9px; font-weight: 800; }
|
||
.queue-box { color: #f3fbff; font-size: 10px; font-weight: 700; }
|
||
.queue-point { margin-top: 2px; color: #6f96ae; font-size: 9px; }
|
||
.queue-time { color: #ffdb73; font-size: 9px; }
|
||
.zone-mini { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
|
||
.zone-mini-item { padding: 7px 5px; border: 1px solid rgba(74, 137, 179, .22); border-radius: 6px; background: rgba(6, 24, 41, .62); text-align: center; }
|
||
.zone-mini-item b { display: block; color: #ecf9ff; font-size: 15px; }
|
||
.zone-mini-item span { color: #7197af; font-size: 8px; }
|
||
|
||
.context-menu {
|
||
position: fixed;
|
||
z-index: 999;
|
||
display: none;
|
||
width: 162px;
|
||
padding: 6px;
|
||
border: 1px solid #3f7da3;
|
||
border-radius: 7px;
|
||
background: #081a2b;
|
||
box-shadow: 0 14px 34px rgba(0, 0, 0, .55);
|
||
}
|
||
.context-menu.open { display: block; }
|
||
.context-title { padding: 5px 8px 7px; border-bottom: 1px solid #24445d; color: #8bd8ef; font-size: 10px; }
|
||
.context-action { width: 100%; margin-top: 4px; padding: 7px 8px; border: 0; border-radius: 4px; color: #eaf7ff; background: transparent; text-align: left; cursor: pointer; font-size: 11px; }
|
||
.context-action:hover:not(:disabled) { background: #104463; }
|
||
.context-action:disabled { color: #526779; cursor: not-allowed; }
|
||
.toast { position: fixed; z-index: 1000; left: 50%; bottom: 28px; display: none; padding: 8px 14px; transform: translateX(-50%); border: 1px solid #3fba7e; border-radius: 6px; color: #dcffeb; background: #0f5b38; box-shadow: 0 6px 20px rgba(0, 0, 0, .45); font-size: 11px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main id="stage">
|
||
<header class="header">
|
||
<div class="brand">
|
||
<div class="brand-mark" aria-hidden="true"></div>
|
||
<div><div class="brand-cn">智能制造物流中心</div><div class="brand-en">SMART LOGISTICS CENTER</div></div>
|
||
</div>
|
||
<h1>装箱区信息看板</h1>
|
||
<div class="clock"><div id="clockTime">--:--:--</div><div id="clockDate">----</div></div>
|
||
</header>
|
||
|
||
<section class="kpis" aria-label="汇总指标">
|
||
<article class="kpi" style="--accent:#37b8d1"><div class="kpi-icon">Σ</div><div><div id="kpiTotal" class="kpi-value">66<small>个</small></div><div class="kpi-label">规划点位总数</div></div></article>
|
||
<article class="kpi" style="--accent:#3f91c6"><div class="kpi-icon">IN</div><div><div id="kpiOccupied" class="kpi-value">0<small>个</small></div><div class="kpi-label">当前占用点位</div></div></article>
|
||
<article class="kpi" style="--accent:#668ba8"><div class="kpi-icon">E</div><div><div id="kpiEmpty" class="kpi-value">0<small>个</small></div><div class="kpi-label">空闲点位</div></div></article>
|
||
<article class="kpi" style="--accent:#ff6574"><div class="kpi-icon">!</div><div><div id="kpiOverdue" class="kpi-value">0<small>个</small></div><div class="kpi-label">超时滞留点位</div></div></article>
|
||
<article class="kpi" style="--accent:#4e7fb8"><div class="kpi-icon">%</div><div><div id="kpiRate" class="kpi-value">0<small>%</small></div><div class="kpi-label">综合点位占用率</div></div></article>
|
||
</section>
|
||
|
||
<section class="toolbar">
|
||
<div class="legend"><span class="legend-title">点位状态</span><span class="legend-item"><i class="legend-dot" style="--c:#71869a"></i>空闲</span><span class="legend-item"><i class="legend-dot" style="--c:#57a8c8"></i>空载具</span><span class="legend-item"><i class="legend-dot" style="--c:#5d92c9"></i>有子卷</span><span class="legend-item"><i class="legend-dot" style="--c:#4eb2b0"></i>合格品</span><span class="legend-item"><i class="legend-dot alert" style="--c:#ff6574"></i>管制/超时</span><span class="legend-item"><i class="legend-dot" style="--c:#f2faff;border-radius:1px"></i>AGV 通道</span></div>
|
||
<div class="toolbar-spacer"></div>
|
||
<div class="control"><label for="searchInput">卷号</label><input id="searchInput" type="text" placeholder="输入卷号或点位编码"><button id="searchBtn" class="btn" type="button">定位</button><span id="searchMessage"></span></div>
|
||
<div class="control"><label for="thresholdInput">超时</label><input id="thresholdInput" type="number" min="1" max="72" step="1"><span>h</span></div>
|
||
<div class="control"><label for="zoomInput">比例</label><input id="zoomInput" type="number" min="55" max="300" step="5"><span>%</span><button id="resetBtn" class="btn secondary" type="button">复位</button></div>
|
||
</section>
|
||
|
||
<section class="content">
|
||
<section class="map-panel">
|
||
<div class="panel-head"><span class="panel-title">装箱区域平面布置图</span><span class="panel-hint">滚轮缩放 · 按住左键拖动 · 双击恢复居中</span></div>
|
||
<div id="mapViewport" class="map-viewport">
|
||
<div id="mapCanvas" class="map-canvas">
|
||
<div class="north-arrow"><b>↑</b><span>北 N</span></div>
|
||
<div class="floor-zone zone-north"><div class="zone-label">北区 · 装箱信息暂存 <span class="zone-count">6 点位</span></div><div id="northGrid" class="slot-grid"></div></div>
|
||
<div class="agv-main one">← AGV 主通道 →</div>
|
||
<div class="floor-zone zone-center"><div class="zone-label">中区 · 待检区 <span class="zone-count">7 排 × 6 列 / 42 点位</span></div><div id="centerGrid" class="slot-grid"></div></div>
|
||
<div class="agv-main two">← AGV 主通道 →</div>
|
||
<div class="floor-zone zone-south"><div class="zone-label">南区 · 装箱作业区 <span class="zone-count">16 点位 + 2 等待位</span></div><div id="southGrid" class="south-grid"></div></div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<aside class="side-panel">
|
||
<div class="side-title"><span>点位实时信息</span><span class="demo-tag">DEMO DATA</span></div>
|
||
<section id="detailCard" class="detail-card" aria-live="polite"></section>
|
||
<div id="zoneMini" class="zone-mini"></div>
|
||
<div class="queue-wrap">
|
||
<section class="queue-group dock-01"><div class="queue-head"><span><b>ZXDJW_01</b><small>装箱区 1 队列</small></span><span id="queueCount01">0 项</span></div><ol id="queueList01" class="queue-list"></ol></section>
|
||
<section class="queue-group dock-02"><div class="queue-head"><span><b>ZXDJW_02</b><small>装箱区 2 队列</small></span><span id="queueCount02">0 项</span></div><ol id="queueList02" class="queue-list"></ol></section>
|
||
</div>
|
||
</aside>
|
||
</section>
|
||
</main>
|
||
|
||
<div id="contextMenu" class="context-menu"><div id="contextTitle" class="context-title">点位</div><button id="copyRollBtn" class="context-action" type="button">复制卷号</button><button id="copyBoxBtn" class="context-action" type="button">复制箱号</button></div>
|
||
<div id="toast" class="toast">复制成功</div>
|
||
|
||
<script>
|
||
(() => {
|
||
'use strict';
|
||
|
||
const STATUS = {
|
||
0: { text: '空闲', color: '#71869a', className: 'empty' },
|
||
1: { text: '空载具', color: '#57a8c8', className: 'carrier' },
|
||
2: { text: '有子卷', color: '#5d92c9', className: 'roll' },
|
||
3: { text: '合格品', color: '#4eb2b0', className: 'qualified' },
|
||
4: { text: '管制品', color: '#ff6574', className: 'controlled' }
|
||
};
|
||
const THRESHOLD_KEY = 'packingDashboardTechThreshold';
|
||
const ZOOM_KEY = 'packingDashboardTechZoom';
|
||
const COLUMN_MAP = [1, 2, 4, 5, 7, 8];
|
||
const NORTH_COLUMN_MAP = [1, 2, 3, 5, 6, 7];
|
||
const pad = value => String(value).padStart(2, '0');
|
||
const rangeCodes = (prefix, start, end) => Array.from({ length: end - start + 1 }, (_, index) => `${prefix}_${pad(start + index)}`);
|
||
const northCodes = rangeCodes('ZJXX', 1, 6);
|
||
const centerCodes = rangeCodes('DJQ', 1, 42);
|
||
const southCodes = rangeCodes('ZXQ', 1, 16);
|
||
const dockCodes = rangeCodes('ZXDJW', 1, 2);
|
||
const allCodes = [...northCodes, ...centerCodes, ...southCodes, ...dockCodes];
|
||
const zoneOf = code => code.startsWith('ZJXX') ? 'north' : code.startsWith('DJQ') ? 'center' : code.startsWith('ZXDJW') ? 'dock' : 'south';
|
||
const pointNameOf = code => code.startsWith('ZJXX') ? '装箱信息暂存位' : code.startsWith('DJQ') ? '待检缓存位' : code.startsWith('ZXDJW') ? '装箱等待位' : Number(code.slice(-2)) <= 8 ? '装箱区 1 作业位' : '装箱区 2 作业位';
|
||
const statusPattern = [2, 3, 0, 1, 2, 4, 3, 0, 2, 1, 3, 2];
|
||
|
||
function createMockPoint(code, index) {
|
||
const zone = zoneOf(code);
|
||
let status = statusPattern[(index * 5 + code.length) % statusPattern.length];
|
||
if (zone === 'dock') status = index % 2 ? 1 : 3;
|
||
const occupied = status >= 2;
|
||
const durationHours = occupied ? ((index * 7) % 19) + .5 : status === 1 ? ((index * 3) % 5) + .2 : 0;
|
||
const entryTime = new Date(Date.now() - durationHours * 3600000);
|
||
const rollNo = occupied ? `R260814${String(index + 1).padStart(4, '0')}` : '';
|
||
const hasBox = occupied && (zone === 'south' || zone === 'dock' || index % 3 === 0);
|
||
const boxNo = hasBox ? `BX2608${String((index % 28) + 1).padStart(3, '0')}` : '';
|
||
return {
|
||
code,
|
||
zone,
|
||
name: pointNameOf(code),
|
||
status,
|
||
rollNo,
|
||
boxNo,
|
||
width: occupied ? [980, 1120, 1250, 1380][index % 4] : null,
|
||
diameter: occupied ? [760, 820, 900][index % 3] : null,
|
||
durationHours,
|
||
entryTime
|
||
};
|
||
}
|
||
|
||
const points = allCodes.map(createMockPoint);
|
||
const pointMap = new Map(points.map(point => [point.code, point]));
|
||
let thresholdHours = Math.max(1, Number(localStorage.getItem(THRESHOLD_KEY)) || 12);
|
||
let fitScale = 1;
|
||
let mapScale = 1;
|
||
let panX = 0;
|
||
let panY = 0;
|
||
let selectedCode = '';
|
||
let contextPoint = null;
|
||
|
||
const $ = id => document.getElementById(id);
|
||
const formatDateTime = date => {
|
||
const d = new Date(date);
|
||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||
};
|
||
const durationText = hours => hours ? `${Math.floor(hours)}h ${Math.round((hours % 1) * 60)}m` : '--';
|
||
const isOverdue = point => point.status >= 2 && point.durationHours >= thresholdHours;
|
||
|
||
function slotElement(point, options = {}) {
|
||
const meta = STATUS[point.status];
|
||
const overdue = isOverdue(point);
|
||
const element = document.createElement('article');
|
||
element.className = `slot ${meta.className}${overdue ? ' overdue' : ''}${options.dock ? ' dock-slot' : ''}`;
|
||
element.style.setProperty('--state-color', overdue ? '#ff6574' : meta.color);
|
||
element.dataset.code = point.code;
|
||
element.dataset.roll = point.rollNo;
|
||
element.dataset.box = point.boxNo;
|
||
element.title = `${point.code}|${point.name}\n库存状态:${overdue ? '超时滞留' : meta.text}\n卷号:${point.rollNo || '空闲'}\n箱号:${point.boxNo || '未组盘'}\n停留:${durationText(point.durationHours)}`;
|
||
const statusText = overdue ? '超时滞留' : meta.text;
|
||
const specification = point.width ? `${point.width} × ${point.diameter}` : '- × -';
|
||
element.innerHTML = `<i class="slot-state-dot" aria-hidden="true"></i>
|
||
<div class="slot-head"><span class="slot-code">${point.code}</span></div>
|
||
<div class="slot-name" title="${point.name}">${point.name}</div>
|
||
<div class="slot-box-panel ${point.boxNo ? 'has-box' : ''}"><span class="slot-box-label">组盘木箱</span><span class="slot-box-value">${point.boxNo || '未组盘'}</span></div>
|
||
<div class="slot-info-row"><span class="slot-info-label">库存状态</span><span class="slot-stock">${statusText}</span></div>
|
||
<div class="slot-info-row"><span class="slot-info-label">卷号</span><span class="slot-info-value roll-no" title="${point.rollNo}">${point.rollNo || '空闲'}</span></div>
|
||
<div class="slot-info-row"><span class="slot-info-label">规格</span><span class="slot-info-value">${specification}</span></div>`;
|
||
element.addEventListener('click', event => {
|
||
event.stopPropagation();
|
||
selectPoint(point.code);
|
||
});
|
||
element.addEventListener('contextmenu', event => showContextMenu(event, point));
|
||
return element;
|
||
}
|
||
|
||
function addAisles(grid, rowCount, columns = [3, 6]) {
|
||
columns.forEach(column => {
|
||
const aisle = document.createElement('div');
|
||
aisle.className = 'agv-column';
|
||
aisle.style.gridColumn = column;
|
||
aisle.style.gridRow = `1 / ${rowCount + 1}`;
|
||
aisle.setAttribute('aria-label', 'AGV 通道');
|
||
grid.appendChild(aisle);
|
||
});
|
||
}
|
||
|
||
function renderMap() {
|
||
const northGrid = $('northGrid');
|
||
const centerGrid = $('centerGrid');
|
||
const southGrid = $('southGrid');
|
||
northGrid.innerHTML = '';
|
||
centerGrid.innerHTML = '';
|
||
southGrid.innerHTML = '';
|
||
addAisles(northGrid, 1, [4]);
|
||
addAisles(centerGrid, 7);
|
||
|
||
northCodes.forEach((code, index) => {
|
||
const slot = slotElement(pointMap.get(code));
|
||
slot.style.gridColumn = NORTH_COLUMN_MAP[index];
|
||
slot.style.gridRow = 1;
|
||
northGrid.appendChild(slot);
|
||
});
|
||
|
||
centerCodes.forEach((code, index) => {
|
||
const slot = slotElement(pointMap.get(code));
|
||
slot.style.gridColumn = COLUMN_MAP[index % 6];
|
||
slot.style.gridRow = Math.floor(index / 6) + 1;
|
||
centerGrid.appendChild(slot);
|
||
});
|
||
|
||
const southAisle = document.createElement('div');
|
||
southAisle.className = 'agv-column';
|
||
southAisle.setAttribute('aria-label', 'AGV 通道');
|
||
southGrid.appendChild(southAisle);
|
||
southCodes.forEach((code, index) => {
|
||
const slot = slotElement(pointMap.get(code));
|
||
slot.style.gridColumn = (index % 8) + 1;
|
||
slot.style.gridRow = Math.floor(index / 8) + 1;
|
||
southGrid.appendChild(slot);
|
||
});
|
||
dockCodes.forEach((code, index) => {
|
||
const slot = slotElement(pointMap.get(code), { dock: true });
|
||
slot.style.gridColumn = 10;
|
||
slot.style.gridRow = index + 1;
|
||
southGrid.appendChild(slot);
|
||
});
|
||
|
||
if (selectedCode) selectPoint(selectedCode);
|
||
updateDashboard();
|
||
}
|
||
|
||
function updateDashboard() {
|
||
const occupied = points.filter(point => point.status >= 2);
|
||
const overdue = occupied.filter(isOverdue);
|
||
$('kpiTotal').innerHTML = `${points.length}<small>个</small>`;
|
||
$('kpiOccupied').innerHTML = `${occupied.length}<small>个</small>`;
|
||
$('kpiEmpty').innerHTML = `${points.length - occupied.length}<small>个</small>`;
|
||
$('kpiOverdue').innerHTML = `${overdue.length}<small>个</small>`;
|
||
$('kpiRate').innerHTML = `${Math.round(occupied.length / points.length * 100)}<small>%</small>`;
|
||
|
||
const zones = [
|
||
['北区', northCodes],
|
||
['中区', centerCodes],
|
||
['南区', [...southCodes, ...dockCodes]]
|
||
];
|
||
$('zoneMini').innerHTML = zones.map(([name, codes]) => {
|
||
const used = codes.filter(code => pointMap.get(code).status >= 2).length;
|
||
return `<div class="zone-mini-item"><b>${used}/${codes.length}</b><span>${name}占用</span></div>`;
|
||
}).join('');
|
||
|
||
renderQueueGroup('queueList01', 'queueCount01', southCodes.slice(0, 8));
|
||
renderQueueGroup('queueList02', 'queueCount02', southCodes.slice(8, 16));
|
||
document.querySelectorAll('.queue-item[data-code]').forEach(item => item.addEventListener('click', () => selectPoint(item.dataset.code)));
|
||
}
|
||
|
||
function renderQueueGroup(listId, countId, codes) {
|
||
const queue = codes.map(code => pointMap.get(code)).filter(point => point.boxNo).sort((a, b) => b.durationHours - a.durationHours);
|
||
$(countId).textContent = `${queue.length} 项`;
|
||
$(listId).innerHTML = queue.length ? queue.map((point, index) => `<li class="queue-item" data-code="${point.code}"><span class="queue-no">${pad(index + 1)}</span><div><div class="queue-box">${point.boxNo}</div><div class="queue-point">${point.code} · ${point.rollNo}</div></div><span class="queue-time">${durationText(point.durationHours)}</span></li>`).join('') : '<li class="queue-item">当前队列为空</li>';
|
||
}
|
||
|
||
function selectPoint(code) {
|
||
const point = pointMap.get(code);
|
||
if (!point) return;
|
||
selectedCode = code;
|
||
document.querySelectorAll('.slot.selected').forEach(element => element.classList.remove('selected'));
|
||
const element = document.querySelector(`.slot[data-code="${code}"]`);
|
||
if (element) element.classList.add('selected');
|
||
const meta = STATUS[point.status];
|
||
const overdue = isOverdue(point);
|
||
const statusColor = overdue ? '#ff6574' : meta.color;
|
||
$('detailCard').classList.toggle('alert', overdue || point.status === 4);
|
||
$('detailCard').style.setProperty('--detail-color', statusColor);
|
||
$('detailCard').innerHTML = `<div class="detail-code">${point.code}</div><div class="detail-name">${point.name}</div><div class="detail-status"><i></i>${overdue ? '超时滞留' : meta.text}</div><div class="detail-rows"><div class="detail-row"><span>卷号</span><b class="em">${point.rollNo || '空闲'}</b></div><div class="detail-row"><span>组盘木箱</span><b class="em">${point.boxNo || '未组盘'}</b></div><div class="detail-row"><span>产品规格</span><b>${point.width ? `${point.width} × ${point.diameter} mm` : '--'}</b></div><div class="detail-row"><span>进入时间</span><b>${point.durationHours ? formatDateTime(point.entryTime) : '--'}</b></div><div class="detail-row"><span>停留时长</span><b>${durationText(point.durationHours)}</b></div></div>`;
|
||
}
|
||
|
||
function locateRoll() {
|
||
const query = $('searchInput').value.trim().toUpperCase();
|
||
const message = $('searchMessage');
|
||
document.querySelectorAll('.search-hit').forEach(element => element.classList.remove('search-hit'));
|
||
message.className = '';
|
||
if (!query) {
|
||
message.textContent = '请输入查询内容';
|
||
message.classList.add('error');
|
||
return;
|
||
}
|
||
const point = points.find(item => item.rollNo.toUpperCase().includes(query) || item.code.includes(query));
|
||
if (!point) {
|
||
message.textContent = '未找到匹配点位';
|
||
message.classList.add('error');
|
||
return;
|
||
}
|
||
const target = document.querySelector(`.slot[data-code="${point.code}"]`);
|
||
if (!target) return;
|
||
target.classList.add('search-hit');
|
||
selectPoint(point.code);
|
||
focusElement(target);
|
||
message.textContent = `已定位 ${point.code}`;
|
||
message.classList.add('ok');
|
||
}
|
||
|
||
function applyTransform() {
|
||
$('mapCanvas').style.transform = `translate(${panX}px, ${panY}px) scale(${mapScale})`;
|
||
$('zoomInput').value = fitScale ? Math.round(mapScale / fitScale * 100) : 100;
|
||
}
|
||
|
||
function fitMap() {
|
||
const viewport = $('mapViewport');
|
||
const canvas = $('mapCanvas');
|
||
fitScale = Math.min(viewport.clientWidth / canvas.offsetWidth, viewport.clientHeight / canvas.offsetHeight) * .96;
|
||
const saved = Number(localStorage.getItem(ZOOM_KEY));
|
||
const ratio = Number.isFinite(saved) && saved >= .55 && saved <= 3 ? saved : 1;
|
||
mapScale = fitScale * ratio;
|
||
panX = (viewport.clientWidth - canvas.offsetWidth * mapScale) / 2;
|
||
panY = (viewport.clientHeight - canvas.offsetHeight * mapScale) / 2;
|
||
applyTransform();
|
||
}
|
||
|
||
function focusElement(element) {
|
||
const viewport = $('mapViewport');
|
||
const canvasRect = $('mapCanvas').getBoundingClientRect();
|
||
const targetRect = element.getBoundingClientRect();
|
||
const worldX = (targetRect.left + targetRect.width / 2 - canvasRect.left) / mapScale;
|
||
const worldY = (targetRect.top + targetRect.height / 2 - canvasRect.top) / mapScale;
|
||
mapScale = Math.max(mapScale, fitScale * 2);
|
||
panX = viewport.clientWidth / 2 - worldX * mapScale;
|
||
panY = viewport.clientHeight / 2 - worldY * mapScale;
|
||
localStorage.setItem(ZOOM_KEY, String(mapScale / fitScale));
|
||
applyTransform();
|
||
}
|
||
|
||
function resetMap() {
|
||
localStorage.setItem(ZOOM_KEY, '1');
|
||
fitMap();
|
||
}
|
||
|
||
function showContextMenu(event, point) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
contextPoint = point;
|
||
selectPoint(point.code);
|
||
const menu = $('contextMenu');
|
||
$('contextTitle').textContent = point.code;
|
||
$('copyRollBtn').disabled = !point.rollNo;
|
||
$('copyBoxBtn').disabled = !point.boxNo;
|
||
menu.classList.add('open');
|
||
const x = Math.min(event.clientX, window.innerWidth - 174);
|
||
const y = Math.min(event.clientY, window.innerHeight - 120);
|
||
menu.style.left = `${x}px`;
|
||
menu.style.top = `${y}px`;
|
||
}
|
||
|
||
function hideContextMenu() { $('contextMenu').classList.remove('open'); }
|
||
|
||
async function copyValue(type) {
|
||
const value = type === 'roll' ? contextPoint?.rollNo : contextPoint?.boxNo;
|
||
if (!value) return;
|
||
try {
|
||
await navigator.clipboard.writeText(value);
|
||
} catch (error) {
|
||
const area = document.createElement('textarea');
|
||
area.value = value;
|
||
area.style.position = 'fixed';
|
||
area.style.opacity = '0';
|
||
document.body.appendChild(area);
|
||
area.select();
|
||
document.execCommand('copy');
|
||
area.remove();
|
||
}
|
||
hideContextMenu();
|
||
const toast = $('toast');
|
||
toast.textContent = `已复制${type === 'roll' ? '卷号' : '箱号'}:${value}`;
|
||
toast.style.display = 'block';
|
||
window.setTimeout(() => { toast.style.display = 'none'; }, 1800);
|
||
}
|
||
|
||
function updateClock() {
|
||
const now = new Date();
|
||
const week = ['日', '一', '二', '三', '四', '五', '六'];
|
||
$('clockTime').textContent = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
|
||
$('clockDate').textContent = `${now.getFullYear()} 年 ${pad(now.getMonth() + 1)} 月 ${pad(now.getDate())} 日 星期${week[now.getDay()]}`;
|
||
}
|
||
|
||
function scaleStage() {
|
||
const scale = Math.min(window.innerWidth / 1920, window.innerHeight / 1080);
|
||
$('stage').style.transform = `scale(${scale})`;
|
||
}
|
||
|
||
function bindEvents() {
|
||
$('searchBtn').addEventListener('click', locateRoll);
|
||
$('searchInput').addEventListener('keydown', event => { if (event.key === 'Enter') locateRoll(); });
|
||
$('thresholdInput').value = thresholdHours;
|
||
$('thresholdInput').addEventListener('change', () => {
|
||
const value = Math.max(1, Math.min(72, Number($('thresholdInput').value) || 12));
|
||
thresholdHours = value;
|
||
$('thresholdInput').value = value;
|
||
localStorage.setItem(THRESHOLD_KEY, String(value));
|
||
renderMap();
|
||
});
|
||
$('zoomInput').addEventListener('change', () => {
|
||
const ratio = Math.max(.55, Math.min(3, (Number($('zoomInput').value) || 100) / 100));
|
||
const viewport = $('mapViewport');
|
||
const canvas = $('mapCanvas');
|
||
mapScale = fitScale * ratio;
|
||
panX = (viewport.clientWidth - canvas.offsetWidth * mapScale) / 2;
|
||
panY = (viewport.clientHeight - canvas.offsetHeight * mapScale) / 2;
|
||
localStorage.setItem(ZOOM_KEY, String(ratio));
|
||
applyTransform();
|
||
});
|
||
$('resetBtn').addEventListener('click', resetMap);
|
||
$('mapViewport').addEventListener('dblclick', resetMap);
|
||
$('copyRollBtn').addEventListener('click', () => copyValue('roll'));
|
||
$('copyBoxBtn').addEventListener('click', () => copyValue('box'));
|
||
document.addEventListener('click', event => { if (!event.target.closest('#contextMenu')) hideContextMenu(); });
|
||
|
||
const viewport = $('mapViewport');
|
||
viewport.addEventListener('wheel', event => {
|
||
event.preventDefault();
|
||
const rect = viewport.getBoundingClientRect();
|
||
const mouseX = event.clientX - rect.left;
|
||
const mouseY = event.clientY - rect.top;
|
||
const worldX = (mouseX - panX) / mapScale;
|
||
const worldY = (mouseY - panY) / mapScale;
|
||
const next = Math.max(fitScale * .55, Math.min(fitScale * 3, mapScale * (event.deltaY < 0 ? 1.11 : .9)));
|
||
panX = mouseX - worldX * next;
|
||
panY = mouseY - worldY * next;
|
||
mapScale = next;
|
||
localStorage.setItem(ZOOM_KEY, String(mapScale / fitScale));
|
||
applyTransform();
|
||
}, { passive: false });
|
||
|
||
let dragging = false;
|
||
let startX = 0;
|
||
let startY = 0;
|
||
let startPanX = 0;
|
||
let startPanY = 0;
|
||
viewport.addEventListener('mousedown', event => {
|
||
if (event.button !== 0 || event.target.closest('.slot')) return;
|
||
dragging = true;
|
||
startX = event.clientX;
|
||
startY = event.clientY;
|
||
startPanX = panX;
|
||
startPanY = panY;
|
||
viewport.classList.add('dragging');
|
||
});
|
||
window.addEventListener('mousemove', event => {
|
||
if (!dragging) return;
|
||
panX = startPanX + event.clientX - startX;
|
||
panY = startPanY + event.clientY - startY;
|
||
applyTransform();
|
||
});
|
||
window.addEventListener('mouseup', () => { dragging = false; viewport.classList.remove('dragging'); });
|
||
window.addEventListener('resize', () => { scaleStage(); fitMap(); });
|
||
}
|
||
|
||
renderMap();
|
||
selectPoint('DJQ_01');
|
||
bindEvents();
|
||
scaleStage();
|
||
requestAnimationFrame(fitMap);
|
||
updateClock();
|
||
window.setInterval(updateClock, 1000);
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|