mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2025-12-18 02:01:22 +01:00
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:
parent
dede314c94
commit
20458e39e2
10 changed files with 4699 additions and 0 deletions
599
demos/postmessage-demo.html
Normal file
599
demos/postmessage-demo.html
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue