Toggle navigation
Toggle navigation
This project
Loading...
Sign in
Hooke
/
map-demo
Go to a project
Toggle navigation
Toggle navigation pinning
Projects
Groups
Snippets
Help
Project
Activity
Repository
Pipelines
Graphs
Issues
0
Merge Requests
0
Wiki
Snippets
Network
Create a new issue
Builds
Commits
Issue Boards
Authored by
hookehuyr
2025-01-23 16:39:08 +0800
Browse Files
Options
Browse Files
Download
Email Patches
Plain Diff
Commit
b1921d457aeb854c48efb047747af6f2cfb16e51
b1921d45
1 parent
ddfde3d4
✨ feat: 新增瓦片切图工具
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
415 additions
and
3 deletions
components.d.ts
package.json
src/main.js
src/route.js
src/utils/TileCutter.js
src/views/mapCutter.vue
vite.config.js
yarn.lock
components.d.ts
View file @
b1921d4
...
...
@@ -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'
]
...
...
package.json
View file @
b1921d4
...
...
@@ -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"
,
...
...
src/main.js
View file @
b1921d4
...
...
@@ -2,7 +2,7 @@
* @Author: hookehuyr hookehuyr@gmail.com
* @Date: 2022-05-31 12:06:19
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 202
4-09-25 17:17:40
* @LastEditTime: 202
5-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'
);
...
...
src/route.js
View file @
b1921d4
/*
* @Date: 2023-05-29 11:10:19
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 202
4-09-20 17:54:57
* @LastEditTime: 202
5-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
:
'瓦片切图工具'
,
},
},
];
...
...
src/utils/TileCutter.js
0 → 100644
View file @
b1921d4
/*
* @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
);
}
src/views/mapCutter.vue
0 → 100644
View file @
b1921d4
<!--
* @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>地图层级: </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>上传图片范围: </div>
<el-input
v-model="map_left_bottom_range"
style="width: 240px"
placeholder="输入左下角经纬度"
@blur="onLBRangeBlur"
/>
<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>
vite.config.js
View file @
b1921d4
...
...
@@ -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 的根目录,并没有转换,永远只是复制到这里。该值可以是文件系统的绝对路径,也可以是相对于项目的根目录路径。
...
...
yarn.lock
View file @
b1921d4
This diff is collapsed. Click to expand it.
Please
register
or
login
to post a comment