This commit is contained in:
Nathan Eckenrode 2025-11-17 17:35:40 -05:00 committed by GitHub
commit d047d758a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 2966 additions and 40 deletions

832
CLAUDE.md Normal file
View file

@ -0,0 +1,832 @@
# CLAUDE.md - AI Assistant Guide for Fantasy Map Generator
## Project Overview
**Fantasy Map Generator** (FMG) is a free web-based application for creating and editing fantasy maps. It's a massive client-side JavaScript application designed for fantasy writers, game masters, and cartographers.
- **Repository**: https://github.com/Azgaar/Fantasy-Map-Generator
- **Live App**: https://azgaar.github.io/Fantasy-Map-Generator
- **Language**: Pure JavaScript (ES6+)
- **License**: MIT
- **Primary Author**: Azgaar (azgaar.fmg@yandex.com)
## Architecture & Technology Stack
### Core Technologies
- **Pure JavaScript (ES6+)**: NO build system, transpilation, or bundling
- **D3.js v7**: SVG manipulation, data visualization, zoom/pan interactions
- **jQuery 3.1.1 + jQuery UI**: DOM manipulation, dialogs, UI components
- **Progressive Web App (PWA)**: Service worker caching, offline support, IndexedDB storage
### Key Libraries (`/libs/`)
```
d3.min.js # Main visualization library
delaunator.min.js # Delaunay triangulation for Voronoi mesh
alea.min.js # Seedable random number generator
three.min.js # 3D rendering support
jszip.min.js # ZIP file creation for exports
polylabel.min.js # Label placement optimization
tinymce/ # Rich text editor for notes
```
### Architecture Pattern
**Global Object Pattern**: No module bundler, everything attached to `window` object:
```javascript
// Global data structures (from main.js:151-158)
let grid = {}; // Initial Voronoi graph
let pack = {}; // Main packed data structure
let seed; // Map generation seed
let mapId; // Unique map identifier
let mapHistory = []; // Undo/redo history
let modules = {}; // Module initialization flags
let notes = []; // User notes
```
## Directory Structure
```
Fantasy-Map-Generator/
├── index.html # 8,184-line monolithic HTML (entire UI)
├── main.js # 1,288 lines - initialization, globals, SVG setup
├── versioning.js # Version management (currently v1.108.11)
├── sw.js # Service worker for PWA caching
├── modules/ # Core application logic (232 JS files)
│ ├── dynamic/ # Dynamically imported modules
│ │ ├── editors/ # Advanced editors (states, religions, cultures)
│ │ ├── overview/ # Data visualization tools
│ │ └── *.js # Auto-update, installation, hierarchy-tree
│ ├── io/ # Input/Output operations
│ │ ├── cloud.js # Cloud storage (Dropbox)
│ │ ├── export.js # Map export functionality
│ │ ├── load.js # Map loading
│ │ └── save.js # Map saving
│ ├── renderers/ # SVG rendering (12 files)
│ │ ├── draw-borders.js
│ │ ├── draw-heightmap.js
│ │ ├── draw-markers.js
│ │ └── ...
│ └── ui/ # UI editors and tools (~35 files)
│ ├── editors.js # Common editor functions
│ ├── options.js # Map configuration
│ └── ...
├── utils/ # Utility functions (15 files)
│ ├── arrayUtils.js
│ ├── colorUtils.js
│ ├── commonUtils.js # debounce, throttle
│ ├── graphUtils.js # Graph algorithms (297 lines)
│ ├── pathUtils.js # SVG path operations (222 lines)
│ └── ...
├── charges/ # 400+ SVG heraldic symbols
├── components/ # Reusable UI components
├── config/ # Heightmap template configurations
├── heightmaps/ # Precreated heightmap resources
├── images/ # Application images/icons
├── styles/ # 12 JSON theme presets (default, night, etc.)
└── .github/
├── pull_request_template.md
└── ISSUE_TEMPLATE/
```
## Key Files
### Entry Points
- **index.html** (line 1-8184): Entire UI structure, loads all scripts
- **main.js** (line 1-1288): Initializes globals, SVG layers, data structures
- **versioning.js**: Current version: `1.108.11` (semantic versioning)
### Core Generators
```javascript
modules/burgs-and-states.js # City/state generation (1,018 lines)
modules/cultures-generator.js # Culture system (1,039 lines)
modules/names-generator.js # Name generation (3,371 lines)
modules/heightmap-generator.js # Terrain generation (445 lines)
modules/river-generator.js # River systems (507 lines)
modules/routes-generator.js # Road/trail generation (537 lines)
modules/religions-generator.js # Religion system (757 lines)
modules/military-generator.js # Military units (405 lines)
modules/markers-generator.js # Map markers (1,163 lines)
modules/coa-generator.js # Coat of arms logic (1,015 lines)
modules/coa-renderer.js # COA SVG rendering (2,527 lines)
modules/provinces-generator.js # Province subdivision (302 lines)
modules/zones-generator.js # Zone assignment (430 lines)
```
## Data Model
### Primary Data Structure: `pack` Object
The `pack` object is the heart of FMG's data model. It contains the entire map state using **typed arrays** for performance:
```javascript
pack = {
cells: {
i: Uint32Array, // Cell indices
v: Array, // Adjacent vertices
c: Array, // Adjacent cells
b: Uint8Array, // Border flags
h: Uint8Array, // Height (0-100)
temp: Int8Array, // Temperature
prec: Uint8Array, // Precipitation
f: Uint16Array, // Feature (biome) ID
t: Int8Array, // Terrain type
haven: Uint16Array, // Harbor ID
harbor: Uint8Array, // Harbor presence
fl: Uint16Array, // Flux (river flow)
r: Uint16Array, // River ID
conf: Uint8Array, // River confluence
pop: Float32Array, // Population density
culture: Uint16Array, // Culture ID
burg: Uint16Array, // Settlement ID
road: Uint16Array, // Road ID
route: Uint16Array, // Route ID
crossroad: Uint16Array,
province: Uint16Array,
state: Uint16Array, // State ownership
religion: Uint16Array
},
vertices: {
p: Array, // Point coordinates [x, y]
v: Array, // Adjacent vertices
c: Array // Adjacent cells
},
features: Array, // Biome/terrain features
cultures: Array, // Culture definitions
states: Array, // Political states
burgs: Array, // Cities/towns/settlements
religions: Array, // Religion definitions
provinces: Array, // Province data
rivers: Array, // River definitions
markers: Array, // Map markers
notes: Array // User notes
}
```
### Typed Array Constants
```javascript
// From main.js:16-20
const INT8_MAX = 127;
const UINT8_MAX = 255;
const UINT16_MAX = 65535;
const UINT32_MAX = 4294967295;
```
### Map Generation Pipeline
```
Heightmap Generation
Voronoi/Delaunay Mesh
Biomes & Climate
Rivers & Water Features
Cultures Assignment
States & Capitals
Burgs (Cities/Towns)
Provinces Subdivision
Routes (Roads/Trails)
Religions Distribution
Military Units
Final Rendering
```
## SVG Layer Organization
FMG uses 70+ predefined SVG layer groups in specific render order (from main.js:40-94):
```javascript
// Background to foreground rendering order
ocean → oceanLayers → oceanPattern → lakes → landmass →
texture → terrs → biomes → cells → rivers → terrain →
regions → borders → routes → temperature → coastline →
ice → population → emblems → labels → icons → armies →
markers → fogging → ruler → debug
```
**Important**: When modifying rendering, respect this layer order to avoid z-index issues.
## Code Conventions & Style
### General Style
1. **Strict Mode**: Every file starts with `"use strict";`
2. **No Semicolons**: Generally omitted (but inconsistent)
3. **Naming Conventions**:
- `camelCase` for variables and functions
- `PascalCase` for classes
- `SCREAMING_SNAKE_CASE` for constants
4. **Comments**: Minimal; code is mostly self-documenting
5. **String Quotes**: Mixed single and double quotes (no standard)
### Module Patterns
**IIFE (Immediately Invoked Function Expression)**:
```javascript
// From burgs-and-states.js:3
window.BurgsAndStates = (() => {
const generate = () => {
// Implementation
};
return { generate };
})();
// Usage elsewhere:
BurgsAndStates.generate();
```
**Global Function Pattern**:
```javascript
// From draw-borders.js:3
function drawBorders() {
// Direct global function
}
```
**Utility Module Pattern**:
```javascript
// From utils/commonUtils.js
function debounce(func, ms) { /* ... */ }
function throttle(func, ms) { /* ... */ }
// Directly callable globally
```
### Loading Strategy
**index.html** loads scripts in three phases:
1. **Libraries** (D3, jQuery, etc.)
2. **Core Modules** (synchronous, no `defer`)
3. **UI Modules** (with `defer` attribute)
4. **Dynamic Imports** (ES6 `import()` for lazy loading)
Example from index.html:
```html
<!-- Core generators loaded first -->
<script src="modules/names-generator.js?v=1.108.11"></script>
<script src="modules/cultures-generator.js?v=1.108.11"></script>
<script src="modules/burgs-and-states.js?v=1.108.11"></script>
<!-- ... -->
<script src="main.js?v=1.108.11"></script>
<!-- UI modules loaded with defer -->
<script defer src="modules/ui/editors.js?v=1.108.11"></script>
<script defer src="modules/ui/heightmap-editor.js?v=1.108.11"></script>
```
### Debug Flags
```javascript
// From main.js:5-11
const PRODUCTION = location.hostname &&
location.hostname !== "localhost" &&
location.hostname !== "127.0.0.1";
const DEBUG = JSON.safeParse(localStorage.getItem("debug")) || {};
const INFO = true;
const TIME = true; // Performance timing
const WARN = true;
const ERROR = true;
// Usage:
TIME && console.time("placeCapitals");
// ... expensive operation ...
TIME && console.timeEnd("placeCapitals");
```
## Development Workflow
### No Build System
- **Zero build configuration**: No webpack, rollup, or bundlers
- **No package.json**: No npm dependencies
- **No transpilation**: No Babel or TypeScript
- **Direct file editing**: Edit JS → Refresh browser → See changes
### Local Development
```bash
# Python simple server
python -m http.server 8080
# PHP built-in server
php -S localhost:8080
# Then visit: http://localhost:8080
```
### Versioning Process
**Manual 3-step versioning** (from pull_request_template.md):
1. **Update VERSION** in `versioning.js`:
```javascript
const VERSION = "1.108.12"; // Increment using semver
```
2. **Update file hashes** in `index.html` for all changed files:
```html
<script src="modules/burgs-and-states.js?v=1.108.12"></script>
```
3. **Update changelog** in `showUpdateWindow()` function (versioning.js) if user-facing
### Git Workflow
**Commit Message Format** (inferred from git log):
```
<type>(<scope>): <description>
Examples:
fix(v1.108.11): add external icons to export in base64 format
feat(ai-generator): update supported AI models list
refactor: drawReliefIcons, v1.108.4
perf: set text-rendering to optimizeSpeed, v1.108.1
chore: update version to 1.108.8
```
**Types**:
- `feat`: New feature
- `fix`: Bug fix
- `refactor`: Code restructuring
- `perf`: Performance improvement
- `chore`: Maintenance tasks
- `docs`: Documentation changes
**Scopes**: Optional, often includes version number or component name
### Pull Request Requirements
From `.github/pull_request_template.md`:
**Required**:
- [ ] Description of change and motivation
- [ ] Type of change (bug fix, feature, refactor, docs, other)
- [ ] Version updated in `versioning.js`
- [ ] Changed files hash updated in `index.html`
**Before submitting**:
1. Test locally (no automated tests)
2. Check console for errors
3. Verify map generation still works
4. Update version following semver
## Common Development Tasks
### Adding a New Feature
1. **Identify the module type**:
- Generator? → `modules/`
- UI Editor? → `modules/ui/`
- Renderer? → `modules/renderers/`
- Utility? → `utils/`
2. **Create the module file**:
```javascript
"use strict";
window.MyNewFeature = (() => {
const generate = () => {
TIME && console.time("myNewFeature");
// Implementation
TIME && console.timeEnd("myNewFeature");
};
return { generate };
})();
```
3. **Add script tag to index.html**:
```html
<script src="modules/my-new-feature.js?v=1.108.12"></script>
```
4. **Update versioning**:
- Increment VERSION in `versioning.js`
- Update hash in `index.html`
- Add changelog entry if user-facing
### Modifying the Data Model
1. **Update pack structure** in relevant generator
2. **Update save/load** in `modules/io/save.js` and `modules/io/load.js`
3. **Test with existing .map files** to ensure backward compatibility
4. **Update any renderers** that use the new data
### Adding a New Renderer
1. **Create renderer file** in `modules/renderers/`:
```javascript
"use strict";
function drawMyFeature() {
const { cells, myFeatures } = pack;
const container = svg.select("#myFeatureLayer");
// D3 rendering logic
container.selectAll("path")
.data(myFeatures)
.join("path")
.attr("d", d => d.path)
.attr("fill", d => d.color);
}
```
2. **Create SVG layer** in main.js:
```javascript
let myFeatureLayer = viewbox.append("g")
.attr("id", "myFeatureLayer");
```
3. **Add to render pipeline** in appropriate location
### Debugging Tips
1. **Use DEBUG flags**:
```javascript
DEBUG && console.log("Debug info:", data);
TIME && console.time("expensiveOperation");
```
2. **Check the debug SVG layer**:
```javascript
debug.append("circle")
.attr("cx", x)
.attr("cy", y)
.attr("r", 5)
.attr("fill", "red");
```
3. **Use browser DevTools**:
- Network tab: Check script loading
- Console: Look for TIME logs
- Sources: Set breakpoints
4. **Test map generation**:
- Generate → Verify no console errors
- Save → Load → Verify data integrity
- Export → Check output quality
## Testing Approach
**No formal testing framework**:
- No Jest, Mocha, or automated tests
- Manual testing only
- User reports via GitHub issues
**Manual testing checklist**:
1. Generate new map with default settings
2. Generate with various custom settings
3. Load existing .map files
4. Test all editors (heightmap, states, cultures, etc.)
5. Export in all formats (SVG, PNG, JSON)
6. Check console for errors/warnings
7. Test on multiple browsers (Chrome, Firefox, Safari)
## Performance Considerations
### Optimization Strategies
1. **Typed Arrays**: Use for large datasets
```javascript
cells.h = new Uint8Array(n); // Heights 0-255
cells.pop = new Float32Array(n); // Population
```
2. **D3 Data Binding**: Efficient DOM updates
```javascript
container.selectAll("path")
.data(features, d => d.id) // Key function
.join("path") // Efficient enter/update/exit
```
3. **Debouncing/Throttling**: For frequent events
```javascript
const onMouseMove = debounce(handleMouseMove, 100);
```
4. **Quadtree for Spatial Queries**:
```javascript
let burgsTree = d3.quadtree();
burgsTree.add([x, y]);
const nearest = burgsTree.find(x, y, radius);
```
5. **IndexedDB**: For large map storage
```javascript
// See libs/indexedDB.js
```
## Important Patterns & Anti-Patterns
### DO:
✅ Use typed arrays for cell data
✅ Respect SVG layer rendering order
✅ Use TIME flags for performance monitoring
✅ Follow IIFE pattern for new modules
✅ Update version numbers consistently
✅ Test backward compatibility with old .map files
✅ Use D3 data binding for DOM updates
✅ Check for null/undefined before accessing pack data
### DON'T:
❌ Add npm dependencies (no build system)
❌ Use ES6 modules (not supported in current architecture)
❌ Modify global data structures directly without updating renderers
❌ Add large libraries (keep bundle size manageable)
❌ Break backward compatibility without migration logic
❌ Add features without updating save/load functionality
❌ Forget to update version hash in index.html
❌ Mix rendering layers (respect z-order)
## Common Patterns in Codebase
### Random Number Generation
```javascript
// Use seeded random for reproducibility
const rand = aleaPRNG(seed);
const value = rand(); // 0-1
// Gaussian distribution
const value = gauss(mean, deviation, min, max, rounds);
```
### Cell Iteration
```javascript
const { cells } = pack;
const n = cells.i.length;
for (let i = 0; i < n; i++) {
if (cells.h[i] < 20) continue; // Skip water
// Process land cells
}
```
### D3 SVG Path Creation
```javascript
const path = d3.line()
.x(d => d[0])
.y(d => d[1])
.curve(d3.curveBasis);
const pathString = path(points);
```
### Graph Traversal
```javascript
// BFS example from graphUtils.js
const queue = [startCell];
const visited = new Uint8Array(cells.i.length);
while (queue.length) {
const cell = queue.shift();
if (visited[cell]) continue;
visited[cell] = 1;
cells.c[cell].forEach(neighbor => {
if (!visited[neighbor]) queue.push(neighbor);
});
}
```
## Key Concepts
### Voronoi/Delaunay Mesh
FMG uses a Voronoi diagram as the base map structure:
- **Cells**: Voronoi regions representing map areas
- **Vertices**: Points where 3+ cells meet
- **Edges**: Borders between cells
- **Delaunay Triangulation**: Dual graph for efficient pathfinding
**Implementation**: Custom `Voronoi` class in `modules/voronoi.js` using Delaunator library
### Heightmap System
Heights stored as `Uint8Array` (0-255):
- **0-19**: Ocean depths
- **20**: Sea level
- **21-99**: Land elevations
- **100+**: Mountains/peaks (clamped to 255)
### Culture & State System
**Cultures**: Linguistic/ethnic groups with naming patterns
**States**: Political entities with territories, capitals, military
Both use expansion algorithms based on cell scoring and distance from capitals.
### Name Generation
Sophisticated system in `modules/names-generator.js` (3,371 lines):
- Cultural naming patterns
- Procedural phoneme generation
- Linguistic rules for realistic names
- Separate generators for burgs, states, cultures, features
## File Modification Guidelines
### When Editing index.html
- **Line count**: 8,184 lines - VERY large file
- **Structure**: UI components inline (not templated)
- **Script tags**: Update version hash when modifying JS files
- **Dialogs**: jQuery UI dialogs defined inline
- **Be careful**: Easy to break HTML structure
### When Editing main.js
- **Global scope**: Everything here is globally accessible
- **SVG layers**: Order matters (lines 40-94)
- **Constants**: Typed array max values defined here
- **Initialization**: Core setup happens here
### When Editing Generators
- **Self-contained**: Each generator should be independent
- **Timing**: Wrap in TIME && console.time/timeEnd
- **Error handling**: Use WARN && console.warn for issues
- **Data mutations**: Update pack object directly
### When Editing Renderers
- **Layer awareness**: Know which SVG layer you're drawing to
- **Clear old content**: Remove previous render before redrawing
- **D3 data binding**: Use .join() for efficient updates
- **Performance**: Large renders should be optimized
## Resources
### Documentation
- **Project Wiki**: https://github.com/Azgaar/Fantasy-Map-Generator/wiki
- **Data Model**: https://github.com/Azgaar/Fantasy-Map-Generator/wiki/Data-model
- **Trello Board**: https://trello.com/b/7x832DG4/fantasy-map-generator
- **Blog**: https://azgaar.wordpress.com
### Community
- **Discord**: https://discordapp.com/invite/X7E84HU
- **Reddit**: https://www.reddit.com/r/FantasyMapGenerator
- **GitHub Issues**: https://github.com/Azgaar/Fantasy-Map-Generator/issues
### Support
- **Patreon**: https://www.patreon.com/azgaar
- **Email**: azgaar.fmg@yandex.com
## Troubleshooting Common Issues
### Map Won't Generate
1. Check console for errors
2. Verify all scripts loaded (Network tab)
3. Check localStorage isn't corrupted
4. Try clearing browser cache
### Save/Load Failures
1. Check IndexedDB quota
2. Verify .map file format (should be valid JSON)
3. Check version compatibility
4. Look for migration errors in console
### Performance Issues
1. Reduce number of cells (Options → Map Size)
2. Disable unused layers
3. Use simpler rendering styles
4. Check for memory leaks in console
### Rendering Glitches
1. Verify SVG layer order
2. Check for NaN values in coordinates
3. Ensure paths are valid SVG syntax
4. Test in different browsers
## Best Practices for AI Assistants
### When Analyzing Code
1. **Start with the data model**: Understand `pack` structure
2. **Check dependencies**: See what functions/modules are used
3. **Look for patterns**: IIFE modules, global functions, D3 usage
4. **Trace data flow**: How data moves through generation pipeline
### When Making Changes
1. **Test locally first**: No CI/CD, manual testing required
2. **Update version properly**: Follow 3-step process
3. **Maintain backward compatibility**: Old .map files should still load
4. **Document complex logic**: Code is under-commented
5. **Check performance**: Use TIME flags to measure
### When Refactoring
1. **Small incremental changes**: Large refactors are risky
2. **Keep the same API**: Don't break existing integrations
3. **Test all features**: Generator, editors, save/load, export
4. **Consider deprecation**: Don't remove features abruptly
### When Adding Features
1. **Check existing code**: Similar features might exist
2. **Follow existing patterns**: IIFE for modules, global for utils
3. **Update save/load**: Persist new data properly
4. **Add UI if needed**: Users should access the feature
5. **Update documentation**: At least in comments/changelog
## Quick Reference
### File to Edit By Task
| Task | Files to Edit |
|------|--------------|
| Add new generator | `modules/my-generator.js`, `index.html` (script tag) |
| Modify UI editor | `modules/ui/my-editor.js` |
| Change rendering | `modules/renderers/draw-my-feature.js` |
| Add utility function | `utils/myUtils.js` |
| Update save format | `modules/io/save.js`, `modules/io/load.js` |
| Change map options | `modules/ui/options.js`, `index.html` (UI) |
| Add SVG layer | `main.js` (lines 40-94), renderer file |
| Update version | `versioning.js`, `index.html` (all script tags) |
### Common Functions
| Function | Location | Purpose |
|----------|----------|---------|
| `rn(value, decimals)` | `utils/numberUtils.js` | Round number |
| `rand(min, max)` | Random utilities | Random in range |
| `gauss(mean, dev, ...)` | `utils/probabilityUtils.js` | Gaussian distribution |
| `debounce(fn, ms)` | `utils/commonUtils.js` | Debounce function |
| `P(probability)` | Probability utils | Random boolean |
| `ra(array)` | Array utils | Random array element |
### Common D3 Patterns
```javascript
// Select SVG layer
const layer = svg.select("#myLayer");
// Data binding
layer.selectAll("path")
.data(features, d => d.id)
.join("path")
.attr("d", d => d.path);
// Zoom/pan
const zoom = d3.zoom()
.scaleExtent([1, 20])
.on("zoom", zoomed);
svg.call(zoom);
```
---
## Summary
Fantasy Map Generator is a **unique codebase** that prioritizes:
1. **Accessibility**: Runs anywhere without build tools
2. **Simplicity**: Direct file editing, no complex tooling
3. **Performance**: Typed arrays, efficient algorithms
4. **User experience**: Rich UI with extensive customization
When working on FMG, embrace its philosophy: **pragmatic simplicity over modern complexity**. The lack of build tools is intentional, making it easy for contributors to jump in without setup overhead.
---
*Last Updated: 2025-11-14*
*Current Version: 1.108.11*
*This guide is for AI assistants working on Fantasy Map Generator codebase.*

