hookehuyr

✨ feat: 新增瓦片切图工具

...@@ -12,6 +12,10 @@ declare module '@vue/runtime-core' { ...@@ -12,6 +12,10 @@ declare module '@vue/runtime-core' {
12 AudioBackground: typeof import('./src/components/audioBackground.vue')['default'] 12 AudioBackground: typeof import('./src/components/audioBackground.vue')['default']
13 AudioBackground1: typeof import('./src/components/audioBackground1.vue')['default'] 13 AudioBackground1: typeof import('./src/components/audioBackground1.vue')['default']
14 AudioList: typeof import('./src/components/audioList.vue')['default'] 14 AudioList: typeof import('./src/components/audioList.vue')['default']
15 + ElButton: typeof import('element-plus/es')['ElButton']
16 + ElInput: typeof import('element-plus/es')['ElInput']
17 + ElOption: typeof import('element-plus/es')['ElOption']
18 + ElSelect: typeof import('element-plus/es')['ElSelect']
15 Floor: typeof import('./src/components/Floor/index.vue')['default'] 19 Floor: typeof import('./src/components/Floor/index.vue')['default']
16 InfoPopup: typeof import('./src/components/InfoPopup.vue')['default'] 20 InfoPopup: typeof import('./src/components/InfoPopup.vue')['default']
17 InfoPopupLite: typeof import('./src/components/InfoPopupLite.vue')['default'] 21 InfoPopupLite: typeof import('./src/components/InfoPopupLite.vue')['default']
......
...@@ -25,6 +25,8 @@ ...@@ -25,6 +25,8 @@
25 "xys_upload": "npm run build_tar && npm run scp-xys && npm run dec-xys && npm run remove_tar" 25 "xys_upload": "npm run build_tar && npm run scp-xys && npm run dec-xys && npm run remove_tar"
26 }, 26 },
27 "dependencies": { 27 "dependencies": {
28 + "@amap/amap-jsapi-loader": "^1.0.1",
29 + "@element-plus/icons-vue": "^2.3.1",
28 "@photo-sphere-viewer/core": "^5.7.3", 30 "@photo-sphere-viewer/core": "^5.7.3",
29 "@photo-sphere-viewer/gallery-plugin": "^5.7.3", 31 "@photo-sphere-viewer/gallery-plugin": "^5.7.3",
30 "@photo-sphere-viewer/gyroscope-plugin": "^5.7.3", 32 "@photo-sphere-viewer/gyroscope-plugin": "^5.7.3",
...@@ -39,6 +41,7 @@ ...@@ -39,6 +41,7 @@
39 "animate.css": "^4.1.1", 41 "animate.css": "^4.1.1",
40 "dayjs": "^1.11.3", 42 "dayjs": "^1.11.3",
41 "default-passive-events": "^2.0.0", 43 "default-passive-events": "^2.0.0",
44 + "element-plus": "^2.9.3",
42 "font-awesome": "^4.7.0", 45 "font-awesome": "^4.7.0",
43 "global": "^4.4.0", 46 "global": "^4.4.0",
44 "html-to-json-parser": "^1.1.0", 47 "html-to-json-parser": "^1.1.0",
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
2 * @Author: hookehuyr hookehuyr@gmail.com 2 * @Author: hookehuyr hookehuyr@gmail.com
3 * @Date: 2022-05-31 12:06:19 3 * @Date: 2022-05-31 12:06:19
4 * @LastEditors: hookehuyr hookehuyr@gmail.com 4 * @LastEditors: hookehuyr hookehuyr@gmail.com
5 - * @LastEditTime: 2024-09-25 17:17:40 5 + * @LastEditTime: 2025-01-23 16:10:45
6 * @FilePath: /map-demo/src/main.js 6 * @FilePath: /map-demo/src/main.js
7 * @Description: 7 * @Description:
8 */ 8 */
...@@ -57,6 +57,8 @@ import 'video.js/dist/video-js.css'; ...@@ -57,6 +57,8 @@ import 'video.js/dist/video-js.css';
57 import 'viewerjs/dist/viewer.css'; 57 import 'viewerjs/dist/viewer.css';
58 import VueViewer from 'v-viewer'; 58 import VueViewer from 'v-viewer';
59 59
60 +import * as ElementPlusIconsVue from '@element-plus/icons-vue'
61 +
60 const pinia = createPinia(); 62 const pinia = createPinia();
61 const app = createApp(App); 63 const app = createApp(App);
62 64
...@@ -102,4 +104,8 @@ app ...@@ -102,4 +104,8 @@ app
102 app.use(VueVideoPlayer) 104 app.use(VueVideoPlayer)
103 app.use(VueViewer); 105 app.use(VueViewer);
104 106
107 +for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
108 + app.component(key, component)
109 +}
110 +
105 app.mount('#app'); 111 app.mount('#app');
......
1 /* 1 /*
2 * @Date: 2023-05-29 11:10:19 2 * @Date: 2023-05-29 11:10:19
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2024-09-20 17:54:57 4 + * @LastEditTime: 2025-01-23 16:22:10
5 * @FilePath: /map-demo/src/route.js 5 * @FilePath: /map-demo/src/route.js
6 * @Description: 文件描述 6 * @Description: 文件描述
7 */ 7 */
...@@ -69,4 +69,12 @@ export default [ ...@@ -69,4 +69,12 @@ export default [
69 title: '详情页', 69 title: '详情页',
70 }, 70 },
71 }, 71 },
72 + {
73 + path: '/map_cutter',
74 + name: '瓦片切图工具',
75 + component: () => import('@/views/mapCutter.vue'),
76 + meta: {
77 + title: '瓦片切图工具',
78 + },
79 + },
72 ]; 80 ];
......
1 +/*
2 + * @Date: 2025-01-22 11:45:30
3 + * @LastEditors: hookehuyr hookehuyr@gmail.com
4 + * @LastEditTime: 2025-01-23 16:38:00
5 + * @FilePath: /map-demo/src/utils/TileCutter.js
6 + * @Description: 文件描述
7 + */
8 +const tileSize = 512;
9 +
10 +export function TileCutter(imageURL, bounds, zoomLevel) {
11 + const img = new Image();
12 + img.crossOrigin = "Anonymous"; // 避免跨域问题
13 + img.src = imageURL;
14 + img.onload = () => {
15 + sliceImageToTiles(img, bounds, zoomLevel);
16 + };
17 +}
18 +
19 +
20 +function sliceImageToTiles(image, bounds, zoomLevel) {
21 + const canvas = document.createElement("canvas");
22 + const ctx = canvas.getContext("2d");
23 +
24 + const imgWidth = image.width;
25 + const imgHeight = image.height;
26 +
27 + const southWest = bounds.getSouthWest();
28 + const northEast = bounds.getNorthEast();
29 +
30 + const lonStart = southWest.lng;
31 + const latStart = southWest.lat;
32 + const lonEnd = northEast.lng;
33 + const latEnd = northEast.lat;
34 +
35 + let tileStartX = lonToTileX(lonStart, zoomLevel);
36 + let tileEndX = lonToTileX(lonEnd, zoomLevel);
37 + let tileStartY = latToTileY(latEnd, zoomLevel); // 取 latEnd 作为起点
38 + let tileEndY = latToTileY(latStart, zoomLevel); // 取 latStart 作为终点
39 +
40 + // 确保 tileStartX <= tileEndX,tileStartY <= tileEndY
41 + tileStartX = Math.min(tileStartX, tileEndX);
42 + tileEndX = Math.max(tileStartX, tileEndX);
43 + tileStartY = Math.min(tileStartY, tileEndY);
44 + tileEndY = Math.max(tileStartY, tileEndY);
45 +
46 + // console.warn(`瓦片编号: X(${tileStartX} -> ${tileEndX}), Y(${tileStartY} -> ${tileEndY})`);
47 +
48 + const cols = tileEndX - tileStartX + 1;
49 + const rows = tileEndY - tileStartY + 1;
50 +
51 + const tileWidth = imgWidth / cols;
52 + const tileHeight = imgHeight / rows;
53 +
54 + const scaleFactor = 2; // 调高分辨率倍率
55 +
56 + canvas.width = tileSize * scaleFactor;
57 + canvas.height = tileSize * scaleFactor;
58 +
59 + ctx.scale(scaleFactor, scaleFactor);
60 +
61 + for (let x = 0; x < cols; x++) {
62 + for (let y = 0; y < rows; y++) {
63 + ctx.clearRect(0, 0, tileSize, tileSize);
64 +
65 + ctx.drawImage(
66 + image,
67 + x * tileWidth, y * tileHeight, tileWidth, tileHeight, // 源图像区域
68 + 0, 0, tileSize, tileSize // 目标画布区域
69 + );
70 +
71 + canvas.toBlob((blob) => {
72 + if (!blob) {
73 + console.error("瓦片转换失败!");
74 + return;
75 + }
76 + const tileX = tileStartX + x;
77 + const tileY = tileStartY + y;
78 + // console.warn(`保存瓦片: ${tileX}_${tileY}_${zoomLevel}.png`);
79 + saveTile(blob, `${tileX}_${tileY}_${zoomLevel}.png`);
80 + }, "image/png", 1.0);
81 + }
82 + }
83 +}
84 +// 经纬度转换为瓦片坐标
85 +function lonToTileX(lon, zoom) {
86 + return Math.floor(((lon + 180) / 360) * Math.pow(2, zoom));
87 +}
88 +
89 +function latToTileY(lat, zoom) {
90 + return Math.floor(
91 + ((1 - Math.log(Math.tan(lat * Math.PI / 180) + 1 / Math.cos(lat * Math.PI / 180)) / Math.PI) / 2) * Math.pow(2, zoom)
92 + );
93 +}
94 +
95 +function saveTile(blob, filename) {
96 + const link = document.createElement("a");
97 + link.href = URL.createObjectURL(blob);
98 + link.download = filename;
99 + document.body.appendChild(link);
100 + link.click();
101 + document.body.removeChild(link);
102 +}
1 +<!--
2 + * @Date: 2025-01-22 11:40:12
3 + * @LastEditors: hookehuyr hookehuyr@gmail.com
4 + * @LastEditTime: 2025-01-23 16:32:53
5 + * @FilePath: /map-demo/src/views/mapCutter.vue
6 + * @Description: 文件描述
7 +-->
8 +<template>
9 + <div style="display: flex; padding: 1rem 0 0 1rem; gap: 1rem; align-items: center;">
10 + <div>目标地图经纬度</div>
11 + <el-input
12 + v-model="map_center"
13 + style="width: 240px"
14 + placeholder="输入经纬度"
15 + @blur="onCenterBlur"
16 + />
17 + </div>
18 + <div style="display: flex; padding: 1rem; gap: 1rem;">
19 + <div style="display: flex; align-items: center;">
20 + <div>地图层级:&nbsp;</div>
21 + <el-select v-model="map_zoom" placeholder="地图层级" style="width: 240px" @change="onZoomChange">
22 + <el-option
23 + v-for="item in zoom_options"
24 + :key="item.value"
25 + :label="item.label"
26 + :value="item.value"
27 + />
28 + </el-select>
29 + </div>
30 +
31 + <div style="display: flex; align-items: center;">
32 + <div>上传图片范围:&nbsp;</div>
33 + <el-input
34 + v-model="map_left_bottom_range"
35 + style="width: 240px"
36 + placeholder="输入左下角经纬度"
37 + @blur="onLBRangeBlur"
38 + />
39 + &nbsp;
40 + <el-input
41 + v-model="map_right_top_range"
42 + style="width: 240px"
43 + placeholder="输入右上角的经纬度"
44 + @blur="onRTRangeBlur"
45 + />
46 + </div>
47 + <div v-if="showUpload">
48 + <input type="file" @change="handleImageUpload" />
49 + <el-button type="primary" @click="cutTiles">切割瓦片</el-button>
50 + </div>
51 + </div>
52 + <div id="map-container"></div>
53 + <div>
54 +
55 + </div>
56 + <div v-if="showUpload" class="controls">
57 + <el-button type="primary" :icon="Top" @click="moveImage('up')">图片上移</el-button>
58 + <el-button type="primary" :icon="Bottom" @click="moveImage('down')">图片下移</el-button>
59 + <el-button type="primary" :icon="Back" @click="moveImage('left')">图片左移</el-button>
60 + <el-button type="primary" :icon="Right" @click="moveImage('right')">图片右移</el-button>
61 + <el-button type="primary" :icon="Plus" @click="scaleImage(1.01)">图片放大</el-button>
62 + <el-button type="primary" :icon="Minus" @click="scaleImage(0.99)">图片缩小</el-button>
63 + <el-button type="primary" @click="rotateMap(10)">地图顺时针旋转</el-button>
64 + <el-button type="primary" @click="rotateMap(-10)">地图逆时针旋转</el-button>
65 + </div>
66 +</template>
67 +
68 +<script setup>
69 +import { onMounted, ref, computed } from "vue";
70 +// import AMapLoader from "@amap/amap-jsapi-loader";
71 +import { TileCutter } from "@/utils/TileCutter";
72 +import { Top, Bottom, Back, Right, Plus, Minus } from '@element-plus/icons-vue'
73 +
74 +const map = ref(null);
75 +const imageLayer = ref(null);
76 +const imageURL = ref(""); // 存储上传的图片
77 +const bounds = ref(null); // 图片覆盖的边界
78 +const mapRotation = ref(0); // 存储地图旋转角度
79 +const zooms = ref([17, 18]);
80 +
81 +const map_zoom = ref(17)
82 +
83 +const zoom_options = [
84 + {
85 + value: 17,
86 + label: 17,
87 + },
88 + {
89 + value: 18,
90 + label: 18,
91 + },
92 +]
93 +
94 +const map_left_bottom_range = ref(null); // 120.583625,31.311858
95 +const map_right_top_range = ref(null); // 120.591047,31.318265
96 +
97 +const map_center = ref(null);
98 +
99 +onMounted(async () => {
100 + loadMap();
101 +});
102 +
103 +function loadMap() {
104 + // 初始化地图
105 + map.value = new AMap.Map('map-container', {
106 + zoom: 17,
107 + zooms: zooms.value,
108 + center: [120.587648, 31.314616],
109 + rotation: 0, // 初始地图角度
110 + layers: [
111 + new AMap.TileLayer.RoadNet(),
112 + new AMap.TileLayer.Satellite(),
113 + ],
114 + });
115 +
116 +
117 + // Canvas作为切片
118 + var layer1 = new AMap.TileLayer.Flexible({
119 + tileSize: 256,
120 + // cacheSize: 300,
121 + zIndex: 200,
122 + createTile: function (x, y, z, success, fail) {
123 + var c = document.createElement('canvas');
124 + c.width = c.height = 256;
125 +
126 + var cxt = c.getContext("2d");
127 + cxt.font = "15px Verdana";
128 + cxt.fillStyle = "#ff976a";
129 + cxt.strokeStyle = "#ff976a";
130 + cxt.strokeRect(0, 0, 256, 256);
131 + cxt.fillText('(' + [x, y, z].join(',') + ')', 10, 30);
132 +
133 + // 通知API切片创建完成
134 + success(c);
135 + }
136 + });
137 +
138 + layer1.setMap(map.value);
139 +
140 + // 监听 zoomchange 事件
141 + map.value.on('zoomchange', () => {
142 + const currentZoom = map.value.getZoom();
143 + if (currentZoom === 18) {
144 + map_zoom.value = 18;
145 + } else {
146 + map_zoom.value = 17;
147 + }
148 + });
149 +}
150 +
151 +function handleImageUpload(event) {
152 + const file = event.target.files[0];
153 + if (!file) return;
154 +
155 + const reader = new FileReader();
156 + reader.onload = (e) => {
157 + imageURL.value = e.target.result;
158 + addImageToMap(imageURL.value);
159 + };
160 + reader.readAsDataURL(file);
161 +}
162 +
163 +function addImageToMap(url) {
164 + if (imageLayer.value) {
165 + map.value.remove(imageLayer.value);
166 + }
167 +
168 + // TAG: 设置图片范围
169 + bounds.value = new AMap.Bounds(map_left_bottom_range.value, map_right_top_range.value); // 设置图片范围 左下角 (西南) -> 右上角 (东北)
170 + imageLayer.value = new AMap.ImageLayer({
171 + url: url,
172 + bounds: bounds.value,
173 + zooms: [17, 18],
174 + opacity: 0.6 // 透明度 (0 完全透明, 1 完全不透明)
175 + });
176 +
177 + map.value.add(imageLayer.value);
178 +}
179 +
180 +
181 +function cutTiles() {
182 + if (!imageURL.value) {
183 + alert("请先上传图片");
184 + return;
185 + }
186 + // TAG: 切割瓦片等级
187 + TileCutter(imageURL.value, bounds.value, map_zoom.value); // 传入图片、地图范围、缩放级别
188 +}
189 +
190 +// ✅ 1. 位置移动
191 +const moveImage = (direction) => {
192 +const offset = 0.0001; // 移动步长(经纬度差值)
193 +const sw = bounds.value.getSouthWest();
194 +const ne = bounds.value.getNorthEast();
195 +
196 +let newBounds;
197 +switch (direction) {
198 + case "up":
199 + newBounds = new AMap.Bounds([sw.lng, sw.lat + offset], [ne.lng, ne.lat + offset]);
200 + break;
201 + case "down":
202 + newBounds = new AMap.Bounds([sw.lng, sw.lat - offset], [ne.lng, ne.lat - offset]);
203 + break;
204 + case "left":
205 + newBounds = new AMap.Bounds([sw.lng - offset, sw.lat], [ne.lng - offset, ne.lat]);
206 + break;
207 + case "right":
208 + newBounds = new AMap.Bounds([sw.lng + offset, sw.lat], [ne.lng + offset, ne.lat]);
209 + break;
210 +}
211 +
212 +bounds.value = newBounds;
213 +imageLayer.value.setBounds(bounds.value);
214 +};
215 +
216 +// ✅ 2. 缩放图片
217 +const scaleImage = (factor) => {
218 +const sw = bounds.value.getSouthWest();
219 +const ne = bounds.value.getNorthEast();
220 +
221 +const centerX = (sw.lng + ne.lng) / 2;
222 +const centerY = (sw.lat + ne.lat) / 2;
223 +const newWidth = (ne.lng - sw.lng) * factor;
224 +const newHeight = (ne.lat - sw.lat) * factor;
225 +
226 +bounds.value = new AMap.Bounds(
227 + [centerX - newWidth / 2, centerY - newHeight / 2],
228 + [centerX + newWidth / 2, centerY + newHeight / 2]
229 +);
230 +
231 +imageLayer.value.setBounds(bounds.value);
232 +};
233 +
234 +// 旋转地图
235 +const rotateMap = (deltaAngle) => {
236 + if (!map.value) return;
237 +
238 + mapRotation.value += deltaAngle;
239 +
240 + console.log(`地图旋转: ${mapRotation.value}°`);
241 +
242 + map.value.setRotation(mapRotation.value);
243 +};
244 +
245 +
246 +const onZoomChange = (value) => { // 调整地图图层
247 + map.value.setZoom(value);
248 +}
249 +
250 +const onLBRangeBlur = () => {
251 + const str = map_left_bottom_range.value;
252 + const formattedArray = str.split(',').map(Number);
253 + map_left_bottom_range.value = formattedArray;
254 +}
255 +const onRTRangeBlur = () => {
256 + const str = map_right_top_range.value;
257 + const formattedArray = str.split(',').map(Number);
258 + map_right_top_range.value = formattedArray;
259 +}
260 +
261 +const showUpload = computed(() => {
262 + return map_left_bottom_range.value && map_right_top_range.value ? true : false;
263 +});
264 +
265 +const onCenterBlur = () => {
266 + const str = map_center.value;
267 + const formattedArray = str.split(',').map(Number);
268 + map.value.setCenter(formattedArray);
269 +}
270 +</script>
271 +
272 +<style>
273 +#map-container {
274 + width: 100%;
275 + height: 100vh;
276 +}
277 +
278 +.controls {
279 + position: absolute;
280 + top: 8rem;
281 + right: 10px;
282 + background: rgba(255, 255, 255, 0.8);
283 + padding: 10px;
284 + border-radius: 5px;
285 + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
286 +}
287 +</style>
...@@ -9,6 +9,7 @@ import { createProxy } from './build/proxy' ...@@ -9,6 +9,7 @@ import { createProxy } from './build/proxy'
9 import DefineOptions from 'unplugin-vue-define-options/vite'; 9 import DefineOptions from 'unplugin-vue-define-options/vite';
10 import AutoImport from 'unplugin-auto-import/vite'; 10 import AutoImport from 'unplugin-auto-import/vite';
11 import postcsspxtoviewport from 'postcss-px-to-viewport' 11 import postcsspxtoviewport from 'postcss-px-to-viewport'
12 +import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
12 13
13 var path = require('path'); 14 var path = require('path');
14 const fs = require('fs'); 15 const fs = require('fs');
...@@ -30,7 +31,7 @@ export default ({ command, mode }) => { ...@@ -30,7 +31,7 @@ export default ({ command, mode }) => {
30 // 将要用到的插件数组。Falsy 虚值的插件将被忽略,插件数组将被扁平化(flatten)。查看 插件 API 获取 Vite 插件的更多细节。 31 // 将要用到的插件数组。Falsy 虚值的插件将被忽略,插件数组将被扁平化(flatten)。查看 插件 API 获取 Vite 插件的更多细节。
31 vue(), 32 vue(),
32 Components({ 33 Components({
33 - resolvers: [VantResolver()], 34 + resolvers: [VantResolver(), ElementPlusResolver()],
34 }), 35 }),
35 // styleImport({ 36 // styleImport({
36 // resolves: [VantResolve()], 37 // resolves: [VantResolve()],
...@@ -56,6 +57,7 @@ export default ({ command, mode }) => { ...@@ -56,6 +57,7 @@ export default ({ command, mode }) => {
56 eslintrc: { 57 eslintrc: {
57 enabled: true, 58 enabled: true,
58 }, 59 },
60 + resolvers: [ElementPlusResolver()],
59 }), 61 }),
60 ], 62 ],
61 publicDir: 'public', // 作为静态资源服务的文件夹。这个目录中的文件会在开发中被服务于 /,在开发模式时,会被拷贝到 outDir 的根目录,并没有转换,永远只是复制到这里。该值可以是文件系统的绝对路径,也可以是相对于项目的根目录路径。 63 publicDir: 'public', // 作为静态资源服务的文件夹。这个目录中的文件会在开发中被服务于 /,在开发模式时,会被拷贝到 outDir 的根目录,并没有转换,永远只是复制到这里。该值可以是文件系统的绝对路径,也可以是相对于项目的根目录路径。
......
This diff is collapsed. Click to expand it.