xsh_1997 1 mēnesi atpakaļ
vecāks
revīzija
a368e8e68f

+ 5 - 2
ruoyi-screen/.env.development

@@ -19,9 +19,12 @@ VITE_WEATHER_CITY=巴青
19 19
 VITE_GIS_BASE_URL=https://bqxm.alafarms.com/agis-api
20 20
 VITE_GIS_ACCESS_TOKEN=8XughK9JVapV1pqaE7HnxfHiKGrLy21L9LbsKskL
21 21
 VITE_GIS_WMTS_LAYER=baqing_wp
22
-# 瓦片边长:512 可向 WMTS 请求 WIDTH/HEIGHT=512 高清图(若服务支持)
22
+# 瓦片边长:256 
23 23
 VITE_GIS_TILE_SIZE=256
24 24
 VITE_GIS_DEFAULT_CENTER=94.046223,31.920382
25 25
 VITE_GIS_DEFAULT_ZOOM=14
26 26
 VITE_GIS_MIN_ZOOM=7
27
-VITE_GIS_MAX_ZOOM=19
27
+# 地图最大缩放;建议与瓦片最高级别一致,避免放到顶过采样缺图
28
+VITE_GIS_MAX_ZOOM=17
29
+# 瓦片请求最高 TileMatrix;与 MAX_ZOOM 相同则每级都请求新瓦片,低 1 级则顶级用放大显示
30
+VITE_GIS_TILE_MAX_ZOOM=16

+ 2 - 1
ruoyi-screen/.env.production

@@ -23,7 +23,8 @@ VITE_GIS_TILE_SIZE=256
23 23
 VITE_GIS_DEFAULT_CENTER=94.046223,31.920382
24 24
 VITE_GIS_DEFAULT_ZOOM=14
25 25
 VITE_GIS_MIN_ZOOM=7
26
-VITE_GIS_MAX_ZOOM=19
26
+VITE_GIS_MAX_ZOOM=17
27
+VITE_GIS_TILE_MAX_ZOOM=16
27 28
 
28 29
 # 大屏免密登录(生产需后端 bigscreen.login.enabled=true 且配置 RSA 密钥)
29 30
 VITE_SCREEN_AUTO_LOGIN=true

+ 196 - 12
ruoyi-screen/src/components/GisMap.vue

@@ -11,7 +11,6 @@ import {
11 11
   resolveGisMapStyle,
12 12
   createGisTransformRequest
13 13
 } from '@/utils/gisLoader'
14
-import { getMapPixelRatio } from '@/utils/screenFit'
15 14
 
