mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2025-12-17 09:41:24 +01:00
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.
599 lines
15 KiB
HTML
599 lines
15 KiB
HTML
<!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>
|