handy 2 месяцев назад
Родитель
Сommit
f78b0d594c
2 измененных файлов с 407 добавлено и 41 удалено
  1. 299 38
      src/App.vue
  2. 108 3
      src/components/WarehouseMap.vue

+ 299 - 38
src/App.vue

@@ -17,7 +17,6 @@
         </span>
       </h1>
       <div class="controls">
-        <span class="warehouse-label">仓库: {{ config.warehouse }}</span>
         <label class="filter-item">
           <span class="selector-label">资源类型</span>
           <select v-model="selectedCategory" class="level-select">
@@ -41,7 +40,7 @@
           </select>
         </label>
         <label class="filter-item">
-          <span class="selector-label">是否有容器</span>
+          <span class="selector-label">容器</span>
           <select v-model="selectedHasContainer" class="level-select">
             <option value="">全部</option>
             <option value="Y">有容器</option>
@@ -58,8 +57,54 @@
               placeholder="输入库位组"
               @keydown.enter="applyLocGroupFilter"
             />
+            <button
+              v-if="locGroupKeywordInput"
+              class="filter-clear-btn"
+              type="button"
+              @click="clearLocGroupFilter"
+            >
+              <svg viewBox="0 0 16 16" aria-hidden="true" class="filter-action-icon">
+                <path
+                  d="M4.22 4.22a.75.75 0 0 1 1.06 0L8 6.94l2.72-2.72a.75.75 0 1 1 1.06 1.06L9.06 8l2.72 2.72a.75.75 0 1 1-1.06 1.06L8 9.06l-2.72 2.72a.75.75 0 1 1-1.06-1.06L6.94 8 4.22 5.28a.75.75 0 0 1 0-1.06z"
+                  fill="currentColor"
+                />
+              </svg>
+            </button>
             <button class="filter-confirm-btn" type="button" @click="applyLocGroupFilter">
-              <svg viewBox="0 0 16 16" aria-hidden="true" class="filter-confirm-icon">
+              <svg viewBox="0 0 16 16" aria-hidden="true" class="filter-action-icon">
+                <path
+                  d="M6.5 2.5a4 4 0 1 0 2.47 7.15l2.69 2.68 1.06-1.06-2.68-2.69A4 4 0 0 0 6.5 2.5zm0 1.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5z"
+                  fill="currentColor"
+                />
+              </svg>
+            </button>
+          </span>
+        </label>
+        <label class="filter-item filter-input-item">
+          <span class="selector-label">库位号</span>
+          <span class="filter-input-wrap">
+            <input
+              v-model="locationIdKeywordInput"
+              class="filter-input"
+              type="text"
+              placeholder="输入库位号"
+              @keydown.enter="applyLocationIdFilter"
+            />
+            <button
+              v-if="locationIdKeywordInput"
+              class="filter-clear-btn"
+              type="button"
+              @click="clearLocationIdFilter"
+            >
+              <svg viewBox="0 0 16 16" aria-hidden="true" class="filter-action-icon">
+                <path
+                  d="M4.22 4.22a.75.75 0 0 1 1.06 0L8 6.94l2.72-2.72a.75.75 0 1 1 1.06 1.06L9.06 8l2.72 2.72a.75.75 0 1 1-1.06 1.06L8 9.06l-2.72 2.72a.75.75 0 1 1-1.06-1.06L6.94 8 4.22 5.28a.75.75 0 0 1 0-1.06z"
+                  fill="currentColor"
+                />
+              </svg>
+            </button>
+            <button class="filter-confirm-btn" type="button" @click="applyLocationIdFilter">
+              <svg viewBox="0 0 16 16" aria-hidden="true" class="filter-action-icon">
                 <path
                   d="M6.5 2.5a4 4 0 1 0 2.47 7.15l2.69 2.68 1.06-1.06-2.68-2.69A4 4 0 0 0 6.5 2.5zm0 1.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5z"
                   fill="currentColor"
@@ -69,23 +114,64 @@
           </span>
         </label>
         <label class="toggle-item">
-          <span class="selector-label">库位组边框</span>
+          <span class="selector-label">边框</span>
           <input v-model="showGroupBorder" class="toggle-input" type="checkbox" />
           <span class="toggle-track">
             <span class="toggle-thumb"></span>
           </span>
         </label>
