From a8e8b92a28fd7b48b6eeae2f2f859ba381a39614 Mon Sep 17 00:00:00 2001
From: Junjie <fallin.jie@qq.com>
Date: 星期六, 07 三月 2026 09:58:47 +0800
Subject: [PATCH] #

---
 src/main/webapp/components/MapCanvas.js | 1703 +++++++++++++++++++++++++++++++++++++++++++++++++++++++---
 1 files changed, 1,598 insertions(+), 105 deletions(-)

diff --git a/src/main/webapp/components/MapCanvas.js b/src/main/webapp/components/MapCanvas.js
index be746ba..be2b7d8 100644
--- a/src/main/webapp/components/MapCanvas.js
+++ b/src/main/webapp/components/MapCanvas.js
@@ -1,13 +1,54 @@
 Vue.component('map-canvas', {
   template: `
     <div style="width: 100%; height: 100%; position: relative;">
-      <div ref="pixiView"></div>
-      <div style="position: absolute; top: 20px; right: 50px;">
-        <div>FPS:{{mapFps}}</div>
+      <div ref="pixiView" style="position: absolute; inset: 0;"></div>
+      <div :style="cycleCapacityPanelStyle()">
+        <div style="display: flex; flex-direction: column; gap: 6px; align-items: flex-start;">
+          <div v-for="item in cycleCapacity.loopList"
+               :key="'loop-' + item.loopNo"
+               @mouseenter="handleLoopCardEnter(item)"
+               @mouseleave="handleLoopCardLeave(item)"
+               style="padding: 6px 10px; border-radius: 4px; background: rgba(11, 35, 58, 0.72); color: #fff; font-size: 12px; line-height: 1.4; white-space: nowrap; pointer-events: auto;">
+            鍦坽{ item.loopNo }} |
+            绔欑偣: {{ item.stationCount || 0 }} |
+            浠诲姟: {{ item.taskCount || 0 }} |
+            鎵胯浇: {{ formatLoadPercent(item.currentLoad) }}
+          </div>
+        </div>
+      </div>
+      <div v-show="shelfTooltip.visible"
+           :style="shelfTooltipStyle()">
+        {{ shelfTooltip.text }}
+      </div>
+      <div style="position: absolute; top: 18px; right: 34px; z-index: 30; display: flex; flex-direction: column; align-items: flex-end; gap: 8px;">
+        <div :style="mapToolFpsStyle()">FPS {{ mapFps }}</div>
+        <button type="button" @click="toggleMapToolPanel" :style="mapToolToggleStyle(showMapToolPanel)">{{ showMapToolPanel ? '鏀惰捣鎿嶄綔' : '鍦板浘鎿嶄綔' }}</button>
+        <div v-show="showMapToolPanel" :style="mapToolBarStyle()">
+          <div :style="mapToolRowStyle()">
+            <button type="button" @click="toggleStationDirection" :style="mapToolButtonStyle(showStationDirection)">{{ showStationDirection ? '闅愯棌绔欑偣鏂瑰悜' : '鏄剧ず绔欑偣鏂瑰悜' }}</button>
+            <button type="button" @click="rotateMap" :style="mapToolButtonStyle(false)">鏃嬭浆</button>
+            <button type="button" @click="toggleMirror" :style="mapToolButtonStyle(mapMirrorX)">{{ mapMirrorX ? '鍙栨秷闀滃儚' : '闀滃儚' }}</button>
+          </div>
+          <div :style="mapToolRowStyle()">
+            <button type="button" @click="openStationColorConfigPage" :style="mapToolButtonStyle(false)">绔欑偣棰滆壊</button>
+          </div>
+          <div v-if="levList && levList.length > 1" :style="mapToolFloorSectionStyle()">
+            <div :style="mapToolSectionLabelStyle()">妤煎眰</div>
+            <div :style="mapToolFloorListStyle()">
+              <button
+                v-for="floor in levList"
+                :key="'tool-floor-' + floor"
+                type="button"
+                @click="selectFloorFromTool(floor)"
+                :style="mapToolFloorButtonStyle(currentLev == floor)"
+              >{{ floor }}F</button>
+            </div>
+          </div>
+        </div>
       </div>
     </div>
   `,
-  props: ['lev', 'crnParam', 'rgvParam', 'devpParam', 'highlightOnParamChange'],
+  props: ['lev', 'levList', 'crnParam', 'rgvParam', 'devpParam', 'stationTaskRange', 'highlightOnParamChange', 'viewportPadding', 'hudPadding'],
   data() {
     return {
       map: [],
@@ -24,6 +65,14 @@
       pixiCrnMap: new Map(),
       pixiDualCrnMap: new Map(),
       pixiRgvMap: new Map(),
+      mapRoot: null,
+      mapRotation: 0,
+      mapMirrorX: false,
+      mapContentSize: { width: 0, height: 0 },
+      mapConfigCodes: {
+        rotate: 'map_canvas_rotation',
+        mirror: 'map_canvas_mirror_x'
+      },
       pixiShelfMap: new Map(),
       pixiTrackMap: new Map(),
       pixiDevpTextureMap: new Map(),
@@ -31,25 +80,79 @@
       pixiDevpTextureMap: new Map(),
       pixiCrnColorTextureMap: new Map(),
       pixiRgvColorTextureMap: new Map(),
+      shelfChunkList: [],
+      shelfChunkSize: 2048,
+      shelfCullPadding: 160,
+      shelfCullRaf: null,
       crnList: [],
       dualCrnList: [],
       rgvList: [],
+      locListMap: new Map(),
+      locListLoaded: false,
+      locListLoading: false,
+      mapRowOffsets: [],
+      mapRowHeights: [],
+      mapColOffsets: [],
+      mapColWidths: [],
+      mapRowColOffsets: [],
+      mapRowColWidths: [],
+      mapRowShelfCells: [],
+      hoveredShelfCell: null,
+      hoverPointer: { x: 0, y: 0 },
+      hoverRaf: null,
       objectsContainer: null,
       objectsContainer2: null,
       tracksContainer: null,
+      tracksGraphics: null,
       shelvesContainer: null,
       graphicsCrn: null,
       graphicsCrnTrack: null,
       graphicsRgvTrack: null,
       graphicsRgv: null,
+      shelfTooltip: {
+        visible: false,
+        x: 0,
+        y: 0,
+        text: '',
+        item: null
+      },
+      shelfTooltipMinScale: 0.4,
+      containerResizeObserver: null,
       timer: null,
       adjustLabelTimer: null,
-      isSwitchingFloor: false
+      isSwitchingFloor: false,
+      cycleCapacity: {
+        loopList: [],
+        totalStationCount: 0,
+        taskStationCount: 0,
+        currentLoad: 0
+      },
+      showMapToolPanel: false,
+      showStationDirection: false,
+      hoverLoopNo: null,
+      hoverLoopStationIdSet: new Set(),
+      loopHighlightColor: 0xfff34d,
+      stationDirectionColor: 0xff5a36,
+      stationStatusColors: {
+        'site-auto': 0x78ff81,
+        'site-auto-run': 0xfa51f6,
+        'site-auto-id': 0xc4c400,
+        'site-auto-run-id': 0x30bffc,
+        'site-enable-in': 0xA81DEE,
+        'site-unauto': 0xb8b8b8,
+        'machine-pakin': 0x30bffc,
+        'machine-pakout': 0x97b400,
+        'site-run-block': 0xe69138
+      }
     }
   },
     mounted() {
     this.currentLev = this.lev || 1;
     this.createMap();
+    this.startContainerResizeObserve();
+    this.loadMapTransformConfig();
+    this.loadStationColorConfig();
+    this.loadLocList();
     this.connectWs();
     
     setTimeout(() => {
@@ -60,12 +163,18 @@
       this.getCrnInfo();
       this.getDualCrnInfo();
       this.getSiteInfo();
+      this.getCycleCapacityInfo();
       this.getRgvInfo();
     }, 1000);
   },
   beforeDestroy() {
     if (this.timer) { clearInterval(this.timer); }
+
+    if (this.hoverRaf) { cancelAnimationFrame(this.hoverRaf); this.hoverRaf = null; }
+    if (this.shelfCullRaf) { cancelAnimationFrame(this.shelfCullRaf); this.shelfCullRaf = null; }
+    if (window.gsap && this.pixiApp && this.pixiApp.stage) { window.gsap.killTweensOf(this.pixiApp.stage.position); }
     if (this.pixiApp) { this.pixiApp.destroy(true, { children: true }); }
+    if (this.containerResizeObserver) { this.containerResizeObserver.disconnect(); this.containerResizeObserver = null; }
     window.removeEventListener('resize', this.resizeToContainer);
     if (this.wsReconnectTimer) { clearTimeout(this.wsReconnectTimer); this.wsReconnectTimer = null; }
     if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) { try { this.ws.close(); } catch (e) {} }
@@ -73,6 +182,14 @@
   watch: {
     lev(newLev) {
       if (newLev != null) { this.changeFloor(newLev); }
+    },
+    viewportPadding: {
+      deep: true,
+      handler(newVal, oldVal) {
+        if (this.mapContentSize && this.mapContentSize.width > 0 && this.mapContentSize.height > 0) {
+          this.adjustStageForViewportPadding(oldVal, newVal);
+        }
+      }
     },
     crnParam: {
       deep: true,
@@ -118,6 +235,139 @@
     }
   },
   methods: {
+    cycleCapacityPanelStyle() {
+      const hud = this.hudPadding || {};
+      const left = Math.max(14, Number(hud.left) || 0);
+      const rightReserve = 220;
+      return {
+        position: 'absolute',
+        top: '12px',
+        left: left + 'px',
+        zIndex: 30,
+        pointerEvents: 'none',
+        maxWidth: 'calc(100% - ' + (left + rightReserve) + 'px)'
+      };
+    },
+    mapToolBarStyle() {
+      return {
+        display: 'flex',
+        flexDirection: 'column',
+        gap: '8px',
+        alignItems: 'stretch',
+        padding: '7px',
+        borderRadius: '14px',
+        background: 'rgba(255, 255, 255, 0.72)',
+        border: '1px solid rgba(160, 180, 205, 0.3)',
+        boxShadow: '0 8px 20px rgba(37, 64, 97, 0.08)',
+        backdropFilter: 'blur(4px)'
+      };
+    },
+    mapToolRowStyle() {
+      return {
+        display: 'flex',
+        gap: '8px',
+        alignItems: 'center',
+        justifyContent: 'flex-end',
+        flexWrap: 'wrap'
+      };
+    },
+    mapToolFloorSectionStyle() {
+      return {
+        display: 'flex',
+        flexDirection: 'column',
+        gap: '4px',
+        paddingTop: '6px',
+        borderTop: '1px solid rgba(160, 180, 205, 0.22)'
+      };
+    },
+    mapToolSectionLabelStyle() {
+      return {
+        color: '#6a7f95',
+        fontSize: '10px',
+        lineHeight: '14px',
+        textAlign: 'right'
+      };
+    },
+    mapToolFloorListStyle() {
+      return {
+        display: 'flex',
+        flexDirection: 'column',
+        gap: '4px',
+        alignItems: 'stretch'
+      };
+    },
+    mapToolFpsStyle() {
+      return {
+        padding: '4px 10px',
+        borderRadius: '999px',
+        background: 'rgba(255, 255, 255, 0.7)',
+        border: '1px solid rgba(160, 180, 205, 0.28)',
+        color: '#48617c',
+        fontSize: '12px',
+        lineHeight: '18px',
+        letterSpacing: '0.04em',
+        boxShadow: '0 6px 16px rgba(37, 64, 97, 0.06)',
+        userSelect: 'none'
+      };
+    },
+    mapToolToggleStyle(active) {
+      return {
+        appearance: 'none',
+        border: '1px solid ' + (active ? 'rgba(96, 132, 170, 0.36)' : 'rgba(160, 180, 205, 0.3)'),
+        background: active ? 'rgba(235, 243, 251, 0.96)' : 'rgba(255, 255, 255, 0.82)',
+        color: '#46617b',
+        height: '30px',
+        padding: '0 12px',
+        borderRadius: '999px',
+        fontSize: '12px',
+        lineHeight: '30px',
+        cursor: 'pointer',
+        whiteSpace: 'nowrap',
+        boxShadow: '0 6px 16px rgba(37, 64, 97, 0.06)'
+      };
+    },
+    mapToolButtonStyle(active) {
+      return {
+        appearance: 'none',
+        border: '1px solid ' + (active ? 'rgba(255, 136, 93, 0.38)' : 'rgba(160, 180, 205, 0.3)'),
+        background: active ? 'rgba(255, 119, 77, 0.16)' : 'rgba(255, 255, 255, 0.88)',
+        color: active ? '#d85a31' : '#4d647d',
+        height: '30px',
+        padding: '0 12px',
+        borderRadius: '10px',
+        fontSize: '12px',
+        lineHeight: '30px',
+        cursor: 'pointer',
+        transition: 'all 0.2s ease',
+        boxShadow: active ? '0 4px 10px rgba(255, 119, 77, 0.12)' : 'none',
+        whiteSpace: 'nowrap'
+      };
+    },
+    mapToolFloorButtonStyle(active) {
+      return {
+        appearance: 'none',
+        border: '1px solid ' + (active ? 'rgba(96, 132, 170, 0.36)' : 'rgba(160, 180, 205, 0.3)'),
+        background: active ? 'rgba(235, 243, 251, 0.96)' : 'rgba(255, 255, 255, 0.88)',
+        color: active ? '#27425c' : '#4d647d',
+        minWidth: '44px',
+        height: '26px',
+        padding: '0 10px',
+        borderRadius: '8px',
+        fontSize: '11px',
+        lineHeight: '26px',
+        cursor: 'pointer',
+        fontWeight: '700',
+        boxShadow: active ? '0 4px 12px rgba(37, 64, 97, 0.08)' : 'none',
+        whiteSpace: 'nowrap'
+      };
+    },
+    toggleMapToolPanel() {
+      this.showMapToolPanel = !this.showMapToolPanel;
+    },
+    selectFloorFromTool(lev) {
+      if (lev == null || lev === this.currentLev) { return; }
+      this.$emit('switch-lev', lev);
+    },
     createMap() {
       this.pixiApp = new PIXI.Application({ backgroundColor: 0xF5F7F9, antialias: false, powerPreference: 'high-performance', autoDensity: true, resolution: Math.min(window.devicePixelRatio || 1, 2) });
       PIXI.settings.SCALE_MODE = PIXI.SCALE_MODES.LINEAR;
@@ -127,22 +377,46 @@
       this.pixiApp.view.style.display = 'block';
       this.resizeToContainer();
       window.addEventListener('resize', this.resizeToContainer);
-      this.graphicsCrnTrack = this.createTrackTexture(25,25);
-      this.graphicsRgvTrack = this.createTrackTexture(25,25);
+      this.graphicsCrnTrack = this.createTrackTexture(25, 25, 10);
+      this.graphicsRgvTrack = this.createTrackTexture(25, 25, 10);
       this.objectsContainer = new PIXI.Container();
       this.objectsContainer2 = new PIXI.Container();
       this.tracksContainer = new PIXI.ParticleContainer(10000, { scale: true, position: true, rotation: false, uvs: false, alpha: false });
-      this.shelvesContainer = new PIXI.ParticleContainer(10000, { scale: true, position: true, rotation: false, uvs: false, alpha: false });
+      this.tracksGraphics = new PIXI.Graphics();
+      this.shelvesContainer = new PIXI.Container();
       this.tracksContainer.autoResize = true;
-      this.shelvesContainer.autoResize = true;
-      this.pixiApp.stage.addChild(this.tracksContainer);
-      this.pixiApp.stage.addChild(this.shelvesContainer);
-      this.pixiApp.stage.addChild(this.objectsContainer);
-      this.pixiApp.stage.addChild(this.objectsContainer2);
+      this.mapRoot = new PIXI.Container();
+      this.pixiApp.stage.addChild(this.mapRoot);
+      this.mapRoot.addChild(this.tracksGraphics);
+      this.mapRoot.addChild(this.tracksContainer);
+      this.mapRoot.addChild(this.shelvesContainer);
+      this.mapRoot.addChild(this.objectsContainer);
+      this.mapRoot.addChild(this.objectsContainer2);
       this.pixiApp.renderer.roundPixels = true;
+      this.hoveredShelfCell = null;
+      this.hoverPointer = { x: 0, y: 0 };
+      this.hoverRaf = null;
       
 
-      //*******************鎷栧姩鐢诲竷*******************
+
+      //*******************shelf hover*******************
+      this.pixiApp.renderer.plugins.interaction.on('pointermove', (event) => {
+        if (!this.isShelfTooltipAllowed()) { this.hideShelfTooltip(); return; }
+        if (!this.map || !this.mapRoot) { return; }
+        const pos = event.data.global;
+        this.hoverPointer.x = pos.x;
+        this.hoverPointer.y = pos.y;
+        if (this.hoverRaf) { return; }
+        this.hoverRaf = requestAnimationFrame(() => {
+          this.hoverRaf = null;
+          this.updateShelfHoverFromPointer(this.hoverPointer);
+        });
+      });
+      this.pixiApp.view.addEventListener('mouseleave', () => {
+        this.hoveredShelfCell = null;
+        this.hideShelfTooltip();
+      });
+      //*******************shelf hover*******************
       let stageOriginalPos;
       let mouseDownPoint;
       let touchBlank = false;
@@ -150,7 +424,7 @@
         const globalPos = event.data.global;
         stageOriginalPos = [this.pixiApp.stage.position.x, this.pixiApp.stage.position.y];
         mouseDownPoint = [globalPos.x, globalPos.y];
-        if (!event.target) { touchBlank = true; }
+        if (!event.target || (event.target && event.target._kind === 'shelf')) { touchBlank = true; }
       });
       this.pixiApp.renderer.plugins.interaction.on('pointermove', (event) => {
         const globalPos = event.data.global;
@@ -158,10 +432,11 @@
           const dx = globalPos.x - mouseDownPoint[0];
           const dy = globalPos.y - mouseDownPoint[1];
           this.pixiApp.stage.position.set(stageOriginalPos[0] + dx, stageOriginalPos[1] + dy);
+          this.scheduleShelfChunkCulling();
         }
       });
       this.pixiApp.renderer.plugins.interaction.on('pointerup', () => { touchBlank = false; });
-      //*******************鎷栧姩鐢诲竷*******************
+
 
       //*******************缂╂斁鐢诲竷*******************
       this.pixiApp.view.addEventListener('wheel', (event) => {
@@ -170,15 +445,21 @@
         const rect = this.pixiApp.view.getBoundingClientRect();
         const sx = event.clientX - rect.left;
         const sy = event.clientY - rect.top;
-        const oldZoom = this.pixiApp.stage.scale.x;
+        const oldZoomX = this.pixiApp.stage.scale.x || 1;
+        const oldZoomY = this.pixiApp.stage.scale.y || 1;
+        const oldZoomAbs = Math.abs(oldZoomX) || 1;
         const delta = event.deltaY;
-        let newZoom = oldZoom * 0.999 ** delta;
-        const worldX = (sx - this.pixiApp.stage.position.x) / oldZoom;
-        const worldY = (sy - this.pixiApp.stage.position.y) / oldZoom;
-        const newPosX = sx - worldX * newZoom;
-        const newPosY = sy - worldY * newZoom;
-        this.pixiApp.stage.setTransform(newPosX, newPosY, newZoom, newZoom, 0, 0, 0, 0, 0);
-          this.scheduleAdjustLabels();
+        let newZoomAbs = oldZoomAbs * 0.999 ** delta;
+        const mirrorX = this.mapMirrorX ? -1 : 1;
+        const newZoomX = mirrorX * newZoomAbs;
+        const newZoomY = newZoomAbs;
+        const worldX = (sx - this.pixiApp.stage.position.x) / oldZoomX;
+        const worldY = (sy - this.pixiApp.stage.position.y) / oldZoomY;
+        const newPosX = sx - worldX * newZoomX;
+        const newPosY = sy - worldY * newZoomY;
+        this.pixiApp.stage.setTransform(newPosX, newPosY, newZoomX, newZoomY, 0, 0, 0, 0, 0);
+        this.scheduleAdjustLabels();
+        this.scheduleShelfChunkCulling();
       });
       //*******************缂╂斁鐢诲竷*******************
 
@@ -205,11 +486,94 @@
       });
       //*******************FPS*******************
     },