16 15
 const props = defineProps({
17 16
   center: {
@@ -44,6 +43,16 @@ let map = null
44 43
 let cmmapglRef = null
45 44
 let zoomLogTimer = null
46 45
 let lastMouseZoomLogAt = 0
46
+let clampingZoom = false
47
+let unbindWheelZoom = null
48
+
49
+/** 滚轮缩放防抖:停止滚动后再合并应用,减少瓦片 canceled */
50
+const WHEEL_ZOOM_DEBOUNCE_MS = 150
51
+const WHEEL_ZOOM_RATE = 0.0022
52
+
53
+let wheelDeltaAccum = 0
54
+let wheelDebounceTimer = null
55
+let lastWheelAround = null
47 56
 
48 57
 function formatLngLat(lngLat) {
49 58
   if (!lngLat) {
@@ -80,7 +89,10 @@ function bindZoomDebugListeners() {
80 89
     return
81 90
   }
82 91
   map.on('load', () => logMapState('load'))
83
-  map.on('zoomend', () => logMapState('zoomend'))
92
+  map.on('zoomend', () => {
93
+    snapZoomToLimits()
94
+    logMapState('zoomend')
95
+  })
84 96
   map.on('moveend', () => logMapState('moveend'))
85 97
   map.on('wheel', () => {
86 98
     if (zoomLogTimer) {
@@ -106,7 +118,14 @@ function destroyMap() {
106 118
     clearTimeout(zoomLogTimer)
107 119
     zoomLogTimer = null
108 120
   }
121
+  if (typeof unbindWheelZoom === 'function') {
122
+    unbindWheelZoom()
123
+    unbindWheelZoom = null
124
+  }
125
+  wheelDeltaAccum = 0
126
+  lastWheelAround = null
109 127
   lastMouseZoomLogAt = 0
128
+  clampingZoom = false
110 129
   if (map) {
111 130
     map.remove()
112 131
     map = null
@@ -127,13 +146,173 @@ function resolveZoom() {
127 146
   return getGisDefaultZoom()
128 147
 }
129 148
 
130
-function applyMapSharpness() {
149
+function applyMapLimits() {
150
+  if (!map) {
151
+    return
152
+  }
153
+  const minZ = getGisMinZoom()
154
+  const maxZ = getGisMaxZoom()
155
+  if (typeof map.setMinZoom === 'function') {
156
+    map.setMinZoom(minZ)
157
+  }
158
+  if (typeof map.setMaxZoom === 'function') {
159
+    map.setMaxZoom(maxZ)
160
+  }
161
+}
162
+
163
+/** 任何越界立即拉回(不用 0.01 容差,否则 17.005 时图层会被 style maxzoom 隐藏) */
164
+function snapZoomToLimits() {
165
+  if (!map || clampingZoom) {
166
+    return
167
+  }
168
+  const minZ = getGisMinZoom()
169
+  const maxZ = getGisMaxZoom()
170
+  const z = map.getZoom()
171
+  if (!Number.isFinite(z)) {
172
+    return
173
+  }
174
+  if (z <= maxZ && z >= minZ) {
175
+    return
176
+  }
177
+  const next = Math.min(maxZ, Math.max(minZ, z))
178
+  clampingZoom = true
179
+  try {
180
+    map.jumpTo({ zoom: next })
181
+  } finally {
182
+    clampingZoom = false
183
+  }
184
+}
185
+
186
+function bindZoomClampListener() {
187
+  if (!map) {
188
+    return
189
+  }
190
+  map.on('zoom', () => {
191
+    if (clampingZoom || !map) {
192
+      return
193
+    }
194
+    const maxZ = getGisMaxZoom()
195
+    const minZ = getGisMinZoom()
196
+    const z = map.getZoom()
197
+    if (!Number.isFinite(z) || (z <= maxZ && z >= minZ)) {
198
+      return
199
+    }
200
+    snapZoomToLimits()
201
+  })
202
+}
203
+
204
+/** 滚轮防抖缩放:拦截原生 scrollZoom,停止滚动后一次性应用累计 delta */
205
+function bindDebouncedWheelZoom() {
206
+  const el = containerRef.value
207
+  if (!el || !map) {
208
+    return () => {}
209
+  }
210
+
211
+  const flushWheelZoom = () => {
212
+    wheelDebounceTimer = null
213
+    if (!map || wheelDeltaAccum === 0) {
214
+      return
215
+    }
216
+    const minZ = getGisMinZoom()
217
+    const maxZ = getGisMaxZoom()
218
+    const z = map.getZoom()
219
+    if (!Number.isFinite(z)) {
220
+      wheelDeltaAccum = 0
221
+      return
222
+    }
223
+
224
+    const delta = wheelDeltaAccum
225
+    wheelDeltaAccum = 0
226
+
227
+    let targetZ = z - delta * WHEEL_ZOOM_RATE
228
+    targetZ = Math.min(maxZ, Math.max(minZ, targetZ))
229
+    if (Math.abs(targetZ - z) < 0.001) {
230
+      return
231
+    }
232
+
233
+    const around = lastWheelAround || (typeof map.getCenter === 'function' ? map.getCenter() : null)
234
+    lastWheelAround = null
235
+
236
+    const nearLimit = targetZ >= maxZ - 0.05 || targetZ <= minZ + 0.05
237
+    if (nearLimit && typeof map.jumpTo === 'function') {
238
+      map.jumpTo(around ? { zoom: targetZ, around } : { zoom: targetZ })
239
+      return
240
+    }
241
+
242
+    if (around && typeof map.easeTo === 'function') {
243
+      map.easeTo({
244
+        zoom: targetZ,
245
+        around,
246
+        duration: 180,
247
+        essential: true
248
+      })
249
+    } else if (typeof map.setZoom === 'function') {
250
+      map.setZoom(targetZ)
251
+    }
252
+  }
253
+
254
+  const onWheel = (e) => {
255
+    if (!map || !props.interactive) {
256
+      return
257
+    }
258
+
259
+    e.preventDefault()
260
+    e.stopImmediatePropagation()
261
+
262
+    const minZ = getGisMinZoom()
263
+    const maxZ = getGisMaxZoom()
264
+    const z = map.getZoom()
265
+    if (!Number.isFinite(z)) {
266
+      return
267
+    }
268
+    if (e.deltaY < 0 && z >= maxZ - 0.01) {
269
+      return
270
+    }
271
+    if (e.deltaY > 0 && z <= minZ + 0.01) {
272
+      return
273
+    }
274
+
275
+    const rect = el.getBoundingClientRect()
276
+    const x = e.clientX - rect.left
277
+    const y = e.clientY - rect.top
278
+    if (typeof map.unproject === 'function') {
279
+      lastWheelAround = map.unproject([x, y])
280
+    }
281
+
282
+    wheelDeltaAccum += e.deltaY
283
+    if (wheelDebounceTimer) {
284
+      clearTimeout(wheelDebounceTimer)
285
+    }
286
+    wheelDebounceTimer = setTimeout(flushWheelZoom, WHEEL_ZOOM_DEBOUNCE_MS)
287
+  }
288
+
289
+  el.addEventListener('wheel', onWheel, { passive: false, capture: true })
290
+  return () => {
291
+    if (wheelDebounceTimer) {
292
+      clearTimeout(wheelDebounceTimer)
293
+      wheelDebounceTimer = null
294
+    }
295
+    wheelDeltaAccum = 0
296
+    lastWheelAround = null
297
+    el.removeEventListener('wheel', onWheel, { capture: true })
298
+  }
299
+}
300
+
301
+function configureScrollZoom() {
302
+  if (!map?.scrollZoom) {
303
+    return
304
+  }
305
+  if (typeof map.scrollZoom.disable === 'function') {
306
+    map.scrollZoom.disable()
307
+  }
308
+}
309
+
310
+function resizeMap() {
131 311
   if (!map) {
132 312
     return
133 313
   }
134
-  const ratio = getMapPixelRatio()
135 314
   if (typeof map.setPixelRatio === 'function') {
136
-    map.setPixelRatio(ratio)
315
+    map.setPixelRatio(1)
137 316
   }
138 317
   if (typeof map.resize === 'function') {
139 318
     map.resize()
@@ -147,6 +326,9 @@ async function initMap() {
147 326
   }
148 327
   try {
149 328
     const cmmapgl = await loadGisSdk()
329
+    if (typeof cmmapgl.setMaxParallelImageRequests === 'function') {
330
+      cmmapgl.setMaxParallelImageRequests(64)
331
+    }
150 332
     cmmapglRef = cmmapgl
151 333
     const token = getGisAccessToken()
152 334
     if (token) {
@@ -163,8 +345,8 @@ async function initMap() {
163 345
       interactive: props.interactive,
164 346
       accessToken: token || undefined,
165 347
       collectResourceTiming: false,
166
-      pixelRatio: getMapPixelRatio(),
167
-      transformRequest: createGisTransformRequest(getMapPixelRatio)
348
+      pixelRatio: 1,
349
+      transformRequest: createGisTransformRequest()
168 350
     })
169 351
     if (props.showNav) {
170 352
       map.addControl(new cmmapgl.NavigationControl(), 'top-right')
@@ -173,8 +355,14 @@ async function initMap() {
173 355
       map.addControl(new cmmapgl.ScaleControl({ unit: 'metric' }), 'bottom-left')
174 356
     }
175 357
     bindZoomDebugListeners()
358
+    bindZoomClampListener()
359
+    unbindWheelZoom = bindDebouncedWheelZoom()
360
+    configureScrollZoom()
176 361
     map.on('load', () => {
177
-      applyMapSharpness()
362
+      applyMapLimits()
363
+      configureScrollZoom()
364
+      snapZoomToLimits()
365
+      resizeMap()
178 366
       emit('ready', map)
179 367
     })
180 368
     map.on('click', (e) => emit('click', e))
@@ -185,10 +373,6 @@ async function initMap() {
185 373
   }
186 374
 }
187 375
 
188
-function resizeMap() {
189
-  applyMapSharpness()
190
-}
191
-
192 376
 watch(
193 377
   () => [props.center, props.zoom],
194 378
   () => {

+ 35 - 14
ruoyi-screen/src/layout/ScreenLayout.vue

@@ -1,21 +1,30 @@
1 1
 <script setup>
2
-import { onMounted, onUnmounted, provide, ref } from "vue"
2
+import { computed, onMounted, onUnmounted, provide, ref } from "vue"
3 3
 import GisMap from "../components/GisMap.vue"
4 4
 import ScreenNavBar from "../components/ScreenNavBar.vue"
5 5
 import { bindScreenScale, DESIGN_HEIGHT, DESIGN_WIDTH } from "../utils/screenFit.js"
6 6
 
7 7
 const viewportRef = ref(null)
8 8
 const gisMapRef = ref(null)
9
+const stageScale = ref(1)
9 10
 provide("screenGisMap", gisMapRef)
10 11
 
12
+const stageWidth = computed(() => Math.round(DESIGN_WIDTH * stageScale.value))
13
+const stageHeight = computed(() => Math.round(DESIGN_HEIGHT * stageScale.value))
14
+
11 15
 let unbindScale = null
12 16
 
13 17
 function resizeGisMap() {
14 18
   gisMapRef.value?.resizeMap?.()
15 19
 }
16 20
 
21
+function onScreenScaleChange(scale) {
22
+  stageScale.value = scale
23
+  resizeGisMap()
24
+}
25
+
17 26
 onMounted(() => {
18
-  unbindScale = bindScreenScale(viewportRef.value, resizeGisMap)
27
+  unbindScale = bindScreenScale(viewportRef.value, onScreenScaleChange)
19 28
   window.addEventListener("resize", resizeGisMap)
20 29
 })
21 30
 
@@ -29,17 +38,27 @@ onUnmounted(() => {
29 38
 
30 39
 <template>
31 40
   <div ref="viewportRef" class="screen-viewport">
41
+    <!-- 舞台用真实像素尺寸,不用 CSS scale,避免 WebGL 地图在 transform 下放大时整屏发黑 -->
32 42
     <div
33 43
       class="screen-stage"
34
-      :style="{ width: DESIGN_WIDTH + 'px', height: DESIGN_HEIGHT + 'px' }"
44
+      :style="{ width: stageWidth + 'px', height: stageHeight + 'px' }"
35 45
     >
36 46
       <div class="screen-map-layer">
37 47
         <GisMap ref="gisMapRef" :interactive="true" />
38 48
       </div>
39 49
       <div class="screen-chrome">
40
-        <ScreenNavBar />
41
-        <div class="screen-body">
42
-          <router-view />
50
+        <div
51
+          class="screen-chrome-inner"
52
+          :style="{
53
+            width: DESIGN_WIDTH + 'px',
54
+            height: DESIGN_HEIGHT + 'px',
55
+            transform: `scale(${stageScale})`,
56
+          }"
57
+        >
58
+          <ScreenNavBar />
59
+          <div class="screen-body">
60
+            <router-view />
61
+          </div>
43 62
         </div>
44 63
       </div>
45 64
     </div>
@@ -60,8 +79,6 @@ onUnmounted(() => {
60 79
 .screen-stage {
61 80
   position: relative;
62 81
   flex-shrink: 0;
63
-  transform: scale(var(--screen-scale, 1));
64
-  transform-origin: center center;
65 82
   box-sizing: border-box;
66 83
   overflow: hidden;
67 84
 }
@@ -73,26 +90,30 @@ onUnmounted(() => {
73 90
 }
74 91
 
75 92
 .screen-chrome {
76
-  position: relative;
93
+  position: absolute;
94
+  inset: 0;
77 95
   z-index: 1;
96
+  overflow: hidden;
97
+  pointer-events: none;
98
+}
99
+
100
+.screen-chrome-inner {
101
+  transform-origin: 0 0;
78 102
   display: flex;
79 103
   flex-direction: column;
80
-  width: 100%;
81
-  height: 100%;
82 104
   pointer-events: none;
83 105
 }
84 106
 
85
-.screen-chrome::before {
107
+.screen-chrome-inner::before {
86 108
   content: "";
87 109
   position: absolute;
88 110
   inset: 0;
89
-  /* background: url("../assets/bg.png") no-repeat center center; */
90 111
   background-size: cover;
91 112
   pointer-events: none;
92 113
   z-index: 0;
93 114
 }
94 115
 
95
-.screen-chrome > * {
116
+.screen-chrome-inner > * {
96 117
   position: relative;
97 118
   z-index: 1;
98 119
 }

+ 37 - 46
ruoyi-screen/src/utils/gisLoader.js

@@ -76,57 +76,23 @@ function buildWmtsTileUrl() {
76 76
   return url
77 77
 }
78 78
 
79
-/** 高 DPI 时提升一级瓦片并同步 x/y,避免只改 TileMatrix 导致错位 */
80
-function boostWmtsTileUrl(url, boost) {
81
-  if (boost <= 0) {
82
-    return url
83
-  }
84
-  const zm = url.match(/TileMatrix=([^&]+)/)
85
-  const xc = url.match(/TileCol=(\d+)/)
86
-  const yr = url.match(/TileRow=(\d+)/)
87
-  if (!zm || !xc || !yr) {
88
-    return url
89
-  }
90
-  const matrixRaw = decodeURIComponent(zm[1])
91
-  const zMatch = matrixRaw.match(/(\d+)$/)
92
-  if (!zMatch) {
93
-    return url
94
-  }
95
-  let z = parseInt(zMatch[1], 10)
96
-  let x = parseInt(xc[1], 10)
97
-  let y = parseInt(yr[1], 10)
98
-  const maxZ = getGisMaxZoom()
99
-  for (let i = 0; i < boost && z < maxZ; i += 1) {
100
-    z += 1
101
-    x *= 2
102
-    y *= 2
103
-  }
104
-  const nextMatrix = matrixRaw.replace(/\d+$/, String(z))
105
-  return url
106
-    .replace(/TileMatrix=[^&]+/, `TileMatrix=${encodeURIComponent(nextMatrix)}`)
107
-    .replace(/TileCol=\d+/, `TileCol=${x}`)
108
-    .replace(/TileRow=\d+/, `TileRow=${y}`)
109
-}
110
-
111 79
 /**
112
- * 瓦片请求改写:zoom 偏移 + 高 DPI 时请求更高级别瓦片(更清晰)
113
- * @param {() => number} getPixelRatio
80
+ * 瓦片请求改写:仅处理 zoom 偏移。
81
+ * TileMatrix 上限由 style 里 source.maxzoom 控制,勿在此改写 col/row,否则易与引擎缓存键不一致导致 canceled。
114 82
  */
115
-export function createGisTransformRequest(getPixelRatio) {
83
+export function createGisTransformRequest() {
116 84
   const zoomOffset = getGisWmtsZoomOffset()
85
+  if (!zoomOffset) {
86
+    return undefined
87
+  }
117 88
   return (url, resourceType) => {
118 89
     if (resourceType !== 'Tile' || !String(url).includes('/wmts')) {
119 90
       return { url }
120 91
     }
121
-    let next = url
122
-    if (zoomOffset) {
123
-      next = next.replace(/TileMatrix=(\d+)/, (_, z) => `TileMatrix=${parseInt(z, 10) + zoomOffset}`)
124
-    }
125
-    const ratio = typeof getPixelRatio === 'function' ? getPixelRatio() : 1
126
-    const boost = ratio >= 2 ? 1 : 0
127
-    if (boost > 0) {
128
-      next = boostWmtsTileUrl(next, boost)
129
-    }
92
+    const next = url.replace(
93
+      /TileMatrix=(\d+)/,
94
+      (_, z) => `TileMatrix=${parseInt(z, 10) + zoomOffset}`
95
+    )
130 96
     return { url: next }
131 97
   }
132 98
 }
@@ -148,16 +114,30 @@ export function buildWmtsMapStyle() {
148 114
         type: 'raster',
149 115
         tiles: [tileUrl],
150 116
         tileSize,
151
-        scheme: 'xyz'
117
+        scheme: 'xyz',
118
+        minzoom: getGisMinZoom(),
119
+        maxzoom: getGisTileMaxZoom()
152 120
       }
153 121
     },
154 122
     layers: [
123
+      {
124
+        id: 'gis-bg',
125
+        type: 'background',
126
+        paint: {
127
+          'background-color': '#1a3a32'
128
+        }
129
+      },
155 130
       {
156 131
         id: 'gis-wmts-layer',
157 132
         type: 'raster',
158 133
         source: 'gis-wmts',
159 134
         minzoom: getGisMinZoom(),
160
-        maxzoom: getGisMaxZoom()
135
+        // 勿与 VITE_GIS_MAX_ZOOM 绑死:zoom 略大于 max 时(easeTo 过冲)图层会被引擎整层隐藏
136
+        maxzoom: 24,
137
+        paint: {
138
+          'raster-fade-duration': 300,
139
+          'raster-resampling': 'linear'
140
+        }
161 141
       }
162 142
     ]
163 143
   }
@@ -196,6 +176,17 @@ export function getGisMaxZoom() {
196 176
   return Number.isFinite(z) ? z : 17
197 177
 }
198 178
 
179
+/** 瓦片源最高级别;默认比地图 maxZoom 低 1,最高级用低级瓦片放大,避免放到顶整屏发黑 */
180
+export function getGisTileMaxZoom() {
181
+  const explicit = Number(import.meta.env.VITE_GIS_TILE_MAX_ZOOM)
182
+  if (Number.isFinite(explicit)) {
183
+    return explicit
184
+  }
185
+  const max = getGisMaxZoom()
186
+  const min = getGisMinZoom()
187
+  return max > min ? max - 1 : max
188
+}
189
+
199 190
 export function isGisConfigured() {
200 191
   return !!(getGisBaseUrl() && getGisAccessToken() && resolveGisMapStyle())
201 192
 }

+ 1 - 18
ruoyi-screen/src/utils/screenFit.js

@@ -24,7 +24,7 @@ export function bindScreenScale(root, onScaleChange) {
24 24
     const scale = Math.min(sx, sy)
25 25
     root.style.setProperty("--screen-scale", String(scale))
26 26
     if (typeof onScaleChange === "function") {
27
-      requestAnimationFrame(onScaleChange)
27
+      requestAnimationFrame(() => onScaleChange(scale))
28 28
     }
29 29
   }
30 30
   update()
@@ -33,20 +33,3 @@ export function bindScreenScale(root, onScaleChange) {
33 33
     window.removeEventListener("resize", update)
34 34
   }
35 35
 }
36
-
37
-/** 读取 screenFit 写入的 --screen-scale(舞台 CSS 缩放比) */
38
-export function getScreenScale(root = document.querySelector(".screen-viewport")) {
39
-  if (!root) {
40
-    return 1
41
-  }
42
-  const raw = getComputedStyle(root).getPropertyValue("--screen-scale").trim()
43
-  const scale = parseFloat(raw)
44
-  return Number.isFinite(scale) && scale > 0 ? scale : 1
45
-}
46
-
47
-/** 地图像素比:补偿舞台 scale 放大,避免 WebGL 画布被拉伸发糊 */
48
-export function getMapPixelRatio(root) {
49
-  const scale = getScreenScale(root)
50
-  const dpr = window.devicePixelRatio || 1
51
-  return Math.min(dpr * scale, 3)
52
-}