Add external API integration for wiki/web UI synchronization

This commit adds a comprehensive external API system that allows external tools
(wikis, web UIs, etc.) to control and synchronize with Fantasy Map Generator.

New Features:
- External API Bridge (modules/external-api.js)
  - Event-driven architecture with EventEmitter
  - Map lifecycle control (create, load, save)
  - Data access methods (rivers, cultures, states, burgs)
  - Data mutation methods with auto-redraw
  - Export support (SVG, PNG, JSON)
  - Change detection with automatic event emission

- PostMessage Communication Layer
  - Auto-enables when FMG is embedded in iframe
  - Bidirectional message passing
  - Request/response pattern with promise support
  - Automatic event forwarding to parent window

- REST API Server (api-server/)
  - Express.js server with full CRUD operations
  - WebSocket support via Socket.IO for real-time updates
  - File upload support for map and CSV import
  - In-memory storage (can be replaced with database)
  - CORS enabled for cross-origin requests
  - Comprehensive endpoints for all map data

- Client Library (api-server/client.js)
  - Simple JavaScript client for REST API
  - Promise-based async methods
  - Works in browser and Node.js

- Demo Pages (demos/)
  - PostMessage integration demo with full UI
  - REST API demo with interactive testing
  - WebSocket demo for real-time events

- Documentation
  - Comprehensive integration guide (EXTERNAL_API_INTEGRATION.md)
  - API reference with TypeScript interfaces
  - Multiple integration examples
  - Troubleshooting guide

Integration Methods:
1. PostMessage Bridge - For iframe embedding
2. REST API - For server-side integration
3. Direct JavaScript API - For same-origin apps

Use Cases:
- Wiki pages that need to display and control maps
- Web UIs that want to edit map data
- External tools that need to sync with FMG
- Real-time collaborative map editing
- Batch operations and automation

Technical Details:
- Zero dependencies for external-api.js (pure JS)
- Auto-initializes on DOMContentLoaded
- Throttled change detection (500ms debounce)
- Deep cloning for data access (prevents mutations)
- Error handling throughout
- Version tagged (v1.0.0)

Updated Files:
- index.html: Added script tag to load external-api module

All APIs are backward compatible and don't affect existing functionality.
This commit is contained in:
Claude 2025-11-04 21:43:06 +00:00
parent dede314c94
commit 20458e39e2
No known key found for this signature in database
10 changed files with 4699 additions and 0 deletions

737
EXTERNAL_API_INTEGRATION.md Normal file
View file