+    startContainerResizeObserve() {
+      if (typeof ResizeObserver === 'undefined' || !this.$el) { return; }
+      this.containerResizeObserver = new ResizeObserver(() => {
+        this.resizeToContainer();
+      });
+      this.containerResizeObserver.observe(this.$el);
+    },
+    getViewportSize() {
+      if (!this.pixiApp || !this.pixiApp.renderer) { return { width: 0, height: 0 }; }
+      const screen = this.pixiApp.renderer.screen;
+      if (screen && screen.width > 0 && screen.height > 0) {
+        return { width: screen.width, height: screen.height };
+      }
+      const rect = this.pixiApp.view ? this.pixiApp.view.getBoundingClientRect() : null;
+      return { width: rect ? rect.width : 0, height: rect ? rect.height : 0 };
+    },
+    getViewportPadding() {
+      return this.normalizeViewportPadding(this.viewportPadding);
+    },
+    normalizeViewportPadding(padding) {
+      const source = padding || {};
+      const normalize = (value) => {
+        const num = Number(value);
+        return isFinite(num) && num > 0 ? num : 0;
+      };
+      return {
+        top: normalize(source.top),
+        right: normalize(source.right),
+        bottom: normalize(source.bottom),
+        left: normalize(source.left)
+      };
+    },
+    getViewportCenter(viewport, padding) {
+      const normalized = this.normalizeViewportPadding(padding);
+      const availableW = Math.max(1, viewport.width - normalized.left - normalized.right);
+      const availableH = Math.max(1, viewport.height - normalized.top - normalized.bottom);
+      return {
+        x: normalized.left + availableW / 2,
+        y: normalized.top + availableH / 2
+      };
+    },
+    adjustStageForViewportPadding(oldPadding, newPadding) {
+      if (!this.pixiApp || !this.pixiApp.stage) { return; }
+      const viewport = this.getViewportSize();
+      if (viewport.width <= 0 || viewport.height <= 0) { return; }
+      const prevCenter = this.getViewportCenter(viewport, oldPadding);
+      const nextCenter = this.getViewportCenter(viewport, newPadding);
+      const deltaX = nextCenter.x - prevCenter.x;
+      const deltaY = nextCenter.y - prevCenter.y;
+      if (Math.abs(deltaX) < 0.5 && Math.abs(deltaY) < 0.5) {
+        return;
+      }
+      const targetX = this.pixiApp.stage.position.x + deltaX;
+      const targetY = this.pixiApp.stage.position.y + deltaY;
+      if (window.gsap) {
+        window.gsap.killTweensOf(this.pixiApp.stage.position);
+        window.gsap.to(this.pixiApp.stage.position, {
+          x: targetX,
+          y: targetY,
+          duration: 0.18,
+          ease: 'power1.out',
+          onUpdate: () => {
+            this.scheduleAdjustLabels();
+            this.scheduleShelfChunkCulling();
+          },
+          onComplete: () => {
+            this.scheduleAdjustLabels();
+            this.scheduleShelfChunkCulling();
+          }
+        });
+        return;
+      }
+      this.pixiApp.stage.position.x = targetX;
+      this.pixiApp.stage.position.y = targetY;
+      this.scheduleAdjustLabels();
+      this.scheduleShelfChunkCulling();
+    },
     resizeToContainer() {
       const w = this.$el.clientWidth || 0;
       const h = this.$el.clientHeight || 0;
       if (w > 0 && h > 0 && this.pixiApp) {
+        const vw = this.pixiApp.renderer && this.pixiApp.renderer.screen ? this.pixiApp.renderer.screen.width : 0;
+        const vh = this.pixiApp.renderer && this.pixiApp.renderer.screen ? this.pixiApp.renderer.screen.height : 0;
+        if (vw === w && vh === h) { return; }
         this.pixiApp.renderer.resize(w, h);
+        if (this.mapContentSize && this.mapContentSize.width > 0 && this.mapContentSize.height > 0) {
+          this.applyMapTransform(true);
+        }
       }
     },
     getMap() {
@@ -217,12 +581,20 @@
     },
     changeFloor(lev) {
       this.currentLev = lev;
+      this.clearLoopStationHighlight();
       this.isSwitchingFloor = true;
+      this.hideShelfTooltip();
+      this.hoveredShelfCell = null;
+      this.mapRowOffsets = [];
+      this.mapRowHeights = [];
+      this.mapColOffsets = [];
+      this.mapColWidths = [];
       if (this.adjustLabelTimer) { clearTimeout(this.adjustLabelTimer); this.adjustLabelTimer = null; }
       this.objectsContainer.removeChildren();
       this.objectsContainer2.removeChildren();
       if (this.tracksContainer) { this.tracksContainer.removeChildren(); }
-      if (this.shelvesContainer) { this.shelvesContainer.removeChildren(); }
+      if (this.tracksGraphics) { this.tracksGraphics.clear(); }
+      this.clearShelfChunks();
       this.crnList = [];
       this.dualCrnList = [];
       this.rgvList = [];
@@ -234,6 +606,13 @@
       this.getMap();
     },
     createMapData(map) {
+      this.clearLoopStationHighlight();
+      this.hideShelfTooltip();
+      this.hoveredShelfCell = null;
+      this.mapRowOffsets = [];
+      this.mapRowHeights = [];
+      this.mapColOffsets = [];
+      this.mapColWidths = [];
       if (window.gsap) {
         this.pixiStaMap && this.pixiStaMap.forEach((s) => { try { window.gsap.killTweensOf(s); } catch (e) {} });
         this.pixiCrnMap && this.pixiCrnMap.forEach((s) => { try { window.gsap.killTweensOf(s); } catch (e) {} });
@@ -243,7 +622,8 @@
       this.objectsContainer.removeChildren();
       this.objectsContainer2.removeChildren();
       if (this.tracksContainer) { this.tracksContainer.removeChildren(); }
-      if (this.shelvesContainer) { this.shelvesContainer.removeChildren(); }
+      if (this.tracksGraphics) { this.tracksGraphics.clear(); }
+      this.clearShelfChunks();
       this.crnList = [];
       this.dualCrnList = [];
       this.rgvList = [];
@@ -322,6 +702,8 @@
           let val = row[colIndex];
           let cellWidth = val.width;
           let cellHeight = val.height;
+          val.rowIndex = rowIndex;
+          val.colIndex = colIndex;
           if (val.isMergedPart) {
             val.posX = anchorX;
             val.posY = yOffsets[rowIndex];
@@ -342,23 +724,36 @@
         }
       });
 
