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

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>