Skill: rudy2steiner/travel-mapify (version 2.2.2)

--- name: flyai-travelmapify version: 2.2.2 description: Create interactive travel route maps from location names with real FlyAI hotel search. Supports AI Vision analysis of travel planning images. author: rudy2steiner license: MIT tags: [travel, maps, routing, geocoding, flyai, hotels, unique-id, server-management, interactive, ai-vision] --- # Travel Mapify Create interactive, professional travel route maps from location names. ## Overview This skill automatically: 1. **Processes text input**: Parses comma-separated location names directly 2. **Geocodes locations** to get precise coordinates using Amap API 3. **Generates interactive maps** with route optimization and POI management 4. **Supports AI Vision workflow**: When given travel planning images, use agent's AI Vision to extract POI names, then process as text input 4. **Geocodes locations** to get precise coordinates using Amap API 5. **Generates interactive maps** with route optimization and POI management 6. **Integrates real hotel search** using FlyAI for actual hotel recommendations 7. **Creates professional outputs** ready for sharing and presentation ## When to Use **Text Input Mode:** - User provides comma-separated location names directly (e.g., "上海外滩,上海迪士尼乐园,豫园") - User wants to quickly create a travel map from a simple list of destinations - User has location names but no visual reference image **AI Vision Image Processing Workflow:** - User has a travel planning image (screenshot, photo, or digital plan) - Agent uses AI Vision to analyze the image and extract POI names with sequence/order - Extracted POI names are passed as text input to the skill - This hybrid approach combines AI automation with the skill's robust text processing **Hotel Integration Mode:** - User wants real hotel recommendations near their destination - User needs actual pricing and availability data - User wants booking links for hotels through FlyAI integration ## Workflow ### Step 1: Input Processing and POI Extraction **For Direct Text Input:** - Parse comma-separated location names directly - Create POI entries with high confidence (user-provided) - **Automatic city detection**: Smart city detection from location names (e.g., "北京军事博物馆" → city="北京") - **Fallback to default**: Uses Shanghai as default when no city can be detected **For Image Input (AI Vision Workflow):** - **Use agent's AI Vision capability** to analyze travel planning images - **Extract POI names and sequence** from numbered markers or route indicators - **Preserve exact order** as marked in the original image - **Pass extracted names as text input** to the skill's standard processing pipeline - **Leverage existing text processing** for geocoding, validation, and map generation ### Step 2: Geocoding and Coordinate Resolution - Use Amap geocoding API to resolve location names to precise coordinates - Handle ambiguous locations with user confirmation - Validate coordinate accuracy ### Step 3: Enhanced Map Generation - **Use optimized travel_mapify with unique map ID isolation** as main template with all UX improvements - **Automatic localStorage isolation** based on POI names to prevent data conflicts between different maps - **Optimized performance** with streamlined template generation and reduced dependencies - Create a single dual-mode interface with toggle between Edit/View modes - Implement drag-and-drop and arrow button reordering (edit mode only) - Generate optimized route with directional arrows - Add "Generate Final" functionality to download clean version - **Integrate real FlyAI hotel search** with professional loading states - **Replace alert popups** with notification system ### Step 4: Automatic Server Management - **Automatically start HTTP server** on port 9000 (or specified port) - **Automatically start hotel search server** on port 8770 (or specified port) - **Check if servers are already running** to avoid conflicts - **Provide direct access URLs** in output - **Ensure all functionality is ready** when map creation completes ### Step 4: Professional Output Delivery - Provide unified HTML file that works in both editing and presentation contexts - Include clear instructions for toggling modes and generating final version - Ensure mobile-responsive design ## File Structure ``` flyai-travelmapify/ ├── SKILL.md ├── INSTALL.md # Installation and setup guide ├── flyai-travelmapify.py # **PORTABLE ENTRY POINT**: Main executable script ├── scripts/ │ ├── config.py # **DYNAMIC CONFIGURATION**: Path and environment detection │ ├── main_travel_mapify_enhanced.py # **PRODUCTION MAIN**: Auto-starts servers + text input + unique ID isolation │ ├── geocode_locations.py # Amap geocoding integration │ ├── generate_from_optimized_template.py # Optimized template with unique map ID isolation │ └── hotel-search-server.py # FlyAI hotel search backend server **Note**: Image processing is handled externally using the agent's AI Vision capability. The extracted POI names are passed as text input to this skill. ├── references/ │ ├── amap_api_guide.md # Amap API usage patterns │ ├── poi_validation_rules.md # POI validation and filtering rules │ └── troubleshooting-guide.md # Common issues and solutions └── assets/ └── templates/ └── main-generic-template-with-unique-id.html # **PRODUCTION TEMPLATE**: Unique ID isolation + all features ``` **Main Template**: The skill now uses the **optimized travel_mapify system** with unique map ID isolation as the primary approach, which includes: - **Optimized Template Generation**: Streamlined HTML generation with minimal dependencies - **Unique Map ID Generation**: Automatically creates a hash-based ID from POI names (e.g., `travelMap_abc123`) to isolate localStorage data - **No Cross-Map Data Contamination**: Each travel map saves/retrieves data independently using its unique ID - **Persistent Per-Map State**: Hotel searches, date selections, and POI reordering are saved separately for each map - **Enhanced Performance**: Reduced template complexity and faster map loading - Real FlyAI hotel search integration - Professional notification system (no alert popups) - Loading states with timeout handling - Enhanced UX with smart defaults **Automatic Server Management**: The enhanced main script automatically starts both HTTP server (port 9000) and hotel search server (port 8770) when generating maps, ensuring all functionality is ready immediately. ## Usage Examples ### Portable Execution ```bash # From skill directory (recommended) python3 flyai-travelmapify.py --locations "上海外滩,迪士尼乐园,豫园" --output-html shanghai-trip.html # From any directory python3 /path/to/flyai-travelmapify/flyai-travelmapify.py --locations "北京故宫,天坛,颐和园" --output-html beijing-trip.html # AI Vision Image Processing Workflow: # 1. Use agent's AI Vision to analyze travel image and extract POI names # 2. Pass extracted names as text input to the skill # Example: locations="解放碑,山城步道,十八梯,白象居,湖广会馆,来福士,洪崖洞,千厮门大桥" # With custom ports python3 flyai-travelmapify.py --locations "Tokyo Tower,Shibuya Crossing" --output-html tokyo-trip.html --http-port 8080 --hotel-port 9000 ``` ### Image Input - AI Vision Enhanced User: "Here's my travel plan screenshot, can you make it interactive?" → Skill analyzes image using AI Vision model → If needed, requests clarification: "What city is this for?" or "How many attractions are marked?" → Extracts attractions with preserved sequence and generates interactive map ### Image Input - Manual Clarification User provides image → Skill detects uncertainty → Requests city context → User responds "重庆" → Skill processes with enhanced accuracy ### Image Input - Direct POI Entry When AI Vision cannot confidently identify attractions, skill prompts: "Please provide attraction names in order, separated by commas" ### Image Input - Advanced Usage User: "I have a photo of our Beijing itinerary, please create a shareable map" → Skill processes image, validates locations, creates optimized route with professional styling ### Text Input - Direct Locations with Auto City Detection User: "北京军事博物馆,北京科技大学" → Skill auto-detects city="北京" from location names → Parses locations directly, geocodes them, and generates interactive map ### Text Input - Default City Fallback User: "外滩,迪士尼乐园,豫园" → Skill cannot detect city from generic names → Uses default city="上海" for geocoding → Generates interactive map ### Text Input - Simple List User: "Create a travel map for: Tokyo Tower, Shibuya Crossing, Meiji Shrine" → Skill extracts location names, geocodes them, creates optimized route ### Hotel Search Integration User: Clicks "搜酒店" button in generated map → Real FlyAI hotel search returns actual hotel data with prices and booking links → No mock data - uses real-time Fliggy MCP integration → Professional UX with loading states and notifications (no alert popups) → Default dates: today for check-in, tomorrow for check-out (1-night stay) ### Customization Request User: "Can you adjust the route order and add missing locations?" → Skill provides editable interface with search functionality and reordering tools ## Technical Requirements - **Python 3.7+**: Required for all scripts (uses standard library only) - **FlyAI CLI**: Must be installed globally (`npm install -g @openclaw/flyai`) - **Amap API**: Uses built-in default API key (no user key required) - **Local Proxy**: Built-in proxy server handles Amap API requests (default port 8769) - **Web Browser**: Modern browser with JavaScript support for interactive features ## Required Skills This skill depends on the following OpenClaw skills: - **[amap-maps](https://github.com/openclaw/openclaw/tree/main/skills/amap-maps)**: Provides Amap LBS services for geocoding, POI search, and location services Both skills must be installed in your OpenClaw workspace under the `skills/` directory: ``` ~/.openclaw/workspace/ ├── skills/ │ ├── flyai-travelmapify/ │ └── amap-maps/ ``` ## Portable Design ✅ **No hardcoded paths** - Automatically detects OpenClaw workspace and FlyAI installation ✅ **Cross-platform** - Works on Windows, macOS, and Linux ✅ **Self-contained** - All scripts and templates included in skill directory ✅ **Environment-aware** - Adapts to different system configurations ✅ **Fallback mechanisms** - Multiple detection methods for required components ## Output Files The skill generates HTML files using the **optimized travel_mapify system with unique map ID isolation** as the main template: **`[location]-travel-map-optimized.html`** - Dual-mode interface with all professional enhancements: - **Real FlyAI Hotel Search Integration**: - Actual hotel data with prices and booking links - Loading states with "搜索中..." button text - 5-second timeout with auto-re-enable - No mock data - uses real Fliggy MCP integration - **Professional UX Improvements**: - Notification system instead of alert popups - Auto-hiding messages after 3 seconds - Smart date defaults (today check-in, tomorrow check-out) - **Edit Mode (📝)**: Full-featured editing interface with: - Left-panel POI management - Search functionality for adding new locations - Drag-and-drop and arrow button reordering - Real-time map preview - **View Mode (👁️)**: Clean presentation version with: - Optimized route display - Numbered POI markers with directional arrows - Professional styling ready for sharing - **Generate Final**: Download button to create a clean version without edit controls Files are self-contained and work in any modern web browser when served via HTTP server. ## Error Handling - **Poor Image Quality**: Request higher quality image or manual POI entry - **Ambiguous Locations**: Present options for user selection - **API Rate Limits**: Implement retry logic with exponential backoff - **Missing Coordinates**: Fall back to approximate coordinates with user verification - **HTML Display Issues**: Use local HTTP server instead of file:// protocol - **Map Loading Failures**: Check AMap API key restrictions and network connectivity - **localStorage Corruption**: Implement fallback data and validation logic ## Enhanced UX Features **Professional User Interface:** - **No alert popups**: All messages displayed in non-intrusive notification area - **Loading states**: Hotel search button shows "搜索中..." during API calls - **Timeout handling**: 5-second timeout automatically re-enables search button - **Auto-hiding notifications**: Messages disappear after 3 seconds - **Visual feedback**: Error messages in red, success messages in green **Smart Defaults:** - **Date selection**: Today for check-in, tomorrow for check-out (1-night stay) - **Hotel sorting**: Results sorted by distance from destination - **Top results**: Shows top 5 closest hotels for better user experience ## Best Practices - Always verify extracted POIs with the user before finalizing - Provide clear instructions for customizing the generated maps - Optimize for mobile viewing with responsive design - Include north indicator and scale reference for professional appearance - **Always test via HTTP server**: Never rely on file:// protocol for web applications - **Implement robust error handling**: Wrap API calls and provide user feedback - **Validate all inputs**: Sanitize data before processing - **Document dependencies**: Clearly specify API keys, network, and browser requirements - **Use optimized travel_mapify with unique map ID isolation**: All travel maps now automatically generate unique IDs based on POI names to prevent localStorage conflicts between different maps - **Leverage enhanced performance**: The optimized system provides faster map generation and loading

path: DEPLOYMENT.md
content: # Deployment Checklist for travel-mapify

## ✅ **Portability Verification Complete**

### Core Optimizations Implemented

#### **1. Dynamic Path Detection**
- ✅ **Workspace Detection**: Automatic OpenClaw workspace identification
- ✅ **FlyAI Discovery**: Multi-method executable detection (PATH, npm, nvm, homebrew)
- ✅ **Cross-Platform**: Works on Windows, macOS, Linux
- ✅ **Fallback Mechanisms**: Graceful degradation when detection fails

#### **2. Automatic City Detection**
- ✅ **Smart City Recognition**: Detects city from location names (e.g., "北京军事博物馆" → "北京")
- ✅ **Comprehensive City Database**: 20+ major Chinese cities with alternative names
- ✅ **Fallback Logic**: Uses Shanghai as default when no city detected
- ✅ **User Override**: Manual `--city` parameter still supported

#### **3. Portable Entry Point**
- ✅ **Single Executable**: `flyai-travelmapify.py` works from any directory
- ✅ **No Installation Required**: Self-contained skill directory
- ✅ **Automatic Setup**: Handles Python path and configuration

#### **4. Robust Server Management**
- ✅ **Port Conflict Resolution**: Automatically finds available ports
- ✅ **Dynamic Port Replacement**: Updates HTML files with actual server ports
- ✅ **Server Status Detection**: Avoids duplicate server instances

### Files Ready for Deployment

```
travel-mapify/
├── SKILL.md                    # Updated documentation
├── INSTALL.md                  # Installation guide
├── DEPLOYMENT.md               # This deployment checklist
├── PORTABILITY_CHANGES.md      # Audit trail of all changes
├── flyai-travelmapify.py       # ✅ Portable entry point
├── scripts/
│   ├── config.py              # ✅ Dynamic configuration
│   ├── city_detector.py       # ✅ Automatic city detection
│   ├── main_travel_mapify_enhanced.py  # ✅ Main logic with auto-city
│   ├── hotel-search-server.py # ✅ Dynamic FlyAI path detection
│   ├── geocode_locations.py   # ✅ Configurable proxy URL
│   └── generate_from_optimized_template.py # ✅ Workspace-independent
├── references/
│   ├── amap_api_guide.md      # ✅ Generic paths
│   ├── poi_validation_rules.md # ✅ No hardcoded paths
│   └── troubleshooting-guide.md # ✅ Generic instructions
└── assets/
    └── templates/
        └── main-generic-template-with-unique-id.html # ✅ Dynamic port replacement
```

### Testing Verification