+      map.forEach((row, rowIndex) => {
+        for (let colIndex = 0; colIndex < row.length; colIndex++) {
+          const val = row[colIndex];
+          if (!val || val.type !== 'devp' || val.type === 'merge') { continue; }
+          val.stationDirectionList = this.resolveStationDirectionList(map, rowIndex, colIndex, val);
+        }
+      });
+
+      this.buildShelfHitGrid(map, rowHeightScaled, yOffsets);
+
+      this.drawTracks(map);
+
       map.forEach((item, index) => {
         this.pixiStageList[index] = [item.length];
         for (let idx = 0; idx < item.length; idx++) {
           let val = item[idx];
+          val.rowIndex = index;
+          val.colIndex = idx;
           if (val.type === 'merge') { continue; }
           if (val.type == undefined || val.type === 'none') { continue; }
+          if (this.isTrackType(val)) {
+            this.collectTrackItem(val);
+            continue;
+          }
+          if (val.type === 'shelf') { continue; }
           let sprite = this.getSprite(val, (e) => {
             //鍥炶皟
           });
           if (sprite == null) { continue; }
-          if (sprite._kind === 'shelf') {
-            this.shelvesContainer.addChild(sprite);
-          } else if (sprite._kind === 'crn-track' || sprite._kind === 'rgv-track') {
-            this.tracksContainer.addChild(sprite);
-          } else {
-            this.objectsContainer.addChild(sprite);
-          }
+          this.objectsContainer.addChild(sprite);
           this.pixiStageList[index][idx] = sprite;
         }
       });
@@ -377,7 +772,7 @@
         sprite.textObj = text;
         sprite.position.set(item.posX, item.posY);
         sprite.interactive = true; // 蹇呴』瑕佽缃墠鑳芥帴鏀朵簨浠�
-        sprite.buttonMode = true; // 璁╁厜鏍囧湪hover鏃跺彉涓烘墜鍨嬫寚閽�
+        sprite.buttonMode = true; // 璁╁厜鏍囧湪hover鏃跺彉涓烘墜鍨嬫寚浜嬩欢
         sprite.on('pointerdown', () => {
           if (window.gsap) { window.gsap.killTweensOf(sprite); }
           sprite.alpha = 1;
@@ -443,7 +838,7 @@
         sprite.textObj = text;
         sprite.position.set(item.posX, item.posY);
         sprite.interactive = true; // 蹇呴』瑕佽缃墠鑳芥帴鏀朵簨浠�
-        sprite.buttonMode = true; // 璁╁厜鏍囧湪hover鏃跺彉涓烘墜鍨嬫寚閽�
+        sprite.buttonMode = true; // 璁╁厜鏍囧湪hover鏃跺彉涓烘墜鍨嬫寚浜嬩欢
         sprite.on('pointerdown', () => {
           if (window.gsap) { window.gsap.killTweensOf(sprite); }
           sprite.alpha = 1;
@@ -474,14 +869,9 @@
           if (bottom > contentH) { contentH = bottom; }
         }
       }
-      const vw = this.pixiApp.view.width;
-      const vh = this.pixiApp.view.height;
-      let scale = Math.min(vw / contentW, vh / contentH) * 0.95;
-      if (!isFinite(scale) || scale <= 0) { scale = 1; }
-      const posX = (vw - contentW * scale) / 2;
-      const posY = (vh - contentH * scale) / 2;
-      this.pixiApp.stage.setTransform(posX, posY, scale, scale, 0, 0, 0, 0, 0);
-      this.adjustLabelScale();
+      this.mapContentSize = { width: contentW, height: contentH };
+      this.buildShelfChunks(map, contentW, contentH);
+      this.applyMapTransform(true);
       this.map = map;
       this.isSwitchingFloor = false;
     },
@@ -526,7 +916,6 @@
       if (!sites) { return; }
       sites.forEach((item) => {
         let id = item.siteId != null ? item.siteId : item.stationId;
-        let status = item.siteStatus != null ? item.siteStatus : item.stationStatus;
         let workNo = item.workNo != null ? item.workNo : item.taskNo;
         if (id == null) { return; }
         let sta = this.pixiStaMap.get(parseInt(id));
@@ -537,21 +926,7 @@
           sta.statusObj = null;
           if (sta.textObj.parent !== sta) { sta.addChild(sta.textObj); sta.textObj.position.set(sta.width / 2, sta.height / 2); }
         }
-        if (status === "site-auto") {
-          this.updateColor(sta, 0x78ff81);
-        } else if (status === "site-auto-run" || status === "site-auto-id" || status === "site-auto-run-id") {
-          this.updateColor(sta, 0xfa51f6);
-        } else if (status === "site-unauto") {
-          this.updateColor(sta, 0xb8b8b8);
-        } else if (status === "machine-pakin") {
-          this.updateColor(sta, 0x30bffc);
-        } else if (status === "machine-pakout") {
-          this.updateColor(sta, 0x97b400);
-        } else if (status === "site-run-block") {
-          this.updateColor(sta, 0xe69138);
-        } else {
-          this.updateColor(sta, 0xb8b8b8);
-        }
+        this.setStationBaseColor(sta, this.getStationStatusColor(this.resolveStationStatus(item)));
       });
     },
     getCrnInfo() {
@@ -569,6 +944,38 @@
     getRgvInfo() {
       if (this.isSwitchingFloor) { return; }
       this.sendWs(JSON.stringify({ url: "/console/latest/data/rgv", data: {} }));
+    },
+    getCycleCapacityInfo() {
+      if (this.isSwitchingFloor) { return; }
+      this.sendWs(JSON.stringify({ url: "/console/latest/data/station/cycle/capacity", data: {} }));
+    },
+    setCycleCapacityInfo(res) {
+      const payload = res && res.code === 200 ? res.data : null;
+      if (res && res.code === 403) { parent.location.href = baseUrl + "/login"; return; }
+      if (!payload) { return; }
+      const loopList = Array.isArray(payload.loopList) ? payload.loopList : [];
+      this.cycleCapacity = {
+        loopList: loopList,
+        totalStationCount: payload.totalStationCount || 0,
+        taskStationCount: payload.taskStationCount || 0,
+        currentLoad: typeof payload.currentLoad === 'number' ? payload.currentLoad : parseFloat(payload.currentLoad || 0)
+      };
+      if (this.hoverLoopNo != null) {
+        const targetLoop = loopList.find(v => v && v.loopNo === this.hoverLoopNo);
+        if (targetLoop) {
+          this.hoverLoopStationIdSet = this.buildStationIdSet(targetLoop.stationIdList);
+          this.applyLoopStationHighlight();
+        } else {
+          this.clearLoopStationHighlight();
+        }
+      }
+    },
+    formatLoadPercent(load) {
+      let value = typeof load === 'number' ? load : parseFloat(load || 0);
+      if (!isFinite(value)) { value = 0; }
+      if (value < 0) { value = 0; }
+      if (value > 1) { value = 1; }
+      return (value * 100).toFixed(1) + "%";
     },
     setCrnInfo(res) {
       let crns = Array.isArray(res) ? res : (res && res.code === 200 ? res.data : null);
@@ -724,6 +1131,7 @@
       if (this.wsReconnectTimer) { clearTimeout(this.wsReconnectTimer); this.wsReconnectTimer = null; }
       this.wsReconnectAttempts = 0;
       this.getMap(this.currentLev);
+      this.getCycleCapacityInfo();
     },
     webSocketOnError(e) {
       this.scheduleReconnect();
@@ -738,6 +1146,8 @@
         this.setDualCrnInfo(JSON.parse(result.data));
       } else if (result.url === "/console/latest/data/rgv") {
         this.setRgvInfo(JSON.parse(result.data));
+      } else if (result.url === "/console/latest/data/station/cycle/capacity") {
+        this.setCycleCapacityInfo(JSON.parse(result.data));
       } else if (typeof result.url === "string" && result.url.indexOf("/basMap/lev/") === 0) {
         this.setMap(JSON.parse(result.data));
       }
@@ -779,11 +1189,12 @@
       }
       return new PIXI.Sprite(texture);
     },
