duqing2026 commited on
Commit
224e9b3
·
1 Parent(s): 9be94e6

chore: 增加默认演示数据,优化模板配置,确保HF运行稳定

Browse files
Files changed (5) hide show
  1. Dockerfile +15 -0
  2. README.md +46 -5
  3. app.py +40 -0
  4. requirements.txt +2 -0
  5. templates/index.html +466 -0
Dockerfile ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ # Create storage directory with correct permissions
11
+ RUN mkdir -p storage && chmod 777 storage
12
+
13
+ EXPOSE 7860
14
+
15
+ CMD ["gunicorn", "-b", "0.0.0.0:7860", "app:app"]
README.md CHANGED
@@ -1,10 +1,51 @@
1
- ---
2
- title: Interactive Map Studio
3
- emoji: 👁
4
- colorFrom: red
5
  colorTo: indigo
6
  sdk: docker
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ title: 交互式地图工坊 (Interactive Map Studio)
2
+ emoji: 🗺️
3
+ colorFrom: blue
 
4
  colorTo: indigo
5
  sdk: docker
6
  pinned: false
7
+ short_description: 为图片添加交互式热点,一键生成可点击的楼层指引、游戏地图或产品展示图。
8
  ---
9
 
10
+ # 交互式地图工坊 (Interactive Map Studio)
11
+
12
+ 这是一个可视化的交互式地图制作工具,专为不需要编程基础的用户设计。你可以上传任意图片(如楼层平面图、游戏地图、产品拆解图),在图片上添加可点击的“热点”标记,并导出为独立的 HTML 文件或嵌入代码。
13
+
14
+ ## ✨ 主要功能
15
+
16
+ - **可视化编辑器**:拖拽上传图片,点击即可添加热点。
17
+ - **自定义热点**:支持修改图标(FontAwesome)、颜色、标题和描述内容。
18
+ - **实时预览**:在编辑过程中随时切换到预览模式,体验最终交互效果。
19
+ - **一键导出**:生成包含所有逻辑的单文件 HTML,无需服务器即可部署,或者嵌入到你的网站中。
20
+ - **多场景适用**:
21
+ - 🏠 **房产/民宿**:展示房间布局和细节说明。
22
+ - 🎮 **游戏攻略**:标记地图上的资源点、BOSS 位置。
23
+ - 🏢 **商场/展会**:制作楼层指引和展位介绍。
24
+ - 🛍️ **电商详情**:在产品图上标记各个部件的功能。
25
+
26
+ ## 🛠️ 技术栈
27
+
28
+ - **Frontend**: Vue 3 + Tailwind CSS (via CDN for simplicity)
29
+ - **Backend**: Python Flask (Serving static files)
30
+ - **Deployment**: Dockerized for Hugging Face Spaces
31
+
32
+ ## 🚀 快速开始
33
+
34
+ 1. 上传一张底图(支持拖拽)。
35
+ 2. 点击图片任意位置添加热点。
36
+ 3. 在左侧面板编辑热点的标题、描述、图标和颜色。
37
+ 4. 点击右上角“导出 HTML”保存成果。
38
+
39
+ ## 📦 部署
40
+
41
+ 本项目已配置 Dockerfile,可直接部署到 Hugging Face Spaces 或任何支持 Docker 的平台。
42
+
43
+ ```bash
44
+ # 本地运行
45
+ docker build -t interactive-map-studio .
46
+ docker run -p 7860:7860 interactive-map-studio
47
+ ```
48
+
49
+ ## 📄 许可证
50
+
51
+ MIT License
app.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask, render_template, request, jsonify, send_from_directory
2
+ import os
3
+ import json
4
+ from datetime import datetime
5
+
6
+ app = Flask(__name__)
7
+
8
+ # Configure Jinja2 to use [[ ]] to avoid conflict with Vue
9
+ class CustomFlask(Flask):
10
+ jinja_options = Flask.jinja_options.copy()
11
+ jinja_options.update(dict(
12
+ variable_start_string='[[',
13
+ variable_end_string=']]',
14
+ ))
15
+
16
+ app = CustomFlask(__name__)
17
+
18
+ # Ensure storage directory exists
19
+ STORAGE_DIR = "storage"
20
+ if not os.path.exists(STORAGE_DIR):
21
+ os.makedirs(STORAGE_DIR)
22
+
23
+ PROJECT_INFO = {
24
+ "name": "interactive-map-studio",
25
+ "title_cn": "交互式地图工坊",
26
+ "short_description": "为图片添加交互式热点,制作可点击的楼层指引、游戏地图或产品展示图。",
27
+ "version": "1.0.0"
28
+ }
29
+
30
+ @app.route('/')
31
+ def index():
32
+ return render_template('index.html', project=PROJECT_INFO)
33
+
34
+ @app.route('/health')
35
+ def health():
36
+ return "OK"
37
+
38
+ if __name__ == '__main__':
39
+ port = int(os.environ.get('PORT', 7860))
40
+ app.run(host='0.0.0.0', port=port)
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ flask
2
+ gunicorn
templates/index.html ADDED
@@ -0,0 +1,466 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>[[ project.title_cn ]] - [[ project.name ]]</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
9
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
10
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
11
+ <style>
12
+ /* Custom Scrollbar */
13
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
14
+ ::-webkit-scrollbar-track { background: #f1f1f1; }
15
+ ::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
16
+ ::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
17
+
18
+ .marker-pulse {
19
+ animation: pulse-animation 2s infinite;
20
+ }
21
+ @keyframes pulse-animation {
22
+ 0% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.7); }
23
+ 70% { box-shadow: 0 0 0 10px rgba(59, 130, 246, 0); }
24
+ 100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); }
25
+ }
26
+ .map-container {
27
+ position: relative;
28
+ overflow: hidden;
29
+ user-select: none;
30
+ cursor: crosshair;
31
+ }
32
+ .map-container.preview-mode {
33
+ cursor: default;
34
+ }
35
+ .marker {
36
+ position: absolute;
37
+ transform: translate(-50%, -50%);
38
+ transition: all 0.2s ease;
39
+ z-index: 10;
40
+ }
41
+ .marker:hover {
42
+ z-index: 20;
43
+ transform: translate(-50%, -50%) scale(1.2);
44
+ }
45
+ .tooltip-card {
46
+ position: absolute;
47
+ z-index: 30;
48
+ background: white;
49
+ border-radius: 0.5rem;
50
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
51
+ padding: 1rem;
52
+ width: 250px;
53
+ pointer-events: none;
54
+ opacity: 0;
55
+ transition: opacity 0.2s;
56
+ transform: translate(-50%, -110%); /* Default above */
57
+ }
58
+ .tooltip-card.active {
59
+ opacity: 1;
60
+ pointer-events: auto;
61
+ }
62
+ /* Arrow */
63
+ .tooltip-card::after {
64
+ content: "";
65
+ position: absolute;
66
+ top: 100%;
67
+ left: 50%;
68
+ margin-left: -8px;
69
+ border-width: 8px;
70
+ border-style: solid;
71
+ border-color: white transparent transparent transparent;
72
+ }
73
+
74
+ [v-cloak] { display: none; }
75
+ </style>
76
+ </head>
77
+ <body class="bg-slate-50 text-slate-800 h-screen flex flex-col overflow-hidden">
78
+ <div id="app" v-cloak class="flex flex-col h-full">
79
+ <!-- Header -->
80
+ <header class="bg-white border-b border-slate-200 px-6 py-3 flex justify-between items-center shadow-sm z-50">
81
+ <div class="flex items-center gap-3">
82
+ <div class="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center text-white text-xl">
83
+ <i class="fa-solid fa-map-location-dot"></i>
84
+ </div>
85
+ <div>
86
+ <h1 class="font-bold text-lg text-slate-800">[[ project.title_cn ]]</h1>
87
+ <p class="text-xs text-slate-500">v[[ project.version ]]</p>
88
+ </div>
89
+ </div>
90
+ <div class="flex items-center gap-3">
91
+ <button @click="isPreview = !isPreview"
92
+ :class="isPreview ? 'bg-indigo-100 text-indigo-700 border-indigo-200' : 'bg-white border-slate-300 hover:bg-slate-50'"
93
+ class="px-4 py-2 rounded-lg border text-sm font-medium flex items-center gap-2 transition-colors">
94
+ <i :class="isPreview ? 'fa-solid fa-eye' : 'fa-regular fa-eye'"></i>
95
+ {{ isPreview ? '退出预览' : '预览交互' }}
96
+ </button>
97
+ <button @click="exportProject" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2 shadow-sm transition-colors">
98
+ <i class="fa-solid fa-download"></i>
99
+ 导出 HTML
100
+ </button>
101
+ </div>
102
+ </header>
103
+
104
+ <!-- Main Content -->
105
+ <div class="flex-1 flex overflow-hidden">
106
+ <!-- Left Sidebar (Settings) -->
107
+ <aside class="w-80 bg-white border-r border-slate-200 flex flex-col z-40">
108
+ <div class="p-4 border-b border-slate-100">
109
+ <h2 class="font-semibold text-slate-800 mb-4">地图设置</h2>
110
+
111
+ <!-- Image Upload -->
112
+ <div class="mb-6">
113
+ <label class="block text-sm font-medium text-slate-700 mb-2">底图上传</label>
114
+ <div class="relative border-2 border-dashed border-slate-300 rounded-lg p-6 text-center hover:border-blue-500 transition-colors bg-slate-50 cursor-pointer"
115
+ @dragover.prevent @drop.prevent="handleDrop">
116
+ <input type="file" ref="fileInput" @change="handleFileChange" accept="image/*" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer">
117
+ <div v-if="!bgImage">
118
+ <i class="fa-regular fa-image text-2xl text-slate-400 mb-2"></i>
119
+ <p class="text-xs text-slate-500">点击或拖拽上传图片</p>
120
+ <button @click.stop="loadDemoImage" class="mt-2 text-xs text-blue-600 hover:underline">使用演示地图</button>
121
+ </div>
122
+ <div v-else class="relative h-20 w-full">
123
+ <img :src="bgImage" class="h-full w-full object-cover rounded">
124
+ <button @click.stop="bgImage = null; markers = []" class="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs hover:bg-red-600">×</button>
125
+ </div>
126
+ </div>
127
+ </div>
128
+
129
+ <!-- Marker Editor (Only visible when a marker is selected) -->
130
+ <div v-if="selectedMarkerIndex !== -1" class="animate-fade-in">
131
+ <div class="flex justify-between items-center mb-3">
132
+ <h3 class="font-medium text-slate-700 text-sm">编辑热点 #{{ selectedMarkerIndex + 1 }}</h3>
133
+ <button @click="deleteMarker(selectedMarkerIndex)" class="text-red-500 text-xs hover:text-red-700">
134
+ <i class="fa-solid fa-trash"></i> 删除
135
+ </button>
136
+ </div>
137
+
138
+ <div class="space-y-3">
139
+ <div>
140
+ <label class="block text-xs font-medium text-slate-500 mb-1">标题</label>
141
+ <input v-model="markers[selectedMarkerIndex].title" type="text" class="w-full px-3 py-2 border border-slate-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" placeholder="输入标题...">
142
+ </div>
143
+ <div>
144
+ <label class="block text-xs font-medium text-slate-500 mb-1">描述内容</label>
145
+ <textarea v-model="markers[selectedMarkerIndex].desc" rows="3" class="w-full px-3 py-2 border border-slate-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" placeholder="输入描述..."></textarea>
146
+ </div>
147
+
148
+ <div class="grid grid-cols-2 gap-3">
149
+ <div>
150
+ <label class="block text-xs font-medium text-slate-500 mb-1">图标</label>
151
+ <select v-model="markers[selectedMarkerIndex].icon" class="w-full px-2 py-2 border border-slate-300 rounded text-sm bg-white">
152
+ <option value="fa-location-dot">📍 定位</option>
153
+ <option value="fa-circle-info">ℹ️ 信息</option>
154
+ <option value="fa-star">⭐ 星标</option>
155
+ <option value="fa-camera">📷 相机</option>
156
+ <option value="fa-shop">🏪 商店</option>
157
+ <option value="fa-restroom">🚻 洗手间</option>
158
+ <option value="fa-utensils">🍴 餐饮</option>
159
+ <option value="fa-plus">➕ 加号</option>
160
+ </select>
161
+ </div>
162
+ <div>
163
+ <label class="block text-xs font-medium text-slate-500 mb-1">颜色</label>
164
+ <input type="color" v-model="markers[selectedMarkerIndex].color" class="w-full h-9 border border-slate-300 rounded cursor-pointer">
165
+ </div>
166
+ </div>
167
+ </div>
168
+ </div>
169
+
170
+ <div v-else class="text-center py-8 text-slate-400 bg-slate-50 rounded-lg border border-dashed border-slate-200">
171
+ <p class="text-sm">点击地图任意位置<br>添加新热点</p>
172
+ </div>
173
+ </div>
174
+
175
+ <!-- Markers List -->
176
+ <div class="flex-1 overflow-y-auto p-4">
177
+ <h3 class="text-xs font-bold text-slate-400 uppercase tracking-wider mb-3">热点列表 ({{ markers.length }})</h3>
178
+ <div class="space-y-2">
179
+ <div v-for="(marker, index) in markers" :key="index"
180
+ @click="selectMarker(index)"
181
+ :class="{'border-blue-500 ring-1 ring-blue-500 bg-blue-50': selectedMarkerIndex === index, 'border-slate-200 hover:border-blue-300': selectedMarkerIndex !== index}"
182
+ class="p-3 border rounded-lg cursor-pointer transition-all flex items-start gap-3 bg-white">
183
+ <div class="w-6 h-6 rounded-full flex items-center justify-center text-white text-xs shrink-0" :style="{backgroundColor: marker.color}">
184
+ <i class="fa-solid" :class="marker.icon"></i>
185
+ </div>
186
+ <div class="overflow-hidden">
187
+ <div class="font-medium text-sm truncate text-slate-700">{{ marker.title || '未命名热点' }}</div>
188
+ <div class="text-xs text-slate-500 truncate">{{ marker.desc || '无描述' }}</div>
189
+ </div>
190
+ </div>
191
+ </div>
192
+ </div>
193
+ </aside>
194
+
195
+ <!-- Canvas Area -->
196
+ <main class="flex-1 bg-slate-100 relative overflow-auto flex items-center justify-center p-8">
197
+ <div v-if="!bgImage" class="text-center text-slate-400">
198
+ <i class="fa-regular fa-image text-6xl mb-4 opacity-30"></i>
199
+ <p>请先上传底图</p>
200
+ </div>
201
+
202
+ <div v-else
203
+ ref="mapContainer"
204
+ class="map-container shadow-2xl rounded-lg bg-white relative inline-block"
205
+ :class="{'preview-mode': isPreview}"
206
+ @click="handleMapClick">
207
+
208
+ <img :src="bgImage" class="max-w-none" style="max-height: 80vh; display: block;">
209
+
210
+ <!-- Markers -->
211
+ <div v-for="(marker, index) in markers" :key="index"
212
+ class="marker w-8 h-8 rounded-full flex items-center justify-center text-white shadow-lg cursor-pointer"
213
+ :class="{'marker-pulse': selectedMarkerIndex === index && !isPreview}"
214
+ :style="{
215
+ left: marker.x + '%',
216
+ top: marker.y + '%',
217
+ backgroundColor: marker.color,
218
+ fontSize: '14px'
219
+ }"
220
+ @click.stop="handleMarkerClick(index)">
221
+ <i class="fa-solid" :class="marker.icon"></i>
222
+
223
+ <!-- Tooltip (Only visible in preview or if active) -->
224
+ <div v-if="isPreview && activeTooltipIndex === index"
225
+ class="tooltip-card active"
226
+ @click.stop>
227
+ <h4 class="font-bold text-slate-800 mb-1">{{ marker.title }}</h4>
228
+ <p class="text-sm text-slate-600 leading-relaxed">{{ marker.desc }}</p>
229
+ </div>
230
+ </div>
231
+ </div>
232
+ </main>
233
+ </div>
234
+ </div>
235
+
236
+ <script>
237
+ const { createApp, ref, reactive, onMounted } = Vue;
238
+
239
+ createApp({
240
+ setup() {
241
+ const bgImage = ref(null);
242
+ const markers = ref([]);
243
+ const selectedMarkerIndex = ref(-1);
244
+ const isPreview = ref(false);
245
+ const activeTooltipIndex = ref(-1);
246
+ const fileInput = ref(null);
247
+ const mapContainer = ref(null);
248
+
249
+ // Demo Image
250
+ const DEMO_IMAGE = "https://images.unsplash.com/photo-1555400038-63f5ba517a47?q=80&w=2070&auto=format&fit=crop";
251
+
252
+ const loadDemoImage = () => {
253
+ // Convert URL to Base64 to ensure offline/export works cleanly without CORS issues if possible,
254
+ // but for demo simple URL is fine. However, html2canvas prefers base64.
255
+ // Let's just use the URL for now, but in a real app we might proxy it.
256
+ bgImage.value = DEMO_IMAGE;
257
+ markers.value = [
258
+ { x: 25, y: 40, color: '#ef4444', icon: 'fa-bed', title: '主卧室', desc: '宽敞的主卧室,配有独立卫浴和步入式衣帽间。' },
259
+ { x: 55, y: 60, color: '#3b82f6', icon: 'fa-couch', title: '客厅', desc: '开放式客厅,连接餐厅,采光极佳。' },
260
+ { x: 75, y: 30, color: '#10b981', icon: 'fa-utensils', title: '厨房', desc: '现代化厨房,配备高端电器和中岛台。' }
261
+ ];
262
+ };
263
+
264
+ onMounted(() => {
265
+ // Auto-load demo to提供默认数据,确保开箱即用
266
+ loadDemoImage();
267
+ });
268
+
269
+ const handleFileChange = (e) => {
270
+ const file = e.target.files[0];
271
+ if (!file) return;
272
+ readFile(file);
273
+ };
274
+
275
+ const handleDrop = (e) => {
276
+ const file = e.dataTransfer.files[0];
277
+ if (file && file.type.startsWith('image/')) {
278
+ readFile(file);
279
+ }
280
+ };
281
+
282
+ const readFile = (file) => {
283
+ const reader = new FileReader();
284
+ reader.onload = (e) => {
285
+ bgImage.value = e.target.result;
286
+ markers.value = [];
287
+ selectedMarkerIndex.value = -1;
288
+ };
289
+ reader.readAsDataURL(file);
290
+ };
291
+
292
+ const handleMapClick = (e) => {
293
+ if (isPreview.value) {
294
+ activeTooltipIndex.value = -1; // Close tooltips
295
+ return;
296
+ }
297
+
298
+ const rect = e.currentTarget.getBoundingClientRect();
299
+ const x = ((e.clientX - rect.left) / rect.width) * 100;
300
+ const y = ((e.clientY - rect.top) / rect.height) * 100;
301
+
302
+ markers.value.push({
303
+ x: x.toFixed(2),
304
+ y: y.toFixed(2),
305
+ title: '新热点',
306
+ desc: '这里是描述内容...',
307
+ color: '#3b82f6',
308
+ icon: 'fa-location-dot'
309
+ });
310
+
311
+ selectedMarkerIndex.value = markers.value.length - 1;
312
+ };
313
+
314
+ const handleMarkerClick = (index) => {
315
+ if (isPreview.value) {
316
+ activeTooltipIndex.value = activeTooltipIndex.value === index ? -1 : index;
317
+ } else {
318
+ selectedMarkerIndex.value = index;
319
+ }
320
+ };
321
+
322
+ const selectMarker = (index) => {
323
+ selectedMarkerIndex.value = index;
324
+ // Exit preview if selecting from list
325
+ isPreview.value = false;
326
+ };
327
+
328
+ const deleteMarker = (index) => {
329
+ markers.value.splice(index, 1);
330
+ selectedMarkerIndex.value = -1;
331
+ };
332
+
333
+ const exportProject = () => {
334
+ if (!bgImage.value) return alert('请先上传图片');
335
+
336
+ const htmlContent = generateExportHTML(bgImage.value, markers.value, PROJECT_INFO);
337
+ const blob = new Blob([htmlContent], { type: 'text/html' });
338
+ const url = URL.createObjectURL(blob);
339
+ const a = document.createElement('a');
340
+ a.href = url;
341
+ a.download = 'interactive-map.html';
342
+ a.click();
343
+ URL.revokeObjectURL(url);
344
+ };
345
+
346
+ // Helper to generate the standalone HTML
347
+ const generateExportHTML = (img, markers, info) => {
348
+ return `<!DOCTYPE html>
349
+ <html lang="zh-CN">
350
+ <head>
351
+ <meta charset="UTF-8">
352
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
353
+ <title>${info.title_cn} Export</title>
354
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
355
+ <style>
356
+ body { margin: 0; padding: 0; background: #f8fafc; font-family: sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
357
+ .map-wrapper { position: relative; display: inline-block; max-width: 100%; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1); border-radius: 8px; overflow: hidden; }
358
+ .map-img { display: block; max-width: 100%; height: auto; }
359
+ .marker {
360
+ position: absolute; width: 32px; height: 32px; border-radius: 50%;
361
+ display: flex; align-items: center; justify-content: center;
362
+ color: white; transform: translate(-50%, -50%); cursor: pointer;
363
+ box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);
364
+ transition: transform 0.2s;
365
+ font-size: 14px;
366
+ }
367
+ .marker:hover { transform: translate(-50%, -50%) scale(1.1); z-index: 100; }
368
+ .tooltip {
369
+ position: absolute; bottom: 120%; left: 50%; transform: translateX(-50%);
370
+ background: white; color: #334155; padding: 12px; border-radius: 6px;
371
+ width: 220px; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1);
372
+ opacity: 0; visibility: hidden; transition: all 0.2s; pointer-events: none;
373
+ text-align: left;
374
+ }
375
+ .tooltip h4 { margin: 0 0 4px 0; color: #1e293b; font-size: 14px; }
376
+ .tooltip p { margin: 0; font-size: 12px; line-height: 1.4; color: #64748b; }
377
+ .tooltip::after {
378
+ content: ""; position: absolute; top: 100%; left: 50%; margin-left: -6px;
379
+ border-width: 6px; border-style: solid; border-color: white transparent transparent transparent;
380
+ }
381
+ .marker.active .tooltip { opacity: 1; visibility: visible; pointer-events: auto; }
382
+
383
+ /* Mobile responsive */
384
+ @media (max-width: 640px) {
385
+ .tooltip { width: 180px; }
386
+ }
387
+ </style>
388
+ </head>
389
+ <body>
390
+ <div class="map-wrapper" id="map">
391
+ <img src="${img}" class="map-img" alt="Map">
392
+ <!-- Markers generated by JS -->
393
+ </div>
394
+
395
+ <script>
396
+ const markers = ${JSON.stringify(markers)};
397
+ const mapEl = document.getElementById('map');
398
+
399
+ let activeMarker = null;
400
+
401
+ markers.forEach((m, i) => {
402
+ const el = document.createElement('div');
403
+ el.className = 'marker';
404
+ el.style.left = m.x + '%';
405
+ el.style.top = m.y + '%';
406
+ el.style.backgroundColor = m.color;
407
+ el.innerHTML = '<i class="fa-solid ' + m.icon + '"></i>' +
408
+ '<div class="tooltip">' +
409
+ '<h4>' + (m.title || '') + '</h4>' +
410
+ '<p>' + (m.desc || '') + '</p>' +
411
+ '</div>';
412
+
413
+ el.addEventListener('click', (e) => {
414
+ e.stopPropagation();
415
+ // Toggle active class
416
+ if (activeMarker && activeMarker !== el) {
417
+ activeMarker.classList.remove('active');
418
+ }
419
+
420
+ if (el.classList.contains('active')) {
421
+ el.classList.remove('active');
422
+ activeMarker = null;
423
+ } else {
424
+ el.classList.add('active');
425
+ activeMarker = el;
426
+ }
427
+ });
428
+
429
+ mapEl.appendChild(el);
430
+ });
431
+
432
+ // Close when clicking elsewhere
433
+ document.addEventListener('click', () => {
434
+ if (activeMarker) {
435
+ activeMarker.classList.remove('active');
436
+ activeMarker = null;
437
+ }
438
+ });
439
+ <\/script>
440
+ </body>
441
+ </html>`;
442
+ };
443
+
444
+ return {
445
+ bgImage,
446
+ markers,
447
+ selectedMarkerIndex,
448
+ isPreview,
449
+ activeTooltipIndex,
450
+ fileInput,
451
+ mapContainer,
452
+ handleFileChange,
453
+ handleDrop,
454
+ handleMapClick,
455
+ handleMarkerClick,
456
+ selectMarker,
457
+ deleteMarker,
458
+ loadDemoImage,
459
+ exportProject,
460
+ PROJECT_INFO: [[ project | tojson ]]
461
+ };
462
+ }
463
+ }).mount('#app');
464
+ </script>
465
+ </body>
466
+ </html>