-        <label class="level-selector">
-          <span class="selector-label">楼层</span>
-          <select v-model.number="currentLevel" class="level-select" @change="handleLevelChange">
-            <option v-for="level in levelRange" :key="level" :value="level">
-              {{ level }}层
-            </option>
-          </select>
+        <label class="toggle-item">
+          <span class="selector-label">卡片</span>
+          <input v-model="showTooltip" class="toggle-input" type="checkbox" />
+          <span class="toggle-track">
+            <span class="toggle-thumb"></span>
+          </span>
         </label>
-        <button class="refresh-btn" :disabled="loading" @click="handleManualRefresh">
-          {{ loading ? '刷新中...' : `刷新 ${refreshCountdownText}` }}
-        </button>
+        <select
+          v-model.number="currentLevel"
+          class="level-select level-select-floor"
+          @change="handleLevelChange"
+        >
+          <option v-for="level in levelRange" :key="level" :value="level">
+            {{ level }}层
+          </option>
+        </select>
+        <div ref="refreshControlRef" class="refresh-control">
+          <button
+            class="refresh-btn"
+            :disabled="loading"
+            @click="handleManualRefresh"
+            @contextmenu.prevent="handleRefreshContextMenu"
+          >
+            {{ refreshCountdownText }}
+          </button>
+          <div
+            v-if="showRefreshPopover"
+            class="refresh-popover"
+            @click.stop
+          >
+            <input
+              ref="refreshIntervalInputRef"
+              v-model="refreshIntervalInput"
+              class="refresh-popover-input"
+              type="text"
+              inputmode="numeric"
+              @keydown.enter="applyRefreshInterval"
+            />
+            <button
+              class="refresh-popover-confirm"
+              type="button"
+              @click="applyRefreshInterval"
+            >
+              <svg viewBox="0 0 16 16" aria-hidden="true" class="filter-action-icon">
+                <path
+                  d="M6.46 11.03 3.43 8a.75.75 0 1 1 1.06-1.06l1.97 1.96 5.05-5.04A.75.75 0 0 1 12.57 4.9l-5.58 5.59a.75.75 0 0 1-1.06 0z"
+                  fill="currentColor"
+                />
+              </svg>
+            </button>
+          </div>
+        </div>
         <button class="logout-btn" @click="handleLogout">退出</button>
       </div>
     </header>
@@ -103,8 +189,11 @@
           :selected-location-attribute="selectedLocationAttribute"
           :selected-has-container="selectedHasContainer"
           :loc-group-keyword="appliedLocGroupKeyword"
+          :location-id-keyword="appliedLocationIdKeyword"
           :show-group-border="showGroupBorder"
+          :show-tooltip="showTooltip"
           @select-loc-group="handleSelectLocGroup"
+          @select-location-id="handleSelectLocationId"
         />
       </div>
     </main>
@@ -112,7 +201,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted, computed, onBeforeUnmount } from 'vue'
+import { ref, onMounted, computed, onBeforeUnmount, nextTick } from 'vue'
 import { fetchLocationData } from './api/location'
 import type { LocationAttributeCode, LocationResourceDataVO } from './types'
 import WarehouseMap from './components/WarehouseMap.vue'
@@ -121,6 +210,7 @@ import { config } from './config'
 import { isAuthenticated, removeToken } from './utils/auth'
 
 const LEVEL_STORAGE_KEY = 'warehouse-map.current-level'