-    createTrackSprite(width, height) {
-      let idx = width + "-" + height;
+    createTrackSprite(width, height, mask) {
+      const trackMask = mask != null ? mask : 10;
+      let idx = width + "-" + height + "-" + trackMask;
       let texture = this.pixiTrackMap.get(idx);
       if (texture == undefined) {
-        texture = this.createTrackTexture(width, height);
+        texture = this.createTrackTexture(width, height, trackMask);
         this.pixiTrackMap.set(idx, texture);
       }
       return new PIXI.Sprite(texture);
@@ -798,26 +1209,71 @@
       graphics.endFill();
       return graphics;
     },
-    createTrackTexture(width, height) {
+    createTrackTexture(width, height, mask) {
+      const TRACK_N = 1;
+      const TRACK_E = 2;
+      const TRACK_S = 4;
+      const TRACK_W = 8;
+      const trackMask = mask != null ? mask : (TRACK_E | TRACK_W);
       const g = new PIXI.Graphics();
-      const t = Math.max(2, Math.round(height * 0.08));
-      const gap = Math.round(height * 0.30);
-      const mid = Math.round(height / 2);
-      const y1 = mid - Math.round(gap / 2);
-      const y2 = mid + Math.round(gap / 2);
-      const tHalf = Math.round(t / 2);
-      const topRailTopY = y1 - tHalf;
-      const bottomRailTopY = y2 - tHalf;
-      g.beginFill(0x666666);
-      g.drawRect(0, topRailTopY, width, t);
-      g.drawRect(0, bottomRailTopY, width, t);
-      g.endFill();
-      g.beginFill(0x777777);
-      const sTop = topRailTopY + t;
-      const sBottom = bottomRailTopY;
-      const sHeight = Math.max(1, sBottom - sTop);
-      for (let i = 0; i < width; i += 5) { g.drawRect(i, sTop, 2, sHeight); }
-      g.endFill();
+      const size = Math.max(1, Math.min(width, height));
+      const rail = Math.max(2, Math.round(size * 0.12));
+      const gap = Math.max(4, Math.round(size * 0.38));
+      const midX = Math.round(width / 2);
+      const midY = Math.round(height / 2);
+      const y1 = midY - Math.round(gap / 2);
+      const y2 = midY + Math.round(gap / 2);
+      const x1 = midX - Math.round(gap / 2);
+      const x2 = midX + Math.round(gap / 2);
+
+      const hasN = (trackMask & TRACK_N) !== 0;
+      const hasE = (trackMask & TRACK_E) !== 0;
+      const hasS = (trackMask & TRACK_S) !== 0;
+      const hasW = (trackMask & TRACK_W) !== 0;
+
+      const hStart = hasW ? 0 : midX;
+      const hEnd = hasE ? width : midX;
+      const vStart = hasN ? 0 : midY;
+      const vEnd = hasS ? height : midY;
+
+      const railColor = 0x555555;
+      const drawLine = (x1p, y1p, x2p, y2p, w, color) => {
+        g.lineStyle(w, color, 1);
+        g.moveTo(x1p, y1p);
+        g.lineTo(x2p, y2p);
+      };
+
+      const hasH = hasW || hasE;
+      const hasV = hasN || hasS;
+      const isCorner = hasH && hasV && !(hasW && hasE) && !(hasN && hasS);
+      if (hasH && !isCorner) {
+        const w = Math.max(1, hEnd - hStart);
+        g.beginFill(railColor);
+        g.drawRect(hStart, midY - Math.round(rail / 2), w, rail);
+        g.endFill();
+      }
+      if (hasV && !isCorner) {
+        const h = Math.max(1, vEnd - vStart);
+        g.beginFill(railColor);
+        g.drawRect(midX - Math.round(rail / 2), vStart, rail, h);
+        g.endFill();
+      }
+      if (isCorner) {
+        const cw = hasE;
+        const ch = hasS;
+        const cx = cw ? (width - 1) : 0;
+        const cy = ch ? (height - 1) : 0;
+        const angStart = (cw && ch) ? Math.PI : (cw ? Math.PI / 2 : (ch ? -Math.PI / 2 : 0));
+        const angEnd = (cw && ch) ? Math.PI * 1.5 : (cw ? Math.PI : (ch ? 0 : Math.PI / 2));
+        const rX = Math.abs(cx - midX);
+        const rY = Math.abs(cy - midY);
+        const rMid = Math.min(rX, rY);
+        g.lineStyle(rail, railColor, 1);
+        g.arc(cx, cy, rMid, angStart, angEnd);
+        g.lineStyle(0, 0, 0);
+      }
+
+      // no sleepers; keep a single continuous line
       const rt = PIXI.RenderTexture.create({ width: width, height: height });
       this.pixiApp.renderer.render(g, rt);
       return rt;
@@ -1000,6 +1456,49 @@
       const brightness = (r * 299 + g * 587 + b * 114) / 1000;
       return brightness > 150 ? '#000000' : '#ffffff';
     },
+    getStationStatusColor(status) {
+      const colorMap = this.stationStatusColors || this.getDefaultStationStatusColors();
+      if (status && colorMap[status] != null) { return colorMap[status]; }
+      return colorMap['site-unauto'] != null ? colorMap['site-unauto'] : 0xb8b8b8;
+    },
+    resolveStationStatus(item) {
+      const status = item && (item.siteStatus != null ? item.siteStatus : item.stationStatus);
+      const taskNo = this.parseStationTaskNo(item && (item.workNo != null ? item.workNo : item.taskNo));
+      const autoing = !!(item && item.autoing);
+      const loading = !!(item && item.loading);
+      const runBlock = !!(item && item.runBlock);
+      const enableIn = !!(item && item.enableIn);
+      if (taskNo === 9998 || enableIn) { return 'site-enable-in'; }
+      if (autoing && loading && taskNo > 0 && !runBlock) {
+        const taskClass = this.getStationTaskClass(taskNo);
+        if (taskClass) { return taskClass; }
+      }
+      if (status) { return status; }
+      if (autoing && loading && taskNo > 0 && runBlock) { return 'site-run-block'; }
+      if (autoing && loading && taskNo > 0) { return 'site-auto-run-id'; }
+      if (autoing && loading) { return 'site-auto-run'; }
+      if (autoing && taskNo > 0) { return 'site-auto-id'; }
+      if (autoing) { return 'site-auto'; }
+      return 'site-unauto';
+    },
+    parseStationTaskNo(value) {
+      const taskNo = parseInt(value, 10);
+      return isNaN(taskNo) ? 0 : taskNo;
+    },
+    getStationTaskClass(taskNo) {
+      if (!(taskNo > 0)) { return null; }
+      const range = this.stationTaskRange || {};
+      if (this.isTaskNoInRange(taskNo, range.inbound)) { return 'machine-pakin'; }
+      if (this.isTaskNoInRange(taskNo, range.outbound)) { return 'machine-pakout'; }
+      return null;
+    },
+    isTaskNoInRange(taskNo, range) {
+      if (!range) { return false; }
+      const start = parseInt(range.start, 10);
+      const end = parseInt(range.end, 10);
+      if (isNaN(start) || isNaN(end)) { return false; }
+      return taskNo >= start && taskNo <= end;
+    },
     getCrnStatusColor(status) {
       if (status === "machine-auto") { return 0x21BA45; }
       if (status === "machine-un-auto") { return 0xBBBBBB; }
@@ -1031,6 +1530,13 @@
         }
         sprite = new PIXI.Sprite(texture);
         sprite._kind = 'devp';
+        const directionOverlay = this.createStationDirectionOverlay(item.width, item.height, item.stationDirectionList);
+        if (directionOverlay) {
+          directionOverlay.visible = this.showStationDirection;
+          sprite.addChild(directionOverlay);
+        }
+        sprite.directionObj = directionOverlay;
+        sprite._stationDirectionList = Array.isArray(item.stationDirectionList) ? item.stationDirectionList.slice() : [];
         let siteId = this.getStationId(value);
         if (siteId === -1) { siteId = item.data; }
         const style = new PIXI.TextStyle({ fontFamily: 'Arial', fontSize: 10, fill: '#000000', stroke: '#ffffff', strokeThickness: 1 });
@@ -1039,7 +1545,11 @@
         text.position.set(sprite.width / 2, sprite.height / 2);
         sprite.addChild(text);
         sprite.textObj = text;
-        if (siteId != null && siteId !== -1) { this.pixiStaMap.set(parseInt(siteId), sprite); }
+        const stationIdInt = parseInt(siteId, 10);
+        if (!isNaN(stationIdInt)) { this.pixiStaMap.set(stationIdInt, sprite); }
+        sprite._stationId = isNaN(stationIdInt) ? null : stationIdInt;
+        sprite._baseColor = 0x00ff7f;
+        sprite._loopHighlighted = false;
         sprite.interactive = true;
         sprite.buttonMode = true;
         sprite.on('pointerdown', () => {
@@ -1049,15 +1559,15 @@
           if (!isNaN(id)) { this.$emit('station-click', id); }
         });
       } else if (item.type == 'crn') {
-        sprite = this.createTrackSprite(item.width, item.height);
+        sprite = this.createTrackSprite(item.width, item.height, item.trackMask);
         sprite._kind = 'crn-track';
         if (this.getDeviceNo(value) > 0) { this.crnList.push(item); }
       } else if (item.type == 'dualCrn') {
-        sprite = this.createTrackSprite(item.width, item.height);
+        sprite = this.createTrackSprite(item.width, item.height, item.trackMask);
         sprite._kind = 'crn-track';
         if (this.getDeviceNo(value) > 0) { this.dualCrnList.push(item); }
       } else if (item.type == 'rgv') {
-        sprite = this.createTrackSprite(item.width, item.height);
+        sprite = this.createTrackSprite(item.width, item.height, item.trackMask);
         sprite._kind = 'rgv-track';
         if (this.getDeviceNo(value) > 0) { this.rgvList.push(item); }
       } else {
@@ -1065,6 +1575,159 @@
       }
       sprite.position.set(item.posX, item.posY);
       return sprite;
+    },
+    collectTrackItem(item) {
+      const value = item.value;
+      if (item.type === 'crn') {
+        if (this.getDeviceNo(value) > 0) { this.crnList.push(item); }
+      } else if (item.type === 'dualCrn') {
+        if (this.getDeviceNo(value) > 0) { this.dualCrnList.push(item); }
+      } else if (item.type === 'rgv') {
+        if (this.getDeviceNo(value) > 0) { this.rgvList.push(item); }
+      }
+    },
+    isTrackType(cell) {
+      return cell && (cell.type === 'crn' || cell.type === 'dualCrn' || cell.type === 'rgv');
+    },
+    resolveMergedCell(map, rowIndex, colIndex) {
+      if (!map || rowIndex < 0 || colIndex < 0) { return null; }
+      const row = map[rowIndex];
+      if (!row || colIndex >= row.length) { return null; }
+      const cell = row[colIndex];
+      if (!cell) { return null; }
+      if (!cell.isMergedPart && cell.type !== 'merge') { return cell; }
+      if (cell.isMergedPart) {
+        for (let c = colIndex - 1; c >= 0; c--) {
+          const left = row[c];
+          if (!left) { continue; }
+          if (!left.isMergedPart && left.type !== 'merge' && left.posX === cell.posX) { return left; }
+        }
+      }
+      if (cell.type === 'merge') {
+        for (let r = rowIndex - 1; r >= 0; r--) {
+          const upRow = map[r];
+          if (!upRow || colIndex >= upRow.length) { continue; }
+          const up = upRow[colIndex];
+          if (!up) { continue; }
+          if (up.type !== 'merge') { return up; }
+        }
+      }
+      return null;
+    },
+    getTrackMask(map, rowIndex, colIndex) {
+      const TRACK_N = 1;
+      const TRACK_E = 2;
+      const TRACK_S = 4;
+      const TRACK_W = 8;
+      const baseRow = map[rowIndex];
+      if (!baseRow) { return 0; }
+      const base = baseRow[colIndex];
+      if (!this.isTrackType(base)) { return 0; }
+      const rowSpan = base.rowSpan || 1;
+      const colSpan = base.colSpan || 1;
+      let mask = 0;
+      const n = this.resolveMergedCell(map, rowIndex - 1, colIndex);
+      const s = this.resolveMergedCell(map, rowIndex + rowSpan, colIndex);
+      const w = this.resolveMergedCell(map, rowIndex, colIndex - 1);
+      const e = this.resolveMergedCell(map, rowIndex, colIndex + colSpan);
+      if (n && n !== base && this.isTrackType(n)) { mask |= TRACK_N; }
+      if (e && e !== base && this.isTrackType(e)) { mask |= TRACK_E; }
+      if (s && s !== base && this.isTrackType(s)) { mask |= TRACK_S; }
+      if (w && w !== base && this.isTrackType(w)) { mask |= TRACK_W; }
+      if (mask === 0) { mask = TRACK_E | TRACK_W; }
+      return mask;
+    },
+    drawTracks(map) {
+      if (!this.tracksGraphics) { return; }
+      this.tracksGraphics.clear();
+      const rail = 3;
+      const color = 0x555555;
+      this.tracksGraphics.lineStyle({ width: rail, color: color, alpha: 1, cap: PIXI.LINE_CAP.ROUND, join: PIXI.LINE_JOIN.ROUND });
+      const drawn = new Set();
+      const toKey = (p) => {
+        const x = Math.round(p.x * 100) / 100;
+        const y = Math.round(p.y * 100) / 100;
+        return x + "," + y;
+      };
+      const edgeKey = (a, b) => {
+        const ka = toKey(a);
+        const kb = toKey(b);
+        return ka < kb ? (ka + "|" + kb) : (kb + "|" + ka);
+      };
+      const centerOf = (cell) => ({ x: cell.posX + cell.width / 2, y: cell.posY + cell.height / 2 });
+
+      for (let r = 0; r < map.length; r++) {
+        const row = map[r];
+        if (!row) { continue; }
+        for (let c = 0; c < row.length; c++) {
+          const cell = row[c];
+          if (!cell || cell.type === 'merge' || !this.isTrackType(cell) || cell.isMergedPart) { continue; }
+          const rowSpan = cell.rowSpan || 1;
+          const colSpan = cell.colSpan || 1;
+          const n = this.resolveMergedCell(map, r - 1, c);
+          const s = this.resolveMergedCell(map, r + rowSpan, c);
+          const w = this.resolveMergedCell(map, r, c - 1);
+          const e = this.resolveMergedCell(map, r, c + colSpan);
+          const hasN = n && this.isTrackType(n);
+          const hasE = e && this.isTrackType(e);
+          const hasS = s && this.isTrackType(s);
+          const hasW = w && this.isTrackType(w);
+          const count = (hasN ? 1 : 0) + (hasE ? 1 : 0) + (hasS ? 1 : 0) + (hasW ? 1 : 0);
+          const straight = (hasN && hasS) || (hasE && hasW);
+          if (count === 2 && !straight) {
+            const cPos = centerOf(cell);
+            let p1 = null;
+            let p2 = null;
+            if (hasN && hasE) { p1 = centerOf(n); p2 = centerOf(e); }
+            else if (hasE && hasS) { p1 = centerOf(e); p2 = centerOf(s); }
+            else if (hasS && hasW) { p1 = centerOf(s); p2 = centerOf(w); }
+            else if (hasW && hasN) { p1 = centerOf(w); p2 = centerOf(n); }
+            if (p1 && p2) {
+              const k1 = edgeKey(cPos, p1);
+              const k2 = edgeKey(cPos, p2);
+              if (!drawn.has(k1) || !drawn.has(k2)) {
+                this.tracksGraphics.moveTo(p1.x, p1.y);
+                this.tracksGraphics.lineTo(cPos.x, cPos.y);
+                this.tracksGraphics.lineTo(p2.x, p2.y);
+              }
+              drawn.add(k1);
+              drawn.add(k2);
+            }
+          }
+        }
+      }
+
+      for (let r = 0; r < map.length; r++) {
+        const row = map[r];
+        if (!row) { continue; }
+        for (let c = 0; c < row.length; c++) {
+          const cell = row[c];
+          if (!cell || cell.type === 'merge' || !this.isTrackType(cell) || cell.isMergedPart) { continue; }
+          const cPos = centerOf(cell);
+          const rowSpan = cell.rowSpan || 1;
+          const colSpan = cell.colSpan || 1;
+          const e = this.resolveMergedCell(map, r, c + colSpan);
+          const s = this.resolveMergedCell(map, r + rowSpan, c);
+          if (e && this.isTrackType(e)) {
+            const p = centerOf(e);
+            const k = edgeKey(cPos, p);
+            if (!drawn.has(k)) {
+              this.tracksGraphics.moveTo(cPos.x, cPos.y);
+              this.tracksGraphics.lineTo(p.x, p.y);
+              drawn.add(k);
+            }
+          }
+          if (s && this.isTrackType(s)) {
+            const p = centerOf(s);
+            const k = edgeKey(cPos, p);
+            if (!drawn.has(k)) {
+              this.tracksGraphics.moveTo(cPos.x, cPos.y);
+              this.tracksGraphics.lineTo(p.x, p.y);
+              drawn.add(k);
+            }
+          }
+        }
+      }
     },
     updateColor(sprite, color) {
       if (sprite && sprite._kind === 'devp') {
@@ -1088,6 +1751,74 @@
       }
       sprite.tint = color;
     },