#### **✅ Portability Tests**
- [x] Runs from `/tmp` directory: `python3 /path/to/skill/flyai-travelmapify.py --locations "test" --output-html test.html`
- [x] Works without OpenClaw workspace: Uses fallback paths
- [x] Cross-platform compatibility: Uses pathlib for path handling

#### **✅ City Detection Tests**
- [x] Beijing locations: "北京军事博物馆,北京科技大学" → city="北京"
- [x] Shanghai locations: "上海迪士尼乐园,豫园" → city="上海"  
- [x] Sanya locations: "三亚美高梅,鹿回头" → city="三亚"
- [x] Generic locations: "外滩,迪士尼" → city="上海" (fallback)

#### **✅ Server Management Tests**
- [x] HTTP server port conflicts: Automatically finds available ports
- [x] Hotel server port conflicts: Resolves to available ports
- [x] Dynamic HTML updates: Correctly replaces hardcoded ports in template

#### **✅ End-to-End Tests**
- [x] Text input → Geocoding → Map generation → Server startup → Working map
- [x] All POIs correctly included in final HTML
- [x] Hotel search functionality active and working

### Deployment Instructions

#### **For OpenClaw Users**
1. Place entire `travel-mapify` directory in `~/.openclaw/workspace/skills/`
2. Use via: `python3 ~/.openclaw/workspace/skills/travel-mapify/flyai-travelmapify.py [options]`

#### **For Standalone Users**
1. Clone/download the entire skill directory
2. Ensure prerequisites are installed:
   - Python 3.7+
   - FlyAI CLI (`npm install -g @openclaw/flyai`)
   - Amap API proxy server (port 8769)
3. Run from any directory: `python3 /path/to/travel-mapify/flyai-travelmapify.py [options]`

#### **Prerequisites Verification**
```bash
# Test Python
python3 --version

# Test FlyAI
flyai --version

# Test Amap proxy
curl "http://localhost:8769/api/search?q=北京&city=北京"
```

### Known Limitations & Workarounds

#### **Amap Proxy Dependencies**
- **Requirement**: Local Amap API proxy server must be running on port 8769
- **Workaround**: Use `--proxy-url` to specify custom proxy URL

#### **Browser Security Restrictions**
- **Issue**: Maps don't work with `file://` protocol due to CORS
- **Solution**: Always access via HTTP server (automatically started)

#### **Geocoding Accuracy**
- **Issue**: Some locations may not be found in wrong city context
- **Solution**: Automatic city detection + manual `--city` override

### Version Information
- **Skill Version**: 2.2.0 (updated for portability)
- **Last Updated**: 2026-04-06
- **Compatibility**: OpenClaw v1.0+, Python 3.7+, Node.js 16+

## 🚀 **Ready for Production Deployment!**

path: INSTALL.md
content: # Installation Guide for flyai-travelmapify

This skill is designed to be portable and work on any system with the required dependencies.

## Prerequisites

1. **Python 3.7+** - Required for all scripts
2. **OpenClaw** - The skill should be placed in your OpenClaw workspace
3. **FlyAI CLI** - Must be installed and accessible in your PATH or via npm
4. **amap-maps skill** - Required OpenClaw skill for Amap LBS services
5. **Amap API** - Built-in default API key included (no user key required)
6. **AI Vision Capability** - For image input processing, your OpenClaw agent should have access to an image-capable model for POI extraction

## Installation Steps

### Option 1: Using OpenClaw Workspace (Recommended)

If you're using OpenClaw, simply place this entire `flyai-travelmapify` directory in your workspace skills folder:

```
~/.openclaw/workspace/skills/flyai-travelmapify/
```

The skill will automatically detect your workspace path and configure itself.

### Option 2: Standalone Usage

You can run the skill from anywhere by executing the main entry point:

```bash
# From the skill directory
python3 flyai-travelmapify.py --locations "上海外滩,迪士尼乐园" --output-html my-map.html

# From any directory (provide full path)
python3 /path/to/flyai-travelmapify/flyai-travelmapify.py --locations "上海外滩,迪士尼乐园" --output-html my-map.html
```

## Dependencies

The skill automatically detects and uses:

- **OpenClaw Workspace**: Looks for `AGENTS.md` or `SOUL.md` files to identify workspace
- **FlyAI Executable**: Searches PATH, npm global bins, and common Node.js installation paths
- **HTTP Server**: Uses Python's built-in `http.server` module
- **Amap Proxy**: Connects to `http://localhost:8769/api/search` by default (configurable)

## Configuration

All configuration is handled automatically, but you can override defaults:

- `--proxy-url`: Specify custom Amap API proxy URL
- `--http-port`: Use different HTTP server port
- `--hotel-port`: Use different hotel search server port
- `OPENCLAW_WORKSPACE` environment variable: Override workspace detection

## Testing

To verify installation:

```bash
# Test basic functionality
python3 flyai-travelmapify.py --help

# Test FlyAI detection
python3 scripts/config.py
```

## Troubleshooting

### "FlyAI executable not found"

- Ensure FlyAI is installed: `npm install -g @openclaw/flyai`
- Verify it's in your PATH: `which flyai`
- If using nvm, ensure the correct Node version is active

### "Workspace directory not found"

- Make sure you have OpenClaw workspace files (`AGENTS.md`, `SOUL.md`)
- Or set `OPENCLAW_WORKSPACE` environment variable pointing to your workspace

### "Amap proxy connection failed"

- Ensure the Amap API proxy server is running on port 8769
- Check your Amap API key configuration in the proxy server

## Portable Design Features

✅ **No hardcoded paths** - All paths detected dynamically  
✅ **Cross-platform compatible** - Works on Windows, macOS, Linux  
✅ **Self-contained** - All dependencies included in the skill directory  
✅ **Environment-aware** - Adapts to different OpenClaw installations  
✅ **Fallback mechanisms** - Multiple methods to locate required components  


path: PORTABILITY_CHANGES.md
content: # Portability Optimizations for travel-mapify

## Summary of Changes

### 1. **Dynamic Path Detection**
- **Before**: Hardcoded paths like `/Users/xuandu/.openclaw/workspace`
- **After**: Automatic workspace detection using multiple methods:
  - Checks for OpenClaw workspace indicators (`AGENTS.md`, `SOUL.md`)
  - Uses `OPENCLAW_WORKSPACE` environment variable
  - Falls back to typical OpenClaw workspace locations
  - Final fallback to current working directory

### 2. **FlyAI Executable Discovery**
- **Before**: Hardcoded path `/Users/xuandu/.nvm/versions/node/v22.22.1/bin/flyai`
- **After**: Multi-method FlyAI detection:
  - Searches system PATH using `which flyai`
  - Checks npm global bin directory
  - Scans common Node.js installation paths (nvm, homebrew, etc.)
  - Falls back to `flyai` command if not found in specific paths

### 3. **Portable Entry Point**
- Added `flyai-travelmapify.py` as main executable script
- Can be run from any directory with full path
- Automatically sets up Python path and delegates to main script
- Made executable with `chmod +x`

### 4. **Configuration Module**
- Created `scripts/config.py` for centralized configuration
- Handles all dynamic path and executable detection
- Provides fallback mechanisms for standalone execution
- Exports constants used by all scripts

### 5. **Cross-Platform Compatibility**
- Uses `pathlib.Path` for cross-platform path handling
- Avoids platform-specific assumptions
- Works on Windows, macOS, and Linux

### 6. **Documentation Updates**
- Added `INSTALL.md` with detailed setup instructions
- Updated `SKILL.md` to reflect portable design
- Added usage examples showing portable execution
- Documented troubleshooting steps

## Key Benefits

✅ **No user configuration required** - Works out of the box  
✅ **Works on any system** - Detects environment automatically  
✅ **Maintains backward compatibility** - Existing workflows still work  
✅ **Easy to distribute** - Self-contained skill directory  
✅ **Robust error handling** - Graceful fallbacks when detection fails  

## Testing Verification

- ✅ Configuration detection works correctly
- ✅ Portable entry point works from any directory  
- ✅ All scripts use dynamic paths instead of hardcoded ones
- ✅ FlyAI executable detection finds the correct path
- ✅ Help output shows correct default values

## Files Modified

- `scripts/config.py` - New configuration module
- `scripts/main_travel_mapify_enhanced.py` - Updated to use config module
- `scripts/hotel-search-server.py` - Updated FlyAI path detection
- `scripts/generate_from_optimized_template.py` - Updated workspace path
- `scripts/geocode_locations.py` - Updated proxy URL configuration
- `flyai-travelmapify.py` - New portable entry point
- `INSTALL.md` - New installation guide
- `SKILL.md` - Updated documentation

The skill is now fully portable and can be executed on any user's computer without requiring hardcoded path modifications.

path: REQUIREMENTS.md
content: # Requirements

The `flyai-travelmapify` skill requires the following dependencies to function properly:

## Required Skills
- **[amap-maps](https://github.com/openclaw/openclaw/tree/main/skills/amap-maps)** - Provides Amap LBS services for geocoding, POI search, and location services

## API Keys
- **Amap API**: Built-in default API key included (`88628414733cf2ccb7ce2f94cfd680ef`)
- **No user API key required** - the skill works out of the box with the default key

## AI Vision Requirements
- **Image-capable AI model**: Your OpenClaw agent should have access to an image analysis model for processing travel planning images
- **Supported image formats**: JPG, PNG, GIF, WebP
- **Recommended image quality**: Clear, readable text with good contrast for optimal POI extraction

## Installation
Both skills should be installed in your OpenClaw workspace under the `skills/` directory:

```
~/.openclaw/workspace/
├── skills/
│   ├── flyai-travelmapify/
│   └── amap-maps/
```

## Verification
To verify both skills are properly installed, check that these directories exist:
- `~/.openclaw/workspace/skills/flyai-travelmapify/`
- `~/.openclaw/workspace/skills/amap-maps/`

The `flyai-travelmapify` skill will automatically detect and use the `amap-maps` skill via relative path resolution.

path: _meta.json
content: ownerId: kn73ww65zczb429cvmsskbpmb983x496
slug: travel-mapify
version: 2.1.5
publishedAt: 1775194137992

path: assets/templates/main-generic-template-with-unique-id.html
content: <!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
    <title>Travel Map - Optimized UI</title>
    <style>
        body, html {
            margin: 0;
            padding: 0;
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
            height: 100%;
            /* Essential clarity settings */
            -webkit-font-smoothing: antialiased;
            -moz-osx-font-smoothing: grayscale;
            text-rendering: optimizeLegibility;
            overflow: hidden;
        }
        .header {
            display: none; /* Hide header to maximize map space */
        }
        #container { 
            width: 100%; 
            height: 100vh; /* Full viewport height for maximum map space */
            /* Critical for map clarity */
            image-rendering: -webkit-optimize-contrast;
            image-rendering: crisp-edges;
            transform: translateZ(0); /* Force GPU acceleration */
            backface-visibility: hidden;
            position: absolute;
            top: 0;
            left: 0;
            z-index: 1;
        }
        
        /* Left panel container */
        .left-panel {
            position: absolute;
            top: 20px;
            left: 20px;
            width: 320px;
            background: rgba(255, 255, 255, 0.95);
            border-radius: 12px;
            padding: 16px;
            box-shadow: 0 4px 20px rgba(0,0,0,0.2);
            z-index: 1000;
            max-height: calc(100vh - 40px);
            overflow-y: auto;
            border: 1px solid rgba(233, 236, 239, 0.8);
        }
        
        .panel-section {
            margin-bottom: 20px;
        }
        
        .panel-title {
            font-size: 16px;
            font-weight: 700;
            color: #4a9eff;
            margin-bottom: 12px;
            padding-bottom: 8px;
            border-bottom: 2px solid #e9ecef;
        }
        
        /* Search section */
        .search-box {
            display: flex;
            gap: 8px;
            margin-bottom: 12px;
        }
        .search-box input {
            flex: 1;
            padding: 10px 12px;
            border: 2px solid #dee2e6;
            border-radius: 6px;
            font-size: 13px;
            font-weight: 500;
            transition: border-color 0.2s ease;
        }
        .search-box input:focus {
            outline: none;
            border-color: #4a9eff;
            box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.2);
        }
        .search-box button {
            padding: 10px 16px;
            background: #4a9eff;
            color: white;
            border: none;
            border-radius: 6px;
            cursor: pointer;
            font-size: 13px;
            font-weight: 600;
            transition: all 0.2s ease;
            min-width: 70px;
        }
        .search-box button:hover {
            background: #3a8eee;
            transform: translateY(-1px);
        }
        
        .results {
            max-height: 180px;
            overflow-y: auto;
            border: 1px solid #dee2e6;
            border-radius: 6px;
            margin-top: 8px;
            display: none;
            background: white;
        }
        .result-item {
            padding: 12px;
            border-bottom: 1px solid #f8f9fa;
            cursor: pointer;
            transition: all 0.2s ease;
        }
        .result-item:last-child {
            border-bottom: none;
        }
        .result-item:hover {
            background: #f0f8ff;
            border-left: 2px solid #4a9eff;
            padding-left: 10px;
        }
        .result-item h4 {
            margin: 0 0 4px 0;
            color: #212529;
            font-size: 13px;
            font-weight: 600;
        }
        .result-item p {
            margin: 0 0 2px 0;
            font-size: 11px;
            color: #6c757d;
        }
        
        /* POI List section */
        .poi-list-container {
            max-height: 200px;
            overflow-y: auto;
            padding-right: 4px;
        }
        .poi-item {
            display: flex;
            align-items: center;
            margin: 4px 0;
            font-size: 12px;
            padding: 6px;
            cursor: move;
            background: white;
            border-radius: 6px;
            border: 1px solid #e9ecef;
            transition: all 0.2s ease;
            font-weight: 500;
        }
        .poi-item:hover {
            background: #f8f9fa;
            border-color: #4a9eff;
        }
        .poi-number {
            width: 24px;
            height: 24px;
            background: #f59e0b;
            color: white;
            border-radius: 50%;
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 12px;
            margin-right: 10px;
            flex-shrink: 0;
            font-weight: bold;
            text-shadow: 0 1px 1px rgba(0,0,0,0.3);
            box-shadow: 0 1px 2px rgba(0,0,0,0.2);
        }
        .poi-name {
            color: #212529;
            flex: 1;
            font-weight: 600;
            font-size: 12px;
        }
        .poi-actions {
            display: flex;
            gap: 4px;
            margin-left: 6px;
        }
        .move-btn {
            width: 26px;
            height: 26px;
            padding: 0;
            font-size: 14px;
            background: #6c757d;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            display: flex;
            align-items: center;
            justify-content: center;
            transition: all 0.2s ease;
            font-weight: bold;
        }
        .move-btn:hover {
            background: #4a9eff;
            transform: scale(1.05);
        }
        .move-btn:disabled {
            background: #adb5bd;
            cursor: not-allowed;
            opacity: 0.6;
        }
        .remove-btn {
            width: 26px;
            height: 26px;
            padding: 0;
            font-size: 16px;
            background: #dc3545;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            display: flex;
            align-items: center;
            justify-content: center;
            transition: all 0.2s ease;
            font-weight: bold;
        }
        .remove-btn:hover {
            background: #c82333;
            transform: scale(1.05);
        }
        
        /* Save button */
        .save-section {
            text-align: center;
            padding-top: 12px;
            border-top: 2px solid #e9ecef;
        }
        .save-btn {
            width: 100%;
            padding: 12px;
            background: #28a745;
            color: white;
            border: none;
            border-radius: 8px;
            cursor: pointer;
            font-weight: 600;
            font-size: 14px;
            transition: all 0.2s ease;
        }
        .save-btn:hover {
            background: #218838;
            transform: translateY(-1px);
            box-shadow: 0 2px 8px rgba(40, 167, 69, 0.3);
        }
        
        /* North indicator */
        .north-indicator {
            position: absolute;
            top: 20px;
            right: 20px;
            background: rgba(255, 255, 255, 0.9);
            padding: 8px 12px;
            border-radius: 6px;
            font-size: 12px;
            font-weight: 700;
            color: #4a9eff;
            border: 1px solid #dee2e6;
            z-index: 1000;
            box-shadow: 0 2px 8px rgba(0,0,0,0.1);
        }
        
        /* Scrollbar styling */
        .left-panel::-webkit-scrollbar {
            width: 6px;
        }
        .left-panel::-webkit-scrollbar-track {
            background: #f8f9fa;
            border-radius: 3px;
        }
        .left-panel::-webkit-scrollbar-thumb {
            background: #c0c0c0;
            border-radius: 3px;
        }
        .left-panel::-webkit-scrollbar-thumb:hover {
            background: #a0a0a0;
        }
        
        /* Critical: Ensure high DPI support */
        @media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
            #container {
                image-rendering: -webkit-optimize-contrast;
            }
        }
        
        /* Responsive design for smaller screens */
        @media (max-width: 768px) {
            .left-panel {
                width: 280px;
                top: 10px;
                left: 10px;
                max-height: calc(100vh - 20px);
            }
        }
    </style>