@ -0,0 +1,737 @@
# External API Integration Guide
This guide explains how to integrate Fantasy Map Generator (FMG) with external tools like wikis, web UIs, or other applications.
## Table of Contents
- [Overview](#overview)
- [Integration Methods](#integration-methods)
- [1. PostMessage Bridge (iframe)](#1-postmessage-bridge-iframe)
- [2. REST API Server](#2-rest-api-server)
- [3. Direct JavaScript API](#3-direct-javascript-api)
- [API Reference](#api-reference)
- [Events](#events)
- [Examples](#examples)
- [Troubleshooting](#troubleshooting)
---
## Overview
The Fantasy Map Generator now includes an **External API** that allows external applications to:
- Control map generation and loading
- Access and modify map data (rivers, cultures, states, burgs, etc.)
- Listen to real-time map changes
- Export maps in various formats
- Synchronize state bidirectionally
### Key Features
**Event-driven architecture** - Subscribe to map changes
**PostMessage bridge** - Embed FMG in iframe and control it
**REST API server** - HTTP endpoints for server-side integration
**WebSocket support** - Real-time bidirectional communication
**Type-safe data access** - Clean API with error handling
---
## Integration Methods
### 1. PostMessage Bridge (iframe)
**Best for:** Web-based wikis, browser extensions, web apps on different domains
#### How It Works
1. Embed FMG in an `<iframe>`
2. Send commands via `postMessage()`
3. Receive responses and events automatically
#### Example
```html
<!-- Your Wiki/Web UI -->
<!DOCTYPE html>
<html>
<body>
<!-- Embed FMG -->
<iframe id="mapFrame" src="https://your-fmg-instance.com/index.html"></iframe>
<script>
const mapFrame = document.getElementById('mapFrame').contentWindow;
// Wait for iframe to load
window.addEventListener('load', () => {
// Create a new map
mapFrame.postMessage({
type: 'CREATE_MAP',
payload: { seed: 'my-world' },
requestId: 1
}, '*');
});
// Listen for responses
window.addEventListener('message', (event) => {
const { type, payload, requestId } = event.data;
if (type === 'RESPONSE' && requestId === 1) {
console.log('Map created!', payload);
}
if (type === 'EVENT' && payload.event === 'rivers:updated') {
console.log('Rivers updated:', payload.data);
}
});
</script>
</body>
</html>
```
#### Available Commands
Send these via `postMessage()`:
```javascript
// Map Lifecycle
{ type: 'CREATE_MAP', payload: { seed, width, height } }
{ type: 'LOAD_MAP', payload: mapData }
{ type: 'SAVE_MAP', payload: { format: 'data' | 'blob' } }
// Data Access
{ type: 'GET_STATE' }
{ type: 'GET_RIVERS' }
{ type: 'GET_CULTURES' }
{ type: 'GET_STATES' }
{ type: 'GET_BURGS' }
// Mutations
{ type: 'UPDATE_RIVERS', payload: [...] }
{ type: 'UPDATE_CULTURES', payload: [...] }
{ type: 'UPDATE_STATES', payload: [...] }
{ type: 'ADD_BURG', payload: {name, x, y, ...} }
// Export
{ type: 'EXPORT_SVG' }
{ type: 'EXPORT_PNG', payload: {width, height} }
{ type: 'EXPORT_JSON', payload: {key} }
```
#### Demo
See `demos/postmessage-demo.html` for a full interactive example.
---
### 2. REST API Server
**Best for:** Server-side integration, microservices, backend systems
#### Setup
1. Install dependencies:
```bash
cd api-server
npm install
```
2. Start the server:
```bash
node server.js
# Server runs on http://localhost:3000
```
#### Endpoints
##### Health Check
```
GET /api/health
```
##### Create Map
```
POST /api/maps
Body: { seed?: string, width?: number, height?: number }
Response: { success: true, mapId: string, pollUrl: string }
```
##### Get Map
```
GET /api/maps/:id
Response: { success: true, map: {...} }
```
##### List Maps
```
GET /api/maps
Response: { success: true, maps: [...] }
```
##### Update Map
```
PUT /api/maps/:id
Body: { data: {...} }
```
##### Delete Map
```
DELETE /api/maps/:id
```
##### Get/Update Rivers
```
GET /api/maps/:id/rivers
PUT /api/maps/:id/rivers
Body: { rivers: [...] }
```
##### Import Rivers from CSV
```
POST /api/maps/:id/rivers/import
Body: multipart/form-data with 'file' field
```
##### Get Cultures/States/Burgs
```
GET /api/maps/:id/cultures
GET /api/maps/:id/states
GET /api/maps/:id/burgs
```
##### Add Burg
```
POST /api/maps/:id/burgs
Body: { name, x, y, cell, population, type }
```
#### Example Usage
```javascript
// Create a new map
const response = await fetch('http://localhost:3000/api/maps', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ seed: 'my-world' })
});
const { mapId } = await response.json();
// Get rivers
const riversResponse = await fetch(`http://localhost:3000/api/maps/${mapId}/rivers`);
const { rivers } = await riversResponse.json();
console.log('Rivers:', rivers);
// Update rivers
await fetch(`http://localhost:3000/api/maps/${mapId}/rivers`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rivers: [
{ i: 1, name: 'Mystic River', type: 'River', discharge: 100 }
]
})
});
```
#### WebSocket Events
Connect to `ws://localhost:3000` for real-time updates:
```javascript
import { io } from 'socket.io-client';
const socket = io('http://localhost:3000');
socket.on('connect', () => {
console.log('Connected!');
});
// Listen to events
socket.on('map:created', (data) => {
console.log('Map created:', data);
});
socket.on('rivers:updated', (data) => {
console.log('Rivers updated:', data);
});
// Send events
socket.emit('map:update', {
mapId: 'map_123',
updates: { /* ... */ }
});
```
#### Demo
See `demos/rest-api-demo.html` and `demos/websocket-demo.html` for interactive examples.
---
### 3. Direct JavaScript API
**Best for:** Same-origin applications, browser extensions with host permissions
#### Access the API
Once FMG is loaded, access the global API:
```javascript
const api = window.FMG_API;
```
#### Methods
##### Map Lifecycle
```javascript
// Create new map
const result = await api.createMap({ seed: 'my-seed' });
if (result.success) {
console.log('Map created:', result.state);
}
// Load map from file or data
const file = document.getElementById('fileInput').files[0];
await api.loadMap(file);
// Or load from string
await api.loadMap(mapDataString);
// Save map
const saved = await api.saveMap('data'); // or 'blob'
console.log('Map data:', saved.data);
```
##### Data Access
```javascript
// Get complete state
const state = api.getMapState();
console.log('Current state:', state);
// Get specific data
const rivers = api.getRivers();
const cultures = api.getCultures();
const states = api.getStates();
const burgs = api.getBurgs();
const religions = api.getReligions();
const markers = api.getMarkers();
const grid = api.getGrid();
// Get any data by key
const data = api.getData('rivers');
```
##### Mutations
```javascript
// Update rivers
api.updateRivers([
{ i: 1, name: 'New River', type: 'River', discharge: 50 },
// ... more rivers
]);
// Update cultures
api.updateCultures([...]);
// Update states
api.updateStates([...]);
// Update burgs
api.updateBurgs([...]);
// Add a new burg
const result = api.addBurg({
name: 'New City',
x: 500,
y: 400,
cell: 1234,
population: 10,
type: 'city',
culture: 1,
state: 1
});
if (result.success) {
console.log('New burg ID:', result.id);
}
```
##### Export
```javascript
// Export SVG
const svg = api.exportSVG();
console.log('SVG:', svg);
// Export PNG (returns blob)
const pngBlob = await api.exportPNG(2048, 2048);
// Export JSON
const json = api.exportJSON(); // All data
const riversJson = api.exportJSON('rivers'); // Specific key
```
##### Events
```javascript
// Subscribe to events
const unsubscribe = api.on('map:changed', (state) => {
console.log('Map changed:', state);
});
// Unsubscribe
unsubscribe();
// Or manually
api.off('map:changed', callback);
// Subscribe once
api.once('map:created', (state) => {
console.log('Map created:', state);
});
// Emit custom events
api.emit('custom:event', { myData: 'test' });
```
---
## API Reference
### Data Structures
#### Map State
```typescript
interface MapState {
seed: string | null;
mapId: string | null;
timestamp: number;
pack: {
cultures: Culture[];
states: State[];
burgs: Burg[];
rivers: River[];
religions: Religion[];
provinces: Province[];
markers: Marker[];
} | null;
grid: {
spacing: number;
cellsX: number;
cellsY: number;
features: Feature[];
} | null;
options: object | null;
}
```
#### River
```typescript
interface River {
i: number; // ID
name: string; // Name
type: string; // 'River', 'Lake', etc.
discharge: number; // m³/s
length: number; // Distance
width: number; // Visual width
basin: number; // Parent river ID
// ... more properties
}
```
#### Culture
```typescript
interface Culture {
i: number; // ID
name: string; // Name
base: number; // Name base ID
shield: string; // Shield type
expansionism: number; // Expansion rate
color: string; // Color
// ... more properties
}
```
#### State
```typescript
interface State {
i: number; // ID
name: string; // Name
color: string; // Color
expansionism: number; // Expansion rate
capital: number; // Capital burg ID
culture: number; // Culture ID
// ... more properties
}
```
#### Burg
```typescript
interface Burg {
i: number; // ID
name: string; // Name
x: number; // X coordinate
y: number; // Y coordinate
cell: number; // Cell ID
population: number; // Population
type: string; // 'city', 'town', etc.
culture: number; // Culture ID
state: number; // State ID
// ... more properties
}
```
---
## Events
### Available Events
#### Map Events
- `map:created` - New map created
- `map:loaded` - Map loaded from file
- `map:changed` - Map modified (throttled)
#### Data Events
- `rivers:updated` - Rivers data updated
- `cultures:updated` - Cultures data updated
- `states:updated` - States data updated
- `burgs:updated` - Burgs data updated
- `burg:added` - New burg added
### Event Payload
All events include relevant data:
```javascript
api.on('rivers:updated', (rivers) => {
console.log('Updated rivers:', rivers);
});
api.on('map:created', (state) => {
console.log('Map state:', state);
});
```
---
## Examples
### Example 1: Wiki Integration
Embed FMG in a wiki page and sync data:
```html
<div id="wiki-map-section">
<iframe id="fmg" src="/fmg/index.html" width="100%" height="600"></iframe>
<script>
// Store map state in wiki
window.addEventListener('message', (event) => {
if (event.data.type === 'EVENT' && event.data.payload.event === 'map:changed') {
// Save to wiki database
saveToWiki(event.data.payload.data);
}
});
// Load stored map on page load
fetch('/wiki/api/map-data')
.then(r => r.json())
.then(mapData => {
document.getElementById('fmg').contentWindow.postMessage({
type: 'LOAD_MAP',
payload: mapData
}, '*');
});
</script>
</div>
```
### Example 2: Custom River Editor
Create a custom UI to edit rivers:
```javascript
// Get current rivers
const rivers = await window.FMG_API.getRivers();
// Show in custom UI
renderRiversTable(rivers);
// Update a river
rivers[0].name = 'Renamed River';
rivers[0].discharge = 150;
// Save changes
window.FMG_API.updateRivers(rivers);
// Listen for updates
window.FMG_API.on('rivers:updated', (updatedRivers) => {
renderRiversTable(updatedRivers);
});
```
### Example 3: Batch Operations via REST API
```javascript
// Create multiple maps
async function createMapBatch(seeds) {
const maps = [];
for (const seed of seeds) {
const res = await fetch('http://localhost:3000/api/maps', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ seed })
});
const { mapId } = await res.json();
maps.push(mapId);
}
return maps;
}
// Use it
const mapIds = await createMapBatch(['world1', 'world2', 'world3']);
console.log('Created maps:', mapIds);
```
### Example 4: Real-time Collaboration
Multiple users editing the same map:
```javascript
// User A's browser
const socket = io('http://localhost:3000');
// Listen for changes from other users
socket.on('rivers:updated', (data) => {
// Update local view
window.FMG_API.updateRivers(data.rivers);
});
// When local user makes changes
window.FMG_API.on('rivers:updated', (rivers) => {
// Broadcast to other users
socket.emit('rivers:updated', {
mapId: currentMapId,
rivers
});
});
```
---
## Troubleshooting
### PostMessage not working
**Problem:** Messages not received in iframe
**Solution:**
1. Ensure iframe has loaded: `iframe.addEventListener('load', ...)`
2. Wait 1-2 seconds after load for API to initialize
3. Check origin in postMessage: use `'*'` or specific origin
4. Open browser console to check for errors
### CORS errors with REST API
**Problem:** `Access-Control-Allow-Origin` errors
**Solution:**
- REST API server has CORS enabled by default
- If using custom server, add CORS middleware
- For production, configure specific origins
### API not available
**Problem:** `window.FMG_API is undefined`
**Solution:**
1. Ensure `external-api.js` is loaded in index.html
2. Wait for DOMContentLoaded event
3. Check browser console for script errors
### Events not firing
**Problem:** Event listeners not receiving events
**Solution:**
1. Subscribe to events BEFORE making changes
2. Check event names (case-sensitive)
3. Ensure change detection is enabled (automatic)
### WebSocket disconnects
**Problem:** Socket disconnects unexpectedly
**Solution:**
1. Check server is running
2. Implement reconnection logic
3. Handle `disconnect` event and reconnect
---
## Advanced Configuration
### Disable PostMessage Bridge
If you don't need iframe integration:
```javascript
// In your fork, remove from external-api.js:
// PostMessageBridge.enable();
```
### Custom Event Throttling
Adjust change detection throttle:
```javascript
// In external-api.js, modify debounce time:
const observer = new MutationObserver(debounce(() => {
// ...
}, 500)); // Change from 500ms to your preference
```
### Enable Debug Logging
```javascript
// Add to external-api.js for verbose logging:
const DEBUG = true;
if (DEBUG) {
eventEmitter.on('*', (event, data) => {
console.log('[FMG API]', event, data);
});
}
```
---
## Support
For issues, questions, or feature requests:
- GitHub Issues: https://github.com/Azgaar/Fantasy-Map-Generator/issues
- Wiki: https://github.com/Azgaar/Fantasy-Map-Generator/wiki
- Discord: [Join community]
---
## License
MIT License - same as Fantasy Map Generator
---
**Happy Mapping! 🗺️**

233
api-server/README.md Normal file
View file

@ -0,0 +1,233 @@
# Fantasy Map Generator - REST API Server
A Node.js/Express server that provides REST API endpoints and WebSocket support for the Fantasy Map Generator.
## Features
- ✅ RESTful API with full CRUD operations
- ✅ WebSocket support for real-time updates
- ✅ Map storage and management
- ✅ CSV import for rivers and other data
- ✅ Export support (SVG, PNG, JSON)
- ✅ CORS enabled for cross-origin requests
- ✅ File upload support
## Quick Start
### Installation
```bash
npm install
```
### Run Server
```bash
# Production
npm start
# Development (with auto-reload)
npm run dev
```
Server will start on `http://localhost:3000`
## API Documentation
Visit `http://localhost:3000` after starting the server to see the full API documentation.
### Quick Examples
#### Create a Map
```bash
curl -X POST http://localhost:3000/api/maps \
-H "Content-Type: application/json" \
-d '{"seed": "my-world"}'
```
Response:
```json
{
"success": true,
"mapId": "map_1234567890_abc123",
"message": "Map creation initiated",
"pollUrl": "/api/maps/map_1234567890_abc123"
}
```
#### Get Map Data
```bash
curl http://localhost:3000/api/maps/map_1234567890_abc123
```
#### Update Rivers
```bash
curl -X PUT http://localhost:3000/api/maps/map_1234567890_abc123/rivers \
-H "Content-Type: application/json" \
-d '{"rivers": [{"i":1,"name":"Mystic River","type":"River"}]}'
```
#### Import Rivers from CSV
```bash
curl -X POST http://localhost:3000/api/maps/map_1234567890_abc123/rivers/import \
-F "file=@rivers.csv"
```
## WebSocket
### Connect
```javascript
import { io } from 'socket.io-client';
const socket = io('http://localhost:3000');
socket.on('connect', () => {
console.log('Connected!');
});
```
### Events
**Server Events (listen):**
- `map:creating` - Map creation started
- `map:created` - Map creation completed
- `map:updated` - Map data updated
- `map:deleted` - Map deleted
- `rivers:updated` - Rivers updated
- `rivers:imported` - Rivers imported from CSV
- `burg:added` - New burg added
**Client Events (emit):**
- `map:update` - Update map data
- `map:created` - Notify map creation complete
- `export:completed` - Export completed
## Configuration
### Environment Variables
```bash
# Port (default: 3000)
PORT=3000
```
### Storage
By default, maps are stored in memory. For production, replace the `Map` with a database:
```javascript
// Replace this in server.js
const maps = new Map();
// With this (example with MongoDB)
const maps = await db.collection('maps');
```
## Deployment
### Docker
```dockerfile
FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
```
Build and run:
```bash
docker build -t fmg-api .
docker run -p 3000:3000 fmg-api
```
### Production Considerations
1. **Database**: Replace in-memory storage with MongoDB, PostgreSQL, etc.
2. **Authentication**: Add JWT or OAuth for protected endpoints
3. **Rate Limiting**: Add express-rate-limit
4. **Validation**: Add input validation with joi or express-validator
5. **Logging**: Add morgan or winston
6. **Monitoring**: Add health checks and metrics
## Architecture
```
Client (Browser/Wiki)
REST API / WebSocket
Server (Express + Socket.IO)
Storage (In-memory Map / Database)
```
## Dependencies
- **express**: Web framework
- **socket.io**: WebSocket server
- **cors**: Cross-origin resource sharing
- **multer**: File upload handling
- **body-parser**: Request body parsing
## Development
### Add New Endpoint
```javascript
// In server.js
app.get('/api/maps/:id/custom', async (req, res) => {
try {
const {id} = req.params;
// Your logic here
res.json({success: true, data: {}});
} catch (error) {
res.status(500).json({success: false, error: error.message});
}
});
```
### Add WebSocket Event
```javascript
// In server.js
io.on('connection', (socket) => {
socket.on('custom:event', (data) => {
// Handle event
io.emit('custom:response', data);
});
});
```
## Testing
### Manual Testing
Use the demo pages:
- REST API: `http://localhost:3000/demos/rest-api-demo.html`
- WebSocket: `http://localhost:3000/demos/websocket-demo.html`
### Automated Testing
```bash
# Install test dependencies
npm install --save-dev mocha chai supertest
# Run tests
npm test
```
## License
MIT - Same as Fantasy Map Generator
## Support
For issues and questions, see the main repository.

260
api-server/client.js Normal file
View file

@ -0,0 +1,260 @@
/**
* Fantasy Map Generator - JavaScript Client Library
*
* A simple client library for interacting with the FMG REST API
*
* Usage:
* const client = new FMGClient('http://localhost:3000/api');
* const map = await client.createMap({ seed: 'my-world' });
*/
class FMGClient {
constructor(baseUrl = 'http://localhost:3000/api') {
this.baseUrl = baseUrl;
}
/**
* Make API request
* @private
*/
async _request(endpoint, options = {}) {
const url = `${this.baseUrl}${endpoint}`;
const config = {
method: options.method || 'GET',
headers: {
...options.headers
}
};
if (options.body) {
if (options.body instanceof FormData) {
config.body = options.body;
} else {
config.headers['Content-Type'] = 'application/json';
config.body = JSON.stringify(options.body);
}
}
try {
const response = await fetch(url, config);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || `HTTP ${response.status}`);
}
return data;
} catch (error) {
throw new Error(`API request failed: ${error.message}`);
}
}
// ============================================================================
// HEALTH
// ============================================================================
/**
* Check API health
* @returns {Promise<Object>} Health status
*/
async health() {
return this._request('/health');
}
// ============================================================================
// MAP OPERATIONS
// ============================================================================
/**
* Create a new map
* @param {Object} options - Map options
* @param {string} options.seed - Map seed
* @param {number} options.width - Map width
* @param {number} options.height - Map height
* @returns {Promise<Object>} Created map info
*/
async createMap(options = {}) {
return this._request('/maps', {
method: 'POST',
body: options
});
}
/**
* Get map by ID
* @param {string} mapId - Map ID
* @returns {Promise<Object>} Map data
*/
async getMap(mapId) {
return this._request(`/maps/${mapId}`);
}
/**
* List all maps
* @returns {Promise<Object>} Map list
*/
async listMaps() {
return this._request('/maps');
}
/**
* Update map
* @param {string} mapId - Map ID
* @param {Object} data - Map data
* @returns {Promise<Object>} Updated map
*/
async updateMap(mapId, data) {
return this._request(`/maps/${mapId}`, {
method: 'PUT',
body: { data }
});
}
/**
* Delete map
* @param {string} mapId - Map ID
* @returns {Promise<Object>} Deletion result
*/
async deleteMap(mapId) {
return this._request(`/maps/${mapId}`, {
method: 'DELETE'
});
}
/**
* Load map from file
* @param {string} mapId - Map ID
* @param {File} file - Map file
* @returns {Promise<Object>} Load result
*/
async loadMap(mapId, file) {
const formData = new FormData();
formData.append('file', file);
return this._request(`/maps/${mapId}/load`, {
method: 'POST',
body: formData
});
}
// ============================================================================
// RIVERS
// ============================================================================
/**
* Get rivers
* @param {string} mapId - Map ID
* @returns {Promise<Array>} Rivers array
*/
async getRivers(mapId) {
const result = await this._request(`/maps/${mapId}/rivers`);
return result.rivers;
}
/**
* Update rivers
* @param {string} mapId - Map ID
* @param {Array} rivers - Rivers array
* @returns {Promise<Object>} Update result
*/
async updateRivers(mapId, rivers) {
return this._request(`/maps/${mapId}/rivers`, {
method: 'PUT',
body: { rivers }
});
}
/**
* Import rivers from CSV
* @param {string} mapId - Map ID
* @param {File} csvFile - CSV file
* @returns {Promise<Object>} Import result
*/
async importRiversCSV(mapId, csvFile) {
const formData = new FormData();
formData.append('file', csvFile);
return this._request(`/maps/${mapId}/rivers/import`, {
method: 'POST',
body: formData
});
}
// ============================================================================
// CULTURES
// ============================================================================
/**
* Get cultures
* @param {string} mapId - Map ID
* @returns {Promise<Array>} Cultures array
*/
async getCultures(mapId) {
const result = await this._request(`/maps/${mapId}/cultures`);
return result.cultures;
}
// ============================================================================
// STATES
// ============================================================================
/**
* Get states
* @param {string} mapId - Map ID
* @returns {Promise<Array>} States array
*/
async getStates(mapId) {
const result = await this._request(`/maps/${mapId}/states`);
return result.states;
}
// ============================================================================
// BURGS
// ============================================================================
/**
* Get burgs (cities/towns)
* @param {string} mapId - Map ID
* @returns {Promise<Array>} Burgs array
*/
async getBurgs(mapId) {
const result = await this._request(`/maps/${mapId}/burgs`);
return result.burgs;
}
/**
* Add new burg
* @param {string} mapId - Map ID
* @param {Object} burgData - Burg data
* @returns {Promise<Object>} Created burg
*/
async addBurg(mapId, burgData) {
return this._request(`/maps/${mapId}/burgs`, {
method: 'POST',
body: burgData
});
}
// ============================================================================
// EXPORT
// ============================================================================
/**
* Export map in specified format
* @param {string} mapId - Map ID
* @param {string} format - Export format (svg, png, json, data)
* @returns {Promise<Object>} Export result
*/
async exportMap(mapId, format) {
return this._request(`/maps/${mapId}/export/${format}`);
}
}
// Export for Node.js and browser
if (typeof module !== 'undefined' && module.exports) {
module.exports = FMGClient;
}
if (typeof window !== 'undefined') {
window.FMGClient = FMGClient;
}

31
api-server/package.json Normal file
View file

@ -0,0 +1,31 @@
{
"name": "fantasy-map-generator-api",
"version": "1.0.0",
"description": "REST API server for Fantasy Map Generator with WebSocket support",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"fantasy",
"map",
"generator",
"api",
"rest",
"websocket"
],
"author": "",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"body-parser": "^1.20.2",
"multer": "^1.4.5-lts.1",
"socket.io": "^4.6.1"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}

830
api-server/server.js Normal file
View file

@ -0,0 +1,830 @@
/**
* Fantasy Map Generator - REST API Server
* Provides HTTP endpoints for external tools to control the map generator
*
* Usage:
* npm install
* node server.js
*
* Then access via http://localhost:3000
*/
const express = require('express');
const cors = require('cors');
const bodyParser = require('body-parser');
const multer = require('multer');
const path = require('path');
const fs = require('fs').promises;
const { createServer } = require('http');
const { Server: SocketIOServer } = require('socket.io');
// Initialize Express
const app = express();
const httpServer = createServer(app);
const io = new SocketIOServer(httpServer, {
cors: {
origin: '*',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']
}
});
// Middleware
app.use(cors());
app.use(bodyParser.json({limit: '50mb'}));
app.use(bodyParser.urlencoded({extended: true, limit: '50mb'}));
// Serve static files from FMG directory
const FMG_ROOT = path.join(__dirname, '..');
app.use('/fmg', express.static(FMG_ROOT));
// File upload configuration
const upload = multer({
storage: multer.memoryStorage(),
limits: {fileSize: 50 * 1024 * 1024} // 50MB
});
// In-memory storage for maps (use database in production)
const maps = new Map();
// ============================================================================
// API ENDPOINTS
// ============================================================================
/**
* GET /api/health
* Health check endpoint
*/
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
version: '1.0.0'
});
});
/**
* POST /api/maps
* Create a new map
* Body: { seed?: string, width?: number, height?: number, ...options }
*/
app.post('/api/maps', async (req, res) => {
try {
const options = req.body;
const mapId = generateMapId();
// Store map creation request
maps.set(mapId, {
id: mapId,
status: 'pending',
options,
createdAt: new Date().toISOString()
});
// Notify via WebSocket that map is being created
io.emit('map:creating', {mapId, options});
res.status(202).json({
success: true,
mapId,
message: 'Map creation initiated. Use WebSocket or polling to get updates.',
pollUrl: `/api/maps/${mapId}`,
websocketEvent: 'map:created'
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
/**
* GET /api/maps/:id
* Get map by ID
*/
app.get('/api/maps/:id', async (req, res) => {
try {
const {id} = req.params;
const map = maps.get(id);
if (!map) {
return res.status(404).json({
success: false,
error: 'Map not found'
});
}
res.json({
success: true,
map
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
/**
* GET /api/maps
* List all maps
*/
app.get('/api/maps', async (req, res) => {
try {
const allMaps = Array.from(maps.values());
res.json({
success: true,
count: allMaps.length,
maps: allMaps
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
/**
* PUT /api/maps/:id
* Update map data
* Body: { data: mapData }
*/
app.put('/api/maps/:id', async (req, res) => {
try {
const {id} = req.params;
const {data} = req.body;
if (!maps.has(id)) {
return res.status(404).json({
success: false,
error: 'Map not found'
});
}
const map = maps.get(id);
map.data = data;
map.updatedAt = new Date().toISOString();
maps.set(id, map);
// Notify via WebSocket
io.emit('map:updated', {mapId: id, data});
res.json({
success: true,
map
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
/**
* DELETE /api/maps/:id
* Delete a map
*/
app.delete('/api/maps/:id', async (req, res) => {
try {
const {id} = req.params;
if (!maps.has(id)) {
return res.status(404).json({
success: false,
error: 'Map not found'
});
}
maps.delete(id);
// Notify via WebSocket
io.emit('map:deleted', {mapId: id});
res.json({
success: true,
message: 'Map deleted'
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
/**
* POST /api/maps/:id/load
* Load map from file
* Body: multipart/form-data with 'file' field
*/
app.post('/api/maps/:id/load', upload.single('file'), async (req, res) => {
try {
const {id} = req.params;
if (!req.file) {
return res.status(400).json({
success: false,
error: 'No file provided'
});
}
const mapData = req.file.buffer.toString('utf-8');
if (!maps.has(id)) {
maps.set(id, {
id,
createdAt: new Date().toISOString()
});
}
const map = maps.get(id);
map.data = mapData;
map.updatedAt = new Date().toISOString();
maps.set(id, map);
// Notify via WebSocket
io.emit('map:loaded', {mapId: id});
res.json({
success: true,
map
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
/**
* GET /api/maps/:id/export/:format
* Export map in specified format
* Formats: svg, png, json, data
*/
app.get('/api/maps/:id/export/:format', async (req, res) => {
try {
const {id, format} = req.params;
const map = maps.get(id);
if (!map) {
return res.status(404).json({
success: false,
error: 'Map not found'
});
}
// Request export via WebSocket and wait for response
io.emit('export:request', {mapId: id, format});
// In a real implementation, you'd wait for the export to complete
// For now, return a pending response
res.json({
success: true,
message: 'Export requested. Listen to WebSocket event for completion.',
websocketEvent: `export:completed:${id}`
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
/**
* GET /api/maps/:id/rivers
* Get rivers data
*/
app.get('/api/maps/:id/rivers', async (req, res) => {
try {
const {id} = req.params;
const map = maps.get(id);
if (!map || !map.data) {
return res.status(404).json({
success: false,
error: 'Map not found or no data available'
});
}
res.json({
success: true,
rivers: map.data.pack?.rivers || []
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
/**
* PUT /api/maps/:id/rivers
* Update rivers data
* Body: { rivers: [...] }
*/
app.put('/api/maps/:id/rivers', async (req, res) => {
try {
const {id} = req.params;
const {rivers} = req.body;
const map = maps.get(id);
if (!map) {
return res.status(404).json({
success: false,
error: 'Map not found'
});
}
if (!map.data) {
map.data = {pack: {}};
}
if (!map.data.pack) {
map.data.pack = {};
}
map.data.pack.rivers = rivers;
map.updatedAt = new Date().toISOString();
maps.set(id, map);
// Notify via WebSocket
io.emit('rivers:updated', {mapId: id, rivers});
res.json({
success: true,
rivers
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
/**
* POST /api/maps/:id/rivers/import
* Import rivers from CSV
* Body: multipart/form-data with 'file' field
*/
app.post('/api/maps/:id/rivers/import', upload.single('file'), async (req, res) => {
try {
const {id} = req.params;
if (!req.file) {
return res.status(400).json({
success: false,
error: 'No file provided'
});
}
const csvData = req.file.buffer.toString('utf-8');
// Parse CSV (basic implementation)
const rows = csvData.split('\n');
const headers = rows[0].split(',').map(h => h.trim().toLowerCase());
const rivers = [];
for (let i = 1; i < rows.length; i++) {
if (!rows[i].trim()) continue;
const values = rows[i].split(',');
const river = {};
headers.forEach((header, index) => {
river[header] = values[index]?.trim();
});
rivers.push(river);
}
// Update map
const map = maps.get(id);
if (!map) {
return res.status(404).json({
success: false,
error: 'Map not found'
});
}
if (!map.data) map.data = {pack: {}};
if (!map.data.pack) map.data.pack = {};
map.data.pack.rivers = rivers;
map.updatedAt = new Date().toISOString();
maps.set(id, map);
// Notify via WebSocket
io.emit('rivers:imported', {mapId: id, rivers});
res.json({
success: true,
count: rivers.length,
rivers
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
/**
* GET /api/maps/:id/cultures
* Get cultures data
*/
app.get('/api/maps/:id/cultures', async (req, res) => {
try {
const {id} = req.params;
const map = maps.get(id);
if (!map || !map.data) {
return res.status(404).json({
success: false,
error: 'Map not found or no data available'
});
}
res.json({
success: true,
cultures: map.data.pack?.cultures || []
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
/**
* GET /api/maps/:id/states
* Get states data
*/
app.get('/api/maps/:id/states', async (req, res) => {
try {
const {id} = req.params;
const map = maps.get(id);
if (!map || !map.data) {
return res.status(404).json({
success: false,
error: 'Map not found or no data available'
});
}
res.json({
success: true,
states: map.data.pack?.states || []
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
/**
* GET /api/maps/:id/burgs
* Get burgs (cities/towns) data
*/
app.get('/api/maps/:id/burgs', async (req, res) => {
try {
const {id} = req.params;
const map = maps.get(id);
if (!map || !map.data) {
return res.status(404).json({
success: false,
error: 'Map not found or no data available'
});
}
res.json({
success: true,
burgs: map.data.pack?.burgs || []
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
/**
* POST /api/maps/:id/burgs
* Add a new burg
* Body: { name, x, y, cell, population, type, ... }
*/
app.post('/api/maps/:id/burgs', async (req, res) => {
try {
const {id} = req.params;
const burgData = req.body;
const map = maps.get(id);
if (!map) {
return res.status(404).json({
success: false,
error: 'Map not found'
});
}
if (!map.data) map.data = {pack: {}};
if (!map.data.pack) map.data.pack = {};
if (!map.data.pack.burgs) map.data.pack.burgs = [];
const newBurg = {
i: map.data.pack.burgs.length,
...burgData
};
map.data.pack.burgs.push(newBurg);
map.updatedAt = new Date().toISOString();
maps.set(id, map);
// Notify via WebSocket
io.emit('burg:added', {mapId: id, burg: newBurg});
res.json({
success: true,
burg: newBurg
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
// ============================================================================
// WEBSOCKET HANDLERS
// ============================================================================
io.on('connection', (socket) => {
console.log('Client connected:', socket.id);
// Client can send updates directly
socket.on('map:update', (data) => {
const {mapId, updates} = data;
const map = maps.get(mapId);
if (map) {
Object.assign(map, updates);
map.updatedAt = new Date().toISOString();
maps.set(mapId, map);
// Broadcast to all clients
io.emit('map:updated', {mapId, updates});
}
});
socket.on('map:created', (data) => {
const {mapId, mapData} = data;
const map = maps.get(mapId);
if (map) {
map.status = 'ready';
map.data = mapData;
map.readyAt = new Date().toISOString();
maps.set(mapId, map);
// Broadcast to all clients
io.emit('map:created', {mapId, mapData});
}
});
socket.on('export:completed', (data) => {
const {mapId, format, exportData} = data;
// Broadcast to all clients
io.emit(`export:completed:${mapId}`, {format, exportData});
});
socket.on('disconnect', () => {
console.log('Client disconnected:', socket.id);
});
});
// ============================================================================
// STATIC PAGES
// ============================================================================
// Serve demo page
app.get('/', (req, res) => {
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Fantasy Map Generator - API Server</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
}
h1 { color: #2c3e50; }
h2 { color: #34495e; margin-top: 30px; }
.endpoint {
background: #f8f9fa;
border-left: 4px solid #007bff;
padding: 15px;
margin: 10px 0;
border-radius: 4px;
}
.method {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-weight: bold;
color: white;
font-size: 12px;
margin-right: 10px;
}
.get { background: #28a745; }
.post { background: #007bff; }
.put { background: #ffc107; color: #000; }
.delete { background: #dc3545; }
code {
background: #e9ecef;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
pre {
background: #2d2d2d;
color: #f8f8f2;
padding: 15px;
border-radius: 4px;
overflow-x: auto;
}
.status { color: #28a745; font-weight: bold; }
</style>
</head>
<body>
<h1>Fantasy Map Generator - API Server</h1>
<p class="status"> Server is running</p>
<h2>REST API Endpoints</h2>
<div class="endpoint">
<span class="method get">GET</span>
<code>/api/health</code>
<p>Health check endpoint</p>
</div>
<div class="endpoint">
<span class="method post">POST</span>
<code>/api/maps</code>
<p>Create a new map</p>
<pre>{
"seed": "optional-seed",
"width": 1920,
"height": 1080
}</pre>
</div>
<div class="endpoint">
<span class="method get">GET</span>
<code>/api/maps</code>
<p>List all maps</p>
</div>
<div class="endpoint">
<span class="method get">GET</span>
<code>/api/maps/:id</code>
<p>Get specific map by ID</p>
</div>
<div class="endpoint">
<span class="method put">PUT</span>
<code>/api/maps/:id</code>
<p>Update map data</p>
</div>
<div class="endpoint">
<span class="method delete">DELETE</span>
<code>/api/maps/:id</code>
<p>Delete a map</p>
</div>
<div class="endpoint">
<span class="method get">GET</span>
<code>/api/maps/:id/rivers</code>
<p>Get rivers data</p>
</div>
<div class="endpoint">
<span class="method put">PUT</span>
<code>/api/maps/:id/rivers</code>
<p>Update rivers data</p>
</div>
<div class="endpoint">
<span class="method post">POST</span>
<code>/api/maps/:id/rivers/import</code>
<p>Import rivers from CSV file</p>
</div>
<div class="endpoint">
<span class="method get">GET</span>
<code>/api/maps/:id/cultures</code>
<p>Get cultures data</p>
</div>
<div class="endpoint">
<span class="method get">GET</span>
<code>/api/maps/:id/states</code>
<p>Get states data</p>
</div>
<div class="endpoint">
<span class="method get">GET</span>
<code>/api/maps/:id/burgs</code>
<p>Get burgs (cities/towns) data</p>
</div>
<div class="endpoint">
<span class="method post">POST</span>
<code>/api/maps/:id/burgs</code>
<p>Add a new burg</p>
</div>
<h2>WebSocket Events</h2>
<p>Connect to WebSocket at: <code>ws://localhost:3000</code></p>
<div class="endpoint">
<strong>Server Events:</strong>
<ul>
<li><code>map:creating</code> - Map creation started</li>
<li><code>map:created</code> - Map creation completed</li>
<li><code>map:updated</code> - Map data updated</li>
<li><code>map:deleted</code> - Map deleted</li>
<li><code>rivers:updated</code> - Rivers data updated</li>
<li><code>rivers:imported</code> - Rivers imported from CSV</li>
<li><code>burg:added</code> - New burg added</li>
</ul>
</div>
<h2>Integration Examples</h2>
<p>Visit these demo pages:</p>
<ul>
<li><a href="/demos/postmessage-demo.html">PostMessage Demo (iframe integration)</a></li>
<li><a href="/demos/rest-api-demo.html">REST API Demo</a></li>
<li><a href="/demos/websocket-demo.html">WebSocket Demo</a></li>
</ul>
<h2>Quick Start</h2>
<pre>// Create a new map
curl -X POST http://localhost:3000/api/maps \\
-H "Content-Type: application/json" \\
-d '{"seed": "my-custom-seed"}'
// Get map data
curl http://localhost:3000/api/maps/MAP_ID
// Update rivers
curl -X PUT http://localhost:3000/api/maps/MAP_ID/rivers \\
-H "Content-Type: application/json" \\
-d '{"rivers": [...]}'</pre>
</body>
</html>
`);
});
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
function generateMapId() {
return 'map_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
// ============================================================================
// START SERVER
// ============================================================================
const PORT = process.env.PORT || 3000;
httpServer.listen(PORT, () => {
console.log('='.repeat(60));
console.log('Fantasy Map Generator - API Server');
console.log('='.repeat(60));
console.log(`Server running on: http://localhost:${PORT}`);
console.log(`WebSocket endpoint: ws://localhost:${PORT}`);
console.log('');
console.log('REST API: http://localhost:' + PORT + '/api');
console.log('Documentation: http://localhost:' + PORT);
console.log('='.repeat(60));
});

599
demos/postmessage-demo.html Normal file
View file

@ -0,0 +1,599 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FMG PostMessage Integration Demo</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
height: 100vh;
display: flex;
flex-direction: column;
background: #f5f5f5;
}
.header {
background: #2c3e50;
color: white;
padding: 15px 20px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
.header h1 {
font-size: 20px;
font-weight: 600;
}
.container {
flex: 1;
display: flex;
overflow: hidden;
}
.sidebar {
width: 350px;
background: white;
border-right: 1px solid #ddd;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.panel {
padding: 20px;
border-bottom: 1px solid #eee;
}
.panel h2 {
font-size: 16px;
margin-bottom: 15px;
color: #2c3e50;
}
.control-group {
margin-bottom: 15px;
}
.control-group label {
display: block;
font-size: 13px;
font-weight: 500;
margin-bottom: 5px;
color: #555;
}
input[type="text"],
input[type="number"],
select,
textarea {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 13px;
font-family: inherit;
}
textarea {
resize: vertical;
min-height: 80px;
font-family: monospace;
font-size: 12px;
}
button {
background: #3498db;
color: white;
border: none;
padding: 10px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
width: 100%;
margin-top: 8px;
transition: background 0.2s;
}
button:hover {
background: #2980b9;
}
button:active {
transform: translateY(1px);
}
button.secondary {
background: #95a5a6;
}
button.secondary:hover {
background: #7f8c8d;
}
button.danger {
background: #e74c3c;
}
button.danger:hover {
background: #c0392b;
}
.map-container {
flex: 1;
position: relative;
background: #ecf0f1;
}
#mapFrame {
width: 100%;
height: 100%;
border: none;
}
.log {
background: #2d2d2d;
color: #f8f8f2;
padding: 10px;
font-family: monospace;
font-size: 11px;
max-height: 200px;
overflow-y: auto;
}
.log-entry {
padding: 4px 0;
border-bottom: 1px solid #3d3d3d;
}
.log-entry.event {
color: #50fa7b;
}
.log-entry.response {
color: #8be9fd;
}
.log-entry.error {
color: #ff5555;
}
.stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin-top: 15px;
}
.stat-card {
background: #f8f9fa;
padding: 12px;
border-radius: 4px;
border-left: 3px solid #3498db;
}
.stat-label {
font-size: 11px;
color: #7f8c8d;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-value {
font-size: 20px;
font-weight: 600;
color: #2c3e50;
margin-top: 4px;
}
.status {
display: inline-block;
padding: 4px 8px;
border-radius: 3px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.status.connected {
background: #d4edda;
color: #155724;
}
.status.disconnected {
background: #f8d7da;
color: #721c24;
}
</style>
</head>
<body>
<div class="header">
<h1>Fantasy Map Generator - PostMessage Integration Demo</h1>
</div>
<div class="container">
<!-- Sidebar Controls -->
<div class="sidebar">
<!-- Connection Status -->
<div class="panel">
<h2>Connection Status</h2>
<span id="status" class="status disconnected">Disconnected</span>
</div>
<!-- Map Controls -->
<div class="panel">
<h2>Map Controls</h2>
<div class="control-group">
<label>Seed (optional)</label>
<input type="text" id="seedInput" placeholder="e.g., 1234567890">
</div>
<button onclick="createMap()">Create New Map</button>
<button onclick="saveMap()" class="secondary">Save Map</button>
<button onclick="getMapState()" class="secondary">Get Map State</button>
</div>
<!-- Data Management -->
<div class="panel">
<h2>Data Management</h2>
<button onclick="getRivers()">Get Rivers</button>
<button onclick="getCultures()">Get Cultures</button>
<button onclick="getStates()">Get States</button>
<button onclick="getBurgs()">Get Burgs</button>
</div>
<!-- Custom Data Update -->
<div class="panel">
<h2>Update Rivers</h2>
<div class="control-group">
<label>Rivers JSON</label>
<textarea id="riversInput" placeholder='[{"i":1,"name":"River Name",...}]'></textarea>
</div>
<button onclick="updateRivers()">Update Rivers</button>
</div>
<!-- Export -->
<div class="panel">
<h2>Export</h2>
<button onclick="exportSVG()">Export SVG</button>
<button onclick="exportPNG()">Export PNG</button>
<button onclick="exportJSON()">Export JSON</button>
</div>
<!-- Statistics -->
<div class="panel">
<h2>Statistics</h2>
<div class="stats">
<div class="stat-card">
<div class="stat-label">Rivers</div>
<div class="stat-value" id="riverCount">0</div>
</div>
<div class="stat-card">
<div class="stat-label">Cultures</div>
<div class="stat-value" id="cultureCount">0</div>
</div>
<div class="stat-card">
<div class="stat-label">States</div>
<div class="stat-value" id="stateCount">0</div>
</div>
<div class="stat-card">
<div class="stat-label">Burgs</div>
<div class="stat-value" id="burgCount">0</div>
</div>
</div>
</div>
<!-- Event Log -->
<div class="panel">
<h2>Event Log</h2>
<div class="log" id="eventLog"></div>
<button onclick="clearLog()" class="secondary" style="margin-top: 10px;">Clear Log</button>
</div>
</div>
<!-- Map Frame -->
<div class="map-container">
<iframe id="mapFrame" src="../index.html"></iframe>
</div>
</div>
<script>
const mapFrame = document.getElementById('mapFrame');
const statusEl = document.getElementById('status');
const eventLog = document.getElementById('eventLog');
let requestId = 0;
const pendingRequests = new Map();
// Initialize when iframe loads
mapFrame.addEventListener('load', () => {
log('Iframe loaded', 'event');
statusEl.textContent = 'Connected';
statusEl.className = 'status connected';
// Request initial state
setTimeout(() => {
sendMessage('GET_STATE');
}, 2000);
});
// Listen for messages from iframe
window.addEventListener('message', (event) => {
const {type, payload, requestId: respId, timestamp} = event.data;
if (!type) return;
log(`← ${type}`, type === 'ERROR' ? 'error' : type === 'RESPONSE' ? 'response' : 'event');
// Handle responses
if (type === 'RESPONSE' && respId && pendingRequests.has(respId)) {
const resolver = pendingRequests.get(respId);
resolver(payload);
pendingRequests.delete(respId);
return;
}
// Handle events
if (type === 'EVENT') {
const {event, data} = payload;
log(`Event: ${event}`, 'event');
// Update statistics based on events
if (event === 'map:changed' || event === 'map:loaded' || event === 'map:created') {
updateStatistics(data);
}
}
// Handle errors
if (type === 'ERROR') {
console.error('Error from iframe:', payload);
}
});
// Send message to iframe
function sendMessage(type, payload = null) {
const reqId = ++requestId;
log(`→ ${type}`, 'response');
mapFrame.contentWindow.postMessage({
type,
payload,
requestId: reqId
}, '*');
// Return promise that resolves when response is received
return new Promise((resolve) => {
pendingRequests.set(reqId, resolve);
// Timeout after 30 seconds
setTimeout(() => {
if (pendingRequests.has(reqId)) {
pendingRequests.delete(reqId);
resolve({success: false, error: 'Request timeout'});
}
}, 30000);
});
}
// Map controls
async function createMap() {
const seed = document.getElementById('seedInput').value.trim();
const options = seed ? {seed} : {};
log('Creating new map...', 'event');
const result = await sendMessage('CREATE_MAP', options);
if (result.success) {
log('Map created successfully', 'event');
updateStatistics(result.state);
} else {
log(`Error: ${result.error}`, 'error');
}
}
async function saveMap() {
log('Saving map...', 'event');
const result = await sendMessage('SAVE_MAP', {format: 'data'});
if (result.success) {
log('Map saved', 'event');
// Download the map file
const blob = new Blob([result.data], {type: 'text/plain'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = result.filename || 'map.map';
a.click();
URL.revokeObjectURL(url);
} else {
log(`Error: ${result.error}`, 'error');
}
}
async function getMapState() {
log('Getting map state...', 'event');
const result = await sendMessage('GET_STATE');
if (result.success) {
log('Map state retrieved', 'event');
console.log('Map state:', result.data);
updateStatistics(result.data);
} else {
log(`Error: ${result.error}`, 'error');
}
}
// Data retrieval
async function getRivers() {
log('Getting rivers...', 'event');
const result = await sendMessage('GET_RIVERS');
if (result.success) {
log(`Retrieved ${result.data.length} rivers`, 'event');
console.log('Rivers:', result.data);
document.getElementById('riverCount').textContent = result.data.length;
} else {
log(`Error: ${result.error}`, 'error');
}
}
async function getCultures() {
log('Getting cultures...', 'event');
const result = await sendMessage('GET_CULTURES');
if (result.success) {
log(`Retrieved ${result.data.length} cultures`, 'event');
console.log('Cultures:', result.data);
document.getElementById('cultureCount').textContent = result.data.length;
} else {
log(`Error: ${result.error}`, 'error');
}
}
async function getStates() {
log('Getting states...', 'event');
const result = await sendMessage('GET_STATES');
if (result.success) {
log(`Retrieved ${result.data.length} states`, 'event');
console.log('States:', result.data);
document.getElementById('stateCount').textContent = result.data.length;
} else {
log(`Error: ${result.error}`, 'error');
}
}
async function getBurgs() {
log('Getting burgs...', 'event');
const result = await sendMessage('GET_BURGS');
if (result.success) {
log(`Retrieved ${result.data.length} burgs`, 'event');
console.log('Burgs:', result.data);
document.getElementById('burgCount').textContent = result.data.length;
} else {
log(`Error: ${result.error}`, 'error');
}
}
// Data updates
async function updateRivers() {
const riversJSON = document.getElementById('riversInput').value.trim();
if (!riversJSON) {
log('Error: No rivers data provided', 'error');
return;
}
try {
const rivers = JSON.parse(riversJSON);
log('Updating rivers...', 'event');
const result = await sendMessage('UPDATE_RIVERS', rivers);
if (result.success) {
log('Rivers updated successfully', 'event');
} else {
log(`Error: ${result.error}`, 'error');
}
} catch (error) {
log(`Error: Invalid JSON - ${error.message}`, 'error');
}
}
// Export functions
async function exportSVG() {
log('Exporting SVG...', 'event');
const result = await sendMessage('EXPORT_SVG');
if (result.success) {
log('SVG exported', 'event');
// Download SVG
const blob = new Blob([result.data], {type: 'image/svg+xml'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'map.svg';
a.click();
URL.revokeObjectURL(url);
} else {
log(`Error: ${result.error}`, 'error');
}
}
async function exportPNG() {
log('Exporting PNG (this may take a while)...', 'event');
const result = await sendMessage('EXPORT_PNG', {width: 2048, height: 2048});
if (result.success) {
log('PNG exported', 'event');
// result.data is a data URL
const a = document.createElement('a');
a.href = result.data;
a.download = 'map.png';
a.click();
} else {
log(`Error: ${result.error}`, 'error');
}
}
async function exportJSON() {
log('Exporting JSON...', 'event');
const result = await sendMessage('EXPORT_JSON');
if (result.success) {
log('JSON exported', 'event');
// Download JSON
const blob = new Blob([result.data], {type: 'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'map.json';
a.click();
URL.revokeObjectURL(url);
} else {
log(`Error: ${result.error}`, 'error');
}
}
// Update statistics display
function updateStatistics(state) {
if (!state || !state.pack) return;
document.getElementById('riverCount').textContent = state.pack.rivers?.length || 0;
document.getElementById('cultureCount').textContent = state.pack.cultures?.length || 0;
document.getElementById('stateCount').textContent = state.pack.states?.length || 0;
document.getElementById('burgCount').textContent = state.pack.burgs?.length || 0;
}
// Logging
function log(message, type = 'event') {
const entry = document.createElement('div');
entry.className = `log-entry ${type}`;
entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
eventLog.appendChild(entry);
eventLog.scrollTop = eventLog.scrollHeight;
}
function clearLog() {
eventLog.innerHTML = '';
}
</script>
</body>
</html>

619
demos/rest-api-demo.html Normal file
View file

@ -0,0 +1,619 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FMG REST API Demo</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
padding: 20px;
background: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
color: #2c3e50;
margin-bottom: 10px;
}
.subtitle {
color: #7f8c8d;
margin-bottom: 30px;
}
.panel {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.panel h2 {
font-size: 18px;
margin-bottom: 15px;
color: #2c3e50;
}
.control-group {
margin-bottom: 15px;
}
.control-group label {
display: block;
font-size: 13px;
font-weight: 500;
margin-bottom: 5px;
color: #555;
}
input[type="text"],
input[type="number"],
select,
textarea {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
font-family: inherit;
}
textarea {
resize: vertical;
min-height: 120px;
font-family: monospace;
font-size: 12px;
}
button {
background: #3498db;
color: white;
border: none;
padding: 12px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
margin-right: 10px;
margin-top: 10px;
transition: background 0.2s;
}
button:hover {
background: #2980b9;
}
button:active {
transform: translateY(1px);
}
button.secondary {
background: #95a5a6;
}
button.secondary:hover {
background: #7f8c8d;
}
button.danger {
background: #e74c3c;
}
button.danger:hover {
background: #c0392b;
}
.response-area {
background: #2d2d2d;
color: #f8f8f2;
padding: 15px;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
max-height: 400px;
overflow-y: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.status {
display: inline-block;
padding: 6px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
margin-bottom: 15px;
}
.status.online {
background: #d4edda;
color: #155724;
}
.status.offline {
background: #f8d7da;
color: #721c24;
}
.map-list {
list-style: none;
}
.map-item {
padding: 12px;
border: 1px solid #ddd;
border-radius: 4px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.map-item:hover {
background: #f8f9fa;
}
.map-info {
flex: 1;
}
.map-id {
font-weight: 600;
color: #2c3e50;
}
.map-date {
font-size: 12px;
color: #7f8c8d;
}
.map-actions button {
padding: 6px 12px;
font-size: 12px;
margin: 0 5px;
}
.loading {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #3498db;
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
code {
background: #e9ecef;
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
font-size: 12px;
}
</style>
</head>
<body>
<div class="container">
<h1>Fantasy Map Generator - REST API Demo</h1>
<p class="subtitle">Interactive demo for testing the REST API endpoints</p>
<!-- Server Status -->
<div class="panel">
<h2>Server Status</h2>
<div class="control-group">
<label>API Base URL</label>
<input type="text" id="apiBaseUrl" value="http://localhost:3000/api">
</div>
<button onclick="checkHealth()">Check Health</button>
<div id="serverStatus"></div>
</div>
<!-- Create Map -->
<div class="panel">
<h2>Create New Map</h2>
<div class="control-group">
<label>Seed (optional)</label>
<input type="text" id="createSeed" placeholder="e.g., myseed123">
</div>
<button onclick="createMap()">Create Map</button>
<div id="createResponse" class="response-area" style="margin-top: 15px; display: none;"></div>
</div>
<!-- Map List -->
<div class="panel">
<h2>Maps</h2>
<button onclick="listMaps()">Refresh List</button>
<ul id="mapList" class="map-list" style="margin-top: 15px;"></ul>
</div>
<div class="grid">
<!-- Get Map Data -->
<div class="panel">
<h2>Get Map Data</h2>
<div class="control-group">
<label>Map ID</label>
<input type="text" id="getMapId" placeholder="map_...">
</div>
<button onclick="getMap()">Get Map</button>
<div id="getMapResponse" class="response-area" style="margin-top: 15px; display: none;"></div>
</div>
<!-- Get Rivers -->
<div class="panel">
<h2>Get Rivers</h2>
<div class="control-group">
<label>Map ID</label>
<input type="text" id="getRiversMapId" placeholder="map_...">
</div>
<button onclick="getRivers()">Get Rivers</button>
<div id="getRiversResponse" class="response-area" style="margin-top: 15px; display: none;"></div>
</div>
<!-- Get Cultures -->
<div class="panel">
<h2>Get Cultures</h2>
<div class="control-group">
<label>Map ID</label>
<input type="text" id="getCulturesMapId" placeholder="map_...">
</div>
<button onclick="getCultures()">Get Cultures</button>
<div id="getCulturesResponse" class="response-area" style="margin-top: 15px; display: none;"></div>
</div>
<!-- Get States -->
<div class="panel">
<h2>Get States</h2>
<div class="control-group">
<label>Map ID</label>
<input type="text" id="getStatesMapId" placeholder="map_...">
</div>
<button onclick="getStates()">Get States</button>
<div id="getStatesResponse" class="response-area" style="margin-top: 15px; display: none;"></div>
</div>
<!-- Get Burgs -->
<div class="panel">
<h2>Get Burgs</h2>
<div class="control-group">
<label>Map ID</label>
<input type="text" id="getBurgsMapId" placeholder="map_...">
</div>
<button onclick="getBurgs()">Get Burgs</button>
<div id="getBurgsResponse" class="response-area" style="margin-top: 15px; display: none;"></div>
</div>
</div>
<!-- Update Rivers -->
<div class="panel">
<h2>Update Rivers</h2>
<div class="control-group">
<label>Map ID</label>
<input type="text" id="updateRiversMapId" placeholder="map_...">
</div>
<div class="control-group">
<label>Rivers JSON</label>
<textarea id="riversData" placeholder='[{"i":1,"name":"New River","type":"River",...}]'></textarea>
</div>
<button onclick="updateRivers()">Update Rivers</button>
<div id="updateRiversResponse" class="response-area" style="margin-top: 15px; display: none;"></div>
</div>
<!-- Import Rivers CSV -->
<div class="panel">
<h2>Import Rivers from CSV</h2>
<div class="control-group">
<label>Map ID</label>
<input type="text" id="importRiversMapId" placeholder="map_...">
</div>
<div class="control-group">
<label>CSV File</label>
<input type="file" id="csvFile" accept=".csv">
</div>
<button onclick="importRiversCSV()">Import Rivers</button>
<div id="importRiversResponse" class="response-area" style="margin-top: 15px; display: none;"></div>
</div>
<!-- Add Burg -->
<div class="panel">
<h2>Add New Burg</h2>
<div class="control-group">
<label>Map ID</label>
<input type="text" id="addBurgMapId" placeholder="map_...">
</div>
<div class="control-group">
<label>Burg Data (JSON)</label>
<textarea id="burgData" placeholder='{"name":"New City","x":100,"y":100,"cell":500,"population":10,"type":"city"}'></textarea>
</div>
<button onclick="addBurg()">Add Burg</button>
<div id="addBurgResponse" class="response-area" style="margin-top: 15px; display: none;"></div>
</div>
</div>
<script>
const getBaseUrl = () => document.getElementById('apiBaseUrl').value.trim();
// Helper to make API calls
async function apiCall(endpoint, method = 'GET', body = null) {
const url = `${getBaseUrl()}${endpoint}`;
const options = {
method,
headers: {}
};
if (body) {
if (body instanceof FormData) {
options.body = body;
} else {
options.headers['Content-Type'] = 'application/json';
options.body = JSON.stringify(body);
}
}
try {
const response = await fetch(url, options);
const data = await response.json();
return {response, data};
} catch (error) {
return {error: error.message};
}
}
// Display response
function displayResponse(elementId, data) {
const el = document.getElementById(elementId);
el.style.display = 'block';
el.textContent = JSON.stringify(data, null, 2);
}
// Server health check
async function checkHealth() {
const statusEl = document.getElementById('serverStatus');
statusEl.innerHTML = '<div class="loading"></div> Checking...';
const {data, error} = await apiCall('/health');
if (error) {
statusEl.innerHTML = `<div class="status offline">Offline</div><p>Error: ${error}</p>`;
} else {
statusEl.innerHTML = `<div class="status online">Online</div><pre>${JSON.stringify(data, null, 2)}</pre>`;
}
}
// Create map
async function createMap() {
const seed = document.getElementById('createSeed').value.trim();
const body = seed ? {seed} : {};
const {data, error} = await apiCall('/maps', 'POST', body);
if (error) {
displayResponse('createResponse', {error});
} else {
displayResponse('createResponse', data);
if (data.mapId) {
// Auto-fill map IDs in other forms
document.getElementById('getMapId').value = data.mapId;
document.getElementById('getRiversMapId').value = data.mapId;
document.getElementById('getCulturesMapId').value = data.mapId;
document.getElementById('getStatesMapId').value = data.mapId;
document.getElementById('getBurgsMapId').value = data.mapId;
}
}
}
// List maps
async function listMaps() {
const {data, error} = await apiCall('/maps');
const listEl = document.getElementById('mapList');
if (error) {
listEl.innerHTML = `<li>Error: ${error}</li>`;
return;
}
if (!data.maps || data.maps.length === 0) {
listEl.innerHTML = '<li>No maps found</li>';
return;
}
listEl.innerHTML = data.maps.map(map => `
<li class="map-item">
<div class="map-info">
<div class="map-id">${map.id}</div>
<div class="map-date">Created: ${map.createdAt}</div>
</div>
<div class="map-actions">
<button onclick="selectMap('${map.id}')" class="secondary">Select</button>
<button onclick="deleteMap('${map.id}')" class="danger">Delete</button>
</div>
</li>
`).join('');
}
// Select map (fill in all forms)
function selectMap(mapId) {
document.getElementById('getMapId').value = mapId;
document.getElementById('getRiversMapId').value = mapId;
document.getElementById('getCulturesMapId').value = mapId;
document.getElementById('getStatesMapId').value = mapId;
document.getElementById('getBurgsMapId').value = mapId;
document.getElementById('updateRiversMapId').value = mapId;
document.getElementById('importRiversMapId').value = mapId;
document.getElementById('addBurgMapId').value = mapId;
}
// Delete map
async function deleteMap(mapId) {
if (!confirm(`Delete map ${mapId}?`)) return;
const {data, error} = await apiCall(`/maps/${mapId}`, 'DELETE');
if (error) {
alert(`Error: ${error}`);
} else {
alert('Map deleted');
listMaps();
}
}
// Get map
async function getMap() {
const mapId = document.getElementById('getMapId').value.trim();
if (!mapId) {
alert('Please enter a map ID');
return;
}
const {data, error} = await apiCall(`/maps/${mapId}`);
displayResponse('getMapResponse', error ? {error} : data);
}
// Get rivers
async function getRivers() {
const mapId = document.getElementById('getRiversMapId').value.trim();
if (!mapId) {
alert('Please enter a map ID');
return;
}
const {data, error} = await apiCall(`/maps/${mapId}/rivers`);
displayResponse('getRiversResponse', error ? {error} : data);
}
// Get cultures
async function getCultures() {
const mapId = document.getElementById('getCulturesMapId').value.trim();
if (!mapId) {
alert('Please enter a map ID');
return;
}
const {data, error} = await apiCall(`/maps/${mapId}/cultures`);
displayResponse('getCulturesResponse', error ? {error} : data);
}
// Get states
async function getStates() {
const mapId = document.getElementById('getStatesMapId').value.trim();
if (!mapId) {
alert('Please enter a map ID');
return;
}
const {data, error} = await apiCall(`/maps/${mapId}/states`);
displayResponse('getStatesResponse', error ? {error} : data);
}
// Get burgs
async function getBurgs() {
const mapId = document.getElementById('getBurgsMapId').value.trim();
if (!mapId) {
alert('Please enter a map ID');
return;
}
const {data, error} = await apiCall(`/maps/${mapId}/burgs`);
displayResponse('getBurgsResponse', error ? {error} : data);
}
// Update rivers
async function updateRivers() {
const mapId = document.getElementById('updateRiversMapId').value.trim();
const riversJSON = document.getElementById('riversData').value.trim();
if (!mapId) {
alert('Please enter a map ID');
return;
}
if (!riversJSON) {
alert('Please enter rivers data');
return;
}
try {
const rivers = JSON.parse(riversJSON);
const {data, error} = await apiCall(`/maps/${mapId}/rivers`, 'PUT', {rivers});
displayResponse('updateRiversResponse', error ? {error} : data);
} catch (e) {
displayResponse('updateRiversResponse', {error: `Invalid JSON: ${e.message}`});
}
}
// Import rivers from CSV
async function importRiversCSV() {
const mapId = document.getElementById('importRiversMapId').value.trim();
const fileInput = document.getElementById('csvFile');
if (!mapId) {
alert('Please enter a map ID');
return;
}
if (!fileInput.files[0]) {
alert('Please select a CSV file');
return;
}
const formData = new FormData();
formData.append('file', fileInput.files[0]);
const {data, error} = await apiCall(`/maps/${mapId}/rivers/import`, 'POST', formData);
displayResponse('importRiversResponse', error ? {error} : data);
}
// Add burg
async function addBurg() {
const mapId = document.getElementById('addBurgMapId').value.trim();
const burgJSON = document.getElementById('burgData').value.trim();
if (!mapId) {
alert('Please enter a map ID');
return;
}
if (!burgJSON) {
alert('Please enter burg data');
return;
}
try {
const burgData = JSON.parse(burgJSON);
const {data, error} = await apiCall(`/maps/${mapId}/burgs`, 'POST', burgData);
displayResponse('addBurgResponse', error ? {error} : data);
} catch (e) {
displayResponse('addBurgResponse', {error: `Invalid JSON: ${e.message}`});
}
}
// Auto-check health on load
window.addEventListener('load', () => {
checkHealth();
listMaps();
});
</script>
</body>
</html>

617
demos/websocket-demo.html Normal file
View file

@ -0,0 +1,617 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FMG WebSocket Demo</title>
<script src="https://cdn.socket.io/4.6.1/socket.io.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
height: 100vh;
display: flex;
flex-direction: column;
background: #f5f5f5;
}
.header {
background: #2c3e50;
color: white;
padding: 15px 20px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
.header h1 {
font-size: 20px;
font-weight: 600;
}
.container {
flex: 1;
display: flex;
overflow: hidden;
}
.sidebar {
width: 350px;
background: white;
border-right: 1px solid #ddd;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.panel {
padding: 20px;
border-bottom: 1px solid #eee;
}
.panel h2 {
font-size: 16px;
margin-bottom: 15px;
color: #2c3e50;
}
.control-group {
margin-bottom: 15px;
}
.control-group label {
display: block;
font-size: 13px;
font-weight: 500;
margin-bottom: 5px;
color: #555;
}
input[type="text"],
input[type="number"],
select,
textarea {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 13px;
font-family: inherit;
}
button {
background: #3498db;
color: white;
border: none;
padding: 10px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
width: 100%;
margin-top: 8px;
transition: background 0.2s;
}
button:hover {
background: #2980b9;
}
button:disabled {
background: #bdc3c7;
cursor: not-allowed;
}
button.secondary {
background: #95a5a6;
}
button.secondary:hover {
background: #7f8c8d;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
padding: 20px;
}
.event-log {
flex: 1;
background: #2d2d2d;
color: #f8f8f2;
padding: 15px;
border-radius: 8px;
font-family: monospace;
font-size: 12px;
overflow-y: auto;
}
.log-entry {
padding: 8px;
border-left: 3px solid transparent;
margin-bottom: 5px;
background: rgba(255,255,255,0.05);
border-radius: 4px;
}
.log-entry.event {
border-left-color: #50fa7b;
}
.log-entry.send {
border-left-color: #8be9fd;
}
.log-entry.error {
border-left-color: #ff5555;
}
.log-entry.info {
border-left-color: #f1fa8c;
}
.log-time {
color: #6272a4;
margin-right: 10px;
}
.log-type {
font-weight: bold;
margin-right: 10px;
}
.event.log-type {
color: #50fa7b;
}
.send.log-type {
color: #8be9fd;
}
.error.log-type {
color: #ff5555;
}
.info.log-type {
color: #f1fa8c;
}
.status {
display: inline-block;
padding: 6px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.status.connected {
background: #d4edda;
color: #155724;
}
.status.disconnected {
background: #f8d7da;
color: #721c24;
}
.status.connecting {
background: #fff3cd;
color: #856404;
}
.stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin-top: 15px;
}
.stat-card {
background: #f8f9fa;
padding: 12px;
border-radius: 4px;
border-left: 3px solid #3498db;
}
.stat-label {
font-size: 11px;
color: #7f8c8d;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-value {
font-size: 20px;
font-weight: 600;
color: #2c3e50;
margin-top: 4px;
}
.event-list {
list-style: none;
margin-top: 10px;
}
.event-item {
padding: 8px;
background: #f8f9fa;
border-radius: 4px;
margin-bottom: 5px;
font-size: 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.event-name {
font-family: monospace;
color: #2c3e50;
font-weight: 600;
}
.event-count {
background: #3498db;
color: white;
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
}
</style>
</head>
<body>
<div class="header">
<h1>Fantasy Map Generator - WebSocket Real-Time Demo</h1>
</div>
<div class="container">
<!-- Sidebar Controls -->
<div class="sidebar">
<!-- Connection -->
<div class="panel">
<h2>WebSocket Connection</h2>
<div class="control-group">
<label>Server URL</label>
<input type="text" id="serverUrl" value="http://localhost:3000">
</div>
<button onclick="connect()" id="connectBtn">Connect</button>
<button onclick="disconnect()" id="disconnectBtn" class="secondary" disabled>Disconnect</button>
<div style="margin-top: 15px;">
<span id="connectionStatus" class="status disconnected">Disconnected</span>
</div>
</div>
<!-- Send Custom Event -->
<div class="panel">
<h2>Send Custom Event</h2>
<div class="control-group">
<label>Event Name</label>
<input type="text" id="eventName" placeholder="e.g., map:update">
</div>
<div class="control-group">
<label>Event Data (JSON)</label>
<textarea id="eventData" placeholder='{"mapId":"map_123","updates":{...}}'></textarea>
</div>
<button onclick="sendEvent()" id="sendBtn" disabled>Send Event</button>
</div>
<!-- Statistics -->
<div class="panel">
<h2>Statistics</h2>
<div class="stats">
<div class="stat-card">
<div class="stat-label">Events Sent</div>
<div class="stat-value" id="sentCount">0</div>
</div>
<div class="stat-card">
<div class="stat-label">Events Received</div>
<div class="stat-value" id="receivedCount">0</div>
</div>
</div>
</div>
<!-- Event Types -->
<div class="panel">
<h2>Event Types Received</h2>
<ul id="eventTypes" class="event-list"></ul>
</div>
<!-- Quick Actions -->
<div class="panel">
<h2>Quick Actions</h2>
<div class="control-group">
<label>Map ID</label>
<input type="text" id="quickMapId" placeholder="map_...">
</div>
<button onclick="simulateMapCreated()" id="actionBtn1" disabled>Simulate: Map Created</button>
<button onclick="simulateRiversUpdated()" id="actionBtn2" disabled>Simulate: Rivers Updated</button>
<button onclick="simulateBurgAdded()" id="actionBtn3" disabled>Simulate: Burg Added</button>
</div>
</div>
<!-- Main Content -->
<div class="main-content">
<div style="margin-bottom: 15px; display: flex; justify-content: space-between; align-items: center;">
<h2 style="color: #2c3e50;">Real-Time Event Log</h2>
<button onclick="clearEventLog()" class="secondary">Clear Log</button>
</div>
<div id="eventLog" class="event-log"></div>
</div>
</div>
<script>
let socket = null;
let sentCount = 0;
let receivedCount = 0;
let eventTypeCounts = {};
// Connect to WebSocket server
function connect() {
const serverUrl = document.getElementById('serverUrl').value.trim();
if (socket && socket.connected) {
logEvent('Already connected', 'info');
return;
}
logEvent(`Connecting to ${serverUrl}...`, 'info');
document.getElementById('connectionStatus').textContent = 'Connecting...';
document.getElementById('connectionStatus').className = 'status connecting';
socket = io(serverUrl);
// Connection events
socket.on('connect', () => {
logEvent(`Connected! Socket ID: ${socket.id}`, 'event');
document.getElementById('connectionStatus').textContent = 'Connected';
document.getElementById('connectionStatus').className = 'status connected';
document.getElementById('connectBtn').disabled = true;
document.getElementById('disconnectBtn').disabled = false;
document.getElementById('sendBtn').disabled = false;
document.getElementById('actionBtn1').disabled = false;
document.getElementById('actionBtn2').disabled = false;
document.getElementById('actionBtn3').disabled = false;
});
socket.on('disconnect', () => {
logEvent('Disconnected from server', 'error');
document.getElementById('connectionStatus').textContent = 'Disconnected';
document.getElementById('connectionStatus').className = 'status disconnected';
document.getElementById('connectBtn').disabled = false;
document.getElementById('disconnectBtn').disabled = true;
document.getElementById('sendBtn').disabled = true;
document.getElementById('actionBtn1').disabled = true;
document.getElementById('actionBtn2').disabled = true;
document.getElementById('actionBtn3').disabled = true;
});
socket.on('connect_error', (error) => {
logEvent(`Connection error: ${error.message}`, 'error');
});
// Listen for all map-related events
const eventNames = [
'map:creating',
'map:created',
'map:updated',
'map:deleted',
'map:loaded',
'rivers:updated',
'rivers:imported',
'cultures:updated',
'states:updated',
'burgs:updated',
'burg:added',
'export:request',
'export:completed'
];
eventNames.forEach(eventName => {
socket.on(eventName, (data) => {
receivedCount++;
document.getElementById('receivedCount').textContent = receivedCount;
if (!eventTypeCounts[eventName]) {
eventTypeCounts[eventName] = 0;
}
eventTypeCounts[eventName]++;
updateEventTypes();
logEvent(`${eventName}: ${JSON.stringify(data, null, 2)}`, 'event');
});
});
// Catch-all for any other events
socket.onAny((eventName, ...args) => {
if (!eventNames.includes(eventName)) {
receivedCount++;
document.getElementById('receivedCount').textContent = receivedCount;
if (!eventTypeCounts[eventName]) {
eventTypeCounts[eventName] = 0;
}
eventTypeCounts[eventName]++;
updateEventTypes();
logEvent(`${eventName}: ${JSON.stringify(args, null, 2)}`, 'event');
}
});
}
// Disconnect from WebSocket server
function disconnect() {
if (socket) {
socket.disconnect();
logEvent('Disconnecting...', 'info');
}
}
// Send custom event
function sendEvent() {
if (!socket || !socket.connected) {
logEvent('Not connected!', 'error');
return;
}
const eventName = document.getElementById('eventName').value.trim();
const eventDataStr = document.getElementById('eventData').value.trim();
if (!eventName) {
logEvent('Event name is required', 'error');
return;
}
try {
const eventData = eventDataStr ? JSON.parse(eventDataStr) : {};
socket.emit(eventName, eventData);
sentCount++;
document.getElementById('sentCount').textContent = sentCount;
logEvent(`Sent ${eventName}: ${JSON.stringify(eventData, null, 2)}`, 'send');
} catch (error) {
logEvent(`Error: Invalid JSON - ${error.message}`, 'error');
}
}
// Quick actions
function simulateMapCreated() {
const mapId = document.getElementById('quickMapId').value.trim() || 'map_' + Date.now();
const data = {
mapId,
mapData: {
seed: 'test-seed',
pack: {
cultures: [{i: 0, name: 'Test Culture'}],
states: [{i: 0, name: 'Test State'}],
burgs: [{i: 0, name: 'Test City'}],
rivers: [{i: 0, name: 'Test River'}]
}
}
};
socket.emit('map:created', data);
sentCount++;
document.getElementById('sentCount').textContent = sentCount;
logEvent(`Sent map:created: ${JSON.stringify(data, null, 2)}`, 'send');
}
function simulateRiversUpdated() {
const mapId = document.getElementById('quickMapId').value.trim() || 'map_' + Date.now();
const data = {
mapId,
rivers: [
{i: 1, name: 'Mystic River', type: 'River', discharge: 100},
{i: 2, name: 'Crystal Brook', type: 'River', discharge: 50}
]
};
socket.emit('rivers:updated', data);
sentCount++;
document.getElementById('sentCount').textContent = sentCount;
logEvent(`Sent rivers:updated: ${JSON.stringify(data, null, 2)}`, 'send');
}
function simulateBurgAdded() {
const mapId = document.getElementById('quickMapId').value.trim() || 'map_' + Date.now();
const data = {
mapId,
burg: {
i: 10,
name: 'New Settlement',
x: 500,
y: 400,
cell: 1234,
population: 5,
type: 'town'
}
};
socket.emit('burg:added', data);
sentCount++;
document.getElementById('sentCount').textContent = sentCount;
logEvent(`Sent burg:added: ${JSON.stringify(data, null, 2)}`, 'send');
}
// Update event types list
function updateEventTypes() {
const listEl = document.getElementById('eventTypes');
const sorted = Object.entries(eventTypeCounts)
.sort((a, b) => b[1] - a[1]);
listEl.innerHTML = sorted.map(([name, count]) => `
<li class="event-item">
<span class="event-name">${name}</span>
<span class="event-count">${count}</span>
</li>
`).join('');
}
// Logging
function logEvent(message, type = 'info') {
const logEl = document.getElementById('eventLog');
const entry = document.createElement('div');
entry.className = `log-entry ${type}`;
const time = new Date().toLocaleTimeString();
entry.innerHTML = `
<span class="log-time">${time}</span>
<span class="log-type ${type}">${type.toUpperCase()}</span>
<span>${escapeHtml(message)}</span>
`;
logEl.appendChild(entry);
logEl.scrollTop = logEl.scrollHeight;
// Keep only last 100 entries
while (logEl.children.length > 100) {
logEl.removeChild(logEl.firstChild);
}
}
function clearEventLog() {
document.getElementById('eventLog').innerHTML = '';
sentCount = 0;
receivedCount = 0;
eventTypeCounts = {};
document.getElementById('sentCount').textContent = '0';
document.getElementById('receivedCount').textContent = '0';
updateEventTypes();
logEvent('Log cleared', 'info');
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Auto-connect on load
window.addEventListener('load', () => {
logEvent('WebSocket demo loaded. Click "Connect" to start.', 'info');
});
</script>
</body>
</html>

View file

@ -8098,6 +8098,9 @@
<script defer src="modules/io/export.js?v=1.95.05"></script>
<script defer src="modules/io/formats.js"></script>
<!-- External API for wiki/web UI integration -->
<script defer src="modules/external-api.js?v=1.0.0"></script>
<!-- Web Components -->
<script defer src="components/fill-box.js"></script>
</body>

770
modules/external-api.js Normal file
View file

@ -0,0 +1,770 @@
/**
* External API Bridge for Fantasy Map Generator
* Provides a clean interface for external tools (wikis, web UIs) to interact with FMG
* @module external-api
* @version 1.0.0
*/
(function() {
'use strict';
// ============================================================================
// EVENT EMITTER SYSTEM
// ============================================================================
class EventEmitter {
constructor() {
this.events = {};
}
on(event, callback) {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(callback);
return () => this.off(event, callback);
}
off(event, callback) {
if (!this.events[event]) return;
this.events[event] = this.events[event].filter(cb => cb !== callback);
}
emit(event, data) {
if (!this.events[event]) return;
this.events[event].forEach(callback => {
try {
callback(data);
} catch (error) {
console.error(`Error in event handler for ${event}:`, error);
}
});
}
once(event, callback) {
const wrapper = (data) => {
callback(data);
this.off(event, wrapper);
};
this.on(event, wrapper);
}
}
const eventEmitter = new EventEmitter();
// ============================================================================
// STATE MANAGEMENT & CHANGE DETECTION
// ============================================================================
let isInitialized = false;
let lastState = null;
let changeDetectionEnabled = true;
function initializeChangeDetection() {
if (isInitialized) return;
// Observe SVG changes for map updates
const mapElement = document.getElementById('map');
if (mapElement) {
const observer = new MutationObserver(debounce(() => {
if (changeDetectionEnabled) {
eventEmitter.emit('map:changed', getMapState());
}
}, 500));
observer.observe(mapElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['d', 'transform', 'points']
});
}
isInitialized = true;
}
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// ============================================================================
// CORE API METHODS
// ============================================================================
const API = {
// ------------------------------------------------------------------------
// MAP LIFECYCLE
// ------------------------------------------------------------------------
/**
* Create a new map with optional parameters
* @param {Object} options - Generation options
* @returns {Promise<Object>} Map state
*/
async createMap(options = {}) {
try {
changeDetectionEnabled = false;
if (options.seed) {
seed = options.seed;
}
// Use existing generate function
await generate(options);
changeDetectionEnabled = true;
eventEmitter.emit('map:created', getMapState());
return {
success: true,
state: getMapState()
};
} catch (error) {
changeDetectionEnabled = true;
return {
success: false,
error: error.message
};
}
},
/**
* Load map from data
* @param {String|Blob|File} mapData - Map data to load
* @returns {Promise<Object>} Load result
*/
async loadMap(mapData) {
try {
changeDetectionEnabled = false;
let file;
if (typeof mapData === 'string') {
// Convert string data to Blob
file = new Blob([mapData], {type: 'text/plain'});
} else {
file = mapData;
}
await new Promise((resolve, reject) => {
uploadMap(file, (error) => {
if (error) reject(error);
else resolve();
});
});
changeDetectionEnabled = true;
eventEmitter.emit('map:loaded', getMapState());
return {
success: true,
state: getMapState()
};
} catch (error) {
changeDetectionEnabled = true;
return {
success: false,
error: error.message
};
}
},
/**
* Save current map
* @param {String} format - 'data' or 'blob'
* @returns {Promise<Object>} Saved map data
*/
async saveMap(format = 'data') {
try {
const mapData = await prepareMapData();
if (format === 'blob') {
return {
success: true,
data: new Blob([mapData], {type: 'text/plain'}),
filename: getFileName() + '.map'
};
}
return {
success: true,
data: mapData,
filename: getFileName() + '.map'
};
} catch (error) {
return {
success: false,
error: error.message
};
}
},
// ------------------------------------------------------------------------
// DATA ACCESS
// ------------------------------------------------------------------------
/**
* Get complete map state
* @returns {Object} Current map state
*/
getMapState() {
return getMapState();
},
/**
* Get specific data structure
* @param {String} key - Data key (rivers, cultures, states, burgs, etc.)
* @returns {*} Requested data
*/
getData(key) {
if (!pack || !pack[key]) {
return null;
}
return JSON.parse(JSON.stringify(pack[key]));
},
/**
* Get rivers data
* @returns {Array} Rivers array
*/
getRivers() {
return pack.rivers ? JSON.parse(JSON.stringify(pack.rivers)) : [];
},
/**
* Get cultures data
* @returns {Array} Cultures array
*/
getCultures() {
return pack.cultures ? JSON.parse(JSON.stringify(pack.cultures)) : [];
},
/**
* Get states data
* @returns {Array} States array
*/
getStates() {
return pack.states ? JSON.parse(JSON.stringify(pack.states)) : [];
},
/**
* Get burgs (cities/towns) data
* @returns {Array} Burgs array
*/
getBurgs() {
return pack.burgs ? JSON.parse(JSON.stringify(pack.burgs)) : [];
},
/**
* Get religions data
* @returns {Array} Religions array
*/
getReligions() {
return pack.religions ? JSON.parse(JSON.stringify(pack.religions)) : [];
},
/**
* Get markers data
* @returns {Array} Markers array
*/
getMarkers() {
return pack.markers ? JSON.parse(JSON.stringify(pack.markers)) : [];
},
/**
* Get grid data
* @returns {Object} Grid object with cells data
*/
getGrid() {
if (!grid) return null;
return {
spacing: grid.spacing,
cellsX: grid.cellsX,
cellsY: grid.cellsY,
features: grid.features,
boundary: grid.boundary
};
},
// ------------------------------------------------------------------------
// MUTATIONS
// ------------------------------------------------------------------------
/**
* Update rivers data
* @param {Array} rivers - New rivers array
* @returns {Object} Update result
*/
updateRivers(rivers) {
try {
changeDetectionEnabled = false;
pack.rivers = rivers;
// Redraw rivers
if (window.Rivers && Rivers.specify) {
Rivers.specify();
}
if (typeof drawRivers === 'function') {
drawRivers();
}
changeDetectionEnabled = true;
eventEmitter.emit('rivers:updated', pack.rivers);
return {success: true};
} catch (error) {
changeDetectionEnabled = true;
return {success: false, error: error.message};
}
},
/**
* Update cultures data
* @param {Array} cultures - New cultures array
* @returns {Object} Update result
*/
updateCultures(cultures) {
try {
changeDetectionEnabled = false;
pack.cultures = cultures;
// Redraw cultures
if (typeof drawCultures === 'function') {
drawCultures();
}
changeDetectionEnabled = true;
eventEmitter.emit('cultures:updated', pack.cultures);
return {success: true};
} catch (error) {
changeDetectionEnabled = true;
return {success: false, error: error.message};
}
},
/**
* Update states data
* @param {Array} states - New states array
* @returns {Object} Update result
*/
updateStates(states) {
try {
changeDetectionEnabled = false;
pack.states = states;
// Redraw states
if (typeof drawStates === 'function') {
drawStates();
}
if (typeof drawBorders === 'function') {
drawBorders();
}
changeDetectionEnabled = true;
eventEmitter.emit('states:updated', pack.states);
return {success: true};
} catch (error) {
changeDetectionEnabled = true;
return {success: false, error: error.message};
}
},
/**
* Update burgs (cities/towns) data
* @param {Array} burgs - New burgs array
* @returns {Object} Update result
*/
updateBurgs(burgs) {
try {
changeDetectionEnabled = false;
pack.burgs = burgs;
// Redraw burgs
if (typeof drawBurgs === 'function') {
drawBurgs();
}
changeDetectionEnabled = true;
eventEmitter.emit('burgs:updated', pack.burgs);
return {success: true};
} catch (error) {
changeDetectionEnabled = true;
return {success: false, error: error.message};
}
},
/**
* Add a new burg (city/town)
* @param {Object} burgData - Burg properties
* @returns {Object} Result with new burg ID
*/
addBurg(burgData) {
try {
const newId = pack.burgs.length;
const burg = {
i: newId,
cell: burgData.cell || 0,
x: burgData.x || 0,
y: burgData.y || 0,
name: burgData.name || Names.getCulture(burgData.culture || 0),
population: burgData.population || 1,
type: burgData.type || 'town',
...burgData
};
pack.burgs.push(burg);
if (typeof drawBurgs === 'function') {
drawBurgs();
}
eventEmitter.emit('burg:added', burg);
return {success: true, id: newId, burg};
} catch (error) {
return {success: false, error: error.message};
}
},
// ------------------------------------------------------------------------
// EXPORT
// ------------------------------------------------------------------------
/**
* Export map as SVG
* @returns {String} SVG string
*/
exportSVG() {
const svgElement = document.getElementById('map');
if (!svgElement) return null;
return svgElement.outerHTML;
},
/**
* Export map as PNG
* @param {Number} width - Image width
* @param {Number} height - Image height
* @returns {Promise<Blob>} PNG blob
*/
async exportPNG(width = 2048, height = 2048) {
return new Promise((resolve, reject) => {
try {
const svgElement = document.getElementById('map');
const svgString = new XMLSerializer().serializeToString(svgElement);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
const img = new Image();
const blob = new Blob([svgString], {type: 'image/svg+xml;charset=utf-8'});
const url = URL.createObjectURL(blob);
img.onload = function() {
ctx.drawImage(img, 0, 0, width, height);
URL.revokeObjectURL(url);
canvas.toBlob((blob) => {
resolve(blob);
}, 'image/png');
};
img.onerror = reject;
img.src = url;
} catch (error) {
reject(error);
}
});
},
/**
* Export specific data as JSON
* @param {String} key - Data key
* @returns {String} JSON string
*/
exportJSON(key) {
const data = key ? API.getData(key) : getMapState();
return JSON.stringify(data, null, 2);
},
// ------------------------------------------------------------------------
// EVENTS
// ------------------------------------------------------------------------
/**
* Subscribe to events
* @param {String} event - Event name
* @param {Function} callback - Callback function
* @returns {Function} Unsubscribe function
*/
on(event, callback) {
return eventEmitter.on(event, callback);
},
/**
* Unsubscribe from events
* @param {String} event - Event name
* @param {Function} callback - Callback function
*/
off(event, callback) {
eventEmitter.off(event, callback);
},
/**
* Subscribe to event once
* @param {String} event - Event name
* @param {Function} callback - Callback function
*/
once(event, callback) {
eventEmitter.once(event, callback);
},
/**
* Emit custom event
* @param {String} event - Event name
* @param {*} data - Event data
*/
emit(event, data) {
eventEmitter.emit(event, data);
}
};
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
function getMapState() {
return {
seed: typeof seed !== 'undefined' ? seed : null,
mapId: typeof mapId !== 'undefined' ? mapId : null,
timestamp: Date.now(),
pack: pack ? {
cultures: pack.cultures || [],
states: pack.states || [],
burgs: pack.burgs || [],
rivers: pack.rivers || [],
religions: pack.religions || [],
provinces: pack.provinces || [],
markers: pack.markers || []
} : null,
grid: grid ? {
spacing: grid.spacing,
cellsX: grid.cellsX,
cellsY: grid.cellsY,
features: grid.features
} : null,
options: typeof options !== 'undefined' ? options : null
};
}
// ============================================================================
// POSTMESSAGE BRIDGE
// ============================================================================
const PostMessageBridge = {
enabled: false,
targetOrigin: '*',
/**
* Enable PostMessage communication
* @param {String} origin - Target origin for postMessage (default: '*')
*/
enable(origin = '*') {
if (this.enabled) return;
this.targetOrigin = origin;
this.enabled = true;
// Listen for messages from parent window
window.addEventListener('message', this.handleMessage.bind(this));
// Forward all events to parent
this.setupEventForwarding();
console.log('[FMG API] PostMessage bridge enabled');
},
/**
* Disable PostMessage communication
*/
disable() {
this.enabled = false;
window.removeEventListener('message', this.handleMessage.bind(this));
console.log('[FMG API] PostMessage bridge disabled');
},
/**
* Handle incoming messages
*/
async handleMessage(event) {
if (!this.enabled) return;
const {type, payload, requestId} = event.data;
if (!type) return;
console.log('[FMG API] Received message:', type, payload);
try {
let result;
switch(type) {
// Map lifecycle
case 'CREATE_MAP':
result = await API.createMap(payload);
break;
case 'LOAD_MAP':
result = await API.loadMap(payload);
break;
case 'SAVE_MAP':
result = await API.saveMap(payload?.format);
break;
// Data access
case 'GET_STATE':
result = {success: true, data: API.getMapState()};
break;
case 'GET_DATA':
result = {success: true, data: API.getData(payload?.key)};
break;
case 'GET_RIVERS':
result = {success: true, data: API.getRivers()};
break;
case 'GET_CULTURES':
result = {success: true, data: API.getCultures()};
break;
case 'GET_STATES':
result = {success: true, data: API.getStates()};
break;
case 'GET_BURGS':
result = {success: true, data: API.getBurgs()};
break;
// Mutations
case 'UPDATE_RIVERS':
result = API.updateRivers(payload);
break;
case 'UPDATE_CULTURES':
result = API.updateCultures(payload);
break;
case 'UPDATE_STATES':
result = API.updateStates(payload);
break;
case 'UPDATE_BURGS':
result = API.updateBurgs(payload);
break;
case 'ADD_BURG':
result = API.addBurg(payload);
break;
// Export
case 'EXPORT_SVG':
result = {success: true, data: API.exportSVG()};
break;
case 'EXPORT_PNG':
const blob = await API.exportPNG(payload?.width, payload?.height);
const reader = new FileReader();
result = await new Promise((resolve) => {
reader.onload = () => resolve({success: true, data: reader.result});
reader.readAsDataURL(blob);
});
break;
case 'EXPORT_JSON':
result = {success: true, data: API.exportJSON(payload?.key)};
break;
default:
result = {success: false, error: `Unknown message type: ${type}`};
}
// Send response
this.sendMessage('RESPONSE', result, requestId);
} catch (error) {
this.sendMessage('ERROR', {
success: false,
error: error.message
}, requestId);
}
},
/**
* Send message to parent window
*/
sendMessage(type, payload, requestId = null) {
if (!this.enabled) return;
if (window.parent === window) return; // Not in iframe
window.parent.postMessage({
type,
payload,
requestId,
timestamp: Date.now()
}, this.targetOrigin);
},
/**
* Forward API events to parent window
*/
setupEventForwarding() {
const events = [
'map:created',
'map:loaded',
'map:changed',
'rivers:updated',
'cultures:updated',
'states:updated',
'burgs:updated',
'burg:added'
];
events.forEach(event => {
API.on(event, (data) => {
this.sendMessage('EVENT', {event, data});
});
});
}
};
// ============================================================================
// INITIALIZATION
// ============================================================================
// Initialize on DOMContentLoaded
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
initializeChangeDetection();
});
} else {
initializeChangeDetection();
}
// Auto-enable PostMessage if in iframe
if (window.self !== window.top) {
setTimeout(() => PostMessageBridge.enable(), 1000);
}
// ============================================================================
// EXPORT API
// ============================================================================
window.FMG_API = API;
window.FMG_PostMessageBridge = PostMessageBridge;
console.log('[FMG API] External API initialized. Access via window.FMG_API');
console.log('[FMG API] Available methods:', Object.keys(API));
})();