+const REFRESH_INTERVAL_STORAGE_KEY = 'warehouse-map.refresh-interval-ms'
 const getInitialLevel = () => {
   const savedLevel = window.localStorage.getItem(LEVEL_STORAGE_KEY)
   const parsedLevel = savedLevel ? Number(savedLevel) : NaN
@@ -130,6 +220,15 @@ const getInitialLevel = () => {
   return config.minLevel
 }
 
+const getInitialRefreshInterval = () => {
+  const savedInterval = window.localStorage.getItem(REFRESH_INTERVAL_STORAGE_KEY)
+  const parsedInterval = savedInterval ? Number(savedInterval) : NaN
+  if (Number.isInteger(parsedInterval) && parsedInterval > 0) {
+    return parsedInterval
+  }
+  return config.refreshInterval
+}
+
 const currentLevel = ref(getInitialLevel())
 const locations = ref<LocationResourceDataVO[]>([])
 const loading = ref(false)
@@ -140,9 +239,17 @@ const selectedLocationAttribute = ref<LocationAttributeCode | ''>('')
 const selectedHasContainer = ref<'Y' | 'N' | ''>('')
 const locGroupKeywordInput = ref('')
 const appliedLocGroupKeyword = ref('')
+const locationIdKeywordInput = ref('')
+const appliedLocationIdKeyword = ref('')
 const showGroupBorder = ref(false)
+const showTooltip = ref(true)
+const refreshIntervalMs = ref(getInitialRefreshInterval())
+const showRefreshPopover = ref(false)
+const refreshIntervalInput = ref(String(Math.max(Math.floor(refreshIntervalMs.value / 1000), 1)))
 const now = ref(Date.now())
-const nextRefreshAt = ref(Date.now() + config.refreshInterval)
+const nextRefreshAt = ref(Date.now() + refreshIntervalMs.value)
+const refreshControlRef = ref<HTMLElement | null>(null)
+const refreshIntervalInputRef = ref<HTMLInputElement | null>(null)
 let refreshTimer: number | null = null
 let countdownTimer: number | null = null
 
@@ -173,10 +280,45 @@ const getLocationAttributeLabel = (attribute: LocationAttributeCode) => {
   return LOCATION_ATTRIBUTE_LABEL_MAP[attribute] || attribute
 }
 
+const copyText = async (text: string) => {
+  if (!text) return
+
+  try {
+    await navigator.clipboard.writeText(text)
+    return
+  } catch (error) {
+    console.warn('Clipboard API copy failed, fallback to execCommand.', error)
+  }
+
+  const textarea = document.createElement('textarea')
+  textarea.value = text
+  textarea.setAttribute('readonly', 'true')
+  textarea.style.position = 'fixed'
+  textarea.style.top = '-9999px'
+  document.body.appendChild(textarea)
+  textarea.select()
+  document.execCommand('copy')
+  document.body.removeChild(textarea)
+}
+
 const applyLocGroupFilter = () => {
   appliedLocGroupKeyword.value = locGroupKeywordInput.value.trim()
 }
 
+const applyLocationIdFilter = () => {
+  appliedLocationIdKeyword.value = locationIdKeywordInput.value.trim()
+}
+
+const clearLocGroupFilter = () => {
+  locGroupKeywordInput.value = ''
+  appliedLocGroupKeyword.value = ''
+}
+
+const clearLocationIdFilter = () => {
+  locationIdKeywordInput.value = ''
+  appliedLocationIdKeyword.value = ''
+}
+
 const refreshCountdownText = computed(() => {
   const remainMs = Math.max(nextRefreshAt.value - now.value, 0)
   const totalSeconds = Math.floor(remainMs / 1000)
@@ -211,11 +353,11 @@ const scheduleNextRefresh = () => {
   if (refreshTimer !== null) {
     window.clearTimeout(refreshTimer)
   }
-  nextRefreshAt.value = Date.now() + config.refreshInterval
+  nextRefreshAt.value = Date.now() + refreshIntervalMs.value
   refreshTimer = window.setTimeout(async () => {
     await loadLocationData()
     scheduleNextRefresh()
-  }, config.refreshInterval)
+  }, refreshIntervalMs.value)
 }
 
 const handleLevelChange = () => {
@@ -229,13 +371,48 @@ const handleManualRefresh = () => {
   scheduleNextRefresh()
 }
 
+const applyRefreshInterval = () => {
+  const nextSeconds = Number(refreshIntervalInput.value.trim())
+  if (!Number.isInteger(nextSeconds) || nextSeconds <= 0) {
+    return
+  }
+
+  refreshIntervalMs.value = nextSeconds * 1000
+  refreshIntervalInput.value = String(nextSeconds)
+  window.localStorage.setItem(REFRESH_INTERVAL_STORAGE_KEY, String(refreshIntervalMs.value))
+  showRefreshPopover.value = false
+  scheduleNextRefresh()
+}
+
+const handleRefreshContextMenu = async () => {
+  refreshIntervalInput.value = String(Math.max(Math.floor(refreshIntervalMs.value / 1000), 1))
+  showRefreshPopover.value = true
+  await nextTick()
+  refreshIntervalInputRef.value?.focus()
+  refreshIntervalInputRef.value?.select()
+}
+
+const handleDocumentClick = (event: MouseEvent) => {
+  if (!showRefreshPopover.value) {
+    return
+  }
+
+  const target = event.target as Node | null
+  if (target && refreshControlRef.value?.contains(target)) {
+    return
+  }
+
+  showRefreshPopover.value = false
+}
+
 const handleLoginSuccess = () => {
   showLoginModal.value = false
   loadLocationData()
   scheduleNextRefresh()
 }
 
-const handleSelectLocGroup = (locGroup1: string) => {
+const handleSelectLocGroup = async (locGroup1: string) => {
+  await copyText(locGroup1)
   if (appliedLocGroupKeyword.value === locGroup1) {
     locGroupKeywordInput.value = ''
     appliedLocGroupKeyword.value = ''
@@ -244,6 +421,22 @@ const handleSelectLocGroup = (locGroup1: string) => {
 
   locGroupKeywordInput.value = locGroup1
   appliedLocGroupKeyword.value = locGroup1
+  locationIdKeywordInput.value = ''
+  appliedLocationIdKeyword.value = ''
+}
+
+const handleSelectLocationId = async (locationId: string) => {
+  await copyText(locationId)
+  if (appliedLocationIdKeyword.value === locationId) {
+    locationIdKeywordInput.value = ''
+    appliedLocationIdKeyword.value = ''
+    return
+  }
+
+  locationIdKeywordInput.value = locationId
+  appliedLocationIdKeyword.value = locationId
+  locGroupKeywordInput.value = ''
+  appliedLocGroupKeyword.value = ''
 }
 
 const handleLoginCancel = () => {
@@ -264,6 +457,7 @@ onMounted(() => {
   countdownTimer = window.setInterval(() => {
     now.value = Date.now()
   }, 1000)
+  document.addEventListener('mousedown', handleDocumentClick)
 
   if (!isAuthenticated()) {
     showLoginModal.value = true
@@ -280,6 +474,7 @@ onBeforeUnmount(() => {
   if (countdownTimer !== null) {
     window.clearInterval(countdownTimer)
   }
+  document.removeEventListener('mousedown', handleDocumentClick)
 })
 </script>
 
@@ -356,17 +551,7 @@ onBeforeUnmount(() => {
 .controls {
   display: flex;
   align-items: center;
-  gap: 10px;
-}
-
-.warehouse-label {
-  font-size: 12px;
-  color: #dce8f3;
-  padding: 5px 10px;
-  background: rgba(111, 140, 167, 0.14);
-  border: 1px solid rgba(111, 140, 167, 0.6);
-  border-radius: 4px;
-  line-height: 1;
+  gap: 6px;
 }
 
 .level-selector {
@@ -379,18 +564,18 @@ onBeforeUnmount(() => {
 .filter-item {
   display: flex;
   align-items: center;
-  gap: 6px;
+  gap: 4px;
   color: #d4e2f2;
 }
 
 .filter-input-item {
-  min-width: 180px;
+  min-width: auto;
 }
 
 .toggle-item {
   display: flex;
   align-items: center;
-  gap: 6px;
+  gap: 4px;
   color: #d4e2f2;
 }
 
@@ -400,7 +585,7 @@ onBeforeUnmount(() => {
 }
 
 .level-select {
-  min-width: 82px;
+  min-width: 76px;
   padding: 6px 24px 6px 8px;
   background: rgba(255, 255, 255, 0.04);
   border: 1px solid rgba(111, 140, 167, 0.4);
@@ -422,16 +607,20 @@ onBeforeUnmount(() => {
   line-height: 1;
 }
 
+.level-select-floor {
+  min-width: 64px;
+}
+
 .filter-input-wrap {
   position: relative;
   display: inline-flex;
-  width: 110px;
+  width: 136px;
 }
 
 .filter-input {
   width: 100%;
   padding: 6px 8px;
-  padding-right: 28px;
+  padding-right: 50px;
   background: rgba(255, 255, 255, 0.04);
   border: 1px solid rgba(111, 140, 167, 0.4);
   color: #eef4f8;
@@ -468,11 +657,28 @@ onBeforeUnmount(() => {
   border-radius: 0 3px 3px 0;
 }
 
+.filter-clear-btn {
+  position: absolute;
+  top: 1px;
+  right: 25px;
+  width: 24px;
+  height: calc(100% - 2px);
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  border: none;
+  border-left: 1px solid rgba(111, 140, 167, 0.3);
+  background: transparent;
+  color: #dce8f3;
+  cursor: pointer;
+}
+
+.filter-clear-btn:hover,
 .filter-confirm-btn:hover {
   background: rgba(111, 140, 167, 0.14);
 }
 
-.filter-confirm-icon {
+.filter-action-icon {
   width: 12px;
   height: 12px;
 }
@@ -521,7 +727,8 @@ onBeforeUnmount(() => {
 }
 
 .refresh-btn {
-  padding: 6px 10px;
+  min-width: 58px;
+  padding: 6px 8px;
   background: rgba(111, 140, 167, 0.12);
   border: 1px solid rgba(111, 140, 167, 0.5);
   color: #e6edf3;
@@ -532,6 +739,60 @@ onBeforeUnmount(() => {
   line-height: 1;
 }
 
+.refresh-control {
+  position: relative;
+}
+
+.refresh-popover {
+  position: absolute;
+  top: calc(100% + 6px);
+  right: 0;
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 4px;
+  background: rgba(20, 29, 37, 0.96);
+  border: 1px solid rgba(111, 140, 167, 0.4);
+  border-radius: 4px;
+  box-shadow: 0 8px 18px rgba(0, 0, 0, 0.24);
+  z-index: 20;
+}
+
+.refresh-popover-input {
+  width: 56px;
+  padding: 6px 8px;
+  background: rgba(255, 255, 255, 0.04);
+  border: 1px solid rgba(111, 140, 167, 0.4);
+  color: #eef4f8;
+  font-size: 12px;
+  border-radius: 4px;
+  outline: none;
+  line-height: 1;
+}
+
+.refresh-popover-input:focus {
+  background: rgba(111, 140, 167, 0.12);
+  border-color: #7a96af;
+}
+
+.refresh-popover-confirm {
+  width: 24px;
+  height: 28px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid rgba(111, 140, 167, 0.3);
+  background: rgba(255, 255, 255, 0.04);
+  color: #dce8f3;
+  border-radius: 4px;
+  cursor: pointer;
+}
+
+.refresh-popover-confirm:hover {
+  background: rgba(111, 140, 167, 0.14);
+  border-color: #7a96af;
+}
+
 .refresh-btn:hover:not(:disabled) {
   background: rgba(111, 140, 167, 0.2);
   border-color: #7a96af;

+ 108 - 3
src/components/WarehouseMap.vue

@@ -8,8 +8,11 @@
           :key="index"
           :class="['grid-cell', getCellClass(cell)]"
           :style="getCellStyle(cell)"
-          :title="getCellTitle(cell)"
+          @mouseenter="handleCellMouseEnter(cell, $event)"
+          @mousemove="handleCellMouseMove($event)"
+          @mouseleave="handleCellMouseLeave"
           @click="handleCellClick(cell)"
+          @contextmenu.prevent="handleCellContextMenu(cell)"
         >
           <div v-if="cell && isCellMatched(cell)" class="cell-content">
             <div class="category-badge" :style="getCategoryStyle(cell)">
@@ -25,12 +28,21 @@
           </div>
         </div>
       </div>
+      <div
+        v-if="props.showTooltip && tooltipVisible && tooltipLines.length"
+        class="cell-tooltip"
+        :style="tooltipStyle"
+      >
+        <div v-for="(line, index) in tooltipLines" :key="index" class="cell-tooltip-line">
+          {{ line }}
+        </div>
+      </div>
     </div>
   </div>
 </template>
 
 <script setup lang="ts">
-import { computed, onBeforeUnmount, onMounted, ref, type CSSProperties } from 'vue'
+import { computed, onBeforeUnmount, onMounted, ref, watch, type CSSProperties } from 'vue'
 import type { LocationResourceDataVO } from '../types'
 
 interface Props {
@@ -40,7 +52,9 @@ interface Props {
   selectedLocationAttribute: string
   selectedHasContainer: string
   locGroupKeyword: string
+  locationIdKeyword: string
   showGroupBorder: boolean
+  showTooltip: boolean
 }
 
 interface ParsedLocation {
@@ -61,12 +75,19 @@ interface GridCell extends LocationResourceDataVO {
 const props = defineProps<Props>()
 const emit = defineEmits<{
   (event: 'select-loc-group', locGroup1: string): void
+  (event: 'select-location-id', locationId: string): void
 }>()
 const mapWrapperRef = ref<HTMLElement | null>(null)
 const wrapperSize = ref({
   width: 0,
   height: 0
 })
+const hoveredCell = ref<GridCell | null>(null)
+const tooltipVisible = ref(false)
+const tooltipPosition = ref({
+  x: 0,
+  y: 0
+})
 
 let resizeObserver: ResizeObserver | null = null
 
@@ -244,8 +265,15 @@ const isCellMatched = (cell: GridCell) => {
   const normalizedKeyword = props.locGroupKeyword.trim().toUpperCase()
   const matchedLocGroup = !normalizedKeyword
     || cell.locGroup1.toUpperCase().includes(normalizedKeyword)
+  const normalizedLocationIdKeyword = props.locationIdKeyword.trim().toUpperCase()
+  const matchedLocationId = !normalizedLocationIdKeyword
+    || cell.locationId.toUpperCase().includes(normalizedLocationIdKeyword)
 
-  return matchedCategory && matchedLocationAttribute && matchedHasContainer && matchedLocGroup
+  return matchedCategory
+    && matchedLocationAttribute
+    && matchedHasContainer
+    && matchedLocGroup
+    && matchedLocationId
 }
 
 const getCellClass = (cell: GridCell | null) => {
@@ -298,6 +326,18 @@ const getCategoryStyle = (cell: GridCell) => {
   }
 }
 
+const tooltipLines = computed(() => {
+  return getCellTitle(hoveredCell.value)
+    .split('\n')
+    .map((line) => line.trim())
+    .filter(Boolean)
+})
+
+const tooltipStyle = computed<CSSProperties>(() => ({
+  left: `${tooltipPosition.value.x}px`,
+  top: `${tooltipPosition.value.y}px`
+}))
+
 const isSameGroupNeighbor = (cell: GridCell, xOffset: number, yOffset: number) => {
   const neighbor = locationMap.value.get(
     `${cell.parsed.gridRow + xOffset}-${cell.parsed.gridCol + yOffset}`
@@ -331,6 +371,41 @@ const handleCellClick = (cell: GridCell | null) => {
   emit('select-loc-group', cell.locGroup1)
 }
 
+const handleCellContextMenu = (cell: GridCell | null) => {
+  if (!cell || !isCellMatched(cell)) return
+  emit('select-location-id', cell.locationId)
+}
+
+const updateTooltipPosition = (event: MouseEvent) => {
+  const tooltipWidth = 300
+  const tooltipHeight = 260
+  const offset = 14
+  const maxX = window.innerWidth - tooltipWidth - 12
+  const maxY = window.innerHeight - tooltipHeight - 12
+
+  tooltipPosition.value = {
+    x: Math.max(12, Math.min(event.clientX + offset, maxX)),
+    y: Math.max(12, Math.min(event.clientY + offset, maxY))
+  }
+}
+
+const handleCellMouseEnter = (cell: GridCell | null, event: MouseEvent) => {
+  if (!props.showTooltip) return
+  hoveredCell.value = cell
+  tooltipVisible.value = true
+  updateTooltipPosition(event)
+}
+
+const handleCellMouseMove = (event: MouseEvent) => {
+  if (!props.showTooltip || !tooltipVisible.value) return
+  updateTooltipPosition(event)
+}
+
+const handleCellMouseLeave = () => {
+  tooltipVisible.value = false
+  hoveredCell.value = null
+}
+
 const updateWrapperSize = () => {
   if (!mapWrapperRef.value) return
   wrapperSize.value = {
@@ -349,6 +424,16 @@ onMounted(() => {
   resizeObserver.observe(mapWrapperRef.value)
 })
 
+watch(
+  () => props.showTooltip,
+  (enabled) => {
+    if (!enabled) {
+      tooltipVisible.value = false
+      hoveredCell.value = null
+    }
+  }
+)
+
 onBeforeUnmount(() => {
   resizeObserver?.disconnect()
 })
@@ -500,4 +585,24 @@ onBeforeUnmount(() => {
   text-overflow: ellipsis;
 }
 
+.cell-tooltip {
+  position: fixed;
+  z-index: 1000;
+  max-width: 300px;
+  padding: 10px 12px;
+  border: 1px solid rgba(255, 255, 255, 0.18);
+  border-radius: 8px;
+  background: rgba(11, 17, 24, 0.96);
+  box-shadow: 0 12px 28px rgba(0, 0, 0, 0.32);
+  color: #edf3f7;
+  font-size: 12px;
+  line-height: 1.45;
+  pointer-events: none;
+  backdrop-filter: blur(6px);
+}
+
+.cell-tooltip-line + .cell-tooltip-line {
+  margin-top: 3px;
+}
+
 </style>