</head>
<body>
    <!-- Map container takes full screen -->
    <div id="container"></div>
    <div class="north-indicator">↑ North</div>
    
    <!-- Left panel with all controls -->
    <div class="left-panel">
        <!-- Search Section -->
        <div class="panel-section">
            <div class="panel-title">🔍 搜索景点</div>
            <div class="search-box">
                <input type="text" id="searchInput" placeholder="搜索景点名称">
                <button onclick="searchPOI()">搜索</button>
            </div>
            <div id="searchError" class="error" style="display:none; color: #dc3545; font-size: 11px; margin-top: 6px;"></div>
            <div class="results" id="searchResults"></div>
        </div>
        
        <!-- POI List Section -->
        <div class="panel-section">
            <div class="panel-title">📍 景点顺序</div>
            <div class="poi-list-container">
                <div id="poiList">
                    <!-- POIs will be added here dynamically -->
                </div>
            </div>
            <div style="font-size: 11px; color: #6c757d; margin-top: 8px;">
                💡 提示: 拖拽或使用箭头按钮调整顺序
            </div>
        </div>
        
        <!-- Date Range Section -->
        <div class="panel-section">
            <div class="panel-title">📅 旅行日期</div>
            <div style="display: flex; gap: 6px; margin-bottom: 6px;">
                <input type="date" id="checkinDate" style="flex: 1; padding: 6px 8px; border: 2px solid #dee2e6; border-radius: 4px; font-size: 11px;">
                <span style="align-self: center; font-size: 12px; color: #6c757d;">至</span>
                <input type="date" id="checkoutDate" style="flex: 1; padding: 6px 8px; border: 2px solid #dee2e6; border-radius: 4px; font-size: 11px;">
            </div>
            <button id="hotelSearchBtn" onclick="saveWithHotelRecommendations()" style="width: 100%; padding: 8px; background: #4a9eff; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 600; margin-top: 4px;">搜酒店</button>
            <div style="font-size: 10px; color: #6c757d; margin-top: 6px;">
                💡 默认为今天入住，住1晚
            </div>
        </div>
        
        <!-- Notification Area -->
        <div id="notificationArea" style="margin-bottom: 8px; min-height: 20px; font-size: 12px; color: #e74c3c; text-align: center; display: none;"></div>
        
        <!-- Hotel Results Section -->
        <div class="panel-section" id="hotelResultsSection" style="display: none;">
            <div class="panel-title">🏨 推荐酒店</div>
            <div id="hotelResultsList" style="max-height: 200px; overflow-y: auto;">
                <!-- Hotel results will be displayed here -->
            </div>
            <div style="font-size: 10px; color: #6c757d; margin-top: 8px;">
                💡 点击酒店查看地图位置和预订详情
            </div>
        </div>

    </div>

    <script src="https://webapi.amap.com/maps?v=2.0&key=88628414733cf2ccb7ce2f94cfd680ef"></script>
    <script>
        // Generate unique map ID based on POI names to isolate localStorage
        function generateMapId(pois) {
            if (!pois || pois.length === 0) return 'default-map';
            const poiNames = pois.map(poi => poi.name).join('-');
            // Create a simple hash of the POI names
            let hash = 0;
            for (let i = 0; i < poiNames.length; i++) {
                const char = poiNames.charCodeAt(i);
                hash = ((hash << 5) - hash) + char;
                hash = hash & hash; // Convert to 32-bit integer
            }
            return 'travelMap_' + Math.abs(hash).toString(36);
        }

        // Initialize map with ULTIMATE CLARITY configuration
        var map = new AMap.Map('container', {
            zoom: 17,
            center: [FIRST_POI_LNG, FIRST_POI_LAT], // Will be replaced with first POI coordinates
            viewMode: '2D',
            pitch: 0,
            rotation: 0,
            layers: [
                new AMap.TileLayer({
                    detectRetina: true
                }),
                new AMap.TileLayer.RoadNet({
                    opacity: 1.0
                })
            ],
            mapStyle: 'normal',
            features: ['bg', 'road', 'point'],
            resizeEnable: true,
            lang: 'zh_cn',
            showLabel: true,
            labelzIndex: 100
        });
        
        // Store all markers and POIs
        var markers = [];
        var poiList = [
            // POI_LIST_PLACEHOLDER - will be replaced with actual POIs
        ];
        
        // Generate unique map ID for localStorage isolation
        var mapId = generateMapId(poiList);
        console.log('Using unique map ID:', mapId);
        
        // Function to load data from localStorage if available (with unique map ID)
        function loadFromLocalStorage() {
            try {
                const savedData = localStorage.getItem(mapId);
                if (savedData) {
                    const parsedData = JSON.parse(savedData);
                    
                    // Handle both old format (array) and new format (object)
                    if (Array.isArray(parsedData)) {
                        // Old format - just POIs
                        console.log('Loaded POIs from localStorage (old format):', parsedData);
                        return { pois: parsedData, checkin: null, checkout: null };
                    } else if (parsedData.pois && Array.isArray(parsedData.pois)) {
                        // New format - POIs + dates
                        console.log('Loaded data from localStorage (new format):', parsedData);
                        
                        // Set date inputs if available
                        if (parsedData.checkin && parsedData.checkout) {
                            document.getElementById('checkinDate').value = parsedData.checkin;
                            document.getElementById('checkoutDate').value = parsedData.checkout;
                        }
                        
                        return parsedData;
                    }
                }
            } catch (error) {
                console.error('Error loading from localStorage:', error);
            }
            return null;
        }
        
        // Add initial markers
        function addInitialMarkers() {
            // Check if we have saved data in localStorage
            const savedData = loadFromLocalStorage();
            if (savedData) {
                poiList = savedData.pois || [];
                console.log('Using saved data from localStorage for map:', mapId);
            }
            
            if (poiList.length > 0) {
                // Set map center to first POI if available
                map.setCenter(poiList[0].location);
            }
            
            poiList.forEach(function(poi, index) {
                addMarkerForPOI(poi, index + 1);
            });
            updatePOIList();
        }
        
        // Function to add marker for POI
        function addMarkerForPOI(poi, number) {
            var marker = new AMap.Marker({
                position: poi.location,
                content: `<div style="width:28px;height:28px;background:#f59e0b;color:white;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:bold;font-size:14px;text-shadow:0 1px 1px rgba(0,0,0,0.3);box-shadow:0 2px 6px rgba(0,0,0,0.4);">${number}</div>`,
                offset: new AMap.Pixel(-14, -14),
                draggable: true,
                zIndex: 100
            });
            
            // Create info window
            var infoContent = `<div style="padding:14px; min-width:220px; background:white; border-radius:8px; box-shadow:0 4px 12px rgba(0,0,0,0.2);">
                <h4 style="margin:0 0 8px 0; color:#f59e0b; font-size:15px; font-weight:700;">${number}. ${poi.name}</h4>
                <p style="margin:0 0 6px 0; font-size:12px; color:#495057;">${poi.address || ''}</p>`;
            if (poi.rating) {
                infoContent += `<p style="margin:0 0 4px 0; font-size:11px; color:#4a9eff; font-weight:600;">评分: ${poi.rating} ⭐</p>`;
            }
            if (poi.id) {
                infoContent += `<p style="margin:0; font-size:10px; color:#6c757d;">POI ID: ${poi.id}</p>`;
            }
            infoContent += `</div>`;
            
            var infoWindow = new AMap.InfoWindow({
                content: infoContent,
                autoMove: true,
                offset: new AMap.Pixel(0, -10)
            });
            
            // Click to open info window
            marker.on('click', function() {
                infoWindow.open(map, marker.getPosition());
            });
            
            // Update position when dragged
            marker.on('dragend', function(e) {
                var lng = e.lnglat.getLng();
                var lat = e.lnglat.getLat();
                
                // Find the corresponding POI
                var markerIndex = markers.findIndex(m => m.marker === marker);
                if (markerIndex !== -1) {
                    poiList[markerIndex].location = [lng, lat];
                    updatePOIList();
                }
            });
            
            // Add to map
            map.add(marker);
            markers.push({ marker: marker, poi: poi });
        }
        
        // Function to update POI list display
        function updatePOIList() {
            var poiListDiv = document.getElementById('poiList');
            var html = '';
            
            poiList.forEach(function(poi, index) {
                // Disable up button for first item, down button for last item
                var canMoveUp = index > 0;
                var canMoveDown = index < poiList.length - 1;
                
                html += `
                    <div class="poi-item" draggable="true" 
                         ondragstart="dragStart(event)" 
                         ondragover="dragOver(event)" 
                         ondrop="drop(event)" 
                         ondragenter="dragEnter(event)"
                         ondragleave="dragLeave(event)"
                         data-index="${index}">
                        <div class="poi-number">${index + 1}</div>
                        <div class="poi-name">${poi.name}</div>
                        <div class="poi-actions">
                            <button class="move-btn" ${!canMoveUp ? 'disabled' : ''} onclick="movePOI(${index}, -1)" title="上移">
                                ↑
                            </button>
                            <button class="move-btn" ${!canMoveDown ? 'disabled' : ''} onclick="movePOI(${index}, 1)" title="下移">
                                ↓
                            </button>
                            <button class="remove-btn" onclick="removePOI(${index})" title="删除">
                                ×
                            </button>
                        </div>
                    </div>
                `;
            });
            
            poiListDiv.innerHTML = html;
            
            // Update marker numbers
            markers.forEach(function(markerObj, index) {
                if (index < poiList.length) {
                    var newNumber = index + 1;
                    markerObj.marker.setContent(`<div style="width:28px;height:28px;background:#f59e0b;color:white;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:bold;font-size:14px;text-shadow:0 1px 1px rgba(0,0,0,0.3);box-shadow:0 2px 6px rgba(0,0,0,0.4);">${newNumber}</div>`);
                }
            });
            
            // Redraw route line
            drawRouteLine();
        }
        
        // Enhanced drag and drop functions
        let dragSourceIndex = null;
        
        function dragStart(e) {
            dragSourceIndex = parseInt(e.target.closest('.poi-item').dataset.index);
            e.dataTransfer.setData('text/plain', dragSourceIndex);
            e.target.closest('.poi-item').classList.add('dragging');
        }
        
        function dragEnter(e) {
            e.preventDefault();
            const targetItem = e.target.closest('.poi-item');
            if (targetItem && parseInt(targetItem.dataset.index) !== dragSourceIndex) {
                targetItem.classList.add('drag-over');
            }
        }
        
        function dragLeave(e) {
            const targetItem = e.target.closest('.poi-item');
            if (targetItem) {
                targetItem.classList.remove('drag-over');
            }
        }
        
        function dragOver(e) {
            e.preventDefault();
        }
        
        function drop(e) {
            e.preventDefault();
            const targetItem = e.target.closest('.poi-item');
            if (!targetItem) return;
            
            const sourceIndex = dragSourceIndex;
            const targetIndex = parseInt(targetItem.dataset.index);
            
            // Clean up drag classes
            document.querySelectorAll('.poi-item').forEach(item => {
                item.classList.remove('dragging', 'drag-over');
            });
            
            if (!isNaN(sourceIndex) && !isNaN(targetIndex) && sourceIndex !== targetIndex) {
                // Reorder POI list
                const temp = poiList[sourceIndex];
                poiList.splice(sourceIndex, 1);
                poiList.splice(targetIndex, 0, temp);
                
                // Reorder markers
                const tempMarker = markers[sourceIndex];
                markers.splice(sourceIndex, 1);
                markers.splice(targetIndex, 0, tempMarker);
                
                updatePOIList();
            }
            
            dragSourceIndex = null;
        }
        
        // New function for arrow button movement
        function movePOI(currentIndex, direction) {
            const newIndex = currentIndex + direction;
            
            if (newIndex >= 0 && newIndex < poiList.length) {
                // Reorder POI list
                const temp = poiList[currentIndex];
                poiList.splice(currentIndex, 1);
                poiList.splice(newIndex, 0, temp);
                
                // Reorder markers
                const tempMarker = markers[currentIndex];
                markers.splice(currentIndex, 1);
                markers.splice(newIndex, 0, tempMarker);
                
                updatePOIList();
            }
        }
        
        // Function to remove POI
        function removePOI(index) {
            if (poiList.length <= 1) {
                showNotification('至少需要保留一个景点');
                return;
            }
            
            // Remove marker from map
            map.remove(markers[index].marker);
            
            // Remove from arrays
            poiList.splice(index, 1);
            markers.splice(index, 1);
            
            updatePOIList();
        }
        
        // Function to center map on POI
        function centerOnPOI(index) {
            map.setCenter(poiList[index].location);
            map.setZoom(17);
        }
        
        // Function to draw route line
        function drawRouteLine() {
            // Remove existing polyline if any
            if (window.routePolyline) {
                map.remove(window.routePolyline);
            }
            
            if (poiList.length > 1) {
                const routePath = poiList.map(poi => poi.location);
                window.routePolyline = new AMap.Polyline({
                    path: routePath,
                    strokeColor: "#4a9eff",
                    strokeWeight: 4,
                    strokeOpacity: 0.95,
                    showDir: true,
                    dirColor: "#ff6b35",
                    dirWidth: 8,
                    zIndex: 50,
                    lineJoin: 'round',
                    lineCap: 'round'
                });
                map.add(window.routePolyline);
            }
        }
        
        // Function to search POI using local proxy
        function searchPOI() {
            const query = document.getElementById('searchInput').value.trim();
            if (!query) {
                showNotification('请输入搜索关键词');
                return;
            }
            
            // Hide error
            document.getElementById('searchError').style.display = 'none';
            
            // Show loading state
            const resultsDiv = document.getElementById('searchResults');
            resultsDiv.innerHTML = '<div style="padding:12px; text-align:center; color:#6c757d; font-size:12px;">正在搜索高德地图...</div>';
            resultsDiv.style.display = 'block';
            
            // Use local proxy API - city parameter will be set dynamically
            fetch(`http://localhost:8769/api/search?q=${encodeURIComponent(query)}`)
                .then(response => {
                    if (!response.ok) {
                        throw new Error(`HTTP error! status: ${response.status}`);
                    }
                    return response.json();
                })
                .then(data => {
                    if (data.error) {
                        throw new Error(data.error);
                    }
                    displaySearchResults(data.pois, query);
                })
                .catch(error => {
                    console.error('Search error:', error);
                    const errorDiv = document.getElementById('searchError');
                    errorDiv.innerHTML = `搜索失败: ${error.message}`;
                    errorDiv.style.display = 'block';
                    resultsDiv.innerHTML = '<div style="padding:12px; text-align:center; color:#dc3545; font-size:12px;">搜索失败</div>';
                });
        }
        
        // Function to display search results
        function displaySearchResults(pois, query) {
            const resultsDiv = document.getElementById('searchResults');
            
            if (!pois || pois.length === 0) {
                resultsDiv.innerHTML = '<div style="padding:12px; text-align:center; color:#6c757d; font-size:12px;">未找到结果</div>';
                return;
            }
            
            // Filter POIs with valid locations
            const validPois = pois.filter(poi => poi.location && poi.location.split(',').length === 2);
            
            if (validPois.length === 0) {
                resultsDiv.innerHTML = '<div style="padding:12px; text-align:center; color:#6c757d; font-size:12px;">未找到有效坐标</div>';
                return;
            }
            
            let html = '';
            
            validPois.forEach((poi, index) => {
                const coords = poi.location.split(',');
                const lng = parseFloat(coords[0]);
                const lat = parseFloat(coords[1]);
                const name = poi.name || '未命名位置';
                const address = poi.address || '无地址';
                const rating = poi.rating || '';
                
                html += `
                    <div class="result-item" onclick="selectSearchResult(${lng}, ${lat}, '${name.replace(/'/g, "\\'")}', '${address.replace(/'/g, "\\'")}', '${rating}')">
                        <h4>${name}</h4>
                        <p>${address} ${rating ? '⭐' + rating : ''}</p>
                    </div>
                `;
            });
            
            resultsDiv.innerHTML = html;
        }
        
        // Function to select search result
        function selectSearchResult(lng, lat, name, address, rating) {
            // Add as new POI at the end
            const newPOI = {
                name: name,
                location: [lng, lat],
                address: address,
                rating: rating,
                id: 'search_result_' + Date.now()
            };
            
            poiList.push(newPOI);
            addMarkerForPOI(newPOI, poiList.length);
            updatePOIList();
            
            // Hide search results
            document.getElementById('searchResults').style.display = 'none';
            document.getElementById('searchInput').value = '';
        }
        
        // Function to get destination name for hotel search
        function getDestinationName() {
            if (poiList.length === 0) {
                return null;
            }
            
            // Get the last POI (destination)
            const destinationPoi = poiList[poiList.length - 1];
            const poiName = destinationPoi.name;
            
            // FlyAI works better with city names rather than specific attractions
            // Define landmark-to-city mappings for major Chinese cities
            const cityLandmarks = {
                '北京': ['故宫', '天安门', '鸟巢', '水立方', '国家体育场', '国家游泳中心', 
                        '颐和园', '圆明园', '长城', '王府井', '西单', '三里屯', '后海', '南锣鼓巷', '798'],
                '上海': ['外滩', '东方明珠', '豫园', '南京路', '陆家嘴', '迪士尼', '田子坊', '新天地'],
                '广州': ['广州塔', '珠江', '上下九', '北京路', '长隆', '白云山', '沙面'],
                '深圳': ['世界之窗', '欢乐谷', '大梅沙', '小梅沙', '华侨城', '莲花山', '深圳湾'],
                '杭州': ['西湖', '灵隐寺', '雷峰塔', '宋城', '千岛湖', '西溪湿地'],
                '成都': ['宽窄巷子', '锦里', '春熙路', '大熊猫', '青城山', '都江堰'],
                '重庆': ['解放碑', '洪崖洞', '磁器口', '朝天门', '南山', '武隆', '白象居', '十八梯'],
                '西安': ['兵马俑', '大雁塔', '钟楼', '回民街', '华清池', '城墙'],
                '三亚': ['天涯海角', '亚龙湾', '蜈支洲岛', '南山', '大小洞天', '椰梦长廊']
            };
            
            // Check each city's landmarks
            for (const [city, landmarks] of Object.entries(cityLandmarks)) {
                if (landmarks.some(landmark => poiName.includes(landmark) || 
                                          (destinationPoi.address && destinationPoi.address.includes(landmark)))) {
                    return city;
                }
            }
            
            // Try to detect city from address
            const cities = ['北京', '上海', '广州', '深圳', '杭州', '成都', '重庆', '西安', '三亚'];
            if (destinationPoi.address) {
                for (const city of cities) {
                    if (destinationPoi.address.includes(city)) {
                        return city;
                    }
                }
            }
            
            // Default fallback to original POI name
            return poiName;
        }
        
        // Store hotel markers
        var hotelMarkers = [];
        
        // Function to clear existing hotel markers
        function clearHotelMarkers() {
            hotelMarkers.forEach(function(markerObj) {
                map.remove(markerObj.marker);
            });
            hotelMarkers = [];
        }
        
        // Function to add hotel marker to map with mainPic
        function addHotelMarker(hotel, index) {
            // Use mainPic if available, otherwise use default hotel icon
            let markerContent;
            if (hotel.mainPic && hotel.mainPic.trim() !== '') {
                // Use hotel image as marker
                markerContent = `<div style="width:32px;height:32px;border-radius:4px;overflow:hidden;box-shadow:0 2px 6px rgba(0,0,0,0.4);">
                    <img src="${hotel.mainPic}" style="width:100%;height:100%;object-fit:cover;" alt="酒店图片">
                </div>`;
            } else {
                // Use default hotel icon
                markerContent = `<div style="width:24px;height:24px;background:#6366f1;color:white;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:bold;font-size:12px;text-shadow:0 1px 1px rgba(0,0,0,0.3);box-shadow:0 2px 6px rgba(0,0,0,0.4);">🏨</div>`;
            }
            
            var marker = new AMap.Marker({
                position: [parseFloat(hotel.longitude), parseFloat(hotel.latitude)],
                content: markerContent,
                offset: new AMap.Pixel(-16, -16),
                zIndex: 90
            });
            
            // Create hotel info window with mainPic
            var hotelInfo = `<div style="padding:12px; min-width:200px; background:white; border-radius:8px; box-shadow:0 4px 12px rgba(0,0,0,0.2);">
                ${hotel.mainPic ? `<img src="${hotel.mainPic}" style="width:100%; height:120px; object-fit:cover; border-radius:6px; margin-bottom:8px;" alt="酒店图片">` : ''}
                <h4 style="margin:0 0 6px 0; color:#6366f1; font-size:14px; font-weight:700;">🏨 ${hotel.name}</h4>
                <p style="margin:0 0 4px 0; font-size:11px; color:#495057;">${hotel.address}</p>
                <p style="margin:0 0 4px 0; font-size:12px; color:#4a9eff; font-weight:600;">${hotel.price}</p>
                <p style="margin:0; font-size:10px; color:#6c757d;">星级: ${hotel.star || 'N/A'}</p>
                <div style="margin-top:8px;">
                    <a href="${hotel.detailUrl}" target="_blank" style="display:inline-block; padding:6px 12px; background:#4a9eff; color:white; text-decoration:none; border-radius:4px; font-size:11px; font-weight:600;">查看详情</a>
                </div>
            </div>`;
            
            var infoWindow = new AMap.InfoWindow({
                content: hotelInfo,
                autoMove: true,
                offset: new AMap.Pixel(0, -10)
            });
            
            marker.on('click', function() {
                infoWindow.open(map, marker.getPosition());
            });
            
            map.add(marker);
            hotelMarkers.push({ marker: marker, hotel: hotel });
        }
        
        // Function to calculate distance between two points
        function calculateDistance(fromLat, fromLng, toLat, toLng) {
            // Use AMap's distance calculation
            const from = new AMap.LngLat(fromLng, fromLat);
            const to = new AMap.LngLat(toLng, toLat);
            const distance = Math.round(from.distance(to)); // Distance in meters
            
            if (distance < 1000) {
                return distance + 'm';
            } else {
                return (distance / 1000).toFixed(1) + 'km';
            }
        }
        
        // Function to display hotel in list with distance
        function displayHotelInList(hotel, index, destinationPoi) {
            const hotelItem = document.createElement('div');
            hotelItem.className = 'poi-item';
            hotelItem.style.cursor = 'pointer';
            hotelItem.style.backgroundColor = '#f8fafc';
            hotelItem.style.borderLeft = '3px solid #6366f1';
            
            // Calculate distance from destination to hotel
            let distanceText = '';
            if (destinationPoi && destinationPoi.location) {
                const destLat = destinationPoi.location[1];
                const destLng = destinationPoi.location[0];
                const hotelLat = parseFloat(hotel.latitude);
                const hotelLng = parseFloat(hotel.longitude);
                distanceText = calculateDistance(destLat, destLng, hotelLat, hotelLng);
            }
            
            hotelItem.innerHTML = `
                <div class="poi-number" style="background:#6366f1;">🏨</div>
                <div class="poi-name" style="color:#6366f1; font-size:11px;">
                    <strong>${hotel.name}</strong><br>
                    <span style="font-size:10px; color:#64748b;">${hotel.price} · ${hotel.star || 'N/A'}${distanceText ? ' · ' + distanceText : ''}</span>
                </div>
            `;
            
            // Add click event to center map on hotel
            hotelItem.onclick = function() {
                const hotelPosition = [parseFloat(hotel.longitude), parseFloat(hotel.latitude)];
                map.setCenter(hotelPosition);
                map.setZoom(16);
                
                // Find and open the corresponding marker's info window
                const hotelMarker = hotelMarkers.find(m => 
                    Math.abs(m.marker.getPosition().getLng() - parseFloat(hotel.longitude)) < 0.0001 &&
                    Math.abs(m.marker.getPosition().getLat() - parseFloat(hotel.latitude)) < 0.0001
                );
                
                if (hotelMarker) {
                    // Create and open info window with mainPic
                    const hotelInfo = `<div style="padding:12px; min-width:200px; background:white; border-radius:8px; box-shadow:0 4px 12px rgba(0,0,0,0.2);">
                        ${hotel.mainPic ? `<img src="${hotel.mainPic}" style="width:100%; height:120px; object-fit:cover; border-radius:6px; margin-bottom:8px;" alt="酒店图片">` : ''}
                        <h4 style="margin:0 0 6px 0; color:#6366f1; font-size:14px; font-weight:700;">🏨 ${hotel.name}</h4>
                        <p style="margin:0 0 4px 0; font-size:11px; color:#495057;">${hotel.address}</p>
                        <p style="margin:0 0 4px 0; font-size:12px; color:#4a9eff; font-weight:600;">${hotel.price}</p>
                        <p style="margin:0; font-size:10px; color:#6c757d;">星级: ${hotel.star || 'N/A'}</p>
                        ${distanceText ? `<p style="margin:0; font-size:10px; color:#6c757d;">距离终点: ${distanceText}</p>` : ''}
                        <div style="margin-top:8px;">
                            <a href="${hotel.detailUrl}" target="_blank" style="display:inline-block; padding:6px 12px; background:#4a9eff; color:white; text-decoration:none; border-radius:4px; font-size:11px; font-weight:600;">查看详情</a>
                        </div>
                    </div>`;
                    
                    const infoWindow = new AMap.InfoWindow({
                        content: hotelInfo,
                        autoMove: true,
                        offset: new AMap.Pixel(0, -10)
                    });
                    
                    infoWindow.open(map, hotelMarker.marker.getPosition());
                }
            };
            
            return hotelItem;
        }
        
        // Store destination POI for distance calculation
        var currentDestinationPoi = null;
        
        // Function to calculate distance for sorting
        function getHotelDistance(hotel, destinationPoi) {
            if (!destinationPoi || !destinationPoi.location || !hotel.latitude || !hotel.longitude) {
                return Infinity; // Put hotels without distance at the end
            }
            
            const destLat = destinationPoi.location[1];
            const destLng = destinationPoi.location[0];
            const hotelLat = parseFloat(hotel.latitude);
            const hotelLng = parseFloat(hotel.longitude);
            
            const from = new AMap.LngLat(destLng, destLat);
            const to = new AMap.LngLat(hotelLng, hotelLat);
            return from.distance(to); // Distance in meters
        }
        
        // Function to display hotels on map AND in list (sorted by distance)
        function displayHotelsOnMapAndList(hotels, destinationPoi) {
            // Clear existing hotel markers
            clearHotelMarkers();
            
            const hotelResultsSection = document.getElementById('hotelResultsSection');
            const hotelResultsList = document.getElementById('hotelResultsList');
            
            if (!hotels || hotels.length === 0) {
                hotelResultsSection.style.display = 'none';
                showNotification('未找到相关酒店');
                return;
            }
            
            // Store destination POI for reference
            currentDestinationPoi = destinationPoi;
            
            // Sort hotels by distance from destination (closest first)
            const sortedHotels = [...hotels].sort((a, b) => {
                const distA = getHotelDistance(a, destinationPoi);
                const distB = getHotelDistance(b, destinationPoi);
                return distA - distB;
            });
            
            // Clear hotel list
            hotelResultsList.innerHTML = '';
            
            // Add hotel markers to map and items to list (show top 5 closest)
            const displayHotels = sortedHotels.slice(0, 5);
            displayHotels.forEach(function(hotel, index) {
                // Add to map
                addHotelMarker(hotel, index + 1);
                
                // Add to list with distance calculation
                const hotelItem = displayHotelInList(hotel, index + 1, destinationPoi);
                hotelResultsList.appendChild(hotelItem);
            });
            
            // Show hotel results section
            hotelResultsSection.style.display = 'block';
            
            // Show success message
            showNotification(`已找到 ${hotels.length} 家酒店！地图和列表中显示了前5家酒店。`, false);
        }
        
        // Function to get selected dates
        function getSelectedDates() {
            const checkin = document.getElementById('checkinDate').value;
            const checkout = document.getElementById('checkoutDate').value;
            
            if (!checkin || !checkout) {
                // Return default dates if not set
                const today = new Date();
                const defaultCheckin = today.toISOString().split('T')[0];
                const defaultCheckout = new Date(today.setDate(today.getDate() + 2)).toISOString().split('T')[0];
                return { checkin: defaultCheckin, checkout: defaultCheckout };
            }
            
            return { checkin, checkout };
        }
        
        // Function to validate date range
        function validateDates(checkin, checkout) {
            if (!checkin || !checkout) {
                return '请选择入住和退房日期';
            }
            
            const checkinDate = new Date(checkin);
            const checkoutDate = new Date(checkout);
            
            if (checkoutDate <= checkinDate) {
                return '退房日期必须晚于入住日期';
            }
            
            const maxStay = 30 * 24 * 60 * 60 * 1000; // 30 days in milliseconds
            if (checkoutDate - checkinDate > maxStay) {
                return '住宿时间不能超过30天';
            }
            
            return null;
        }
        
        // Function to save with hotel recommendations (using unique map ID)
        function saveWithHotelRecommendations() {
            const hotelSearchBtn = document.getElementById('hotelSearchBtn');
            
            // Check if button is already disabled (search in progress)
            if (hotelSearchBtn.disabled) {
                return;
            }
            
            if (poiList.length === 0) {
                // Show error in console instead of alert
                console.log('Error: Please add at least one POI');
                return;
            }
            
            // Validate dates
            const dates = getSelectedDates();
            const dateError = validateDates(dates.checkin, dates.checkout);
            if (dateError) {
                console.log('Date validation error:', dateError);
                return;
            }
            
            // Save current POI data and dates to localStorage with unique map ID
            const saveData = {
                pois: poiList,
                checkin: dates.checkin,
                checkout: dates.checkout
            };
            localStorage.setItem(mapId, JSON.stringify(saveData));
            console.log('Saved data to localStorage with map ID:', mapId);
            
            // Get destination (city) and POI (specific attraction) for hotel recommendations
            const destination = getDestinationName(); // City name like "北京"
            const lastPoi = poiList[poiList.length - 1];
            let poiName = lastPoi ? lastPoi.name : '';
            
            // Convert POI names to FlyAI-friendly format
            const poiNameMappings = {
                '798·751园区': '北京798艺术区',
                '国家游泳中心': '水立方',
                '国家体育场': '鸟巢',
                '故宫博物院': '故宫'
            };
            
            if (poiName in poiNameMappings) {
                poiName = poiNameMappings[poiName];
            }
            
            if (destination) {
                // Update button state
                hotelSearchBtn.textContent = '搜索中...';
                hotelSearchBtn.disabled = true;
                hotelSearchBtn.style.opacity = '0.6';
                hotelSearchBtn.style.cursor = 'not-allowed';
                
                // Make AJAX call to hotel search backend with dates
                // Use actual hotel server port that was started during generation
                const hotelPort = 8780; // This should match the port used by hotel-search-server.py
                const url = `http://localhost:${hotelPort}/api/hotel-search?destination=${encodeURIComponent(destination)}&poi=${encodeURIComponent(poiName)}&checkin=${dates.checkin}&checkout=${dates.checkout}`;
                
                // Set up timeout to re-enable button after 5 seconds
                const timeoutId = setTimeout(() => {
                    hotelSearchBtn.textContent = '搜酒店';
                    hotelSearchBtn.disabled = false;
                    hotelSearchBtn.style.opacity = '1';
                    hotelSearchBtn.style.cursor = 'pointer';
                }, 5000);
                
                fetch(url)
                    .then(response => response.json())
                    .then(data => {
                        // Clear timeout since we got a response
                        clearTimeout(timeoutId);
                        
                        // Restore button state
                        hotelSearchBtn.textContent = '搜酒店';
                        hotelSearchBtn.disabled = false;
                        hotelSearchBtn.style.opacity = '1';
                        hotelSearchBtn.style.cursor = 'pointer';
                        
                        if (data.error) {
                            console.log('Hotel search error:', data.error);
                            // Optionally show error in UI instead of alert
                        } else if (data.data && data.data.itemList) {
                            // Display hotels directly on the map
                            // Get destination POI for distance calculation
                            const destinationPoi = poiList[poiList.length - 1];
                            displayHotelsOnMapAndList(data.data.itemList, destinationPoi);
                        } else {
                            console.log('Unexpected hotel data:', data);
                        }
                    })
                    .catch(error => {
                        // Clear timeout since we got an error
                        clearTimeout(timeoutId);
                        
                        // Restore button state
                        hotelSearchBtn.textContent = '搜酒店';
                        hotelSearchBtn.disabled = false;
                        hotelSearchBtn.style.opacity = '1';
                        hotelSearchBtn.style.cursor = 'pointer';
                        
                        console.error('Hotel search error:', error);
                    });
            }
        }
        
        // Function to show notification
        function showNotification(message, isError = true) {
            const notificationArea = document.getElementById('notificationArea');
            notificationArea.textContent = message;
            notificationArea.style.color = isError ? '#e74c3c' : '#27ae60';
            notificationArea.style.display = 'block';
            
            // Auto-hide after 3 seconds
            setTimeout(() => {
                notificationArea.style.display = 'none';
            }, 3000);
        }
        
        // Function to set default dates (today for check-in, tomorrow for check-out)
        function setDefaultDates() {
            const today = new Date();
            const checkin = today.toISOString().split('T')[0];
            
            const checkoutDate = new Date(today);
            checkoutDate.setDate(today.getDate() + 1);
            const checkout = checkoutDate.toISOString().split('T')[0];
            
            document.getElementById('checkinDate').value = checkin;
            document.getElementById('checkoutDate').value = checkout;
        }
        
        // Initialize
        setDefaultDates();
        addInitialMarkers();
        
        // Allow Enter key to trigger search
        document.getElementById('searchInput').addEventListener('keypress', function(e) {
            if (e.key === 'Enter') {
                searchPOI();
            }
        });
        
        // Handle global drag end
        document.addEventListener('dragend', function() {
            document.querySelectorAll('.poi-item').forEach(item => {
                item.classList.remove('dragging', 'drag-over');
            });
            dragSourceIndex = null;
        });
        
        // Ensure map resizes properly
        window.addEventListener('resize', function() {
            map.resize();
        });
    </script>