270
OBSIDIAN_INTEGRATION.md Normal file
View file

@ -0,0 +1,270 @@
# Obsidian Vault Integration for Fantasy Map Generator
## Overview
Fantasy Map Generator now supports deep integration with your Obsidian vault for managing map lore and notes! This allows you to:
- Store all your world lore in Markdown format
- Edit notes in a modern Markdown editor (no more Win95-style TinyMCE!)
- Automatically link map elements to Obsidian notes by coordinates
- Keep your notes in sync between FMG and Obsidian
- Use [[wikilinks]] to connect related notes
- Edit in either FMG or Obsidian - changes sync both ways
## Setup
### 1. Install Obsidian Local REST API Plugin
1. Open Obsidian
2. Go to **Settings** → **Community Plugins**
3. Click **Browse** and search for "Local REST API"
4. Install and **Enable** the plugin
5. Go to **Settings** → **Local REST API**
6. Copy your **API Key** (you'll need this!)
7. Note the **Server Port** (default: 27123)
### 2. Configure in Fantasy Map Generator
1. Open Fantasy Map Generator
2. Go to **Menu****Tools****⚙ Obsidian**
3. Enter your settings:
- **API URL**: `http://127.0.0.1:27123` (default)
- **API Key**: Paste from Obsidian plugin settings
- **Vault Name**: Name of your Obsidian vault
4. Click **Test Connection** to verify
5. Click **Save Configuration**
## Usage
### Linking Map Elements to Notes
When you click on a **burg** or **marker** in FMG:
1. FMG searches your Obsidian vault for notes with matching coordinates in YAML frontmatter
2. Shows you the top 5-8 closest matches
3. You select the note you want, or create a new one
### Note Format
Notes in your vault should have YAML frontmatter like this:
```markdown
---
fmg-id: burg123
fmg-type: burg
coordinates:
x: 234.5
y: 456.7
lat: 45.23
lon: -73.45
tags:
- capital
- settlement
- ancient
aliases:
- Eldoria
- The Ancient City
---
# Eldoria
The ancient capital sits upon the [[River Mystral]], founded in year 1203.
## History
The city was established by [[King Aldric the First]]...
## Notable Locations
- [[The Grand Library]]
- [[Temple of the Seven Stars]]
- [[Market Square]]
```
### Coordinate Matching
Since your burgs/markers may have been imported from PostgreSQL without FMG IDs, the system matches by **X/Y coordinates**:
- FMG extracts the coordinates from the clicked element
- Searches all `.md` files in your vault for matching `x:` and `y:` values
- Calculates distance and shows closest matches
- You pick the right one!
### Supported Coordinate Formats
The system recognizes these formats in YAML frontmatter:
```yaml
# Nested object (recommended)
coordinates:
x: 123.4
y: 567.8
# Or flat
x: 123.4
y: 567.8
# Case insensitive
X: 123.4
Y: 567.8
```
### Creating New Notes
If no matches are found:
1. FMG offers to create a new note
2. Enter a name (e.g., "Eldoria")
3. Optionally specify a folder (e.g., "Locations/Cities")
4. FMG generates a template with coordinates
5. Opens in the Markdown editor
6. Saved directly to your Obsidian vault!
### Editing Notes
The modern Markdown editor includes:
- **Live preview**: Toggle between edit/preview modes
- **[[Wikilinks]]**: Link to other notes in your vault
- **Syntax highlighting**: Clean monospace font
- **Open in Obsidian**: Button to jump to the note in Obsidian app
- **Save to Vault**: Changes sync immediately
### Using Wikilinks
Create connections between notes:
```markdown
The [[King Aldric the First]] ruled from [[Eldoria]].
The city controls access to [[River Mystral]].
```
When you save in FMG, these links work in Obsidian!
## Migration from PostgreSQL
If you have existing lore in PostgreSQL with coordinates:
1. Export your data to Markdown files with YAML frontmatter
2. Include `x`, `y`, `lat`, `lon` in the frontmatter
3. Place files in your Obsidian vault
4. FMG will auto-match by coordinates!
Example export script template:
```python
for location in locations:
frontmatter = f"""---
fmg-type: {location.type}
coordinates:
x: {location.x}
y: {location.y}
lat: {location.lat}
lon: {location.lon}
tags: {location.tags}
---
# {location.name}
{location.description}
"""
with open(f"vault/{location.name}.md", "w") as f:
f.write(frontmatter)
```
## Tips & Tricks
### Organize Your Vault
Create folders for different types:
```
My Vault/
├── Locations/
│ ├── Cities/
│ ├── Landmarks/
│ └── Regions/
├── Characters/
├── History/
└── Lore/
```
### Use Templates
Create Obsidian templates for different element types:
- `Templates/City.md`
- `Templates/Landmark.md`
- `Templates/Character.md`
### Search and Graph
In Obsidian:
- Use **Search** (`Ctrl+Shift+F`) to find notes by coordinates
- Use **Graph View** to see connections between locations
- Use **Tags** to organize by type
### Sync Across Devices
Use Obsidian Sync or Git to keep your vault synced across computers!
## Troubleshooting
### Connection Failed
- Make sure Obsidian is running
- Verify the Local REST API plugin is enabled
- Check the port number (default 27123)
- Try restarting Obsidian
### No Matches Found
- Check that your notes have `x:` and `y:` fields in frontmatter
- Verify coordinates are numbers, not strings
- Try increasing the search radius
### Changes Not Appearing in Obsidian
- Obsidian should auto-detect file changes
- If not, try switching to another note and back
- Or close/reopen the note
## Advanced
### Custom Coordinate Systems
If you use a different coordinate system:
1. Map your coordinates to FMG's system
2. Store both in frontmatter:
```yaml
coordinates:
x: 234.5 # FMG coordinates
y: 456.7
custom_x: 1000 # Your system
custom_y: 2000
```
### Database Bridge
For the future PostgreSQL migration:
1. Keep coordinates in both Obsidian and database
2. Use coordinates as the join key
3. Sync changes via API
4. Eventually replace file storage with DB
## Future Features
Planned enhancements:
- [ ] Time slider - view notes across historical periods
- [ ] Automatic tagging by region/culture
- [ ] Bulk import from database
- [ ] Real-time collaboration
- [ ] Custom Markdown extensions
---
**Enjoy your modern, Markdown-powered world-building! 🗺️✨**

View file

@ -1830,6 +1830,7 @@
<td>
<select id="onloadBehavior" data-stored="onloadBehavior">
<option value="random" selected>Generate random map</option>
<option value="default">Open default map</option>
<option value="lastSaved">Open last saved map</option>
</select>
</td>
@ -2061,6 +2062,9 @@
Namesbase
</button>
<button id="editNotesButton" data-tip="Click to open Notes Editor" data-shortcut="Shift + O">Notes</button>
<button id="obsidianConfigButton" data-tip="Configure Obsidian vault integration for modern Markdown notes" onclick="openObsidianConfig()">
⚙ Obsidian
</button>
<button id="editProvincesButton" data-tip="Click to open Provinces Editor" data-shortcut="Shift + P">
Provinces
</button>
@ -4962,6 +4966,107 @@
</div>
</div>
<!-- Obsidian Notes Editor (Modern Markdown) -->
<div id="obsidianNotesEditor" class="dialog stable" style="display: none">
<div style="margin-bottom: 0.8em; padding-bottom: 0.8em; border-bottom: 1px solid #ddd">
<div style="display: flex; align-items: center; gap: 1em; margin-bottom: 0.5em;">
<div style="flex: 1;">
<span style="font-size: 0.9em; color: #666;">📁 </span>
<span id="obsidianNotePath" style="font-size: 0.9em; color: #666;"></span>
</div>
<button id="openInObsidian" onclick="openInObsidian()" data-tip="Open this note in Obsidian app" style="padding: 4px 12px;">
Open in Obsidian
</button>
</div>
<input id="obsidianNoteName" type="text" placeholder="Note name" style="width: 100%; padding: 8px; font-size: 1.1em; font-weight: bold; border: 1px solid #ddd; border-radius: 4px;"/>
</div>
<div style="display: flex; gap: 12px; height: calc(100% - 120px);">
<!-- Editor pane -->
<div style="flex: 1; display: flex; flex-direction: column;">
<div style="margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center;">
<strong style="color: #666;">Markdown</strong>
<button id="togglePreview" onclick="togglePreviewMode()" style="padding: 4px 12px;">👁 Preview</button>
</div>
<textarea
id="obsidianMarkdownEditor"
oninput="updateMarkdownPreview()"
style="flex: 1; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace; font-size: 14px; padding: 12px; border: 1px solid #ddd; border-radius: 4px; resize: none;"
placeholder="Write your markdown here..."
></textarea>
<div
id="obsidianMarkdownPreview"
style="display: none; flex: 1; padding: 12px; border: 1px solid #ddd; border-radius: 4px; overflow-y: auto; background: #fafafa;"
></div>
</div>
</div>
<div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #ddd; display: flex; justify-content: space-between;">
<div>
<span style="font-size: 0.9em; color: #666;">💡 Tip: Use [[wikilinks]] to link to other notes</span>
</div>
<div style="display: flex; gap: 8px;">
<button onclick="$('#obsidianNotesEditor').dialog('close')" style="padding: 6px 16px;">Cancel</button>
<button onclick="saveObsidianNote()" style="padding: 6px 16px; background: #5e81ac; color: white; border: none; border-radius: 4px; cursor: pointer;">
💾 Save to Vault
</button>
</div>
</div>
</div>
<!-- Obsidian Configuration Dialog -->
<div id="obsidianConfig" class="dialog stable" style="display: none">
<div style="padding: 1em;">
<p style="margin-bottom: 1em;">Configure connection to your Obsidian vault via the Local REST API plugin.</p>
<div style="margin-bottom: 1em;">
<label for="obsidianApiUrl" style="display: block; margin-bottom: 0.5em; font-weight: bold;">API URL:</label>
<input
id="obsidianApiUrl"
type="text"
value="http://127.0.0.1:27123"
style="width: 100%; padding: 8px; font-family: monospace;"
placeholder="http://127.0.0.1:27123"
/>
<small style="color: #666;">Default: http://127.0.0.1:27123</small>
</div>
<div style="margin-bottom: 1em;">
<label for="obsidianApiKey" style="display: block; margin-bottom: 0.5em; font-weight: bold;">API Key:</label>
<input
id="obsidianApiKey"
type="password"
style="width: 100%; padding: 8px; font-family: monospace;"
placeholder="Enter your API key from Obsidian plugin settings"
/>
<small style="color: #666;">Get this from Obsidian → Settings → Local REST API</small>
</div>
<div style="margin-bottom: 1em;">
<label for="obsidianVaultName" style="display: block; margin-bottom: 0.5em; font-weight: bold;">Vault Name:</label>
<input
id="obsidianVaultName"
type="text"
style="width: 100%; padding: 8px;"
placeholder="My Vault"
/>
<small style="color: #666;">Name of your Obsidian vault (for opening links)</small>
</div>
<div style="margin: 1.5em 0; padding: 1em; background: #f0f0f0; border-radius: 4px;">
<strong>Status:</strong> <span id="obsidianStatus" style="color: #666;">Not configured</span>
</div>
<div style="display: flex; gap: 8px; justify-content: flex-end;">
<button onclick="testObsidianConnection()" style="padding: 8px 16px;">Test Connection</button>
<button onclick="$('#obsidianConfig').dialog('close')" style="padding: 8px 16px;">Cancel</button>
<button onclick="saveObsidianConfig()" style="padding: 8px 16px; background: #5e81ac; color: white; border: none; border-radius: 4px;">
Save Configuration
</button>
</div>
</div>
</div>
<div id="aiGenerator" class="dialog stable" style="display: none">
<div style="display: flex; flex-direction: column; gap: 0.3em; width: 100%">
<textarea id="aiGeneratorResult" placeholder="Generated text will appear here" cols="30" rows="10"></textarea>
@ -6087,6 +6192,15 @@
browser
</button>
</div>
<div style="margin-top: 0.8em; padding-top: 0.8em; border-top: 1px solid #ccc">
<strong>Default map</strong>
<button onclick="saveAsDefaultMap()" data-tip="Set this map as the default that opens on load (requires 'Open default map' onload behavior)">
Set as default
</button>
<button onclick="clearDefaultMap()" data-tip="Clear the current default map setting">
Clear default
</button>
</div>
<p>
Maps are saved in <i>.map</i> format, that can be loaded back via the <i>Load</i> in menu. There is no way to
restore the progress if file is lost. Please keep old save files on your machine or cloud storage as backups.
@ -8118,7 +8232,7 @@
<script src="modules/ui/style-presets.js?v=1.100.00"></script>
<script src="modules/ui/general.js?v=1.100.00"></script>
<script src="modules/ui/options.js?v=1.106.2"></script>
<script src="main.js?v=1.108.1"></script>
<script src="main.js?v=1.108.13">
<script defer src="modules/ui/style.js?v=1.108.4"></script>
<script defer src="modules/ui/editors.js?v=1.108.5"></script>
@ -8140,7 +8254,7 @@
<script defer src="modules/ui/rivers-editor.js?v=1.106.0"></script>
<script defer src="modules/ui/rivers-creator.js?v=1.106.0"></script>
<script defer src="modules/ui/relief-editor.js?v=1.99.00"></script>
<script defer src="modules/ui/burg-editor.js?v=1.106.6"></script>
<script defer src="modules/ui/burg-editor.js?v=1.108.13.1"></script>
<script defer src="modules/ui/units-editor.js?v=1.108.12"></script>
<script defer src="modules/ui/notes-editor.js?v=1.107.3"></script>
<script defer src="modules/ui/ai-generator.js?v=1.108.8"></script>
@ -8155,7 +8269,7 @@
<script defer src="modules/ui/regiment-editor.js?v=1.108.5"></script>
<script defer src="modules/ui/battle-screen.js?v=1.108.5"></script>
<script defer src="modules/ui/emblems-editor.js?v=1.99.00"></script>
<script defer src="modules/ui/markers-editor.js?v=1.108.5"></script>
<script defer src="modules/ui/markers-editor.js?v=1.108.13.1"></script>
<script defer src="modules/ui/3d.js?v=1.99.00"></script>
<script defer src="modules/ui/submap-tool.js?v=1.106.2"></script>
<script defer src="modules/ui/transform-tool.js?v=1.106.2"></script>
@ -8163,10 +8277,13 @@
<script defer src="modules/coa-renderer.js?v=1.99.00"></script>
<script defer src="libs/rgbquant.min.js"></script>
<script defer src="libs/jquery.ui.touch-punch.min.js"></script>
<script defer src="modules/io/save.js?v=1.107.4"></script>
<script defer src="modules/io/load.js?v=1.108.0"></script>
<script defer src="modules/io/save.js?v=1.108.13"></script>
<script defer src="modules/io/load.js?v=1.108.13"></script>
<script defer src="modules/io/cloud.js?v=1.106.0"></script>
<script defer src="modules/io/export.js?v=1.108.11"></script>
<script defer src="modules/io/obsidian-bridge.js?v=1.108.13.3"></script>
<script defer src="modules/ui/obsidian-notes-editor.js?v=1.108.13.4"></script>
<script defer src="modules/ui/obsidian-config.js?v=1.108.13"></script>
<script defer src="modules/renderers/draw-features.js?v=1.108.2"></script>
<script defer src="modules/renderers/draw-borders.js?v=1.104.0"></script>

22
main.js
View file

@ -291,6 +291,28 @@ async function checkLoadParameters() {
return;
}
// restore onloadBehavior from localStorage if saved
const storedBehavior = localStorage.getItem("onloadBehavior");
if (storedBehavior) {
byId("onloadBehavior").value = storedBehavior;
}
// check if there is a default map saved to indexedDB
if (byId("onloadBehavior").value === "default") {
try {
const blob = await ldb.get("defaultMap");
if (blob) {
WARN && console.warn("Loading default map");
uploadMap(blob);
return;
} else {
WARN && console.warn("No default map set, generating random map");
}
} catch (error) {
ERROR && console.error(error);
}
}
// check if there is a map saved to indexedDB
if (byId("onloadBehavior").value === "lastSaved") {
try {

View file

@ -198,17 +198,22 @@ window.BurgsAndStates = (() => {
else b.y = rn(b.y - shift, 2);
}
// define emblem
const state = pack.states[b.state];
const stateCOA = state.coa;
let kinship = 0.25;
if (b.capital) kinship += 0.1;
else if (b.port) kinship -= 0.1;
if (b.culture !== state.culture) kinship -= 0.25;
b.type = getType(i, b.port);
const type = b.capital && P(0.2) ? "Capital" : b.type === "Generic" ? "City" : b.type;
b.coa = COA.generate(stateCOA, kinship, null, type);
b.coa.shield = COA.getShield(b.culture, b.state);
// Attach biome info
b.biome = pack.cells.biome[i];
// Attach province info if available
b.province = pack.cells.province ? pack.cells.province[i] : undefined;
// define emblem
const state = pack.states[b.state];
const stateCOA = state.coa;
let kinship = 0.25;
if (b.capital) kinship += 0.1;
else if (b.port) kinship -= 0.1;
if (b.culture !== state.culture) kinship -= 0.25;
b.type = getType(i, b.port);
const type = b.capital && P(0.2) ? "Capital" : b.type === "Generic" ? "City" : b.type;
b.coa = COA.generate(stateCOA, kinship, null, type);
b.coa.shield = COA.getShield(b.culture, b.state);
}
// de-assign port status if it's the only one on feature

View file

@ -280,7 +280,7 @@ async function parseLoadedData(data, mapVersion) {
family === usedFamily && unicodeRange === usedRange && variant === usedVariant
);
if (!defaultFont) fonts.push(usedFont);
declareFont(usedFont);
if (typeof declareFont !== "undefined") declareFont(usedFont);
});
}
}
@ -460,7 +460,7 @@ async function parseLoadedData(data, mapVersion) {
if (isVisible(scaleBar)) turnOn("toggleScaleBar");
if (isVisibleNode(byId("vignette"))) turnOn("toggleVignette");
getCurrentPreset();
if (typeof getCurrentPreset !== "undefined") getCurrentPreset();
}
{
@ -477,7 +477,7 @@ async function parseLoadedData(data, mapVersion) {
}
// add custom heightmap color scheme if any
if (heightmapColorSchemes) {
if (typeof heightmapColorSchemes !== "undefined" && heightmapColorSchemes && typeof addCustomColorScheme !== "undefined") {
const oceanScheme = byId("oceanHeights")?.getAttribute("scheme");
if (oceanScheme && !(oceanScheme in heightmapColorSchemes)) addCustomColorScheme(oceanScheme);
const landScheme = byId("#landHeights")?.getAttribute("scheme");
@ -487,7 +487,7 @@ async function parseLoadedData(data, mapVersion) {
{
// add custom texture if any
const textureHref = texture.attr("data-href");
if (textureHref) updateTextureSelectValue(textureHref);
if (textureHref && typeof updateTextureSelectValue !== "undefined") updateTextureSelectValue(textureHref);
}
// data integrity checks
@ -625,7 +625,7 @@ async function parseLoadedData(data, mapVersion) {
capitalBurgs.forEach(burg => {
burg.capital = 0;
moveBurgToGroup(burg.i, "towns");
if (typeof moveBurgToGroup !== "undefined") moveBurgToGroup(burg.i, "towns");
});
return;
@ -640,7 +640,7 @@ async function parseLoadedData(data, mapVersion) {
capitalBurgs.forEach((burg, i) => {
if (!i) return;
burg.capital = 0;
moveBurgToGroup(burg.i, "towns");
if (typeof moveBurgToGroup !== "undefined") moveBurgToGroup(burg.i, "towns");
});
return;
@ -650,7 +650,7 @@ async function parseLoadedData(data, mapVersion) {
ERROR &&
console.error(`[Data integrity] State ${state.i} has no capital. Assigning the first burg as capital`);
stateBurgs[0].capital = 1;
moveBurgToGroup(stateBurgs[0].i, "cities");
if (typeof moveBurgToGroup !== "undefined") moveBurgToGroup(stateBurgs[0].i, "cities");
}
});
@ -731,9 +731,9 @@ async function parseLoadedData(data, mapVersion) {
{
if (window.restoreDefaultEvents) restoreDefaultEvents();
focusOn(); // based on searchParams focus on point, cell or burg
invokeActiveZooming();
fitMapToScreen();
if (typeof focusOn !== "undefined") focusOn(); // based on searchParams focus on point, cell or burg
if (typeof invokeActiveZooming !== "undefined") invokeActiveZooming();
if (typeof fitMapToScreen !== "undefined") fitMapToScreen();
}
WARN && console.warn(`TOTAL: ${rn((performance.now() - uploadMap.timeStart) / 1000, 2)}s`);

View file

@ -0,0 +1,710 @@
"use strict";
// Obsidian Vault Integration for Fantasy Map Generator
// Uses Obsidian Local REST API plugin
// https://github.com/coddingtonbear/obsidian-local-rest-api
const ObsidianBridge = (() => {
// Configuration
const config = {
apiUrl: "http://127.0.0.1:27123",
apiKey: "", // Set via UI
enabled: false,
vaultName: ""
};
// Cache for vault file list
let vaultFilesCache = {
files: null,
timestamp: null,
ttl: 5 * 60 * 1000 // 5 minutes cache
};
// Index: fmg-id → file path for fast lookups
let fmgIdIndex = {};
// Initialize from localStorage
function init() {
const stored = localStorage.getItem("obsidianConfig");
if (stored) {
try {
const parsed = JSON.parse(stored);
Object.assign(config, parsed);
} catch (error) {
ERROR && console.error("Failed to load Obsidian config:", error);
}
}
// Load FMG ID index from localStorage
const storedIndex = localStorage.getItem("obsidianFmgIdIndex");
if (storedIndex) {
try {
fmgIdIndex = JSON.parse(storedIndex);
INFO && console.log(`Loaded FMG ID index with ${Object.keys(fmgIdIndex).length} entries`);
} catch (error) {
ERROR && console.error("Failed to load FMG ID index:", error);
fmgIdIndex = {};
}
}
// Pre-warm cache if Obsidian is already enabled
if (config.enabled) {
INFO && console.log("Obsidian enabled, pre-warming cache...");
prewarmCache();
}
}
// Save configuration
function saveConfig() {
localStorage.setItem("obsidianConfig", JSON.stringify(config));
}
// Test connection to Obsidian
async function testConnection() {
if (!config.apiKey) {
throw new Error("API key not set. Please configure in Options.");
}
try {
const response = await fetch(`${config.apiUrl}/`, {
headers: {
Authorization: `Bearer ${config.apiKey}`
}
});
if (!response.ok) {
throw new Error(`Connection failed: ${response.status}`);
}
const data = await response.json();
INFO && console.log("Obsidian connection successful:", data);
config.enabled = true;
saveConfig();
// Pre-warm the cache in the background (don't await)
prewarmCache();
return true;
} catch (error) {
ERROR && console.error("Obsidian connection failed:", error);
config.enabled = false;
saveConfig();
throw error;
}
}
// Pre-warm the vault files cache in the background
async function prewarmCache() {
try {
INFO && console.log("Pre-warming vault file cache...");
await getVaultFiles();
INFO && console.log("Vault file cache pre-warmed successfully!");
// Also build the complete FMG ID index
await buildCompleteIndex();
} catch (error) {
WARN && console.warn("Failed to pre-warm cache:", error);
// Don't throw - this is just optimization
}
}
// Build complete index of all fmg-ids in the vault
async function buildCompleteIndex() {
try {
INFO && console.log("Building complete FMG ID index...");
TIME && console.time("buildCompleteIndex");
const files = vaultFilesCache.files || await getVaultFiles();
let indexed = 0;
let skipped = 0;
for (const filePath of files) {
try {
const content = await getNote(filePath);
const {frontmatter} = parseFrontmatter(content);
const fmgId = frontmatter["fmg-id"] || frontmatter.fmgId;
if (fmgId) {
fmgIdIndex[fmgId] = filePath;
indexed++;
} else {
skipped++;
}
} catch (error) {
DEBUG && console.debug(`Skipping file ${filePath}:`, error);
skipped++;
}
}
// Save the complete index
saveFmgIdIndex();
TIME && console.timeEnd("buildCompleteIndex");
INFO && console.log(`Complete FMG ID index built: ${indexed} notes indexed, ${skipped} skipped`);
} catch (error) {
ERROR && console.error("Failed to build complete index:", error);
}
}
// Recursively scan a directory and all subdirectories for .md files
async function scanDirectory(path = "") {
const response = await fetch(`${config.apiUrl}/vault/${encodeURIComponent(path)}`, {
headers: {
Authorization: `Bearer ${config.apiKey}`
}
});
if (!response.ok) {
throw new Error(`Failed to fetch directory ${path}: ${response.status}`);
}
const data = await response.json();
const entries = data.files || [];
const mdFiles = [];
for (const entry of entries) {
const fullPath = path ? `${path}${entry}` : entry;
if (entry.endsWith("/")) {
// It's a directory - recurse into it
DEBUG && console.log(`Scanning directory: ${fullPath}`);
const subFiles = await scanDirectory(fullPath);
mdFiles.push(...subFiles);
} else if (entry.endsWith(".md")) {
// It's a markdown file - add it
mdFiles.push(fullPath);
}
}
return mdFiles;
}
// Clear the vault files cache
function clearVaultCache() {
vaultFilesCache.files = null;
vaultFilesCache.timestamp = null;
INFO && console.log("Vault file cache cleared");
}
// Save FMG ID index to localStorage
function saveFmgIdIndex() {
try {
localStorage.setItem("obsidianFmgIdIndex", JSON.stringify(fmgIdIndex));
DEBUG && console.log(`Saved FMG ID index with ${Object.keys(fmgIdIndex).length} entries`);
} catch (error) {
ERROR && console.error("Failed to save FMG ID index:", error);
}
}
// Add entry to FMG ID index
function addToFmgIdIndex(fmgId, filePath) {
if (!fmgId) return;
fmgIdIndex[fmgId] = filePath;
saveFmgIdIndex();
DEBUG && console.log(`Added to index: ${fmgId}${filePath}`);
}
// Get file path from FMG ID index
function getFromFmgIdIndex(fmgId) {
return fmgIdIndex[fmgId] || null;
}
// Get all markdown files from vault (recursively, with caching)
async function getVaultFiles(forceRefresh = false) {
if (!config.enabled) {
throw new Error("Obsidian not connected");
}
// Check cache
const now = Date.now();
const cacheValid = vaultFilesCache.files !== null &&
vaultFilesCache.timestamp !== null &&
(now - vaultFilesCache.timestamp) < vaultFilesCache.ttl;
if (cacheValid && !forceRefresh) {
INFO && console.log(`getVaultFiles: Using cached list (${vaultFilesCache.files.length} files)`);
return vaultFilesCache.files;
}
try {
TIME && console.time("getVaultFiles");
INFO && console.log("getVaultFiles: Scanning vault (cache miss or expired)...");
// Recursively scan all directories
const mdFiles = await scanDirectory("");
// Update cache
vaultFilesCache.files = mdFiles;
vaultFilesCache.timestamp = now;
INFO && console.log(`getVaultFiles: Found ${mdFiles.length} markdown files (recursive scan, cached)`);
DEBUG && console.log("Sample files:", mdFiles.slice(0, 10));
TIME && console.timeEnd("getVaultFiles");
return mdFiles;
} catch (error) {
ERROR && console.error("Failed to get vault files:", error);
TIME && console.timeEnd("getVaultFiles");
throw error;
}
}
// Get note content by path
async function getNote(notePath) {
if (!config.enabled) {
throw new Error("Obsidian not connected");
}
try {
const response = await fetch(`${config.apiUrl}/vault/${encodeURIComponent(notePath)}`, {
headers: {
Authorization: `Bearer ${config.apiKey}`,
Accept: "text/markdown"
}
});
if (!response.ok) {
throw new Error(`Failed to fetch note: ${response.status}`);
}
return await response.text();
} catch (error) {
ERROR && console.error("Failed to get note:", error);
throw error;
}
}
// Update note content
async function updateNote(notePath, content) {
if (!config.enabled) {
throw new Error("Obsidian not connected");
}
try {
const response = await fetch(`${config.apiUrl}/vault/${encodeURIComponent(notePath)}`, {
method: "PUT",
headers: {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "text/markdown"
},
body: content
});
if (!response.ok) {
throw new Error(`Failed to update note: ${response.status}`);
}
INFO && console.log("Note updated successfully:", notePath);
return true;
} catch (error) {
ERROR && console.error("Failed to update note:", error);
throw error;
}
}
// Create new note
async function createNote(notePath, content) {
if (!config.enabled) {
throw new Error("Obsidian not connected");
}
try {
const response = await fetch(`${config.apiUrl}/vault/${encodeURIComponent(notePath)}`, {
method: "PUT",
headers: {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "text/markdown"
},
body: content
});
if (!response.ok) {
throw new Error(`Failed to create note: ${response.status}`);
}
INFO && console.log("Note created successfully:", notePath);
return true;
} catch (error) {
ERROR && console.error("Failed to create note:", error);
throw error;
}
}
// Parse YAML frontmatter from markdown content
function parseFrontmatter(content) {
const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---/;
const match = content.match(frontmatterRegex);
if (!match) {
return {frontmatter: {}, content};
}
const frontmatterText = match[1];
const bodyContent = content.slice(match[0].length).trim();
// Simple YAML parser (handles basic key-value pairs and nested objects)
const frontmatter = {};
const lines = frontmatterText.split("\n");
let currentKey = null;
let currentObj = frontmatter;
let indentLevel = 0;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const indent = line.search(/\S/);
const colonIndex = trimmed.indexOf(":");
if (colonIndex === -1) continue;
const key = trimmed.slice(0, colonIndex).trim();
const value = trimmed.slice(colonIndex + 1).trim();
if (indent === 0) {
currentObj = frontmatter;
currentKey = key;
if (value) {
// Simple value
frontmatter[key] = parseValue(value);
} else {
// Nested object or array
frontmatter[key] = {};
currentObj = frontmatter[key];
}
} else if (currentObj && key) {
// Nested property
currentObj[key] = parseValue(value);
}
}
return {frontmatter, content: bodyContent};
}
// Parse YAML value (handle strings, numbers, arrays)
function parseValue(value) {
if (!value) return null;
// Array
if (value.startsWith("[") && value.endsWith("]")) {
return value
.slice(1, -1)
.split(",")
.map(v => v.trim())
.filter(v => v);
}
// Number
if (!isNaN(value) && value !== "") {
return parseFloat(value);
}
// Boolean
if (value === "true") return true;
if (value === "false") return false;
// String (remove quotes if present)
return value.replace(/^["']|["']$/g, "");
}
// Calculate distance between two coordinates
function calculateDistance(x1, y1, x2, y2) {
const dx = x2 - x1;
const dy = y2 - y1;
return Math.sqrt(dx * dx + dy * dy);
}
// Find notes by coordinates
async function findNotesByCoordinates(x, y, limit = 8) {
TIME && console.time("findNotesByCoordinates");
try {
const files = await getVaultFiles();
const mdFiles = files.filter(f => f.endsWith(".md"));
const matches = [];
for (const filePath of mdFiles) {
try {
const content = await getNote(filePath);
const {frontmatter} = parseFrontmatter(content);
// Check for coordinates in frontmatter
let noteX, noteY;
// Support various coordinate formats
if (frontmatter.coordinates) {
noteX = frontmatter.coordinates.x || frontmatter.coordinates.X;
noteY = frontmatter.coordinates.y || frontmatter.coordinates.Y;
} else {
noteX = frontmatter.x || frontmatter.X;
noteY = frontmatter.y || frontmatter.Y;
}
if (noteX !== undefined && noteY !== undefined) {
const distance = calculateDistance(x, y, noteX, noteY);
matches.push({
path: filePath,
name: filePath.replace(/\.md$/, "").split("/").pop(),
frontmatter,
distance,
coordinates: {x: noteX, y: noteY}
});
}
} catch (error) {
// Skip files that can't be read
DEBUG && console.debug("Skipping file:", filePath, error);
}
}
// Sort by distance and return top matches
matches.sort((a, b) => a.distance - b.distance);
const results = matches.slice(0, limit);
TIME && console.timeEnd("findNotesByCoordinates");
INFO && console.log(`Found ${results.length} nearby notes for (${x}, ${y})`);
return results;
} catch (error) {
ERROR && console.error("Failed to find notes by coordinates:", error);
TIME && console.timeEnd("findNotesByCoordinates");
throw error;
}
}
// Find note by FMG ID in frontmatter (with index for fast lookup)
async function findNoteByFmgId(fmgId) {
if (!fmgId) return null;
try {
// First, check the index for instant lookup
const indexedPath = getFromFmgIdIndex(fmgId);
if (indexedPath) {
INFO && console.log(`Found note in index: ${fmgId}${indexedPath}`);
try {
const content = await getNote(indexedPath);
const {frontmatter} = parseFrontmatter(content);
// Verify the fmg-id still matches (file might have been modified)
if (frontmatter["fmg-id"] === fmgId || frontmatter.fmgId === fmgId) {
return {
path: indexedPath,
name: indexedPath.replace(/\.md$/, "").split("/").pop(),
content,
frontmatter
};
} else {
// Index is stale, remove the entry
WARN && console.warn(`Index entry stale for ${fmgId}, removing`);
delete fmgIdIndex[fmgId];
saveFmgIdIndex();
}
} catch (error) {
// File no longer exists, remove from index
WARN && console.warn(`Indexed file not found: ${indexedPath}, removing from index`);
delete fmgIdIndex[fmgId];
saveFmgIdIndex();
}
}
// Not in index or index was stale, search all files
INFO && console.log(`Searching vault for fmg-id: ${fmgId}`);
const files = await getVaultFiles();
const mdFiles = files.filter(f => f.endsWith(".md"));
for (const filePath of mdFiles) {
try {
const content = await getNote(filePath);
const {frontmatter} = parseFrontmatter(content);
if (frontmatter["fmg-id"] === fmgId || frontmatter.fmgId === fmgId) {
// Found it! Add to index for next time
addToFmgIdIndex(fmgId, filePath);
INFO && console.log(`Found note and added to index: ${fmgId}${filePath}`);
return {
path: filePath,
name: filePath.replace(/\.md$/, "").split("/").pop(),
content,
frontmatter
};
}
} catch (error) {
DEBUG && console.debug("Skipping file:", filePath, error);
}
}
return null;
} catch (error) {
ERROR && console.error("Failed to find note by FMG ID:", error);
throw error;
}
}
// Generate note template for FMG element
function generateNoteTemplate(element, type, elementId) {
const {x, y} = element;
const lat = pack.cells.lat?.[element.cell] || 0;
const lon = pack.cells.lon?.[element.cell] || 0;
const frontmatter = {
"fmg-id": elementId || element.id || `${type}${element.i}`,
"fmg-type": type,
coordinates: {x, y, lat, lon},
tags: [type],
created: new Date().toISOString()
};
if (element.name) {
frontmatter.aliases = [element.name];
}
const yaml = Object.entries(frontmatter)
.map(([key, value]) => {
if (typeof value === "object" && !Array.isArray(value)) {
const nested = Object.entries(value)
.map(([k, v]) => ` ${k}: ${v}`)
.join("\n");
return `${key}:\n${nested}`;
} else if (Array.isArray(value)) {
return `${key}:\n - ${value.join("\n - ")}`;
}
return `${key}: ${value}`;
})
.join("\n");
const title = element.name || `${type} ${element.i}`;
return `---
${yaml}
---
# ${title}
*This note was created from Fantasy Map Generator*
## Description
Add your lore here...
## History
## Notable Features
## Related
`;
}
// Search notes by text query (searches in filename and frontmatter)
async function searchNotes(query) {
if (!query || query.trim() === "") {
return [];
}
const allFiles = await getVaultFiles();
const searchTerm = query.toLowerCase();
const results = [];
for (const filePath of allFiles) {
const fileName = filePath.split("/").pop().replace(".md", "").toLowerCase();
// Check if filename matches
if (fileName.includes(searchTerm)) {
try {
const content = await getNote(filePath);
const {frontmatter} = parseFrontmatter(content);
results.push({
path: filePath,
name: filePath.split("/").pop().replace(".md", ""),
frontmatter,
matchType: "filename"
});
} catch (error) {
WARN && console.warn(`Could not read file ${filePath}:`, error);
}
}
}
return results;
}
// List all notes with basic info (reads frontmatter - slow for large vaults)
async function listAllNotes() {
const allFiles = await getVaultFiles();
const notes = [];
INFO && console.log(`listAllNotes: Processing ${allFiles.length} files`);
for (const filePath of allFiles) {
try {
const content = await getNote(filePath);
const {frontmatter} = parseFrontmatter(content);
notes.push({
path: filePath,
name: filePath.split("/").pop().replace(".md", ""),
frontmatter,
folder: filePath.includes("/") ? filePath.substring(0, filePath.lastIndexOf("/")) : ""
});
} catch (error) {
WARN && console.warn(`Could not read file ${filePath}:`, error);
}
}
// Sort by path
notes.sort((a, b) => a.path.localeCompare(b.path));
INFO && console.log(`listAllNotes: Returning ${notes.length} notes`);
DEBUG && console.log("Sample note paths:", notes.slice(0, 5).map(n => n.path));
return notes;
}
// List just file paths (fast - no content reading)
async function listAllNotePaths() {
const allFiles = await getVaultFiles();
INFO && console.log(`listAllNotePaths: Found ${allFiles.length} files`);
// Convert to note objects with just path and name
const notes = allFiles.map(filePath => ({
path: filePath,
name: filePath.split("/").pop().replace(".md", ""),
folder: filePath.includes("/") ? filePath.substring(0, filePath.lastIndexOf("/")) : ""
}));
// Sort by path
notes.sort((a, b) => a.path.localeCompare(b.path));
return notes;
}
return {
init,
config,
saveConfig,
testConnection,
getVaultFiles,
clearVaultCache,
getNote,
updateNote,
createNote,
parseFrontmatter,
findNotesByCoordinates,
findNoteByFmgId,
generateNoteTemplate,
searchNotes,
listAllNotes,
listAllNotePaths,
addToFmgIdIndex,
getFromFmgIdIndex,
buildCompleteIndex
};
})();
// Initialize on load
ObsidianBridge.init();

View file

@ -167,6 +167,36 @@ async function saveToStorage(mapData, showTip = false) {
showTip && tip("Map is saved to the browser storage", false, "success");
}
// save current map as the default map
async function saveAsDefaultMap() {
if (customization) return tip("Map cannot be saved in EDIT mode, please complete the edit and retry", false, "error");
try {
const mapData = prepareMapData();
const blob = new Blob([mapData], {type: "text/plain"});
await ldb.set("defaultMap", blob);
localStorage.setItem("onloadBehavior", "default");
byId("onloadBehavior").value = "default";
tip("Map is set as default and will open on load", true, "success", 5000);
} catch (error) {
ERROR && console.error(error);
tip("Failed to set default map", true, "error", 3000);
}
}
// clear the default map setting
async function clearDefaultMap() {
try {
await ldb.set("defaultMap", null);
localStorage.removeItem("onloadBehavior");
byId("onloadBehavior").value = "random";
tip("Default map cleared", false, "success", 2000);
} catch (error) {
ERROR && console.error(error);
tip("Failed to clear default map", false, "error", 2000);
}
}
// download map file
function saveToMachine(mapData, filename) {
const blob = new Blob([mapData], {type: "text/plain"});

View file

@ -1,3 +1,26 @@
// Assign biome and province info to existing markers, burgs, and provinces after loading a map
window.assignBiomeAndProvinceInfo = function() {
// Markers
if (pack.markers && pack.cells && pack.cells.biome) {
pack.markers.forEach(marker => {
if (marker.cell !== undefined) {
marker.biome = pack.cells.biome[marker.cell];
marker.province = pack.cells.province ? pack.cells.province[marker.cell] : undefined;
}
});
}
// Burgs
if (pack.burgs && pack.cells && pack.cells.biome) {
pack.burgs.forEach(burg => {
if (burg.cell !== undefined) {
burg.biome = pack.cells.biome[burg.cell];
burg.province = pack.cells.province ? pack.cells.province[burg.cell] : undefined;
}
});
}
// Provinces (if you want to attach biome info, though provinces are usually collections of cells)
// You could aggregate biomes for each province if needed
};
"use strict";
window.Markers = (function () {
@ -154,10 +177,20 @@ window.Markers = (function () {
if (marker.cell === undefined) return;
const i = last(pack.markers)?.i + 1 || 0;
const [x, y] = getMarkerCoordinates(marker.cell);
marker = {...base, x, y, ...marker, i};
pack.markers.push(marker);
occupied[marker.cell] = true;
return marker;
// Attach biome and province info
const biome = pack.cells.biome[marker.cell];
const province = pack.cells.province ? pack.cells.province[marker.cell] : undefined;
// Add Obsidian note path (customize as needed)
const obsidianNotePath = `Neblub/Orbis/Markers/${marker.type}-${marker.cell}`;
marker = {...base, x, y, ...marker, i, biome, province, obsidianNotePath};
// Utility to open Obsidian note for a marker
window.openObsidianNote = function(notePath) {
const uri = `obsidian://open?vault=Neblub&file=${encodeURIComponent(notePath)}`;
window.open(uri, '_blank');
};
pack.markers.push(marker);
occupied[marker.cell] = true;
return marker;
}
function deleteMarker(markerId) {

View file

@ -486,9 +486,17 @@ function editBurg(id) {
}
function editBurgLegend() {
const id = elSelected.attr("data-id");
const name = elSelected.text();
editNotes("burg" + id, name);
const id = +elSelected.attr("data-id");
const burg = pack.burgs[id];
// Use Obsidian integration if available, otherwise fall back to old notes system
if (typeof editObsidianNote !== "undefined") {
const coordinates = {x: burg.x, y: burg.y};
editObsidianNote("burg" + id, "burg", coordinates);
} else {
const name = elSelected.text();
editNotes("burg" + id, name);
}
}
function showTemperatureGraph() {

View file

@ -481,7 +481,7 @@ function overviewBurgs(settings = {stateId: null, cultureId: null}) {
}
function downloadBurgsData() {
let data = `Id,Burg,Province,Province Full Name,State,State Full Name,Culture,Religion,Population,X,Y,Latitude,Longitude,Elevation (${heightUnit.value}),Temperature,Temperature likeness,Capital,Port,Citadel,Walls,Plaza,Temple,Shanty Town,Emblem,City Generator Link\n`; // headers
let data = `Id,Burg,Province,Province Full Name,State,State Full Name,Culture,Religion,Population,X,Y,Latitude,Longitude,Elevation (${heightUnit.value}),Temperature,Temperature likeness,Biome,Province Id,Capital,Port,Citadel,Walls,Plaza,Temple,Shanty Town,Emblem,City Generator Link\n`; // headers
const valid = pack.burgs.filter(b => b.i && !b.removed); // all valid burgs
valid.forEach(b => {
@ -506,6 +506,11 @@ function overviewBurgs(settings = {stateId: null, cultureId: null}) {
data += convertTemperature(temperature) + ",";
data += getTemperatureLikeness(temperature) + ",";
// add biome and province id
const biomeName = b.biome !== undefined ? window.Biomes.getDefault().name[b.biome] : "";
data += biomeName + ",";
data += b.province !== undefined ? b.province + "," : ",";
// add status data
data += b.capital ? "capital," : ",";
data += b.port ? "port," : ",";

View file

@ -221,7 +221,14 @@ function editMarker(markerI) {
function editMarkerLegend() {
const id = element.id;
editNotes(id, id);
// Use Obsidian integration if available, otherwise fall back to old notes system
if (typeof editObsidianNote !== "undefined" && marker) {
const coordinates = {x: marker.x, y: marker.y};
editObsidianNote(id, "marker", coordinates);
} else {
editNotes(id, id);
}
}
function toggleMarkerLock() {

View file

@ -68,7 +68,9 @@ function overviewMarkers() {
function addLines() {
const lines = pack.markers
.map(({i, type, icon, pinned, lock}) => {
.map(({i, type, icon, pinned, lock, biome, province}) => {
const biomeName = biome !== undefined ? window.Biomes.getDefault().name[biome] : "";
const provinceName = province !== undefined && pack.provinces[province] ? pack.provinces[province].name : "";
return /* html */ `
<div class="states" data-i=${i} data-type="${type}">
${
@ -77,6 +79,8 @@ function overviewMarkers() {
: `<span data-tip="Marker icon" style="width:1.2em">${icon}</span>`
}
<div data-tip="Marker type" style="width:10em">${type}</div>
<div data-tip="Biome" style="width:10em">${biomeName}</div>
<div data-tip="Province" style="width:10em">${provinceName}</div>
<span style="padding-right:.1em" data-tip="Edit marker" class="icon-pencil"></span>
<span style="padding-right:.1em" data-tip="Focus on marker position" class="icon-dot-circled pointer"></span>
<span style="padding-right:.1em" data-tip="Pin marker (display only pinned markers)" class="icon-pin ${
@ -209,11 +213,11 @@ function overviewMarkers() {
}
function exportMarkers() {
const headers = "Id,Type,Icon,Name,Note,X,Y,Latitude,Longitude\n";
const headers = "Id,Type,Icon,Name,Note,X,Y,Latitude,Longitude,Biome,Province\n";
const quote = s => '"' + s.replaceAll('"', '""') + '"';
const body = pack.markers.map(marker => {
const {i, type, icon, x, y} = marker;
const {i, type, icon, x, y, biome, province} = marker;
const note = notes.find(note => note.id === "marker" + i);
const name = note ? quote(note.name) : "Unknown";
@ -221,8 +225,10 @@ function overviewMarkers() {
const lat = getLatitude(y, 2);
const lon = getLongitude(x, 2);
const biomeName = biome !== undefined ? window.Biomes.getDefault().name[biome] : "";
const provinceName = province !== undefined && pack.provinces[province] ? pack.provinces[province].name : "";
return [i, type, icon, name, legend, x, y, lat, lon].join(",");
return [i, type, icon, name, legend, x, y, lat, lon, biomeName, provinceName].join(",");
});
const data = headers + body.join("\n");

View file

@ -0,0 +1,73 @@
"use strict";
// Obsidian Configuration UI
function openObsidianConfig() {
// Load current config
const {apiUrl, apiKey, vaultName, enabled} = ObsidianBridge.config;
byId("obsidianApiUrl").value = apiUrl || "http://127.0.0.1:27123";
byId("obsidianApiKey").value = apiKey || "";
byId("obsidianVaultName").value = vaultName || "";
updateObsidianStatus(enabled);
$("#obsidianConfig").dialog({
title: "Obsidian Vault Configuration",
width: "600px",
position: {my: "center", at: "center", of: "svg"}
});
}
function updateObsidianStatus(enabled) {
const statusEl = byId("obsidianStatus");
if (enabled) {
statusEl.textContent = "✅ Connected";
statusEl.style.color = "#2ecc71";
} else {
statusEl.textContent = "❌ Not connected";
statusEl.style.color = "#e74c3c";
}
}
async function testObsidianConnection() {
const apiUrl = byId("obsidianApiUrl").value.trim();
const apiKey = byId("obsidianApiKey").value.trim();
if (!apiUrl || !apiKey) {
tip("Please enter both API URL and API Key", false, "error", 3000);
return;
}
// Temporarily set config for testing
Object.assign(ObsidianBridge.config, {apiUrl, apiKey});
byId("obsidianStatus").textContent = "Testing connection...";
byId("obsidianStatus").style.color = "#f39c12";
try {
await ObsidianBridge.testConnection();
updateObsidianStatus(true);
tip("Successfully connected to Obsidian!", true, "success", 3000);
} catch (error) {
updateObsidianStatus(false);
tip("Connection failed: " + error.message, true, "error", 5000);
}
}
function saveObsidianConfig() {
const apiUrl = byId("obsidianApiUrl").value.trim();
const apiKey = byId("obsidianApiKey").value.trim();
const vaultName = byId("obsidianVaultName").value.trim();
if (!apiUrl || !apiKey) {
tip("Please enter both API URL and API Key", false, "error", 3000);
return;
}
Object.assign(ObsidianBridge.config, {apiUrl, apiKey, vaultName});
ObsidianBridge.saveConfig();
$("#obsidianConfig").dialog("close");
tip("Obsidian configuration saved", true, "success", 2000);
}

View file

@ -0,0 +1,806 @@
"use strict";
// Modern Markdown Notes Editor with Obsidian Integration
function editObsidianNote(elementId, elementType, coordinates) {
const {x, y} = coordinates;
// Show choice dialog: automatic search or manual browse
showSearchMethodDialog(elementId, elementType, coordinates);
}
function showSearchMethodDialog(elementId, elementType, coordinates) {
const element = getElementData(elementId, elementType);
const elementName = element.name || elementId;
alertMessage.innerHTML = `
<div style="padding: 1em;">
<p style="margin-bottom: 1em;"><strong>${elementName}</strong></p>
<p style="margin-bottom: 1.5em; color: #666;">How would you like to find the note for this ${elementType}?</p>
<div style="margin: 1em 0; padding: 12px; background: #f0f8ff; border: 1px solid #0066cc; border-radius: 4px;">
<div style="font-weight: bold; margin-bottom: 4px;">🔍 Automatic Search</div>
<div style="font-size: 0.9em; color: #666;">Search by linked ID or nearby coordinates</div>
</div>
<div style="margin: 1em 0; padding: 12px; background: #fff8e1; border: 1px solid #ffa000; border-radius: 4px;">
<div style="font-weight: bold; margin-bottom: 4px;">📁 Browse Manually</div>
<div style="font-size: 0.9em; color: #666;">Browse your vault's folder tree</div>
</div>
</div>
`;
$("#alert").dialog({
title: "Select Note",
width: "450px",
buttons: {
"Search": function () {
$(this).dialog("close");
// Show loading and do automatic search
showLoadingDialog();
findOrCreateNote(elementId, elementType, coordinates)
.then(noteData => {
showMarkdownEditor(noteData, elementType, elementId, coordinates);
})
.catch(error => {
ERROR && console.error("Failed to load note:", error);
tip("Failed to load Obsidian note: " + error.message, true, "error", 5000);
closeDialogs("#obsidianNoteLoading");
});
},
"Browse": function () {
$(this).dialog("close");
promptCreateNewNote(elementId, elementType, coordinates)
.then(noteData => {
showMarkdownEditor(noteData, elementType, elementId, coordinates);
})
.catch(error => {
if (error.message !== "Cancelled") {
ERROR && console.error("Failed to load note:", error);
tip("Failed to load Obsidian note: " + error.message, true, "error", 5000);
}
});
},
"Cancel": function () {
$(this).dialog("close");
}
},
position: {my: "center", at: "center", of: "svg"}
});
}
async function findOrCreateNote(elementId, elementType, coordinates) {
const {x, y} = coordinates;
// First try to find by FMG ID
let note = await ObsidianBridge.findNoteByFmgId(elementId);
if (note) {
INFO && console.log("Found note by FMG ID:", note.path);
closeDialogs("#obsidianNoteLoading");
// Show dialog with option to open linked note or choose different one
return await showLinkedNoteDialog(note, elementId, elementType, coordinates);
}
// Find by coordinates
const matches = await ObsidianBridge.findNotesByCoordinates(x, y, 8);
closeDialogs("#obsidianNoteLoading");
if (matches.length === 0) {
// No matches - offer to create new note
return await promptCreateNewNote(elementId, elementType, coordinates);
}
if (matches.length === 1) {
// Single match - show dialog with option to use it or choose different one
const match = matches[0];
const content = await ObsidianBridge.getNote(match.path);
const noteData = {
path: match.path,
name: match.name,
content,
frontmatter: match.frontmatter
};
return await showSingleMatchDialog(noteData, elementId, elementType, coordinates);
}
// Multiple matches - show selection dialog
return await showNoteSelectionDialog(matches, elementId, elementType, coordinates);
}
async function showLinkedNoteDialog(note, elementId, elementType, coordinates) {
return new Promise((resolve, reject) => {
alertMessage.innerHTML = `
<div style="padding: 1em;">
<p style="margin-bottom: 1em;"><strong> Found linked note:</strong></p>
<div style="padding: 12px; background: #f0f8ff; border: 1px solid #0066cc; border-radius: 4px; margin-bottom: 1.5em;">
<div style="font-weight: bold; margin-bottom: 4px;">${note.name}</div>
<div style="font-size: 0.9em; color: #666;">Path: ${note.path}</div>
</div>
<p style="font-size: 0.9em; color: #666;">This element is already linked to the note above. You can open it or choose a different note.</p>
</div>
`;
$("#alert").dialog({
title: "Linked Note Found",
width: "500px",
buttons: {
"Open Linked Note": function () {
$(this).dialog("close");
resolve(note);
},
"Choose Different Note": async function () {
$(this).dialog("close");
try {
const differentNote = await promptCreateNewNote(elementId, elementType, coordinates);
resolve(differentNote);
} catch (error) {
reject(error);
}
},
Cancel: function () {
$(this).dialog("close");
reject(new Error("Cancelled"));
}
},
position: {my: "center", at: "center", of: "svg"}
});
});
}
async function showSingleMatchDialog(note, elementId, elementType, coordinates) {
return new Promise((resolve, reject) => {
alertMessage.innerHTML = `
<div style="padding: 1em;">
<p style="margin-bottom: 1em;"><strong> Found note by coordinates:</strong></p>
<div style="padding: 12px; background: #f0fff0; border: 1px solid #00aa00; border-radius: 4px; margin-bottom: 1.5em;">
<div style="font-weight: bold; margin-bottom: 4px;">${note.name}</div>
<div style="font-size: 0.9em; color: #666;">Path: ${note.path}</div>
</div>
<p style="font-size: 0.9em; color: #666;">Found a note near this location. You can use it or browse for a different one.</p>
</div>
`;
$("#alert").dialog({
title: "Note Found Nearby",
width: "500px",
buttons: {
"Use This Note": function () {
$(this).dialog("close");
resolve(note);
},
"Browse/Search": async function () {
$(this).dialog("close");
try {
const differentNote = await promptCreateNewNote(elementId, elementType, coordinates);
resolve(differentNote);
} catch (error) {
reject(error);
}
},
Cancel: function () {
$(this).dialog("close");
reject(new Error("Cancelled"));
}
},
position: {my: "center", at: "center", of: "svg"}
});
});
}
function showLoadingDialog() {
alertMessage.innerHTML = `
<div style="text-align: center; padding: 2em;">
<div class="spinner" style="margin: 0 auto 1em;"></div>
<p>Searching Obsidian vault for matching notes...</p>
</div>
`;
$("#alert").dialog({
title: "Loading from Obsidian",
width: "400px",
closeOnEscape: false,
buttons: {},
dialogClass: "no-close",
position: {my: "center", at: "center", of: "svg"}
});
}
async function showNoteSelectionDialog(matches, elementId, elementType, coordinates) {
return new Promise((resolve, reject) => {
const matchList = matches
.map(
(match, index) => `
<div class="note-match" data-index="${index}" style="
padding: 12px;
margin: 8px 0;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
" onmouseover="this.style.background='#f0f0f0'" onmouseout="this.style.background='white'">
<div style="font-weight: bold; margin-bottom: 4px;">${match.name}</div>
<div style="font-size: 0.9em; color: #666;">
Distance: ${match.distance.toFixed(1)} units<br/>
Coordinates: (${match.coordinates.x}, ${match.coordinates.y})<br/>
Path: ${match.path}
</div>
</div>
`
)
.join("");
alertMessage.innerHTML = `
<div style="max-height: 60vh; overflow-y: auto;">
<p style="margin-bottom: 1em;">Found ${matches.length} notes near this location. Select one:</p>
${matchList}
</div>
`;
$("#alert").dialog({
title: "Select Obsidian Note",
width: "600px",
buttons: {
"Create New": async function () {
$(this).dialog("close");
try {
const newNote = await promptCreateNewNote(elementId, elementType, coordinates);
resolve(newNote);
} catch (error) {
reject(error);
}
},
Cancel: function () {
$(this).dialog("close");
reject(new Error("Cancelled"));
}
},
position: {my: "center", at: "center", of: "svg"}
});
// Add click handlers to matches
document.querySelectorAll(".note-match").forEach((el, index) => {
el.addEventListener("click", async () => {
$("#alert").dialog("close");
try {
const match = matches[index];
const content = await ObsidianBridge.getNote(match.path);
resolve({
path: match.path,
name: match.name,
content,
frontmatter: match.frontmatter
});
} catch (error) {
reject(error);
}
});
});
});
}
async function promptCreateNewNote(elementId, elementType, coordinates) {
return new Promise((resolve, reject) => {
const element = getElementData(elementId, elementType);
const suggestedName = element.name || `${elementType}-${element.i}`;
// Build context info for the element
let contextInfo = "";
if (element.state) {
contextInfo += `<div style="color: #666; font-size: 0.9em;">State: ${element.state}</div>`;
}
if (element.province) {
contextInfo += `<div style="color: #666; font-size: 0.9em;">Province: ${element.province}</div>`;
}
// Pre-fill search with state or element name
const defaultSearch = element.state || element.name || "";
alertMessage.innerHTML = `
<div style="margin-bottom: 1.5em;">
<p><strong>${element.name || elementId}</strong></p>
${contextInfo}
<p style="margin-top: 0.5em;">No matching notes found by coordinates.</p>
</div>
<div style="margin: 1.5em 0; padding: 1em; background: #f5f5f5; border-radius: 4px;">
<label for="obsidianSearch" style="display: block; margin-bottom: 0.5em; font-weight: bold;">Search your vault:</label>
<input id="obsidianSearch" type="text" placeholder="Type to search..." value="${defaultSearch}" style="width: 100%; padding: 8px; font-size: 1em; margin-bottom: 8px;"/>
<button id="obsidianSearchBtn" style="padding: 6px 12px;">Search</button>
<button id="obsidianBrowseBtn" style="padding: 6px 12px; margin-left: 8px;">Browse All Notes</button>
<div id="obsidianSearchResults" style="margin-top: 1em; max-height: 200px; overflow-y: auto;"></div>
</div>
<div style="margin-top: 1.5em; padding-top: 1.5em; border-top: 1px solid #ddd;">
<p style="font-weight: bold; margin-bottom: 1em;">Or create a new note:</p>
<div style="margin: 1em 0;">
<label for="newNoteName" style="display: block; margin-bottom: 0.5em;">Note name:</label>
<input id="newNoteName" type="text" value="${suggestedName}" style="width: 100%; padding: 8px; font-size: 1em;"/>
</div>
<div style="margin: 1em 0;">
<label for="newNotePath" style="display: block; margin-bottom: 0.5em;">Folder (optional):</label>
<input id="newNotePath" type="text" placeholder="e.g., Locations/Cities" style="width: 100%; padding: 8px; font-size: 1em;"/>
</div>
</div>
`;
$("#alert").dialog({
title: "Find or Create Note",
width: "600px",
buttons: {
Create: async function () {
const name = byId("newNoteName").value.trim();
const folder = byId("newNotePath").value.trim();
if (!name) {
tip("Please enter a note name", false, "error");
return;
}
const notePath = folder ? `${folder}/${name}.md` : `${name}.md`;
$(this).dialog("close");
try {
const template = ObsidianBridge.generateNoteTemplate(element, elementType, elementId);
await ObsidianBridge.createNote(notePath, template);
const {frontmatter} = ObsidianBridge.parseFrontmatter(template);
// Add to FMG ID index for instant future lookups
const fmgId = frontmatter["fmg-id"] || frontmatter.fmgId;
if (fmgId) {
ObsidianBridge.addToFmgIdIndex(fmgId, notePath);
INFO && console.log(`New note added to index: ${fmgId}${notePath}`);
}
resolve({
path: notePath,
name,
content: template,
frontmatter,
isNew: true
});
} catch (error) {
reject(error);
}
},
Cancel: function () {
$(this).dialog("close");
reject(new Error("Cancelled"));
}
},
position: {my: "center", at: "center", of: "svg"}
});
// Add event handlers for search and browse
const searchBtn = byId("obsidianSearchBtn");
const browseBtn = byId("obsidianBrowseBtn");
const searchInput = byId("obsidianSearch");
const resultsDiv = byId("obsidianSearchResults");
const performSearch = async () => {
const query = searchInput.value.trim();
if (!query) {
resultsDiv.innerHTML = "<p style='color: #999;'>Enter a search term</p>";
return;
}
resultsDiv.innerHTML = "<p>Searching...</p>";
try {
const results = await ObsidianBridge.searchNotes(query);
if (results.length === 0) {
resultsDiv.innerHTML = "<p style='color: #999;'>No matching notes found</p>";
return;
}
resultsDiv.innerHTML = results
.map(
(note, index) => `
<div class="search-result" data-index="${index}" style="
padding: 8px;
margin: 4px 0;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
background: white;
" onmouseover="this.style.background='#e8e8e8'" onmouseout="this.style.background='white'">
<div style="font-weight: bold;">${note.name}</div>
<div style="font-size: 0.85em; color: #666;">${note.path}</div>
</div>
`
)
.join("");
// Add click handlers
document.querySelectorAll(".search-result").forEach((el, index) => {
el.addEventListener("click", async () => {
$("#alert").dialog("close");
try {
const note = results[index];
const content = await ObsidianBridge.getNote(note.path);
resolve({
path: note.path,
name: note.name,
content,
frontmatter: note.frontmatter
});
} catch (error) {
reject(error);
}
});
});
} catch (error) {
resultsDiv.innerHTML = `<p style='color: red;'>Search failed: ${error.message}</p>`;
}
};
const showBrowse = async () => {
resultsDiv.innerHTML = "<p>Loading file list...</p>";
try {
// Use fast path-only listing (doesn't read file contents)
const allNotes = await ObsidianBridge.listAllNotePaths();
if (allNotes.length === 0) {
resultsDiv.innerHTML = "<p style='color: #999;'>No notes in vault</p>";
return;
}
INFO && console.log(`Displaying ${allNotes.length} notes in folder tree`);
// Build folder tree
const tree = buildFolderTree(allNotes);
resultsDiv.innerHTML = renderFolderTree(tree, allNotes);
// Add click handlers to files
document.querySelectorAll(".tree-file").forEach(el => {
el.addEventListener("click", async () => {
const index = parseInt(el.dataset.index);
$("#alert").dialog("close");
try {
const note = allNotes[index];
// Read the file content only when clicked
const content = await ObsidianBridge.getNote(note.path);
const {frontmatter} = ObsidianBridge.parseFrontmatter(content);
resolve({
path: note.path,
name: note.name,
content,
frontmatter
});
} catch (error) {
reject(error);
}
});
});
// Add click handlers to folder toggles
document.querySelectorAll(".tree-folder-toggle").forEach(el => {
el.addEventListener("click", e => {
e.stopPropagation();
const folder = el.parentElement.nextElementSibling;
const isCollapsed = folder.style.display === "none";
folder.style.display = isCollapsed ? "block" : "none";
el.textContent = isCollapsed ? "▼" : "▶";
});
});
} catch (error) {
resultsDiv.innerHTML = `<p style='color: red;'>Failed to load notes: ${error.message}</p>`;
}
};
searchBtn.addEventListener("click", performSearch);
browseBtn.addEventListener("click", showBrowse);
searchInput.addEventListener("keypress", e => {
if (e.key === "Enter") performSearch();
});
});
}
function buildFolderTree(notes) {
const root = {folders: {}, files: []};
INFO && console.log(`buildFolderTree: Processing ${notes.length} notes`);
notes.forEach((note, index) => {
const parts = note.path.split("/");
const fileName = parts[parts.length - 1];
DEBUG && console.log(`Processing note ${index}: ${note.path} (${parts.length} parts)`);
if (parts.length === 1) {
// Root level file
root.files.push({name: fileName, index, path: note.path});
DEBUG && console.log(` -> Added to root files: ${fileName}`);
} else {
// Navigate/create folder structure
let current = root;
for (let i = 0; i < parts.length - 1; i++) {
const folderName = parts[i];
if (!current.folders[folderName]) {
current.folders[folderName] = {folders: {}, files: []};
DEBUG && console.log(` -> Created folder: ${folderName}`);
}
current = current.folders[folderName];
}
// Add file to final folder
current.files.push({name: fileName, index, path: note.path});
DEBUG && console.log(` -> Added to folder: ${fileName}`);
}
});
INFO && console.log("Folder tree structure:", root);
return root;
}
function renderFolderTree(node, allNotes, indent = 0) {
let html = "";
const indentPx = indent * 20;
// Render folders
for (const [folderName, folderData] of Object.entries(node.folders || {})) {
html += `
<div style="margin-left: ${indentPx}px;">
<div style="padding: 4px; cursor: pointer; user-select: none;">
<span class="tree-folder-toggle" style="display: inline-block; width: 16px; font-size: 12px;"></span>
<span style="font-weight: bold;">📁 ${folderName}</span>
</div>
<div class="tree-folder-content" style="display: block;">
${renderFolderTree(folderData, allNotes, indent + 1)}
</div>
</div>
`;
}
// Render files in current folder
html += renderFiles(node.files || [], indent);
return html;
}
function renderFiles(files, indent) {
const indentPx = indent * 20;
return files
.map(
file => `
<div class="tree-file" data-index="${file.index}" style="
margin-left: ${indentPx}px;
padding: 4px 8px;
cursor: pointer;
border-radius: 3px;
" onmouseover="this.style.background='#e8e8e8'" onmouseout="this.style.background='transparent'">
<span style="font-size: 12px;">📄</span> ${file.name.replace(".md", "")}
</div>
`
)
.join("");
}
function getElementData(elementId, elementType) {
// Extract element data based on type
if (elementType === "burg") {
const burgId = parseInt(elementId.replace("burg", ""));
const burg = pack.burgs[burgId];
// Enhance with state and province names
const stateId = burg.state;
const provinceId = burg.province;
return {
...burg,
state: stateId && pack.states[stateId] ? pack.states[stateId].name : null,
province: provinceId && pack.provinces[provinceId] ? pack.provinces[provinceId].name : null
};
} else if (elementType === "marker") {
const markerId = parseInt(elementId.replace("marker", ""));
const marker = pack.markers[markerId];
// Enhance with state and province if marker has a cell
if (marker.cell) {
const cell = pack.cells;
const stateId = cell.state[marker.cell];
const provinceId = cell.province[marker.cell];
return {
...marker,
state: stateId && pack.states[stateId] ? pack.states[stateId].name : null,
province: provinceId && pack.provinces[provinceId] ? pack.provinces[provinceId].name : null
};
}
return marker;
} else {
// Generic element
const el = document.getElementById(elementId);
return {
id: elementId,
name: elementId,
x: parseFloat(el?.getAttribute("cx") || 0),
y: parseFloat(el?.getAttribute("cy") || 0)
};
}
}
function showMarkdownEditor(noteData, elementType, elementId, coordinates) {
const {path, name, content, frontmatter, isNew} = noteData;
// Extract frontmatter and body
const {content: bodyContent} = ObsidianBridge.parseFrontmatter(content);
// Set up the dialog
byId("obsidianNotePath").textContent = path;
byId("obsidianNoteName").value = name;
byId("obsidianMarkdownEditor").value = content;
byId("obsidianMarkdownPreview").innerHTML = renderMarkdown(bodyContent);
// Store current note data and FMG element info
showMarkdownEditor.currentNote = noteData;
showMarkdownEditor.originalContent = content;
showMarkdownEditor.elementId = elementId;
showMarkdownEditor.elementType = elementType;
showMarkdownEditor.coordinates = coordinates;
$("#obsidianNotesEditor").dialog({
title: `Obsidian Note: ${name}`,
width: Math.min(svgWidth * 0.9, 1200),
height: svgHeight * 0.85,
position: {my: "center", at: "center", of: "svg"},
close: () => {
showMarkdownEditor.currentNote = null;
showMarkdownEditor.originalContent = null;
}
});
// Update preview on edit
updateMarkdownPreview();
if (isNew) {
tip("New note created in Obsidian vault", true, "success", 3000);
}
}
function updateMarkdownPreview() {
const content = byId("obsidianMarkdownEditor").value;
const {content: bodyContent} = ObsidianBridge.parseFrontmatter(content);
byId("obsidianMarkdownPreview").innerHTML = renderMarkdown(bodyContent);
}
function renderMarkdown(markdown) {
// Simple Markdown renderer (will be replaced with marked.js)
let html = markdown;
// Headers
html = html.replace(/^### (.*$)/gim, "<h3>$1</h3>");
html = html.replace(/^## (.*$)/gim, "<h2>$1</h2>");
html = html.replace(/^# (.*$)/gim, "<h1>$1</h1>");
// Bold
html = html.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>");
html = html.replace(/\_\_(.*?)\_\_/g, "<strong>$1</strong>");
// Italic
html = html.replace(/\*(.*?)\*/g, "<em>$1</em>");
html = html.replace(/\_(.*?)\_/g, "<em>$1</em>");
// Links
html = html.replace(/\[([^\]]+)\]\(([^\)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
// Wikilinks [[Page]]
html = html.replace(/\[\[([^\]]+)\]\]/g, '<span class="wikilink">$1</span>');
// Lists
html = html.replace(/^\* (.*)$/gim, "<li>$1</li>");
html = html.replace(/^\- (.*)$/gim, "<li>$1</li>");
html = html.replace(/(<li>.*<\/li>)/s, "<ul>$1</ul>");
// Paragraphs
html = html.replace(/\n\n/g, "</p><p>");
html = "<p>" + html + "</p>";
// Clean up
html = html.replace(/<p><\/p>/g, "");
html = html.replace(/<p>(<h[1-6]>)/g, "$1");
html = html.replace(/(<\/h[1-6]>)<\/p>/g, "$1");
return html;
}
async function saveObsidianNote() {
if (!showMarkdownEditor.currentNote) {
tip("No note loaded", false, "error");
return;
}
let content = byId("obsidianMarkdownEditor").value;
const {path} = showMarkdownEditor.currentNote;
const elementId = showMarkdownEditor.elementId;
const coordinates = showMarkdownEditor.coordinates;
// Update/add frontmatter with FMG ID and coordinates
if (elementId && coordinates) {
content = updateFrontmatterWithFmgData(content, elementId, coordinates);
}
try {
await ObsidianBridge.updateNote(path, content);
// Update the FMG ID index if this note has an fmg-id
if (elementId) {
const {frontmatter} = ObsidianBridge.parseFrontmatter(content);
const fmgId = frontmatter["fmg-id"] || frontmatter.fmgId;
if (fmgId) {
// Add to index using internal method
ObsidianBridge.addToFmgIdIndex(fmgId, path);
}
}
showMarkdownEditor.originalContent = content;
// Update the editor to show the new frontmatter
byId("obsidianMarkdownEditor").value = content;
tip("Note saved to Obsidian vault (linked to FMG element)", true, "success", 3000);
} catch (error) {
ERROR && console.error("Failed to save note:", error);
tip("Failed to save note: " + error.message, true, "error", 5000);
}
}
function updateFrontmatterWithFmgData(content, elementId, coordinates) {
const {x, y} = coordinates;
const {frontmatter, content: bodyContent} = ObsidianBridge.parseFrontmatter(content);
// Update frontmatter with FMG data
frontmatter["fmg-id"] = elementId;
frontmatter["x"] = Math.round(x * 100) / 100;
frontmatter["y"] = Math.round(y * 100) / 100;
// Rebuild frontmatter
let frontmatterLines = ["---"];
for (const [key, value] of Object.entries(frontmatter)) {
if (typeof value === "object" && value !== null) {
// Handle nested objects
frontmatterLines.push(`${key}:`);
for (const [nestedKey, nestedValue] of Object.entries(value)) {
frontmatterLines.push(` ${nestedKey}: ${nestedValue}`);
}
} else {
frontmatterLines.push(`${key}: ${value}`);
}
}
frontmatterLines.push("---");
return frontmatterLines.join("\n") + "\n" + bodyContent;
}
function openInObsidian() {
if (!showMarkdownEditor.currentNote) return;
const {path} = showMarkdownEditor.currentNote;
const vaultName = ObsidianBridge.config.vaultName || "vault";
const obsidianUrl = `obsidian://open?vault=${encodeURIComponent(vaultName)}&file=${encodeURIComponent(path)}`;
window.open(obsidianUrl, "_blank");
tip("Opening in Obsidian app...", false, "success", 2000);
}
function togglePreviewMode() {
const editor = byId("obsidianMarkdownEditor");
const preview = byId("obsidianMarkdownPreview");
const isPreviewMode = editor.style.display === "none";
if (isPreviewMode) {
editor.style.display = "block";
preview.style.display = "none";
byId("togglePreview").textContent = "👁 Preview";
} else {
updateMarkdownPreview();
editor.style.display = "none";
preview.style.display = "block";
byId("togglePreview").textContent = "✏ Edit";
}
}

0
run_python_server.sh Normal file → Executable file
View file

View file

@ -13,7 +13,9 @@
* Example: 1.102.2 -> Major version 1, Minor version 102, Patch version 2
*/
const VERSION = "1.108.12";
const VERSION = "1.108.13";
if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format or parsing function");
{