hookehuyr

✨ feat: 新增瓦片切图工具

......@@ -12,6 +12,10 @@ declare module '@vue/runtime-core' {
AudioBackground: typeof import('./src/components/audioBackground.vue')['default']
AudioBackground1: typeof import('./src/components/audioBackground1.vue')['default']
AudioList: typeof import('./src/components/audioList.vue')['default']
ElButton: typeof import('element-plus/es')['ElButton']
ElInput: typeof import('element-plus/es')['ElInput']
ElOption: typeof import('element-plus/es')['ElOption']
ElSelect: typeof import('element-plus/es')['ElSelect']
Floor: typeof import('./src/components/Floor/index.vue')['default']
InfoPopup: typeof import('./src/components/InfoPopup.vue')['default']
InfoPopupLite: typeof import('./src/components/InfoPopupLite.vue')['default']
......
......@@ -25,6 +25,8 @@
"xys_upload": "npm run build_tar && npm run scp-xys && npm run dec-xys && npm run remove_tar"
},
"dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1",
"@element-plus/icons-vue": "^2.3.1",
"@photo-sphere-viewer/core": "^5.7.3",
"@photo-sphere-viewer/gallery-plugin": "^5.7.3",
"@photo-sphere-viewer/gyroscope-plugin": "^5.7.3",
......@@ -39,6 +41,7 @@
"animate.css": "^4.1.1",
"dayjs": "^1.11.3",
"default-passive-events": "^2.0.0",
"element-plus": "^2.9.3",
"font-awesome": "^4.7.0",
"global": "^4.4.0",
"html-to-json-parser": "^1.1.0",
......
......@@ -2,7 +2,7 @@
* @Author: hookehuyr hookehuyr@gmail.com
* @Date: 2022-05-31 12:06:19
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2024-09-25 17:17:40
* @LastEditTime: 2025-01-23 16:10:45
* @FilePath: /map-demo/src/main.js
* @Description:
*/
......@@ -57,6 +57,8 @@ import 'video.js/dist/video-js.css';
import 'viewerjs/dist/viewer.css';
import VueViewer from 'v-viewer';
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
const pinia = createPinia();
const app = createApp(App);
......@@ -102,4 +104,8 @@ app
app.use(VueVideoPlayer)
app.use(VueViewer);
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.mount('#app');
......
/*
* @Date: 2023-05-29 11:10:19
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2024-09-20 17:54:57
* @LastEditTime: 2025-01-23 16:22:10
* @FilePath: /map-demo/src/route.js
* @Description: 文件描述
*/
......@@ -69,4 +69,12 @@ export default [
title: '详情页',
},
},
{
path: '/map_cutter',
name: '瓦片切图工具',
component: () => import('@/views/mapCutter.vue'),
meta: {
title: '瓦片切图工具',
},
},
];
......
/*
* @Date: 2025-01-22 11:45:30
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-01-23 16:38:00
* @FilePath: /map-demo/src/utils/TileCutter.js
* @Description: 文件描述
*/
const tileSize = 512;
export function TileCutter(imageURL, bounds, zoomLevel) {
const img = new Image();
img.crossOrigin = "Anonymous"; // 避免跨域问题
img.src = imageURL;
img.onload = () => {
sliceImageToTiles(img, bounds, zoomLevel);
};
}
function sliceImageToTiles(image, bounds, zoomLevel) {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const imgWidth = image.width;
const imgHeight = image.height;
const southWest = bounds.getSouthWest();
const northEast = bounds.getNorthEast();
const lonStart = southWest.lng;
const latStart = southWest.lat;
const lonEnd = northEast.lng;
const latEnd = northEast.lat;
let tileStartX = lonToTileX(lonStart, zoomLevel);
let tileEndX = lonToTileX(lonEnd, zoomLevel);
let tileStartY = latToTileY(latEnd, zoomLevel); // 取 latEnd 作为起点
let tileEndY = latToTileY(latStart, zoomLevel); // 取 latStart 作为终点
// 确保 tileStartX <= tileEndX,tileStartY <= tileEndY
tileStartX = Math.min(tileStartX, tileEndX);
tileEndX = Math.max(tileStartX, tileEndX);
tileStartY = Math.min(tileStartY, tileEndY);
tileEndY = Math.max(tileStartY, tileEndY);
// console.warn(`瓦片编号: X(${tileStartX} -> ${tileEndX}), Y(${tileStartY} -> ${tileEndY})`);
const cols = tileEndX - tileStartX + 1;
const rows = tileEndY - tileStartY + 1;
const tileWidth = imgWidth / cols;
const tileHeight = imgHeight / rows;
const scaleFactor = 2; // 调高分辨率倍率
canvas.width = tileSize * scaleFactor;
canvas.height = tileSize * scaleFactor;
ctx.scale(scaleFactor, scaleFactor);
for (let x = 0; x < cols; x++) {
for (let y = 0; y < rows; y++) {
ctx.clearRect(0, 0, tileSize, tileSize);
ctx.drawImage(
image,
x * tileWidth, y * tileHeight, tileWidth, tileHeight, // 源图像区域
0, 0, tileSize, tileSize // 目标画布区域
);
canvas.toBlob((blob) => {
if (!blob) {
console.error("瓦片转换失败!");
return;
}
const tileX = tileStartX + x;
const tileY = tileStartY + y;
// console.warn(`保存瓦片: ${tileX}_${tileY}_${zoomLevel}.png`);
saveTile(blob, `${tileX}_${tileY}_${zoomLevel}.png`);
}, "image/png", 1.0);
}
}
}
// 经纬度转换为瓦片坐标
function lonToTileX(lon, zoom) {
return Math.floor(((lon + 180) / 360) * Math.pow(2, zoom));
}
function latToTileY(lat, zoom) {
return Math.floor(
((1 - Math.log(Math.tan(lat * Math.PI / 180) + 1 / Math.cos(lat * Math.PI / 180)) / Math.PI) / 2) * Math.pow(2, zoom)
);
}
function saveTile(blob, filename) {
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
<!--
* @Date: 2025-01-22 11:40:12
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-01-23 16:32:53
* @FilePath: /map-demo/src/views/mapCutter.vue
* @Description: 文件描述
-->
<template>
<div style="display: flex; padding: 1rem 0 0 1rem; gap: 1rem; align-items: center;">
<div>目标地图经纬度</div>
<el-input
v-model="map_center"
style="width: 240px"
placeholder="输入经纬度"
@blur="onCenterBlur"
/>
</div>
<div style="display: flex; padding: 1rem; gap: 1rem;">
<div style="display: flex; align-items: center;">
<div>地图层级:&nbsp;</div>
<el-select v-model="map_zoom" placeholder="地图层级" style="width: 240px" @change="onZoomChange">
<el-option
v-for="item in zoom_options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</div>
<div style="display: flex; align-items: center;">
<div>上传图片范围:&nbsp;</div>
<el-input
v-model="map_left_bottom_range"
style="width: 240px"
placeholder="输入左下角经纬度"
@blur="onLBRangeBlur"
/>
&nbsp;
<el-input
v-model="map_right_top_range"
style="width: 240px"
placeholder="输入右上角的经纬度"
@blur="onRTRangeBlur"
/>
</div>
<div v-if="showUpload">
<input type="file" @change="handleImageUpload" />
<el-button type="primary" @click="cutTiles">切割瓦片</el-button>
</div>
</div>
<div id="map-container"></div>
<div>
</div>
<div v-if="showUpload" class="controls">
<el-button type="primary" :icon="Top" @click="moveImage('up')">图片上移</el-button>
<el-button type="primary" :icon="Bottom" @click="moveImage('down')">图片下移</el-button>
<el-button type="primary" :icon="Back" @click="moveImage('left')">图片左移</el-button>
<el-button type="primary" :icon="Right" @click="moveImage('right')">图片右移</el-button>
<el-button type="primary" :icon="Plus" @click="scaleImage(1.01)">图片放大</el-button>
<el-button type="primary" :icon="Minus" @click="scaleImage(0.99)">图片缩小</el-button>
<el-button type="primary" @click="rotateMap(10)">地图顺时针旋转</el-button>
<el-button type="primary" @click="rotateMap(-10)">地图逆时针旋转</el-button>
</div>
</template>
<script setup>
import { onMounted, ref, computed } from "vue";
// import AMapLoader from "@amap/amap-jsapi-loader";
import { TileCutter } from "@/utils/TileCutter";
import { Top, Bottom, Back, Right, Plus, Minus } from '@element-plus/icons-vue'
const map = ref(null);
const imageLayer = ref(null);
const imageURL = ref(""); // 存储上传的图片
const bounds = ref(null); // 图片覆盖的边界
const mapRotation = ref(0); // 存储地图旋转角度
const zooms = ref([17, 18]);
const map_zoom = ref(17)
const zoom_options = [
{
value: 17,
label: 17,
},
{
value: 18,
label: 18,
},
]
const map_left_bottom_range = ref(null); // 120.583625,31.311858
const map_right_top_range = ref(null); // 120.591047,31.318265
const map_center = ref(null);
onMounted(async () => {
loadMap();
});
function loadMap() {
// 初始化地图
map.value = new AMap.Map('map-container', {
zoom: 17,
zooms: zooms.value,
center: [120.587648, 31.314616],
rotation: 0, // 初始地图角度
layers: [
new AMap.TileLayer.RoadNet(),
new AMap.TileLayer.Satellite(),
],
});
// Canvas作为切片
var layer1 = new AMap.TileLayer.Flexible({
tileSize: 256,
// cacheSize: 300,
zIndex: 200,
createTile: function (x, y, z, success, fail) {
var c = document.createElement('canvas');
c.width = c.height = 256;
var cxt = c.getContext("2d");
cxt.font = "15px Verdana";
cxt.fillStyle = "#ff976a";
cxt.strokeStyle = "#ff976a";
cxt.strokeRect(0, 0, 256, 256);
cxt.fillText('(' + [x, y, z].join(',') + ')', 10, 30);
// 通知API切片创建完成
success(c);
}
});
layer1.setMap(map.value);
// 监听 zoomchange 事件
map.value.on('zoomchange', () => {
const currentZoom = map.value.getZoom();
if (currentZoom === 18) {
map_zoom.value = 18;
} else {
map_zoom.value = 17;
}
});
}
function handleImageUpload(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
imageURL.value = e.target.result;
addImageToMap(imageURL.value);
};
reader.readAsDataURL(file);
}
function addImageToMap(url) {
if (imageLayer.value) {
map.value.remove(imageLayer.value);
}
// TAG: 设置图片范围
bounds.value = new AMap.Bounds(map_left_bottom_range.value, map_right_top_range.value); // 设置图片范围 左下角 (西南) -> 右上角 (东北)
imageLayer.value = new AMap.ImageLayer({
url: url,
bounds: bounds.value,
zooms: [17, 18],
opacity: 0.6 // 透明度 (0 完全透明, 1 完全不透明)
});
map.value.add(imageLayer.value);
}
function cutTiles() {
if (!imageURL.value) {
alert("请先上传图片");
return;
}
// TAG: 切割瓦片等级
TileCutter(imageURL.value, bounds.value, map_zoom.value); // 传入图片、地图范围、缩放级别
}
// ✅ 1. 位置移动
const moveImage = (direction) => {
const offset = 0.0001; // 移动步长(经纬度差值)
const sw = bounds.value.getSouthWest();
const ne = bounds.value.getNorthEast();
let newBounds;
switch (direction) {
case "up":
newBounds = new AMap.Bounds([sw.lng, sw.lat + offset], [ne.lng, ne.lat + offset]);
break;
case "down":
newBounds = new AMap.Bounds([sw.lng, sw.lat - offset], [ne.lng, ne.lat - offset]);
break;
case "left":
newBounds = new AMap.Bounds([sw.lng - offset, sw.lat], [ne.lng - offset, ne.lat]);
break;
case "right":
newBounds = new AMap.Bounds([sw.lng + offset, sw.lat], [ne.lng + offset, ne.lat]);
break;
}
bounds.value = newBounds;
imageLayer.value.setBounds(bounds.value);
};
// ✅ 2. 缩放图片
const scaleImage = (factor) => {
const sw = bounds.value.getSouthWest();
const ne = bounds.value.getNorthEast();
const centerX = (sw.lng + ne.lng) / 2;
const centerY = (sw.lat + ne.lat) / 2;
const newWidth = (ne.lng - sw.lng) * factor;
const newHeight = (ne.lat - sw.lat) * factor;
bounds.value = new AMap.Bounds(
[centerX - newWidth / 2, centerY - newHeight / 2],
[centerX + newWidth / 2, centerY + newHeight / 2]
);
imageLayer.value.setBounds(bounds.value);
};
// 旋转地图
const rotateMap = (deltaAngle) => {
if (!map.value) return;
mapRotation.value += deltaAngle;
console.log(`地图旋转: ${mapRotation.value}°`);
map.value.setRotation(mapRotation.value);
};
const onZoomChange = (value) => { // 调整地图图层
map.value.setZoom(value);
}
const onLBRangeBlur = () => {
const str = map_left_bottom_range.value;
const formattedArray = str.split(',').map(Number);
map_left_bottom_range.value = formattedArray;
}
const onRTRangeBlur = () => {
const str = map_right_top_range.value;
const formattedArray = str.split(',').map(Number);
map_right_top_range.value = formattedArray;
}
const showUpload = computed(() => {
return map_left_bottom_range.value && map_right_top_range.value ? true : false;
});
const onCenterBlur = () => {
const str = map_center.value;
const formattedArray = str.split(',').map(Number);
map.value.setCenter(formattedArray);
}
</script>
<style>
#map-container {
width: 100%;
height: 100vh;
}
.controls {
position: absolute;
top: 8rem;
right: 10px;
background: rgba(255, 255, 255, 0.8);
padding: 10px;
border-radius: 5px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
</style>
......@@ -9,6 +9,7 @@ import { createProxy } from './build/proxy'
import DefineOptions from 'unplugin-vue-define-options/vite';
import AutoImport from 'unplugin-auto-import/vite';
import postcsspxtoviewport from 'postcss-px-to-viewport'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
var path = require('path');
const fs = require('fs');
......@@ -30,7 +31,7 @@ export default ({ command, mode }) => {
// 将要用到的插件数组。Falsy 虚值的插件将被忽略,插件数组将被扁平化(flatten)。查看 插件 API 获取 Vite 插件的更多细节。
vue(),
Components({
resolvers: [VantResolver()],
resolvers: [VantResolver(), ElementPlusResolver()],
}),
// styleImport({
// resolves: [VantResolve()],
......@@ -56,6 +57,7 @@ export default ({ command, mode }) => {
eslintrc: {
enabled: true,
},
resolvers: [ElementPlusResolver()],
}),
],
publicDir: 'public', // 作为静态资源服务的文件夹。这个目录中的文件会在开发中被服务于 /,在开发模式时,会被拷贝到 outDir 的根目录,并没有转换,永远只是复制到这里。该值可以是文件系统的绝对路径,也可以是相对于项目的根目录路径。
......
This diff is collapsed. Click to expand it.