add v0.0.2
This commit is contained in:
107
plan/v0.0.2.md
Normal file
107
plan/v0.0.2.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# 地图距离计算网页版 (v0.0.2)
|
||||
|
||||
## 需求
|
||||
|
||||
创建一个纯网页版的距离计算工具:
|
||||
- AMAP_KEY 可编辑并保存到浏览器 localStorage
|
||||
- 上传 JSON 数据文件计算多组距离
|
||||
- 调用高德地图 API 计算距离
|
||||
- 表格展示结果:来源地址 | 目的地地址 | 距离(km)
|
||||
- 导出 CSV 功能
|
||||
|
||||
## JSON 输入格式
|
||||
|
||||
```json
|
||||
[
|
||||
{"from": "北京市", "to": "上海市"},
|
||||
{"from": "广州市", "to": "深圳市"}
|
||||
]
|
||||
```
|
||||
|
||||
## 实现方案
|
||||
|
||||
1. **Key 管理**:使用 localStorage 保存/读取 AMAP_KEY
|
||||
2. **文件上传**:`<input type="file" accept=".json">` + FileReader 解析
|
||||
3. **高德 API**:使用 `Geocode` 地理编码 API
|
||||
- URL: `https://restapi.amap.com/v3/geocode/geo?address={address}&key={key}`
|
||||
- 将地址转换为经纬度坐标
|
||||
4. **距离计算**:使用 Haversine 公式计算两点间直线距离
|
||||
- 地球半径: 6371000 米
|
||||
- 转换为公里,保留2位小数
|
||||
5. **CSV 导出**:原生 JS 生成 Blob 下载
|
||||
|
||||
## 依赖
|
||||
|
||||
- **无外部 JS 库**,纯原生 HTML/CSS/JavaScript 实现
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
public/
|
||||
web.html # 完整的网页实现
|
||||
```
|
||||
|
||||
## 关键代码
|
||||
|
||||
### 地理编码
|
||||
```javascript
|
||||
async function geocode(address, key) {
|
||||
const url = `https://restapi.amap.com/v3/geocode/geo?address=${encodeURIComponent(address)}&key=${key}`;
|
||||
const res = await fetch(url).then(r => r.json());
|
||||
if (res.status === '1' && res.geocodes?.length > 0) {
|
||||
const [lng, lat] = res.geocodes[0].location.split(',').map(Number);
|
||||
return { lng, lat };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
### Haversine 距离计算
|
||||
```javascript
|
||||
function calculateDistance(from, to) {
|
||||
const R = 6371000; // 地球半径(米)
|
||||
const dLat = (to.lat - from.lat) * Math.PI / 180;
|
||||
const dLng = (to.lng - from.lng) * Math.PI / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(from.lat * Math.PI / 180) *
|
||||
Math.cos(to.lat * Math.PI / 180) *
|
||||
Math.sin(dLng / 2) ** 2;
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
```
|
||||
|
||||
### 获取距离
|
||||
```javascript
|
||||
async function getDistance(fromAddress, toAddress, key) {
|
||||
const [from, to] = await Promise.all([
|
||||
geocode(fromAddress, key),
|
||||
geocode(toAddress, key)
|
||||
]);
|
||||
if (!from || !to) return '解析失败';
|
||||
const distance = calculateDistance(from, to);
|
||||
return (distance / 1000).toFixed(2) + ' km';
|
||||
}
|
||||
```
|
||||
|
||||
### CSV 导出
|
||||
```javascript
|
||||
function exportCSV(results) {
|
||||
const csv = 'from,to,distance\n' + results.map(r => `${r.from},${r.to},${r.distance}`).join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'distance_results.csv';
|
||||
a.click();
|
||||
}
|
||||
```
|
||||
|
||||
## 验证步骤
|
||||
|
||||
1. 浏览器打开 `public/web.html`
|
||||
2. 输入 AMAP_KEY 并保存
|
||||
3. 上传测试 JSON 文件
|
||||
4. 查看计算结果表格
|
||||
5. 点击导出 CSV 验证下载
|
||||
48
public/docs-web.md
Normal file
48
public/docs-web.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# 网页版地图距离计算器
|
||||
|
||||
在线使用:[https://kevisual.xiongxiao.me/root/test-map-distance/web.html](https://kevisual.xiongxiao.me/root/test-map-distance/web.html)
|
||||
|
||||
## 功能特性
|
||||
|
||||
- AMAP_KEY 保存到浏览器localStorage,无需重复输入
|
||||
- 上传 JSON 文件批量计算距离
|
||||
- 使用 Haversine 公式计算两点间直线距离
|
||||
- 结果表格展示
|
||||
- 导出 CSV 文件
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 1. 配置高德地图 Key
|
||||
|
||||
1. 访问 [高德开放平台](https://console.amap.com/dev/key/app) 注册/登录账号
|
||||
2. 创建 Web 应用,获取 API Key
|
||||
3. 在网页中输入 Key,点击「保存 Key」
|
||||
|
||||
### 2. 准备 JSON 数据
|
||||
|
||||
创建 JSON 文件,格式如下:
|
||||
|
||||
```json
|
||||
[
|
||||
{"from": "北京市", "to": "上海市"},
|
||||
{"from": "广州市", "to": "深圳市"},
|
||||
{"from": "杭州市", "to": "南京市"}
|
||||
]
|
||||
```
|
||||
|
||||
### 3. 上传并计算
|
||||
|
||||
1. 点击「选择文件」,上传准备好的 JSON 文件
|
||||
2. 点击「开始计算」
|
||||
3. 等待计算完成,查看结果表格
|
||||
|
||||
### 4. 导出结果
|
||||
|
||||
计算完成后,点击「导出 CSV」下载结果文件。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 需要有效的 AMAP_KEY 才能正常使用
|
||||
- 地理编码服务有每日调用限制
|
||||
- 地址越精确,计算结果越准确
|
||||
- 直线距离与实际行驶距离可能存在差异
|
||||
@@ -132,6 +132,13 @@
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h1>地图距离计算工具 - 使用说明</h1>
|
||||
<!-- 文档切换导航 -->
|
||||
<div class="mt-3">
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-light btn-sm" id="btn-docs" onclick="loadDoc('docs.md', this)">脚本执行</button>
|
||||
<button type="button" class="btn btn-light btn-sm active" id="btn-web-docs" onclick="loadDoc('docs-web.md', this)">网页版</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body" id="content">
|
||||
<div class="loading">
|
||||
@@ -154,9 +161,24 @@
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||
|
||||
<script>
|
||||
async function loadDocs() {
|
||||
// 文档切换功能
|
||||
async function loadDoc(docFile, btn) {
|
||||
// 更新按钮状态
|
||||
document.querySelectorAll('.btn-group .btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
|
||||
// 显示加载状态
|
||||
document.getElementById('content').innerHTML = `
|
||||
<div class="loading">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">加载中...</span>
|
||||
</div>
|
||||
<p class="mt-3">文档加载中...</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await fetch('./docs.md');
|
||||
const response = await fetch('./' + docFile);
|
||||
if (!response.ok) {
|
||||
throw new Error('文档加载失败');
|
||||
}
|
||||
@@ -177,7 +199,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
loadDocs();
|
||||
// 默认加载网页版文档
|
||||
loadDoc('docs-web.md', document.getElementById('btn-web-docs'));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
270
public/web.html
Normal file
270
public/web.html
Normal file
@@ -0,0 +1,270 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>地图距离计算器</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
h1 { color: #333; text-align: center; }
|
||||
.section {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.section h2 { margin-top: 0; color: #666; font-size: 16px; }
|
||||
.input-group { display: flex; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
|
||||
input[type="text"], input[type="file"] {
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
input[type="text"] { flex: 1; min-width: 200px; }
|
||||
button {
|
||||
padding: 10px 20px;
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { background: #40a9ff; }
|
||||
button:disabled { background: #ccc; cursor: not-allowed; }
|
||||
.status { padding: 10px; border-radius: 4px; margin-top: 10px; }
|
||||
.status.success { background: #f6ffed; color: #52c41a; }
|
||||
.status.error { background: #fff2f0; color: #ff4d4f; }
|
||||
.status.info { background: #e6f7ff; color: #1890ff; }
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 10px;
|
||||
}
|
||||
th, td {
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
th { background: #fafafa; font-weight: 600; }
|
||||
.distance { color: #1890ff; font-weight: 500; }
|
||||
.failed { color: #ff4d4f; }
|
||||
#exportBtn { display: none; margin-top: 20px; }
|
||||
.progress { color: #666; font-size: 14px; margin-top: 10px; }
|
||||
.help { font-size: 12px; color: #999; margin-top: 5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>地图距离计算器</h1>
|
||||
|
||||
<!-- Key 设置 -->
|
||||
<div class="section">
|
||||
<h2>高德地图 Key</h2>
|
||||
<div class="input-group">
|
||||
<input type="text" id="amapKey" placeholder="请输入高德地图 API Key">
|
||||
<button onclick="saveKey()">保存 Key</button>
|
||||
</div>
|
||||
<div class="help">Key 将保存到浏览器本地存储</div>
|
||||
<div id="keyStatus"></div>
|
||||
</div>
|
||||
|
||||
<!-- 文件上传 -->
|
||||
<div class="section">
|
||||
<h2>上传 JSON 数据</h2>
|
||||
<div class="input-group">
|
||||
<input type="file" id="jsonFile" accept=".json">
|
||||
<button onclick="uploadAndCalculate()">开始计算</button>
|
||||
</div>
|
||||
<div class="help">JSON 格式: [{"from": "北京市", "to": "上海市"}, ...]</div>
|
||||
<div id="fileStatus"></div>
|
||||
<div id="progress" class="progress"></div>
|
||||
</div>
|
||||
|
||||
<!-- 结果表格 -->
|
||||
<div class="section" id="resultSection" style="display: none;">
|
||||
<h2>计算结果</h2>
|
||||
<table id="resultTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>来源地址</th>
|
||||
<th>目的地地址</th>
|
||||
<th>距离 (km)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="resultBody"></tbody>
|
||||
</table>
|
||||
<button id="exportBtn" onclick="exportCSV()">导出 CSV</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const STORAGE_KEY = 'amap_key';
|
||||
let results = [];
|
||||
|
||||
// 页面加载时读取 Key
|
||||
window.onload = function() {
|
||||
const savedKey = localStorage.getItem(STORAGE_KEY);
|
||||
if (savedKey) {
|
||||
document.getElementById('amapKey').value = savedKey;
|
||||
showStatus('keyStatus', '已自动加载保存的 Key', 'success');
|
||||
}
|
||||
};
|
||||
|
||||
// 保存 Key
|
||||
function saveKey() {
|
||||
const key = document.getElementById('amapKey').value.trim();
|
||||
if (!key) {
|
||||
showStatus('keyStatus', '请输入 API Key', 'error');
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, key);
|
||||
showStatus('keyStatus', 'Key 已保存到浏览器', 'success');
|
||||
}
|
||||
|
||||
// 显示状态消息
|
||||
function showStatus(elementId, message, type) {
|
||||
const el = document.getElementById(elementId);
|
||||
el.className = 'status ' + type;
|
||||
el.textContent = message;
|
||||
}
|
||||
|
||||
// 上传并计算
|
||||
async function uploadAndCalculate() {
|
||||
const key = localStorage.getItem(STORAGE_KEY);
|
||||
if (!key) {
|
||||
showStatus('fileStatus', '请先保存高德地图 API Key', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const fileInput = document.getElementById('jsonFile');
|
||||
if (!fileInput.files.length) {
|
||||
showStatus('fileStatus', '请选择 JSON 文件', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const file = fileInput.files[0];
|
||||
showStatus('fileStatus', '正在读取文件...', 'info');
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('JSON 格式错误,应为数组格式');
|
||||
}
|
||||
|
||||
showStatus('fileStatus', `开始计算 ${data.length} 条记录...`, 'info');
|
||||
|
||||
results = [];
|
||||
const tbody = document.getElementById('resultBody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const item = data[i];
|
||||
const from = item.from;
|
||||
const to = item.to;
|
||||
|
||||
document.getElementById('progress').textContent =
|
||||
`正在计算 ${i + 1}/${data.length}: ${from} → ${to}`;
|
||||
|
||||
const distance = await getDistance(from, to, key);
|
||||
const result = { from, to, distance };
|
||||
results.push(result);
|
||||
|
||||
// 添加表格行
|
||||
const row = tbody.insertRow();
|
||||
row.insertCell(0).textContent = from;
|
||||
row.insertCell(1).textContent = to;
|
||||
const distCell = row.insertCell(2);
|
||||
distCell.textContent = distance;
|
||||
distCell.className = distance.includes('失败') || distance.includes('解析失败') ? 'failed' : 'distance';
|
||||
}
|
||||
|
||||
document.getElementById('progress').textContent = `计算完成,共 ${results.length} 条记录`;
|
||||
document.getElementById('resultSection').style.display = 'block';
|
||||
document.getElementById('exportBtn').style.display = 'inline-block';
|
||||
showStatus('fileStatus', '计算完成', 'success');
|
||||
|
||||
} catch (err) {
|
||||
showStatus('fileStatus', '错误: ' + err.message, 'error');
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 地理编码:将地址转换为经纬度
|
||||
* 参考 index.ts 的 geocode 函数
|
||||
*/
|
||||
async function geocode(address, key) {
|
||||
const url = `https://restapi.amap.com/v3/geocode/geo?address=${encodeURIComponent(address)}&key=${key}`;
|
||||
const res = await fetch(url).then(r => r.json());
|
||||
|
||||
if (res.status === '1' && res.geocodes?.length > 0) {
|
||||
const [lng, lat] = res.geocodes[0].location.split(',').map(Number);
|
||||
return { lng, lat };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算两点间的直线距离(米)
|
||||
* 参考 index.ts 的 calculateDistance 函数 (Haversine 公式)
|
||||
*/
|
||||
function calculateDistance(from, to) {
|
||||
const R = 6371000; // 地球半径(米)
|
||||
const dLat = (to.lat - from.lat) * Math.PI / 180;
|
||||
const dLng = (to.lng - from.lng) * Math.PI / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(from.lat * Math.PI / 180) *
|
||||
Math.cos(to.lat * Math.PI / 180) *
|
||||
Math.sin(dLng / 2) ** 2;
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算两个地址间的直线距离
|
||||
* 参考 index.ts 的 getDistance 函数
|
||||
*/
|
||||
async function getDistance(fromAddress, toAddress, key) {
|
||||
const [from, to] = await Promise.all([
|
||||
geocode(fromAddress, key),
|
||||
geocode(toAddress, key)
|
||||
]);
|
||||
|
||||
if (!from || !to) {
|
||||
return '解析失败';
|
||||
}
|
||||
|
||||
const distance = calculateDistance(from, to);
|
||||
return (distance / 1000).toFixed(2) + ' km';
|
||||
}
|
||||
|
||||
// 导出 CSV
|
||||
function exportCSV() {
|
||||
if (!results.length) return;
|
||||
|
||||
const headers = ['from,to,distance\n'];
|
||||
const rows = results.map(r =>
|
||||
`"${r.from}","${r.to}","${r.distance}"`
|
||||
);
|
||||
const csv = headers.concat(rows).join('\n');
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'distance_results.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user