+    setStationBaseColor(sprite, color) {
+      if (!sprite) { return; }
+      sprite._baseColor = color;
+      if (this.isStationInHoverLoop(sprite)) {
+        this.applyHighlightColor(sprite);
+      } else {
+        this.updateColor(sprite, color);
+        sprite._loopHighlighted = false;
+      }
+    },
+    applyHighlightColor(sprite) {
+      if (!sprite) { return; }
+      this.updateColor(sprite, this.loopHighlightColor);
+      sprite._loopHighlighted = true;
+    },
+    isStationInHoverLoop(sprite) {
+      if (!sprite || sprite._stationId == null || !this.hoverLoopStationIdSet) { return false; }
+      return this.hoverLoopStationIdSet.has(sprite._stationId);
+    },
+    buildStationIdSet(stationIdList) {
+      const set = new Set();
+      if (!Array.isArray(stationIdList)) { return set; }
+      stationIdList.forEach((id) => {
+        const v = parseInt(id, 10);
+        if (!isNaN(v)) { set.add(v); }
+      });
+      return set;
+    },
+    applyLoopStationHighlight() {
+      if (!this.pixiStaMap) { return; }
+      this.pixiStaMap.forEach((sprite) => {
+        if (!sprite) { return; }
+        if (this.isStationInHoverLoop(sprite)) {
+          this.applyHighlightColor(sprite);
+        } else if (sprite._loopHighlighted) {
+          const baseColor = (typeof sprite._baseColor === 'number') ? sprite._baseColor : 0xb8b8b8;
+          this.updateColor(sprite, baseColor);
+          sprite._loopHighlighted = false;
+        }
+      });
+    },
+    clearLoopStationHighlight() {
+      if (this.pixiStaMap) {
+        this.pixiStaMap.forEach((sprite) => {
+          if (!sprite || !sprite._loopHighlighted) { return; }
+          const baseColor = (typeof sprite._baseColor === 'number') ? sprite._baseColor : 0xb8b8b8;
+          this.updateColor(sprite, baseColor);
+          sprite._loopHighlighted = false;
+        });
+      }
+      this.hoverLoopNo = null;
+      this.hoverLoopStationIdSet = new Set();
+    },
+    handleLoopCardEnter(loopItem) {
+      if (!loopItem) { return; }
+      this.hoverLoopNo = loopItem.loopNo;
+      this.hoverLoopStationIdSet = this.buildStationIdSet(loopItem.stationIdList);
+      this.applyLoopStationHighlight();
+    },
+    handleLoopCardLeave(loopItem) {
+      if (!loopItem) {
+        this.clearLoopStationHighlight();
+        return;
+      }
+      if (this.hoverLoopNo === loopItem.loopNo) {
+        this.clearLoopStationHighlight();
+      }
+    },
     isJson(str) {
       try { JSON.parse(str); return true; } catch (e) { return false; }
     },
@@ -1100,16 +1831,521 @@
     getStationId(obj) {
       if (this.isJson(obj)) { let data = JSON.parse(obj); if (data.stationId == null || data.stationId == undefined) { return -1; } return data.stationId; } else { return -1; }
     },