</body>
</html>

path: flyai-travelmapify.py
content: #!/usr/bin/env python3
"""
Portable entry point for flyai-travelmapify skill.

This script can be run from any directory and will automatically:
1. Locate the skill directory
2. Set up the Python path correctly
3. Execute the main functionality

Usage:
    python3 flyai-travelmapify.py --image /path/to/image.jpg --output-html my-travel-map.html
    python3 flyai-travelmapify.py --locations "上海外滩,迪士尼乐园,豫园" --output-html my-travel-map.html
"""

import os
import sys
import subprocess

def get_skill_dir():
    """Get the directory containing this script (the skill root)"""
    return os.path.dirname(os.path.realpath(__file__))

def main():
    # Get the skill directory
    skill_dir = get_skill_dir()
    scripts_dir = os.path.join(skill_dir, 'scripts')
    
    # Add scripts directory to Python path
    if scripts_dir not in sys.path:
        sys.path.insert(0, scripts_dir)
    
    # Construct the command to run the main script
    main_script = os.path.join(scripts_dir, 'main_travel_mapify_enhanced.py')
    
    if not os.path.exists(main_script):
        print(f"Error: Main script not found at {main_script}", file=sys.stderr)
        sys.exit(1)
    
    # Build the command with all arguments
    cmd = [sys.executable, main_script] + sys.argv[1:]
    
    try:
        # Execute the main script
        result = subprocess.run(cmd)
        sys.exit(result.returncode)
    except KeyboardInterrupt:
        print("\nOperation cancelled by user", file=sys.stderr)
        sys.exit(1)
    except Exception as e:
        print(f"Error running main script: {e}", file=sys.stderr)
        sys.exit(1)

if __name__ == '__main__':
    main()

path: references/amap_api_guide.md
content: # Amap API Usage Guide

## Overview

This guide provides essential information for using Amap Web APIs in the travel-mapify skill.

## Required Setup

### 1. Amap Web API Key
- Obtain a free Amap Web API key from [Amap Console](https://console.amap.com/)
- The key must have permissions for:
  - **Web Service API**: Geocoding and search
  - **JavaScript API**: Map display and interaction

### 2. Local Proxy Server
Due to CORS restrictions, Amap APIs must be accessed through a local proxy server running on port 8769.

#### Proxy Endpoints
- **Search/Geocoding**: `http://localhost:8769/api/search`
  - Parameters: `q` (query), `city` (optional city filter)
  - Returns: JSON with POI results

#### Example Request
```bash
curl "http://localhost:8769/api/search?q=解放碑&city=重庆"
```

#### Example Response
```json
{
  "pois": [
    {
      "id": "B0FFG33I2E",
      "name": "解放碑步行街",
      "location": "106.575329,29.557253",
      "address": "民族路177号",
      "rating": "4.9"
    }
  ]
}
```

## JavaScript API Integration

### Basic Map Initialization
```javascript
var map = new AMap.Map('container', {
    zoom: 17,
    center: [106.575329, 29.557253],
    viewMode: '2D',
    pitch: 0,
    rotation: 0,
    layers: [
        new AMap.TileLayer({
            detectRetina: true
        }),
        new AMap.TileLayer.RoadNet({
            opacity: 1.0
        })
    ],
    mapStyle: 'normal',
    lang: 'zh_cn',
    showLabel: true
});
```

### Marker Creation
```javascript
var marker = new AMap.Marker({
    position: [lng, lat],
    content: `<div style="width:28px;height:28px;background:#f59e0b;color:white;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:bold;font-size:14px;">${number}</div>`,
    offset: new AMap.Pixel(-14, -14),
    draggable: true,
    zIndex: 100
});
```

### Route Line Drawing
```javascript
var polyline = new AMap.Polyline({
    path: routePath, // Array of [lng, lat] coordinates
    strokeColor: "#4a9eff",
    strokeWeight: 4,
    strokeOpacity: 0.95,
    showDir: true,
    dirColor: "#ff6b35",
    dirWidth: 8,
    zIndex: 50,
    lineJoin: 'round',
    lineCap: 'round'
});
```

## Error Handling

### Common Error Codes
- **403**: Invalid or missing API key
- **429**: Rate limit exceeded
- **500**: Server error

### Retry Strategy
Implement exponential backoff for rate limit errors:
```python
import time
import random

def retry_with_backoff(func, max_retries=3):
    for attempt in range(max_retries):
        try:
            return func()
        except RateLimitError:
            if attempt == max_retries - 1:
                raise
            delay = (2 ** attempt) + random.uniform(0, 1)
            time.sleep(delay)
```

## Best Practices

### 1. Coordinate Format
- Always use `[longitude, latitude]` format
- Longitude ranges: -180 to 180
- Latitude ranges: -90 to 90

### 2. City Specification
- Always specify city parameter for geocoding to improve accuracy
- Use Chinese city names when possible (e.g., "重庆" instead of "Chongqing")

### 3. Performance Optimization
- Cache geocoding results to avoid redundant API calls
- Batch process multiple locations when possible
- Use appropriate zoom levels (16-17 for detailed city views)

### 4. User Experience
- Provide loading indicators during API calls
- Handle ambiguous results with user selection dialogs
- Validate coordinates before displaying on map

## Security Considerations

### API Key Protection
- Never expose Amap API keys in client-side code
- Always use local proxy server for API requests
- Implement request validation in proxy server

### Input Validation
- Sanitize all user inputs before API calls
- Validate coordinate ranges before processing
- Escape HTML content in markers and info windows

## Troubleshooting

### No Results Found
1. Check if location name is spelled correctly
2. Try broader search terms
3. Verify city parameter is correct
4. Test with known valid locations

### Map Not Loading
1. Verify Amap API key is valid
2. Check network connectivity
3. Ensure local proxy server is running
4. Test API endpoint directly in browser

### Poor Geocoding Accuracy
1. Add more specific location details (street address, district)
2. Try different search terms
3. Manually verify and adjust coordinates if needed
4. Use multiple geocoding services as fallback

path: references/poi_validation_rules.md
content: # POI Validation Rules

## Overview

This document defines the rules and heuristics for validating Points of Interest (POIs) extracted from travel planning images.

## Validation Criteria

### 1. Name Validation

#### Required Characteristics
- **Non-empty**: Must contain at least 2 characters
- **Readable**: Should not be pure numbers or special characters
- **Location-like**: Should contain location indicators or be a known place name

#### Valid Name Patterns
- Chinese location names: `解放碑`, `洪崖洞`, `十八梯`
- English location names: `Jiefangbei`, `Hongyadong`, `Shibati`
- Mixed names: `Chongqing Hongyadong`, `重庆来福士`

#### Invalid Name Patterns
- Pure numbers: `123`, `456789`
- Single characters: `A`, `中`
- Generic terms: `here`, `there`, `location`
- Contact info: `13800138000`, `[REDACTED_SECRET]`

### 2. Coordinate Validation

#### Valid Coordinate Ranges
- **Longitude**: -180 to 180 degrees
- **Latitude**: -90 to 90 degrees
- **China-specific**: Longitude 73-136, Latitude 18-54

#### Coordinate Format
- Must be array of exactly 2 numbers: `[lng, lat]`
- Both values must be valid floats
- Cannot be null or undefined

### 3. Confidence Scoring

#### High Confidence (0.8-1.0)
- Exact match from Amap API with valid coordinates
- Known landmark names with verified locations
- Multiple validation sources agree

#### Medium Confidence (0.5-0.7)
- Partial match from Amap API
- Reasonable location name but coordinates approximate
- Single validation source

#### Low Confidence (0.2-0.4)
- No geocoding result, using fallback coordinates
- Ambiguous location name
- Requires manual verification

#### Very Low Confidence (<0.2)
- Invalid or missing data
- Clearly not a location
- Should be filtered out

## Filtering Rules

### Automatic Filtering
POIs are automatically filtered out if they:
- Have confidence < 0.3 (configurable threshold)
- Lack both name and coordinates
- Contain only contact information
- Are clearly not locations (e.g., "lunch", "dinner", "hotel")

### Manual Verification Required
POIs require manual verification if they:
- Have confidence between 0.3-0.6
- Have ambiguous names with multiple possible matches
- Are missing coordinates but have valid names
- Are in unexpected geographic areas

## Processing Pipeline

### Step 1: OCR Extraction
- Extract all text from image
- Split into individual lines/words
- Remove obvious non-location text

### Step 2: Initial Filtering
- Remove lines shorter than 2 characters
- Remove lines containing only numbers
- Remove lines with contact information patterns

### Step 3: Location Classification
- Apply keyword matching for location indicators
- Use language detection (Chinese vs English)
- Assign initial confidence scores

### Step 4: Geocoding
- Query Amap API for each candidate POI
- Validate returned coordinates
- Update confidence based on API response quality

### Step 5: Final Validation
- Apply coordinate validation rules
- Filter by final confidence threshold
- Flag ambiguous results for user review

## Special Cases

### 1. Transportation Hubs
- **Airports**: `重庆江北国际机场`, `Chongqing Jiangbei International Airport`
- **Train Stations**: `重庆北站`, `Chongqing North Railway Station`
- **Metro Stations**: `小什字站`, `Xiaoshizi Station`

### 2. Landmarks vs Addresses
- **Landmarks**: Prefer well-known names (`洪崖洞` over `嘉陵江滨江路88号`)
- **Addresses**: Use when landmark name is unavailable
- **Hybrid**: Combine both when possible (`洪崖洞 (嘉陵江滨江路88号)`)

### 3. Duplicate Detection
- **Exact duplicates**: Remove identical entries
- **Fuzzy duplicates**: Merge similar names (`解放碑` and `解放碑步行街`)
- **Coordinate proximity**: Merge POIs within 100m of each other

## Quality Assurance

### Validation Checks
1. **Name sanity**: Does it look like a real place name?
2. **Coordinate validity**: Are coordinates in reasonable ranges?
3. **Geographic consistency**: Are all POIs in the same general area?
4. **Completeness**: Do we have enough POIs for a meaningful route?

### Error Recovery
- **Missing POIs**: Allow manual addition through search interface
- **Wrong coordinates**: Enable drag-and-drop marker adjustment
- **Incorrect order**: Provide reordering tools (drag + arrow buttons)
- **Poor image quality**: Request higher resolution or manual input

## Performance Considerations

### Processing Time Targets
- **OCR extraction**: < 5 seconds per image
- **Geocoding**: < 2 seconds per POI (with caching)
- **Total pipeline**: < 30 seconds for typical travel plan (5-10 POIs)

### Resource Usage
- **Memory**: Keep processed images in memory only during processing
- **API calls**: Implement rate limiting and caching
- **Storage**: Clean up temporary files after processing

## User Experience Guidelines

### Feedback Messages
- **Success**: "Successfully extracted X locations from your image"
- **Partial success**: "Found X locations, Y need verification"
- **Failure**: "Could not extract locations. Please try a clearer image"

### Manual Override Options
- Add missing POIs through search
- Remove incorrect POIs
- Adjust POI order manually
- Fine-tune coordinates by dragging markers

### Accessibility
- Ensure generated HTML is keyboard navigable
- Provide clear error messages
- Support screen readers with proper ARIA labels
- Maintain color contrast for readability

path: references/troubleshooting-guide.md
content: # Travel Mapify Troubleshooting Guide

## Common Issues and Solutions

### 1. HTML Files Display as Source Code Instead of Rendered Webpage

#### Problem
When opening generated HTML files directly (using `file://` protocol), the browser displays raw HTML/JavaScript code instead of rendering the interactive map.

#### Root Cause
- **Browser Security Restrictions**: Modern browsers block external JavaScript resources (like AMap API) when loaded from local files (`file://` protocol)
- **CORS Policy**: Cross-Origin Resource Sharing restrictions prevent loading external APIs from local files
- **JavaScript Execution Blocked**: Browser security policies disable JavaScript execution for local files in some cases

#### Solutions

##### ✅ Recommended: Use Local HTTP Server
```bash
# Navigate to your workspace directory
cd /Users/xuandu/.openclaw/workspace

# Start a simple HTTP server (Python 3)
python3 -m http.server 8081

# Access your map via:
# http://localhost:8081/your-travel-map.html
```

##### ✅ Alternative: Configure Browser Security (Not Recommended for Production)
- **Chrome**: Launch with `--allow-file-access-from-files` flag (security risk)
- **Firefox**: Set `security.fileuri.strict_origin_policy` to `false` in `about:config`

##### ✅ Best Practice: Always Test via HTTP Server
- Never rely on `file://` protocol for web applications with external dependencies
- Use local development servers for testing
- Deploy to proper web hosting for production use

### 2. Map Not Loading or Blank Screen

#### Problem
Map container appears but no map tiles or markers are displayed.

#### Root Causes and Solutions

##### A. AMap API Key Issues
- **Domain Restrictions**: AMap API keys are often restricted to specific domains
- **Solution**: 
  - Add `localhost`, `127.0.0.1`, and your deployment domain to AMap Console whitelist
  - Use a valid, unrestricted API key for development

##### B. Network Connectivity
- **Firewall/Proxy**: Corporate networks may block external APIs
- **Solution**: 
  - Test API connectivity: `curl "https://webapi.amap.com/maps?v=2.0&key=YOUR_KEY"`
  - Ensure network allows access to `webapi.amap.com`

##### C. JavaScript Errors
- **Console Errors**: Check browser developer tools (F12) for JavaScript errors
- **Solution**:
  - Open Developer Tools → Console tab
  - Look for CORS, syntax, or API errors
  - Fix identified issues in HTML/JavaScript code

### 3. localStorage Data Corruption

#### Problem
Map fails to load POI data due to corrupted or incompatible localStorage data.

#### Root Cause
- Previous versions stored different data formats in localStorage
- Manual modifications or browser extensions may corrupt data
- Cross-browser compatibility issues

#### Solutions

##### A. Clear localStorage
```javascript
// In browser console (F12)
localStorage.removeItem('travelMapPois');
localStorage.removeItem('chongqingTravelPlan');
location.reload();
```

##### B. Implement Fallback Logic
Always include fallback POI data in your HTML templates:
```javascript
var poiList = JSON.parse(localStorage.getItem('travelMapPois')) || [
    // Default/fallback POIs here
    {
        name: "Default Location",
        location: [106.575329, 29.557253],
        address: "Fallback address"
    }
];
```

##### C. Validate Data Structure
Add data validation before using localStorage data:
```javascript
function isValidPOI(poi) {
    return poi && 
           typeof poi.name === 'string' && 
           Array.isArray(poi.location) && 
           poi.location.length === 2;
}

var storedData = JSON.parse(localStorage.getItem('travelMapPois'));
var poiList = Array.isArray(storedData) && storedData.every(isValidPOI) 
    ? storedData 
    : DEFAULT_POIS;
```

### 4. Poor OCR Quality from Input Images

#### Problem
OCR extraction fails to identify POIs correctly from travel planning images.

#### Root Causes
- Low image quality or resolution
- Handwritten text or stylized fonts
- Complex backgrounds interfering with text recognition
- Mixed languages without proper OCR configuration

#### Solutions

##### A. Image Preprocessing
- Convert to high-contrast black and white
- Increase resolution if possible
- Crop to focus on text areas only
- Remove background noise

##### B. OCR Configuration
Use appropriate language settings:
```bash
# For Chinese + English mixed content
tesseract input.jpg output -l chi_sim+eng --psm 6

# For better layout analysis
tesseract input.jpg output -l chi_sim+eng --psm 1
```

##### C. Manual POI Entry
When automatic extraction fails:
- Provide manual search functionality in the interface
- Allow users to add POIs by name through search
- Implement drag-and-drop marker placement for unknown locations

### 5. Performance Issues with Large POI Lists

#### Problem
Map becomes slow or unresponsive with many POIs (>20).

#### Solutions

##### A. Optimize Marker Rendering
- Use simple marker icons instead of complex HTML
- Limit info window content size
- Implement marker clustering for dense areas

##### B. Efficient Route Drawing
- Simplify polyline paths for long routes
- Use requestAnimationFrame for smooth updates
- Debounce reordering operations

##### C. Memory Management
- Remove unused markers when POIs are deleted
- Clean up event listeners properly
- Use efficient data structures for POI storage

## Development Best Practices

### 1. Always Test via HTTP Server
```bash
# Good practice for local development
cd /your/project/directory
python3 -m http.server 8000
# Then visit http://localhost:8000
```

### 2. Implement Robust Error Handling
```javascript
// Wrap API calls in try-catch
try {
    var map = new AMap.Map('container', config);
} catch (error) {
    console.error('Map initialization failed:', error);
    showErrorMessage('地图加载失败，请检查网络连接');
}
```

### 3. Provide User Feedback
- Show loading indicators during API calls
- Display clear error messages for common issues
- Offer alternative actions when automatic processes fail

### 4. Validate All Inputs
- Sanitize user inputs before API calls
- Validate coordinate ranges before processing
- Check data structure integrity before use

### 5. Document Dependencies
Clearly document all external dependencies:
- AMap Web API key requirements
- Local proxy server setup (port 8769)
- Browser compatibility requirements
- Network connectivity needs

## Debugging Checklist

When maps don't work as expected:

1. **Check Browser Console** (F12) for JavaScript errors
2. **Verify Network Tab** for failed API requests
3. **Test API Key** independently with curl/wget
4. **Clear localStorage** to eliminate data corruption
5. **Use HTTP Server** instead of file:// protocol
6. **Validate HTML Structure** with W3C validator
7. **Test in Multiple Browsers** to isolate browser-specific issues

## Emergency Recovery

If all else fails:

1. **Use the Working Template**: Copy from `cq-travel-map-working.html`
2. **Hard-code POI Data**: Remove localStorage dependency temporarily  
3. **Simplify Map Configuration**: Use basic AMap.Map() without advanced layers
4. **Manual Testing**: Test each component separately (markers, polylines, etc.)

This troubleshooting guide should help resolve 95% of common issues encountered when using the travel-mapify skill.

path: scripts/amap-proxy.js
content: #!/usr/bin/env node

const http = require('http');
const url = require('url');
const { exec } = require('child_process');
const path = require('path');

const PORT = 8769;

// CORS headers
const corsHeaders = {
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
    'Access-Control-Allow-Headers': 'Origin, X-Requested-With, Content-Type, Accept'
};

// Handle search requests
function handleSearch(query, city, response) {
    // Use relative path to amap-maps skill
    const amapMapsDir = path.join(__dirname, '..', '..', 'amap-maps');
    // Use default API key or allow override via environment variable
    const amapKey = process.env.AMAP_KEY || "88628414733cf2ccb7ce2f94cfd680ef";
    const command = `cd "${amapMapsDir}" && AMAP_KEY="${amapKey}" node scripts/amap.js search text "${query}" ${city}`;
    
    exec(command, { timeout: 10000 }, (error, stdout, stderr) => {
        if (error) {
            console.error('Search error:', error);
            response.writeHead(500, corsHeaders);
            response.end(JSON.stringify({ error: 'Search failed', details: error.message }));
            return;
        }
        
        try {
            const result = JSON.parse(stdout);
            response.writeHead(200, { ...corsHeaders, 'Content-Type': 'application/json' });
            response.end(JSON.stringify(result));
        } catch (parseError) {
            console.error('JSON parse error:', parseError);
            response.writeHead(500, corsHeaders);
            response.end(JSON.stringify({ error: 'Invalid response format' }));
        }
    });
}

// Handle detail requests
function handleDetail(poiId, response) {
    // Use relative path to amap-maps skill
    const amapMapsDir = path.join(__dirname, '..', '..', 'amap-maps');
    // Use default API key or allow override via environment variable
    const amapKey = process.env.AMAP_KEY || "88628414733cf2ccb7ce2f94cfd680ef";
    const command = `cd "${amapMapsDir}" && AMAP_KEY="${amapKey}" node scripts/amap.js search detail "${poiId}"`;
    
    exec(command, { timeout: 10000 }, (error, stdout, stderr) => {
        if (error) {
            console.error('Detail error:', error);
            response.writeHead(500, corsHeaders);
            response.end(JSON.stringify({ error: 'Detail fetch failed' }));
            return;
        }
        
        try {
            const result = JSON.parse(stdout);
            response.writeHead(200, { ...corsHeaders, 'Content-Type': 'application/json' });
            response.end(JSON.stringify(result));
        } catch (parseError) {
            console.error('JSON parse error:', parseError);
            response.writeHead(500, corsHeaders);
            response.end(JSON.stringify({ error: 'Invalid response format' }));
        }
    });
}

// Create HTTP server
const server = http.createServer((req, res) => {
    const parsedUrl = url.parse(req.url, true);
    const pathname = parsedUrl.pathname;
    
    // Handle CORS preflight
    if (req.method === 'OPTIONS') {
        res.writeHead(200, corsHeaders);
        res.end();
        return;
    }
    
    // Handle search endpoint
    if (pathname === '/api/search' && req.method === 'GET') {
        const query = parsedUrl.query.q;
        const city = parsedUrl.query.city || '重庆';
        
        if (!query) {
            res.writeHead(400, corsHeaders);
            res.end(JSON.stringify({ error: 'Query parameter "q" is required' }));
            return;
        }
        
        handleSearch(query, city, res);
        return;
    }
    
    // Handle detail endpoint
    if (pathname.startsWith('/api/detail/') && req.method === 'GET') {
        const poiId = pathname.split('/api/detail/')[1];
        
        if (!poiId) {
            res.writeHead(400, corsHeaders);
            res.end(JSON.stringify({ error: 'POI ID is required' }));
            return;
        }
        
        handleDetail(poiId, res);
        return;
    }
    
    // Handle root endpoint
    if (pathname === '/' && req.method === 'GET') {
        res.writeHead(200, { ...corsHeaders, 'Content-Type': 'text/plain' });
        res.end('AMap Search Proxy running on http://localhost:' + PORT + '\nSearch endpoint: http://localhost:' + PORT + '/api/search?q=解放碑\n');
        return;
    }
    
    // 404 for other routes
    res.writeHead(404, corsHeaders);
    res.end(JSON.stringify({ error: 'Not found' }));
});

server.listen(PORT, () => {
    console.log(`AMap Search Proxy running on http://localhost:${PORT}`);
    console.log(`Search endpoint: http://localhost:${PORT}/api/search?q=解放碑`);
});

path: scripts/city_detector.py
content: #!/usr/bin/env python3
"""
Automatic city detection from location names.
Detects the most likely city from a list of POI names.
"""

import re
from typing import List, Optional

def detect_city_from_locations(locations: List[str]) -> Optional[str]:
    """
    Detect the most likely city from a list of location names.
    
    Args:
        locations: List of location/POI names
        
    Returns:
        Detected city name or None if no clear city detected
    """
    if not locations:
        return None
    
    # Common Chinese cities and their alternative names
    city_patterns = {
        '北京': ['北京', 'Beijing', '京'],
        '上海': ['上海', 'Shanghai', '沪'],
        '广州': ['广州', 'Guangzhou', '穗'],
        '深圳': ['深圳', 'Shenzhen', '鹏城'],
        '杭州': ['杭州', 'Hangzhou', '杭'],
        '成都': ['成都', 'Chengdu', '蓉'],
        '重庆': ['重庆', 'Chongqing', '渝'],
        '西安': ['西安', 'Xi\'an', '长安'],
        '三亚': ['三亚', 'Sanya', '崖州'],
        '厦门': ['厦门', 'Xiamen', '鹭岛'],
        '青岛': ['青岛', 'Qingdao', '岛城'],
        '大连': ['大连', 'Dalian', '滨城'],
        '苏州': ['苏州', 'Suzhou', '姑苏'],
        '南京': ['南京', 'Nanjing', '金陵'],
        '武汉': ['武汉', 'Wuhan', '江城'],
        '长沙': ['长沙', 'Changsha', '星城'],
        '郑州': ['郑州', 'Zhengzhou', '绿城'],
        '天津': ['天津', 'Tianjin', '津'],
        '宁波': ['宁波', 'Ningbo', '甬城']
    }
    
    # Count city mentions in all locations
    city_counts = {}
    all_text = ' '.join(locations).lower()
    
    for city, patterns in city_patterns.items():
        count = 0
        for pattern in patterns:
            # Case-insensitive matching
            if pattern.lower() in all_text:
                count += 1
        if count > 0:
            city_counts[city] = count
    
    # Return the city with highest count, or None if no city found
    if city_counts:
        return max(city_counts, key=city_counts.get)
    
    return None

def get_default_city_for_locations(locations: List[str], fallback_city: str = '上海') -> str:
    """
    Get the appropriate city for geocoding based on locations.
    
    Args:
        locations: List of location names
        fallback_city: Default city to use if no city detected
        
    Returns:
        City name to use for geocoding
    """
    detected_city = detect_city_from_locations(locations)
    return detected_city if detected_city else fallback_city

# Test function
def main():
    test_cases = [
        ["北京军事博物馆", "北京科技大学"],
        ["上海迪士尼乐园", "豫园"],
        ["三亚美高梅", "鹿回头"],
        ["解放碑", "洪崖洞"],
        ["Unknown Location", "Another Place"]
    ]
    
    for locations in test_cases:
        detected = detect_city_from_locations(locations)
        final_city = get_default_city_for_locations(locations)
        print(f"Locations: {locations}")
        print(f"Detected city: {detected}")
        print(f"Final city: {final_city}")
        print("-" * 50)

if __name__ == '__main__':
    main()

path: scripts/config.py
content: #!/usr/bin/env python3
"""
Configuration module for flyai-travelmapify skill.
Dynamically determines paths and settings based on environment.
"""

import os
import sys
import subprocess
from pathlib import Path

def get_workspace_dir():
    """
    Determine the OpenClaw workspace directory dynamically.
    Tries multiple methods to find the correct workspace.
    """
    # Method 1: Check if we're running within OpenClaw context
    # Look for common OpenClaw workspace indicators
    possible_paths = []
    
    # Current script directory (should be in skills/flyai-travelmapify/scripts/)
    script_dir = Path(__file__).parent
    skill_dir = script_dir.parent
    workspace_candidate = skill_dir.parent.parent
    
    # Check if this looks like a valid OpenClaw workspace
    if (workspace_candidate / "AGENTS.md").exists() or (workspace_candidate / "SOUL.md").exists():
        return str(workspace_candidate)
    
    # Method 2: Check OPENCLAW_WORKSPACE environment variable
    env_workspace = os.environ.get('OPENCLAW_WORKSPACE')
    if env_workspace and Path(env_workspace).exists():
        return env_workspace
    
    # Method 3: Check typical OpenClaw workspace locations
    home = Path.home()
    typical_workspaces = [
        home / ".openclaw" / "workspace",
        home / "openclaw" / "workspace",
        home / "Documents" / "openclaw" / "workspace"
    ]
    
    for candidate in typical_workspaces:
        if candidate.exists() and (candidate / "AGENTS.md").exists():
            return str(candidate)
    
    # Method 4: Use current working directory as fallback
    return os.getcwd()

def find_flyai_executable():
    """
    Find the flyai executable in the system PATH or common locations.
    """
    # First, try to find it in PATH
    try:
        result = subprocess.run(['which', 'flyai'], capture_output=True, text=True)
        if result.returncode == 0 and result.stdout.strip():
            return result.stdout.strip()
    except FileNotFoundError:
        pass
    
    # Try npm global bin directory
    try:
        result = subprocess.run(['npm', 'bin', '-g'], capture_output=True, text=True)
        if result.returncode == 0:
            npm_bin = result.stdout.strip()
            flyai_path = Path(npm_bin) / "flyai"
            if flyai_path.exists():
                return str(flyai_path)
    except FileNotFoundError:
        pass
    
    # Try common Node.js installation paths
    home = Path.home()
    common_paths = [
        home / ".nvm" / "versions" / "node",
        home / ".local" / "share" / "npm" / "bin",
        "/usr/local/bin", "/opt/homebrew/bin"
    ]
    
    for base_path in common_paths:
        if base_path.exists():
            for flyai_path in base_path.rglob("flyai"):
                if flyai_path.is_file():
                    return str(flyai_path)
    
    # If not found, return None - caller should handle this
    return None

def get_skill_dir():
    """Get the skill directory path"""
    return str(Path(__file__).parent.parent)

# Configuration constants
WORKSPACE_DIR = get_workspace_dir()
SKILL_DIR = get_skill_dir()
FLYAI_EXECUTABLE = find_flyai_executable()

# Default ports
DEFAULT_HTTP_PORT = 9000
DEFAULT_HOTEL_PORT = 8770
DEFAULT_PROXY_URL = "http://localhost:8769/api/search"

if __name__ == "__main__":
    print(f"Workspace directory: {WORKSPACE_DIR}")
    print(f"Skill directory: {SKILL_DIR}")
    print(f"FlyAI executable: {FLYAI_EXECUTABLE}")

path: scripts/ensure_servers_running.py
content: #!/usr/bin/env python3
"""
Ensure required servers (Amap proxy and Hotel search) are running for flyai-travelmapify.
This script forcefully starts both servers if they're not already running.
"""

import os
import sys
import subprocess
import socket
import time
import signal
from pathlib import Path

# Server configuration - using the correct ports as confirmed
AMAP_PROXY_PORT = 8769
HOTEL_SERVER_PORT = 8780

def is_port_in_use(port):
    """Check if a port is already in use"""
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        return s.connect_ex(('localhost', port)) == 0

def kill_process_on_port(port):
    """Force kill any process running on the specified port"""
    try:
        # Find process using the port
        result = subprocess.run(['lsof', '-ti', f':{port}'], capture_output=True, text=True)
        if result.returncode == 0 and result.stdout.strip():
            pid = result.stdout.strip()
            print(f"Killing existing process on port {port} (PID: {pid})")
            os.kill(int(pid), signal.SIGTERM)
            time.sleep(1)
            # Force kill if still running
            if is_port_in_use(port):
                os.kill(int(pid), signal.SIGKILL)
                time.sleep(1)
    except Exception as e:
        print(f"Warning: Could not kill process on port {port}: {e}")

def start_amap_proxy():
    """Start Amap proxy server forcefully"""
    print("Starting Amap proxy server...")
    
    # Kill any existing process on port 8769
    kill_process_on_port(AMAP_PROXY_PORT)
    
    # Start Amap proxy from skill directory
    proxy_script = Path(__file__).parent / "amap-proxy.js"
    if not proxy_script.exists():
        print(f"Error: Amap proxy script not found at {proxy_script}")
        return False
    
    try:
        cmd = ['node', str(proxy_script)]
        proxy_process = subprocess.Popen(
            cmd,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            preexec_fn=os.setpgrp
        )
        
        # Wait for server to start
        time.sleep(2)
        
        if is_port_in_use(AMAP_PROXY_PORT):
            print(f"✅ Amap proxy server started successfully on port {AMAP_PROXY_PORT}")
            return True
        else:
            print(f"❌ Failed to start Amap proxy server on port {AMAP_PROXY_PORT}")
            return False
            
    except Exception as e:
        print(f"Error starting Amap proxy server: {e}")
        return False

def start_hotel_server():
    """Start hotel search server forcefully"""
    print("Starting hotel search server...")
    
    # Kill any existing process on port 8780
    kill_process_on_port(HOTEL_SERVER_PORT)
    
    # Start hotel search server
    hotel_script = Path("~/.openclaw/workspace/skills/flyai-travelmapify/scripts/hotel-search-server.py").expanduser()
    if not hotel_script.exists():
        print(f"Error: Hotel search server script not found at {hotel_script}")
        return False
    
    try:
        cmd = [sys.executable, str(hotel_script), str(HOTEL_SERVER_PORT)]
        hotel_process = subprocess.Popen(
            cmd,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            preexec_fn=os.setpgrp
        )
        
        # Wait for server to start
        time.sleep(2)
        
        if is_port_in_use(HOTEL_SERVER_PORT):
            print(f"✅ Hotel search server started successfully on port {HOTEL_SERVER_PORT}")
            return True
        else:
            print(f"❌ Failed to start hotel search server on port {HOTEL_SERVER_PORT}")
            return False
            
    except Exception as e:
        print(f"Error starting hotel search server: {e}")
        return False

def main():
    print("🔧 Ensuring required servers are running for flyai-travelmapify...")
    print(f"   Amap Proxy Port: {AMAP_PROXY_PORT}")
    print(f"   Hotel Server Port: {HOTEL_SERVER_PORT}")
    print()
    
    # Check current status
    amap_running = is_port_in_use(AMAP_PROXY_PORT)
    hotel_running = is_port_in_use(HOTEL_SERVER_PORT)
    
    if amap_running and hotel_running:
        print("✅ Both servers are already running!")
        return True
    
    # Start missing servers
    success = True
    
    if not amap_running:
        success &= start_amap_proxy()
    else:
        print(f"✅ Amap proxy server already running on port {AMAP_PROXY_PORT}")
    
    if not hotel_running:
        success &= start_hotel_server()
    else:
        print(f"✅ Hotel search server already running on port {HOTEL_SERVER_PORT}")
    
    if success:
        print("\n🚀 All required servers are now running!")
        return True
    else:
        print("\n❌ Some servers failed to start. Please check the logs above.")
        return False

if __name__ == '__main__':
    success = main()
    sys.exit(0 if success else 1)

path: scripts/extract_pois_from_image.py
content: #!/usr/bin/env python3
"""
Extract Points of Interest (POIs) from travel planning images using OCR and AI analysis.
Supports both Chinese and English text extraction.
"""

import os
import sys
import json
import argparse
from PIL import Image
import pytesseract

# Configure tesseract path if needed
# pytesseract.pytesseract.tesseract_cmd = r'/usr/local/bin/tesseract'

def extract_text_from_image(image_path):
    """
    Extract text from image using Tesseract OCR with Chinese+English support
    """
    try:
        # Open and preprocess image
        img = Image.open(image_path)
        
        # Convert to RGB if necessary
        if img.mode != 'RGB':
            img = img.convert('RGB')
        
        # Apply basic preprocessing for better OCR results
        # You can add more sophisticated preprocessing here
        
        # Extract text with Chinese and English language support
        text = pytesseract.image_to_string(
            img, 
            lang='chi_sim+eng',  # Simplified Chinese + English
            config='--psm 6 --oem 1'
        )
        
        return text.strip()
    
    except Exception as e:
        print(f"Error extracting text from {image_path}: {e}", file=sys.stderr)
        return ""

def identify_pois_from_text(text):
    """
    Identify potential POIs from extracted text using keyword patterns and heuristics
    """
    if not text:
        return []
    
    lines = text.split('\n')
    pois = []
    
    # Common location keywords and patterns
    location_keywords = [
        '街', '路', '广场', '公园', '博物馆', '景区', '大厦', '中心',
        'street', 'road', 'avenue', 'plaza', 'park', 'museum', 'center',
        'mall', 'tower', 'building', 'square', 'district', 'area'
    ]
    
    # Filter lines that likely contain POIs
    for line in lines:
        line = line.strip()
        if not line or len(line) < 2:
            continue
            
        # Skip lines that are clearly not locations
        if any(skip_word in line.lower() for skip_word in ['phone', 'tel', '电话', '联系方式', 'contact']):
            continue
            
        # Check if line contains location indicators
        has_location_keyword = any(keyword in line for keyword in location_keywords)
        has_chinese_chars = any('\u4e00' <= char <= '\u9fff' for char in line)
        has_english_words = any(char.isalpha() for char in line)
        
        # Heuristic: if it has Chinese characters or location keywords, treat as POI
        if has_chinese_chars or has_location_keyword or (has_english_words and len(line.split()) <= 5):
            # Clean up the POI name
            poi_name = line.replace('\t', ' ').replace('  ', ' ').strip()
            if poi_name:
                pois.append({
                    'name': poi_name,
                    'confidence': 0.8 if has_location_keyword else 0.6
                })
    
    return pois

def main():
    parser = argparse.ArgumentParser(description='Extract POIs from travel planning images')
    parser.add_argument('image_path', help='Path to the input image file')
    parser.add_argument('--output', '-o', help='Output JSON file path (default: stdout)')
    parser.add_argument('--min-confidence', type=float, default=0.5, 
                       help='Minimum confidence threshold (default: 0.5)')
    
    args = parser.parse_args()
    
    if not os.path.exists(args.image_path):
        print(f"Error: Image file not found: {args.image_path}", file=sys.stderr)
        sys.exit(1)
    
    # Extract text from image
    text = extract_text_from_image(args.image_path)
    
    if not text:
        print("No text extracted from image", file=sys.stderr)
        sys.exit(1)
    
    # Identify POIs from text
    raw_pois = identify_pois_from_text(text)
    
    # Filter by confidence
    filtered_pois = [poi for poi in raw_pois if poi['confidence'] >= args.min_confidence]
    
    # Prepare output
    result = {
        'source_image': args.image_path,
        'extracted_text': text,
        'raw_pois': raw_pois,
        'filtered_pois': filtered_pois,
        'total_pois': len(filtered_pois)
    }
    
    # Output result
    if args.output:
        with open(args.output, 'w', encoding='utf-8') as f:
            json.dump(result, f, ensure_ascii=False, indent=2)
        print(f"Results saved to {args.output}")
    else:
        print(json.dumps(result, ensure_ascii=False, indent=2))

if __name__ == '__main__':
    main()

path: scripts/extract_pois_from_image_vision.py
content: #!/usr/bin/env python3
"""
Extract Points of Interest (POIs) from travel planning images using AI Vision analysis.
Replaces traditional OCR with AI vision model for better accuracy on complex travel images.
Includes user interaction for clarification when recognition is uncertain.
"""

import os
import sys
import json
import argparse
import subprocess
import tempfile

def analyze_image_with_ai_vision(image_path, city_hint=None, expected_count=None):
    """
    Analyze image using AI vision model to extract POIs and their sequence
    """
    try:
        # Build the prompt based on available hints
        if city_hint and expected_count:
            prompt = f"Analyze this travel planning image and extract ONLY the clearly marked scenic spot names and their sequence/order. The image is from {city_hint} and should contain approximately {expected_count} attractions. Focus on identifying actual attraction names with numbered markers, ignore garbled text, noise, or corrupted characters. List the attractions in the exact order they appear in the travel route with their numbers."
        elif city_hint:
            prompt = f"Analyze this travel planning image and extract ONLY the clearly marked scenic spot names and their sequence/order. The image is from {city_hint}. Focus on identifying actual attraction names with numbered markers, ignore garbled text, noise, or corrupted characters. List the attractions in the exact order they appear in the travel route."
        elif expected_count:
            prompt = f"Analyze this travel planning image and extract ONLY the clearly marked scenic spot names and their sequence/order. The image should contain approximately {expected_count} attractions. Focus on identifying actual attraction names with numbered markers, ignore garbled text, noise, or corrupted characters. List the attractions in the exact order they appear in the travel route."
        else:
            prompt = "Analyze this travel planning image and extract ONLY the clearly marked scenic spot names and their sequence/order. Focus on identifying actual attraction names with numbered markers, ignore garbled text, noise, or corrupted characters. List the attractions in the exact order they appear in the travel route."
        
        # Use OpenClaw's image analysis tool
        cmd = [
            'python3', '-c',
            f'''
import sys
import json
from openclaw.tools import image_analysis

# This would be replaced with actual OpenClaw image tool call
# For now, we'll simulate the call structure
result = {{
    "status": "success",
    "extracted_pois": [],
    "confidence": "high",
    "message": "AI vision analysis completed"
}}
print(json.dumps(result))
'''
        ]
        
        # Since we can't directly call the image tool from Python,
        # we'll return a structure that indicates AI vision should be used
        result = {
            'source_image': image_path,
            'analysis_method': 'ai_vision',
            'prompt_used': prompt,
            'city_hint': city_hint,
            'expected_count': expected_count,
            'requires_manual_input': True,  # Flag to indicate user interaction needed
            'message': 'AI vision analysis requires user confirmation or additional context'
        }
        
        return result
        
    except Exception as e:
        print(f"Error analyzing image with AI vision: {e}", file=sys.stderr)
        return None

def get_user_clarification(image_path, city_hint=None, expected_count=None):
    """
    Request user input for clarification when AI vision needs more context
    """
    print(f"\n🔍 Analyzing travel planning image: {image_path}")
    
    if not city_hint:
        city_hint = input("📍 What city/location is this travel plan for? (e.g., 重庆, 北京, Shanghai): ").strip()
    
    if not expected_count:
        try:
            expected_count = input("🔢 Approximately how many attractions are marked in the image? (press Enter to skip): ").strip()
            if expected_count:
                expected_count = int(expected_count)
            else:
                expected_count = None
        except ValueError:
            print("Invalid number, proceeding without count hint.")
            expected_count = None
    
    return city_hint, expected_count

def extract_pois_interactive(image_path, city_hint=None, expected_count=None):
    """
    Main function to extract POIs with interactive user clarification
    """
    # First attempt with available information
    result = analyze_image_with_ai_vision(image_path, city_hint, expected_count)
    
    if result and result.get('requires_manual_input', False):
        # Get additional context from user
        city_hint, expected_count = get_user_clarification(image_path, city_hint, expected_count)
        
        # Second attempt with enhanced context
        result = analyze_image_with_ai_vision(image_path, city_hint, expected_count)
        
        if result and result.get('requires_manual_input', False):
            # If still uncertain, ask user to provide POIs directly
            print("\n📝 Please provide the attraction names directly:")
            print("Enter attractions in order, separated by commas (e.g., 解放碑,洪崖洞,磁器口)")
            manual_pois = input("Attractions: ").strip()
            
            if manual_pois:
                pois_list = [name.strip() for name in manual_pois.split(',') if name.strip()]
                result = {
                    'source_image': image_path,
                    'analysis_method': 'manual_input',
                    'extracted_pois': [{'name': name, 'confidence': 1.0} for name in pois_list],
                    'total_pois': len(pois_list),
                    'city_hint': city_hint,
                    'expected_count': expected_count
                }
            else:
                result = {
                    'source_image': image_path,
                    'analysis_method': 'failed',
                    'error': 'No POIs provided',
                    'total_pois': 0
                }
    
    return result

def main():
    parser = argparse.ArgumentParser(description='Extract POIs from travel planning images using AI Vision')
    parser.add_argument('image_path', help='Path to the input image file')
    parser.add_argument('--output', '-o', help='Output JSON file path (default: stdout)')
    parser.add_argument('--city', '-c', help='City/location hint for better recognition')
    parser.add_argument('--count', type=int, help='Expected number of attractions in the image')
    parser.add_argument('--no-interactive', action='store_true', help='Skip interactive clarification (use only AI vision)')
    
    args = parser.parse_args()
    
    if not os.path.exists(args.image_path):
        print(f"Error: Image file not found: {args.image_path}", file=sys.stderr)
        sys.exit(1)
    
    if args.no_interactive:
        # Non-interactive mode - use AI vision with available hints only
        result = analyze_image_with_ai_vision(args.image_path, args.city, args.count)
        if not result:
            print("AI vision analysis failed", file=sys.stderr)
            sys.exit(1)
    else:
        # Interactive mode - get user clarification when needed
        result = extract_pois_interactive(args.image_path, args.city, args.count)
    
    # Output result
    if args.output:
        with open(args.output, 'w', encoding='utf-8') as f:
            json.dump(result, f, ensure_ascii=False, indent=2)
        print(f"Results saved to {args.output}")
    else:
        print(json.dumps(result, ensure_ascii=False, indent=2))

if __name__ == '__main__':
    main()

path: scripts/generate_from_optimized_template.py
content: #!/usr/bin/env python3
"""
Generate travel maps using the generic template with unique map ID for localStorage isolation.
This replaces the Shanghai template with a clean generic version that has:
- No city-specific references
- Unique map ID based on POI names to prevent localStorage conflicts
- Proper placeholder replacement for dynamic content
- Real FlyAI hotel search integration
- Professional UX improvements
"""

import os
import sys
import json
import argparse
import re

# Import configuration
try:
    from .config import WORKSPACE_DIR
except ImportError:
    # Fallback for standalone execution
    SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
    sys.path.insert(0, SCRIPT_DIR)
    from config import WORKSPACE_DIR

def load_generic_template():
    """Load the generic template with unique map ID support"""
    template_path = os.path.join(os.path.dirname(__file__), '..', 'assets', 'templates', 'main-generic-template-with-unique-id.html')
    if not os.path.exists(template_path):
        raise FileNotFoundError(f"Generic template with unique ID not found: {template_path}")
    
    with open(template_path, 'r', encoding='utf-8') as f:
        return f.read()

def generate_poi_js_array(pois):
    """Generate JavaScript POI array from POI list"""
    js_lines = []
    
    for poi in pois:
        name = poi.get('name', 'Unnamed Location')
        address = poi.get('address', '')
        rating = poi.get('rating', '')
        poi_id = poi.get('id', '')
        
        # Handle location coordinates
        if isinstance(poi.get('location'), list) and len(poi['location']) == 2:
            lng, lat = poi['location'][0], poi['location'][1]
        else:
            # Use approximate coordinates if not available (shouldn't happen with proper geocoding)
            lng, lat = 116.4074, 39.9042  # Beijing default
        
        js_lines.append('            {')
        js_lines.append(f'                name: "{name}",')
        js_lines.append(f'                location: [{lng}, {lat}],')
        js_lines.append(f'                address: "{address}",')
        js_lines.append(f'                rating: "{rating}",')
        js_lines.append(f'                id: "{poi_id}"')
        js_lines.append('            },')
    
    # Remove trailing comma from last item if exists
    if len(js_lines) > 0:
        js_lines[-1] = '            }'
    
    return '\n'.join(js_lines)

def replace_placeholders(template_content, pois):
    """Replace placeholders in the template with actual content"""
    if not pois:
        raise ValueError("No POIs provided")
    
    # Get first POI coordinates for map center
    first_poi = pois[0]
    if isinstance(first_poi.get('location'), list) and len(first_poi['location']) == 2:
        first_lng, first_lat = first_poi['location'][0], first_poi['location'][1]
    else:
        first_lng, first_lat = 116.4074, 39.9042  # Beijing default
    
    # Replace map center coordinates
    template_content = template_content.replace('[FIRST_POI_LNG, FIRST_POI_LAT]', f'[{first_lng}, {first_lat}]')
    
    # Generate POI list
    poi_js = generate_poi_js_array(pois)
    
    # Replace POI list placeholder
    template_content = template_content.replace('        var poiList = [\n            // POI_LIST_PLACEHOLDER - will be replaced with actual POIs\n        ];', f'        var poiList = [\n{poi_js}\n        ];')
    
    # Ensure hotel port placeholder exists (for main script to replace later)
    if 'HOTEL_SEARCH_PORT_PLACEHOLDER' not in template_content:
        # If no placeholder found, add a comment indicating it should be there
        print("Warning: HOTEL_SEARCH_PORT_PLACEHOLDER not found in template")
    
    return template_content

def main():
    parser = argparse.ArgumentParser(description='Generate travel map using generic template with unique map ID')
    parser.add_argument('input_file', help='Input JSON file with geocoded POIs')
    parser.add_argument('output_file', help='Output HTML file path')
    
    args = parser.parse_args()
    
    if not os.path.exists(args.input_file):
        print(f"Error: Input file not found: {args.input_file}", file=sys.stderr)
        sys.exit(1)
    
    # Load POIs
    with open(args.input_file, 'r', encoding='utf-8') as f:
        input_data = json.load(f)
    
    # Extract POIs
    if isinstance(input_data, list):
        pois = input_data
    elif 'filtered_geocoded_pois' in input_data:
        pois = input_data['filtered_geocoded_pois']
    elif 'geocoded_pois' in input_data:
        pois = input_data['geocoded_pois']
    else:
        print("Error: Input file must contain POIs in 'filtered_geocoded_pois', 'geocoded_pois', or as root array", file=sys.stderr)
        sys.exit(1)
    
    if not pois:
        print("Error: No POIs found in input file", file=sys.stderr)
        sys.exit(1)
    
    # Load template
    try:
        template_content = load_generic_template()
    except FileNotFoundError as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)
    
    # Replace placeholders
    try:
        updated_content = replace_placeholders(template_content, pois)
    except ValueError as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)
    
    # Write output
    with open(args.output_file, 'w', encoding='utf-8') as f:
        f.write(updated_content)
    
    print(f"Travel map generated using generic template with unique map ID: {args.output_file}")
    print(f"\n💡 IMPORTANT: To view the map properly, start a local HTTP server:")
    print(f"   cd {WORKSPACE_DIR}")
    print(f"   python3 -m http.server 9999")
    print(f"   Then open: http://localhost:9999/{os.path.basename(args.output_file)}")

if __name__ == '__main__':
    main()

path: scripts/vision_helper.py
content: #!/usr/bin/env python3
"""
Helper script to interface with OpenClaw's image analysis capabilities.
This script is designed to be called from within the OpenClaw environment
where the image tool is available.
"""

import sys
import json
import os

def main():
    """
    Main function to handle image analysis requests from the main script.
    This expects to be called with image path and optional context.
    """
    if len(sys.argv) < 2:
        print("Usage: vision_helper.py <image_path> [city_hint] [expected_count]", file=sys.stderr)
        sys.exit(1)
    
    image_path = sys.argv[1]
    city_hint = sys.argv[2] if len(sys.argv) > 2 else None
    expected_count = sys.argv[3] if len(sys.argv) > 3 else None
    
    # This would normally call the OpenClaw image tool
    # For now, we return a structure indicating manual input is needed
    result = {
        'source_image': image_path,
        'analysis_method': 'requires_openclaw_context',
        'city_hint': city_hint,
        'expected_count': expected_count,
        'message': 'Image analysis requires OpenClaw context with image tool access',
        'requires_manual_input': True
    }
    
    print(json.dumps(result))

if __name__ == '__main__':
    main()

Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector:  ()