+    parseMapValue(obj) {
+      if (obj == null) { return null; }
+      if (typeof obj === 'object') { return obj; }
+      if (!this.isJson(obj)) { return null; }
+      try {
+        return JSON.parse(obj);
+      } catch (e) {
+        return null;
+      }
+    },
+    normalizeDirectionList(direction) {
+      const aliasMap = {
+        top: 'top',
+        up: 'top',
+        north: 'top',
+        bottom: 'bottom',
+        down: 'bottom',
+        south: 'bottom',
+        left: 'left',
+        west: 'left',
+        right: 'right',
+        east: 'right'
+      };
+      let rawList = [];
+      if (Array.isArray(direction)) {
+        rawList = direction;
+      } else if (typeof direction === 'string') {
+        rawList = direction.split(/[,\s|/]+/);
+      }
+      const result = [];
+      const seen = new Set();
+      rawList.forEach((item) => {
+        const key = aliasMap[String(item || '').trim().toLowerCase()];
+        if (!key || seen.has(key)) { return; }
+        seen.add(key);
+        result.push(key);
+      });
+      return result;
+    },
+    resolveStationDirectionList(map, rowIndex, colIndex, item) {
+      const valueObj = this.parseMapValue(item && item.value);
+      const fromValue = this.normalizeDirectionList(valueObj && valueObj.direction);
+      if (fromValue.length > 0) { return fromValue; }
+      const rowSpan = item && item.rowSpan ? item.rowSpan : 1;
+      const colSpan = item && item.colSpan ? item.colSpan : 1;
+      const fallback = [];
+      const candidateList = [
+        { key: 'top', cell: this.resolveMergedCell(map, rowIndex - 1, colIndex) },
+        { key: 'right', cell: this.resolveMergedCell(map, rowIndex, colIndex + colSpan) },
+        { key: 'bottom', cell: this.resolveMergedCell(map, rowIndex + rowSpan, colIndex) },
+        { key: 'left', cell: this.resolveMergedCell(map, rowIndex, colIndex - 1) }
+      ];
+      candidateList.forEach((candidate) => {
+        if (this.isStationDirectionNeighbor(candidate.cell)) {
+          fallback.push(candidate.key);
+        }
+      });
+      return fallback;
+    },
+    isStationDirectionNeighbor(cell) {
+      if (!cell) { return false; }
+      if (cell.type === 'devp') { return true; }
+      return this.isTrackType(cell);
+    },
+    createStationDirectionOverlay(width, height, directionList) {
+      if (!Array.isArray(directionList) || directionList.length === 0) { return null; }
+      const container = new PIXI.Container();
+      const arrowSize = Math.max(4, Math.min(width, height) * 0.22);
+      const margin = Math.max(2, Math.min(width, height) * 0.12);
+      directionList.forEach((direction) => {
+        const arrow = new PIXI.Graphics();
+        this.drawStationDirectionArrow(arrow, width, height, direction, arrowSize, margin);
+        container.addChild(arrow);
+      });
+      return container;
+    },
+    drawStationDirectionArrow(graphics, width, height, direction, size, margin) {
+      if (!graphics) { return; }
+      const halfBase = Math.max(2, size * 0.45);
+      const stemLen = Math.max(3, size * 0.7);
+      const centerX = width / 2;
+      const centerY = height / 2;
+      graphics.beginFill(this.stationDirectionColor, 0.95);
+      if (direction === 'top') {
+        const tipY = margin;
+        const baseY = margin + size;
+        const stemY = Math.min(centerY - 2, baseY + stemLen);
+        if (stemY > baseY) { graphics.moveTo(centerX, stemY); graphics.lineTo(centerX, baseY); }
+        graphics.moveTo(centerX, tipY);
+        graphics.lineTo(centerX - halfBase, baseY);
+        graphics.lineTo(centerX + halfBase, baseY);
+      } else if (direction === 'right') {
+        const tipX = width - margin;
+        const baseX = width - margin - size;
+        const stemX = Math.max(centerX + 2, baseX - stemLen);
+        if (stemX < baseX) { graphics.moveTo(stemX, centerY); graphics.lineTo(baseX, centerY); }
+        graphics.moveTo(tipX, centerY);
+        graphics.lineTo(baseX, centerY - halfBase);
+        graphics.lineTo(baseX, centerY + halfBase);
+      } else if (direction === 'bottom') {
+        const tipY = height - margin;
+        const baseY = height - margin - size;
+        const stemY = Math.max(centerY + 2, baseY - stemLen);
+        if (stemY < baseY) { graphics.moveTo(centerX, stemY); graphics.lineTo(centerX, baseY); }
+        graphics.moveTo(centerX, tipY);
+        graphics.lineTo(centerX - halfBase, baseY);
+        graphics.lineTo(centerX + halfBase, baseY);
+      } else if (direction === 'left') {
+        const tipX = margin;
+        const baseX = margin + size;
+        const stemX = Math.min(centerX - 2, baseX + stemLen);
+        if (stemX > baseX) { graphics.moveTo(stemX, centerY); graphics.lineTo(baseX, centerY); }
+        graphics.moveTo(tipX, centerY);
+        graphics.lineTo(baseX, centerY - halfBase);
+        graphics.lineTo(baseX, centerY + halfBase);
+      }
+      graphics.closePath();
+      graphics.endFill();
+    },
+    applyStationDirectionVisibility() {
+      if (!this.pixiStaMap) { return; }
+      this.pixiStaMap.forEach((sprite) => {
+        if (!sprite || !sprite.directionObj) { return; }
+        sprite.directionObj.visible = this.showStationDirection;
+      });
+    },
     getTrackSiteNo(obj) {
       if (this.isJson(obj)) { let data = JSON.parse(obj); if (data.trackSiteNo == null || data.trackSiteNo == undefined) { return -1; } return data.trackSiteNo; } else { return -1; }
     },
+    buildShelfHitGrid(map, rowHeights, rowOffsets) {
+      if (!map || !Array.isArray(map)) { return; }
+      this.mapRowOffsets = Array.isArray(rowOffsets) ? rowOffsets.slice() : [];
+      this.mapRowHeights = Array.isArray(rowHeights) ? rowHeights.slice() : [];
+      const rowColOffsets = [];
+      const rowColWidths = [];
+      const rowShelfCells = new Array(map.length);
+      let maxCols = 0;
+      for (let r = 0; r < map.length; r++) {
+        const row = map[r];
+        if (row && row.length > maxCols) { maxCols = row.length; }
+        rowShelfCells[r] = [];
+      }
+      const colWidths = new Array(maxCols);
+      for (let c = 0; c < maxCols; c++) {
+        let w = null;
+        for (let r = 0; r < map.length; r++) {
+          const cell = map[r] && map[r][c];
+          if (!cell) { continue; }
+          if (cell.cellWidth != null && cell.cellWidth !== '') {
+            const base = Number(cell.cellWidth);
+            if (isFinite(base) && base > 0) { w = base / 40; break; }
+          }
+        }
+        colWidths[c] = (w && isFinite(w) && w > 0) ? w : 25;
+      }
+      const colOffsets = new Array(maxCols);
+      let xCursor = 0;
+      for (let c = 0; c < maxCols; c++) {
+        colOffsets[c] = xCursor;
+        xCursor += colWidths[c];
+      }
+      for (let r = 0; r < map.length; r++) {
+        const row = map[r];
+        if (!row || row.length === 0) {
+          rowColOffsets[r] = [];
+          rowColWidths[r] = [];
+          continue;
+        }
+        const widths = new Array(row.length);
+        for (let c = 0; c < row.length; c++) {
+          const cell = row[c];
+          let w = null;
+          if (cell && cell.cellWidth != null && cell.cellWidth !== '') {
+            const base = Number(cell.cellWidth);
+            if (isFinite(base) && base > 0) { w = base / 40; }
+          }
+          widths[c] = (w && isFinite(w) && w > 0) ? w : 25;
+        }
+        const offsets = new Array(row.length);
+        let x = 0;
+        for (let c = 0; c < row.length; c++) {
+          offsets[c] = x;
+          x += widths[c];
+        }
+        rowColOffsets[r] = offsets;
+        rowColWidths[r] = widths;
+      }
+      this.mapColWidths = colWidths;
+      this.mapColOffsets = colOffsets;
+      this.mapRowColOffsets = rowColOffsets;
+      this.mapRowColWidths = rowColWidths;
+      this.mapRowShelfCells = rowShelfCells;
+
+      for (let r = 0; r < map.length; r++) {
+        const row = map[r];
+        if (!row) { continue; }
+        for (let c = 0; c < row.length; c++) {
+          const cell = row[c];
+          if (!cell || cell.type !== 'shelf') { continue; }
+          const startRow = this.findIndexByOffsets(this.mapRowOffsets, this.mapRowHeights, cell.posY + 0.01);
+          const endRow = this.findIndexByOffsets(this.mapRowOffsets, this.mapRowHeights, cell.posY + cell.height - 0.01);
+          if (startRow < 0) { continue; }
+          const last = endRow >= 0 ? endRow : startRow;
+          for (let rr = startRow; rr <= last; rr++) {
+            if (!rowShelfCells[rr]) { rowShelfCells[rr] = []; }
+            rowShelfCells[rr].push(cell);
+          }
+        }
+      }
+    },
+    clearShelfChunks() {
+      if (this.shelfCullRaf) {
+        cancelAnimationFrame(this.shelfCullRaf);
+        this.shelfCullRaf = null;
+      }
+      this.shelfChunkList = [];
+      if (!this.shelvesContainer) { return; }
+      const children = this.shelvesContainer.removeChildren();
+      children.forEach((child) => {
+        if (child && typeof child.destroy === 'function') {
+          child.destroy({ children: true, texture: true, baseTexture: true });
+        }
+      });
+    },
+    buildShelfChunks(map, contentW, contentH) {
+      this.clearShelfChunks();
+      if (!this.pixiApp || !this.pixiApp.renderer || !this.shelvesContainer || !Array.isArray(map)) { return; }
+      const chunkSize = Math.max(256, parseInt(this.shelfChunkSize, 10) || 2048);
+      const chunkMap = new Map();
+      for (let r = 0; r < map.length; r++) {
+        const row = map[r];
+        if (!row) { continue; }
+        for (let c = 0; c < row.length; c++) {
+          const cell = row[c];
+          if (!cell || cell.type !== 'shelf' || cell.type === 'merge') { continue; }
+          const startChunkX = Math.floor(cell.posX / chunkSize);
+          const endChunkX = Math.floor((cell.posX + Math.max(1, cell.width) - 0.01) / chunkSize);
+          const startChunkY = Math.floor(cell.posY / chunkSize);
+          const endChunkY = Math.floor((cell.posY + Math.max(1, cell.height) - 0.01) / chunkSize);
+          for (let chunkY = startChunkY; chunkY <= endChunkY; chunkY++) {
+            for (let chunkX = startChunkX; chunkX <= endChunkX; chunkX++) {
+              const key = chunkX + ',' + chunkY;
+              let list = chunkMap.get(key);
+              if (!list) {
+                list = [];
+                chunkMap.set(key, list);
+              }
+              list.push(cell);
+            }
+          }
+        }
+      }
+
+      const chunkList = [];
+      chunkMap.forEach((cells, key) => {
+        const keyParts = key.split(',');
+        const chunkX = parseInt(keyParts[0], 10) || 0;
+        const chunkY = parseInt(keyParts[1], 10) || 0;
+        const chunkLeft = chunkX * chunkSize;
+        const chunkTop = chunkY * chunkSize;
+        const chunkWidth = Math.max(1, Math.min(chunkSize, contentW - chunkLeft));
+        const chunkHeight = Math.max(1, Math.min(chunkSize, contentH - chunkTop));
+        const graphics = new PIXI.Graphics();
+        graphics.beginFill(0xb6e2e2);
+        graphics.lineStyle(1, 0xffffff, 1);
+        for (let i = 0; i < cells.length; i++) {
+          const cell = cells[i];
+          graphics.drawRect(cell.posX - chunkLeft, cell.posY - chunkTop, cell.width, cell.height);
+        }
+        graphics.endFill();
+        const texture = this.pixiApp.renderer.generateTexture(
+          graphics,
+          PIXI.SCALE_MODES.LINEAR,
+          1,
+          new PIXI.Rectangle(0, 0, chunkWidth, chunkHeight)
+        );
+        graphics.destroy(true);
+        const sprite = new PIXI.Sprite(texture);
+        sprite.position.set(chunkLeft, chunkTop);
+        sprite._chunkBounds = {
+          x: chunkLeft,
+          y: chunkTop,
+          width: chunkWidth,
+          height: chunkHeight
+        };
+        this.shelvesContainer.addChild(sprite);
+        chunkList.push(sprite);
+      });
+      this.shelfChunkList = chunkList;
+      this.updateVisibleShelfChunks();
+    },
+    getViewportLocalBounds(padding) {
+      if (!this.mapRoot || !this.pixiApp) { return null; }
+      const viewport = this.getViewportSize();
+      const pad = Math.max(0, Number(padding) || 0);
+      const points = [
+        new PIXI.Point(-pad, -pad),
+        new PIXI.Point(viewport.width + pad, -pad),
+        new PIXI.Point(-pad, viewport.height + pad),
+        new PIXI.Point(viewport.width + pad, viewport.height + pad)
+      ];
+      let minX = Infinity;
+      let minY = Infinity;
+      let maxX = -Infinity;
+      let maxY = -Infinity;
+      points.forEach((point) => {
+        const local = this.mapRoot.toLocal(point);
+        if (local.x < minX) { minX = local.x; }
+        if (local.y < minY) { minY = local.y; }
+        if (local.x > maxX) { maxX = local.x; }
+        if (local.y > maxY) { maxY = local.y; }
+      });
+      if (!isFinite(minX) || !isFinite(minY) || !isFinite(maxX) || !isFinite(maxY)) { return null; }
+      return { minX: minX, minY: minY, maxX: maxX, maxY: maxY };
+    },
+    updateVisibleShelfChunks() {
+      if (!this.shelfChunkList || this.shelfChunkList.length === 0) { return; }
+      const localBounds = this.getViewportLocalBounds(this.shelfCullPadding);
+      if (!localBounds) { return; }
+      for (let i = 0; i < this.shelfChunkList.length; i++) {
+        const sprite = this.shelfChunkList[i];
+        const bounds = sprite && sprite._chunkBounds;
+        if (!bounds) { continue; }
+        const visible = bounds.x < localBounds.maxX &&
+          bounds.x + bounds.width > localBounds.minX &&
+          bounds.y < localBounds.maxY &&
+          bounds.y + bounds.height > localBounds.minY;
+        if (sprite.visible !== visible) {
+          sprite.visible = visible;
+        }
+      }
+    },
+    scheduleShelfChunkCulling() {
+      if (this.shelfCullRaf) { return; }
+      this.shelfCullRaf = requestAnimationFrame(() => {
+        this.shelfCullRaf = null;
+        this.updateVisibleShelfChunks();
+      });
+    },
+    findIndexByOffsets(offsets, sizes, value) {
+      if (!offsets || !sizes || offsets.length === 0) { return -1; }
+      for (let i = 0; i < offsets.length; i++) {
+        const start = offsets[i];
+        const end = start + (sizes[i] || 0);
+        if (value >= start && value < end) { return i; }
+      }
+      return -1;
+    },
+    updateShelfHoverFromPointer(globalPos) {
+      if (!this.map || !this.mapRoot) { return; }
+      if (!this.mapRowOffsets.length || !this.mapColOffsets.length) { return; }
+      const local = this.mapRoot.toLocal(new PIXI.Point(globalPos.x, globalPos.y));
+      const rowIndex = this.findIndexByOffsets(this.mapRowOffsets, this.mapRowHeights, local.y);
+      if (rowIndex < 0) { if (this.hoveredShelfCell) { this.hoveredShelfCell = null; this.hideShelfTooltip(); } return; }
+      let cell = null;
+      if (this.mapRowShelfCells && this.mapRowShelfCells[rowIndex]) {
+        const list = this.mapRowShelfCells[rowIndex];
+        for (let i = 0; i < list.length; i++) {
+          const it = list[i];
+          if (!it) { continue; }
+          if (local.x >= it.posX && local.x < it.posX + it.width &&
+              local.y >= it.posY && local.y < it.posY + it.height) {
+            cell = it;
+            break;
+          }
+        }
+      }
+      if (!cell || cell.type !== 'shelf') { if (this.hoveredShelfCell) { this.hoveredShelfCell = null; this.hideShelfTooltip(); } return; }
+      if (this.hoveredShelfCell !== cell) {
+        this.hoveredShelfCell = cell;
+        this.shelfTooltip.item = cell;
+        this.shelfTooltip.text = this.getShelfArrangeInfo(cell);
+        this.shelfTooltip.visible = true;
+      }
+      this.updateShelfTooltipPositionByGlobal(globalPos);
+    },
+    normalizeLocTypeKey(value) {
+      if (value == null) { return null; }
+      const str = String(value).trim();
+      if (!str) { return null; }
+      const parts = str.split('-').filter(p => p !== '');
+      if (parts.length >= 3) { return parts.slice(0, parts.length - 1).join('-'); }
+      return str;
+    },
+    loadLocList() {
+      if (!window.$ || typeof baseUrl === 'undefined') { return; }
+      if (this.locListLoading) { return; }
+      this.locListLoading = true;
+      $.ajax({
+        url: baseUrl + "/console/map/locList",
+        headers: { 'token': localStorage.getItem('token') },
+        dataType: 'json',
+        method: 'GET',
+        success: (res) => {
+          if (res && !Array.isArray(res)) {
+            if (res.code === 403) { parent.location.href = baseUrl + "/login"; return; }
+            if (res.code !== 200) { return; }
+          }
+          const list = Array.isArray(res) ? res : (res && res.code === 200 ? res.data : null);
+          if (!list || !Array.isArray(list)) { return; }
+          const map = new Map();
+          list.forEach((item) => {
+            if (!item) { return; }
+            const locType = item.locType != null ? item.locType : item.loc_type;
+            if (locType != null && locType !== '') {
+              const normalizedType = this.normalizeLocTypeKey(locType);
+              if (normalizedType && !map.has(normalizedType)) { map.set(normalizedType, item); }
+            }
+          });
+          this.locListMap = map;
+          this.locListLoaded = true;
+          if (this.shelfTooltip.visible) {
+            this.shelfTooltip.text = this.getShelfArrangeInfo(this.shelfTooltip.item);
+          }
+        },
+        complete: () => {
+          this.locListLoading = false;
+        }
+      });
+    },
+    showShelfTooltip(e, item) {
+      if (!item) { return; }
+      if (!this.isShelfTooltipAllowed()) { this.hideShelfTooltip(); return; }
+      if (!this.locListLoaded && !this.locListLoading) { this.loadLocList(); }
+      this.shelfTooltip.item = item;
+      this.shelfTooltip.text = this.getShelfArrangeInfo(item);
+      this.updateShelfTooltipPosition(e);
+      this.shelfTooltip.visible = true;
+    },
+    updateShelfTooltipPosition(e) {
+      if (!e || !e.data || !e.data.global) { return; }
+      this.updateShelfTooltipPositionByGlobal(e.data.global);
+    },
+    updateShelfTooltipPositionByGlobal(globalPos) {
+      if (!this.isShelfTooltipAllowed()) { this.hideShelfTooltip(); return; }
+      if (!globalPos) { return; }
+      this.shelfTooltip.x = globalPos.x + 12;
+      this.shelfTooltip.y = globalPos.y + 12;
+    },
+    hideShelfTooltip() {
+      this.shelfTooltip.visible = false;
+      this.shelfTooltip.item = null;
+    },
+    isShelfTooltipAllowed() {
+      return this.getStageAbsScale() >= this.shelfTooltipMinScale;
+    },
+    getStageAbsScale() {
+      if (!this.pixiApp || !this.pixiApp.stage) { return 1; }
+      return Math.abs(this.pixiApp.stage.scale.x || 1);
+    },
+    updateShelfTooltipVisibilityByScale() {
+      if (this.shelfTooltip.visible && !this.isShelfTooltipAllowed()) {
+        this.hideShelfTooltip();
+        this.hoveredShelfCell = null;
+      }
+    },
+    getShelfArrangeInfo(item) {
+      const parts = [];
+      const matchKey = this.getShelfMatchKey(item);
+      if (matchKey != null) { parts.push('鍧愭爣:' + matchKey); }
+      const locInfo = (matchKey != null) ? this.locListMap.get(matchKey) : null;
+      if (locInfo) {
+        const locNo = locInfo.locNo != null ? locInfo.locNo : locInfo.loc_no;
+        const displayLocNo = this.stripLocLayer(locNo);
+        if (displayLocNo != null) { parts.push('鎺掑垪:' + displayLocNo); }
+      }
+      return parts.join('  ');
+    },
+    getShelfMatchKey(item) {
+      if (!item) { return null; }
+      const direct = item.locType != null ? item.locType : (item.loc_type != null ? item.loc_type : null);
+      const directKey = this.normalizeLocTypeKey(direct);
+      if (directKey) { return directKey; }
+      const rowIndex = item.rowIndex;
+      const colIndex = item.colIndex;
+      if (rowIndex == null || colIndex == null) { return null; }
+      const key0 = rowIndex + '-' + colIndex;
+      if (this.locListLoaded && this.locListMap && this.locListMap.size > 0) {
+        if (this.locListMap.has(key0)) { return key0; }
+      }
+      return null;
+    },
+    stripLocLayer(locNo) {
+      if (locNo == null) { return null; }
+      const str = String(locNo).trim();
+      if (!str) { return null; }
+      const parts = str.split('-').filter(p => p !== '');
+      if (parts.length >= 3) { return parts.slice(0, parts.length - 1).join('-'); }
+      return str;
+    },
+    shelfTooltipStyle() {
+      return {
+        position: 'absolute',
+        left: this.shelfTooltip.x + 'px',
+        top: this.shelfTooltip.y + 'px',
+        background: 'rgba(0,0,0,0.75)',
+        color: '#ffffff',
+        padding: '4px 8px',
+        borderRadius: '4px',
+        fontSize: '12px',
+        pointerEvents: 'none',
+        whiteSpace: 'nowrap',
+        zIndex: 10
+      };
+    },
     adjustLabelScale() {
-      const s = this.pixiApp && this.pixiApp.stage ? (this.pixiApp.stage.scale.x || 1) : 1;
+      const s = this.pixiApp && this.pixiApp.stage ? Math.abs(this.pixiApp.stage.scale.x || 1) : 1;
       const minPx = 14;
-      const vw = this.pixiApp.view.width;
-      const vh = this.pixiApp.view.height;
-      const pos = this.pixiApp.stage.position;
+      const viewport = this.getViewportSize();
+      const vw = viewport.width;
+      const vh = viewport.height;
       const margin = 50;
+      const mirrorSign = this.mapMirrorX ? -1 : 1;
+      const inverseRotation = -((this.mapRotation % 360) * Math.PI / 180);
+      const tmpPoint = new PIXI.Point();
       this.pixiStaMap && this.pixiStaMap.forEach((sprite) => {
         const textObj = sprite && sprite.textObj;
         if (!textObj) { return; }
@@ -1117,11 +2353,11 @@
         let scale = minPx / (base * s);
         if (!isFinite(scale)) { scale = 1; }
         scale = Math.max(0.8, Math.min(scale, 3));
-        textObj.scale.set(scale);
+        textObj.scale.set(scale * mirrorSign, scale);
+        textObj.rotation = inverseRotation;
         textObj.position.set(sprite.width / 2, sprite.height / 2);
-        const sx = pos.x + sprite.x * s;
-        const sy = pos.y + sprite.y * s;
-        const on = sx >= -margin && sy >= -margin && sx <= vw + margin && sy <= vh + margin;
+        sprite.getGlobalPosition(tmpPoint);
+        const on = tmpPoint.x >= -margin && tmpPoint.y >= -margin && tmpPoint.x <= vw + margin && tmpPoint.y <= vh + margin;
         textObj.visible = (s >= 0.25) && on;
       });
       this.pixiCrnMap && this.pixiCrnMap.forEach((sprite) => {
@@ -1131,11 +2367,11 @@
         let scale = minPx / (base * s);
         if (!isFinite(scale)) { scale = 1; }
         scale = Math.max(0.8, Math.min(scale, 3));
-        textObj.scale.set(scale);
+        textObj.scale.set(scale * mirrorSign, scale);
+        textObj.rotation = inverseRotation;
         textObj.position.set(sprite.width / 2, sprite.height / 2);
-        const sx = pos.x + sprite.x * s;
-        const sy = pos.y + sprite.y * s;
-        const on = sx >= -margin && sy >= -margin && sx <= vw + margin && sy <= vh + margin;
+        sprite.getGlobalPosition(tmpPoint);
+        const on = tmpPoint.x >= -margin && tmpPoint.y >= -margin && tmpPoint.x <= vw + margin && tmpPoint.y <= vh + margin;
         textObj.visible = (s >= 0.25) && on;
       });
       this.pixiDualCrnMap && this.pixiDualCrnMap.forEach((sprite) => {
@@ -1145,11 +2381,11 @@
         let scale = minPx / (base * s);
         if (!isFinite(scale)) { scale = 1; }
         scale = Math.max(0.8, Math.min(scale, 3));
-        textObj.scale.set(scale);
+        textObj.scale.set(scale * mirrorSign, scale);
+        textObj.rotation = inverseRotation;
         textObj.position.set(sprite.width / 2, sprite.height / 2);
-        const sx = pos.x + sprite.x * s;
-        const sy = pos.y + sprite.y * s;
-        const on = sx >= -margin && sy >= -margin && sx <= vw + margin && sy <= vh + margin;
+        sprite.getGlobalPosition(tmpPoint);
+        const on = tmpPoint.x >= -margin && tmpPoint.y >= -margin && tmpPoint.x <= vw + margin && tmpPoint.y <= vh + margin;
         textObj.visible = (s >= 0.25) && on;
       });
       this.pixiRgvMap && this.pixiRgvMap.forEach((sprite) => {
@@ -1159,20 +2395,277 @@
         let scale = minPx / (base * s);
         if (!isFinite(scale)) { scale = 1; }
         scale = Math.max(0.8, Math.min(scale, 3));
-        textObj.scale.set(scale);
+        textObj.scale.set(scale * mirrorSign, scale);
+        textObj.rotation = inverseRotation;
         textObj.position.set(sprite.width / 2, sprite.height / 2);
-        const sx = pos.x + sprite.x * s;
-        const sy = pos.y * s + pos.y;
-        const on = sx >= -margin && sy >= -margin && sx <= vw + margin && sy <= vh + margin;
+        sprite.getGlobalPosition(tmpPoint);
+        const on = tmpPoint.x >= -margin && tmpPoint.y >= -margin && tmpPoint.x <= vw + margin && tmpPoint.y <= vh + margin;
         textObj.visible = (s >= 0.25) && on;
       });
+    },
+    rotateMap() {
+      this.mapRotation = (this.mapRotation + 90) % 360;
+      this.applyMapTransform(true);
+      this.saveMapTransformConfig();
+    },
+    toggleStationDirection() {
+      this.showStationDirection = !this.showStationDirection;
+      this.applyStationDirectionVisibility();
+    },
+    toggleMirror() {
+      this.mapMirrorX = !this.mapMirrorX;
+      this.applyMapTransform(true);
+      this.saveMapTransformConfig();
+    },
+    openStationColorConfigPage() {
+      if (typeof window === 'undefined') { return; }
+      const url = (typeof baseUrl !== 'undefined' ? baseUrl : '') + '/views/watch/stationColorConfig.html';
+      const layerInstance = (window.top && window.top.layer) || window.layer;
+      if (layerInstance && typeof layerInstance.open === 'function') {
+        layerInstance.open({
+          type: 2,
+          title: '绔欑偣棰滆壊閰嶇疆',
+          maxmin: true,
+          area: ['980px', '760px'],
+          shadeClose: false,
+          content: url
+        });
+        return;
+      }
+      window.open(url, '_blank');
+    },
+    parseRotation(value) {
+      const num = parseInt(value, 10);
+      if (!isFinite(num)) { return 0; }
+      const rot = ((num % 360) + 360) % 360;
+      return (rot === 90 || rot === 180 || rot === 270) ? rot : 0;
+    },
+    parseMirror(value) {
+      if (value === true || value === false) { return value; }
+      if (value == null) { return false; }
+      const str = String(value).toLowerCase();
+      return str === '1' || str === 'true' || str === 'y';
+    },
+    getDefaultStationStatusColors() {
+      return {
+        'site-auto': 0x78ff81,
+        'site-auto-run': 0xfa51f6,
+        'site-auto-id': 0xc4c400,
+        'site-auto-run-id': 0x30bffc,
+        'site-enable-in': 0xA81DEE,
+        'site-unauto': 0xb8b8b8,
+        'machine-pakin': 0x30bffc,
+        'machine-pakout': 0x97b400,
+        'site-run-block': 0xe69138
+      };
+    },
+    parseColorConfigValue(value, fallback) {
+      if (typeof value === 'number' && isFinite(value)) {
+        return value;
+      }
+      const str = String(value == null ? '' : value).trim();
+      if (!str) { return fallback; }
+      if (/^#[0-9a-fA-F]{6}$/.test(str)) { return parseInt(str.slice(1), 16); }
+      if (/^#[0-9a-fA-F]{3}$/.test(str)) {
+        const expanded = str.charAt(1) + str.charAt(1) + str.charAt(2) + str.charAt(2) + str.charAt(3) + str.charAt(3);
+        return parseInt(expanded, 16);
+      }
+      if (/^0x[0-9a-fA-F]{6}$/i.test(str)) { return parseInt(str.slice(2), 16); }
+      if (/^[0-9]+$/.test(str)) {
+        const num = parseInt(str, 10);
+        return isNaN(num) ? fallback : num;
+      }
+      return fallback;
+    },
+    loadStationColorConfig() {
+      if (!window.$ || typeof baseUrl === 'undefined') { return; }
+      $.ajax({
+        url: baseUrl + "/watch/stationColor/config/auth",
+        headers: { 'token': localStorage.getItem('token') },
+        dataType: 'json',
+        method: 'GET',
+        success: (res) => {
+          if (!res || res.code !== 200 || !res.data) {
+            if (res && res.code === 403) { parent.location.href = baseUrl + "/login"; }
+            return;
+          }
+          this.applyStationColorConfigPayload(res.data);
+        }
+      });
+    },
+    applyStationColorConfigPayload(data) {
+      const defaults = this.getDefaultStationStatusColors();
+      const nextColors = Object.assign({}, defaults);
+      const items = Array.isArray(data.items) ? data.items : [];
+      items.forEach((item) => {
+        if (!item || !item.status || defaults[item.status] == null) { return; }
+        nextColors[item.status] = this.parseColorConfigValue(item.color, defaults[item.status]);
+      });
+      this.stationStatusColors = nextColors;
+    },
+    buildMissingMapConfigList(byCode) {
+      const createList = [];
+      if (!byCode[this.mapConfigCodes.rotate]) {
+        createList.push({
+          name: '鍦板浘鏃嬭浆',
+          code: this.mapConfigCodes.rotate,
+          value: String(this.mapRotation || 0),
+          type: 1,
+          status: 1,
+          selectType: 'map'
+        });
+      }
+      if (!byCode[this.mapConfigCodes.mirror]) {
+        createList.push({
+          name: '鍦板浘闀滃儚',
+          code: this.mapConfigCodes.mirror,
+          value: this.mapMirrorX ? '1' : '0',
+          type: 1,
+          status: 1,
+          selectType: 'map'
+        });
+      }
+      return createList;
+    },
+    createMapConfigs(createList) {
+      if (!window.$ || typeof baseUrl === 'undefined' || !Array.isArray(createList) || createList.length === 0) { return; }
+      createList.forEach((cfg) => {
+        $.ajax({
+          url: baseUrl + "/config/add/auth",
+          headers: { 'token': localStorage.getItem('token') },
+          method: 'POST',
+          data: cfg
+        });
+      });
+    },
+    loadMapTransformConfig() {
+      if (!window.$ || typeof baseUrl === 'undefined') { return; }
+      $.ajax({
+        url: baseUrl + "/config/listAll/auth",
+        headers: { 'token': localStorage.getItem('token') },
+        dataType: 'json',
+        method: 'GET',
+        success: (res) => {
+          if (!res || res.code !== 200 || !Array.isArray(res.data)) {
+            if (res && res.code === 403) { parent.location.href = baseUrl + "/login"; }
+            return;
+          }
+          const byCode = {};
+          res.data.forEach((item) => {
+            if (item && item.code) { byCode[item.code] = item; }
+          });
+          const rotateCfg = byCode[this.mapConfigCodes.rotate];
+          const mirrorCfg = byCode[this.mapConfigCodes.mirror];
+          if (rotateCfg && rotateCfg.value != null) {
+            this.mapRotation = this.parseRotation(rotateCfg.value);
+          }
+          if (mirrorCfg && mirrorCfg.value != null) {
+            this.mapMirrorX = this.parseMirror(mirrorCfg.value);
+          }
+          this.createMapConfigs(this.buildMissingMapConfigList(byCode));
+          if (this.mapContentSize && this.mapContentSize.width > 0) {
+            this.applyMapTransform(true);
+          }
+        }
+      });
+    },
+    saveMapTransformConfig() {
+      if (!window.$ || typeof baseUrl === 'undefined') { return; }
+      const updateList = [
+        { code: this.mapConfigCodes.rotate, value: String(this.mapRotation || 0) },
+        { code: this.mapConfigCodes.mirror, value: this.mapMirrorX ? '1' : '0' }
+      ];
+      $.ajax({
+        url: baseUrl + "/config/updateBatch",
+        headers: { 'token': localStorage.getItem('token') },
+        data: JSON.stringify(updateList),
+        dataType: 'json',
+        contentType: 'application/json;charset=UTF-8',
+        method: 'POST'
+      });
+    },
+    getTransformedContentSize() {
+      const size = this.mapContentSize || { width: 0, height: 0 };
+      const w = size.width || 0;
+      const h = size.height || 0;
+      const rot = ((this.mapRotation % 360) + 360) % 360;
+      const swap = rot === 90 || rot === 270;
+      return { width: swap ? h : w, height: swap ? w : h };
+    },
+    fitStageToContent() {
+      if (!this.pixiApp || !this.mapContentSize) { return; }
+      const size = this.getTransformedContentSize();
+      const contentW = size.width || 0;
+      const contentH = size.height || 0;
+      if (contentW <= 0 || contentH <= 0) { return; }
+      const viewport = this.getViewportSize();
+      const vw = viewport.width;
+      const vh = viewport.height;
+      const padding = this.getViewportPadding();
+      const availableW = Math.max(1, vw - padding.left - padding.right);
+      const availableH = Math.max(1, vh - padding.top - padding.bottom);
+      let scale = Math.min(availableW / contentW, availableH / contentH) * 0.95;
+      if (!isFinite(scale) || scale <= 0) { scale = 1; }
+      const baseW = this.mapContentSize.width || contentW;
+      const baseH = this.mapContentSize.height || contentH;
+      const mirrorX = this.mapMirrorX ? -1 : 1;
+      const scaleX = scale * mirrorX;
+      const scaleY = scale;
+      const centerX = padding.left + availableW / 2;
+      const centerY = padding.top + availableH / 2;
+      const posX = centerX - (baseW / 2) * scaleX;
+      const posY = centerY - (baseH / 2) * scaleY;
+      this.pixiApp.stage.setTransform(posX, posY, scaleX, scaleY, 0, 0, 0, 0, 0);
+    },
+    applyMapTransform(fitToView) {
+      if (!this.mapRoot || !this.mapContentSize) { return; }
+      const contentW = this.mapContentSize.width || 0;
+      const contentH = this.mapContentSize.height || 0;
+      if (contentW <= 0 || contentH <= 0) { return; }
+      this.mapRoot.pivot.set(contentW / 2, contentH / 2);
+      this.mapRoot.position.set(contentW / 2, contentH / 2);
+      this.mapRoot.rotation = (this.mapRotation % 360) * Math.PI / 180;
+      this.mapRoot.scale.set(1, 1);
+      if (fitToView) { this.fitStageToContent(); }
+      this.scheduleAdjustLabels();
+      this.scheduleShelfChunkCulling();
     },
     scheduleAdjustLabels() {
       if (this.adjustLabelTimer) { clearTimeout(this.adjustLabelTimer); }
       this.adjustLabelTimer = setTimeout(() => {
         this.adjustLabelScale();
+        this.updateShelfTooltipVisibilityByScale();
         this.adjustLabelTimer = null;
       }, 20);
     }
   }
 });
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

--
Gitblit v1.9.1