Storyteller Suite - Map System

Overview

The map system provides interactive maps for worldbuilding using Leaflet.js. Features include:

Technology Stack

Library Purpose
leaflet Core map rendering
leaflet-draw Drawing tools
leaflet.markercluster Marker clustering

Map Code Block

Maps are embedded in Markdown using code blocks:

```storyteller-map
id: map-001
name: Kingdom Map
type: image
image: maps/kingdom.png
width: 2048
height: 1536
zoom: 2
center: [768, 512]
markers:
  - id: loc-001
    name: Castle Stormhaven
    lat: 400
    lng: 600
    icon: castle
    color: gold
```

Processor (processor.ts)

// Register code block processor
this.registerMarkdownCodeBlockProcessor('storyteller-map', 
    async (source, el, ctx) => {
        const config = parseMapConfig(source);
        const container = el.createDiv({ cls: 'storyteller-map-container' });
        await renderMap(container, config, this.plugin);
    }
);

Map Types

Image Map

Custom artwork as the base layer:

const map: StoryMap = {
    type: 'image',
    backgroundImagePath: 'maps/world.png',
    width: 4096,
    height: 2048,
    // Bounds calculated from dimensions
};

// ObsidianTileLayer.ts - Custom tile loading
class ObsidianTileLayer extends L.TileLayer {
    getTileUrl(coords: L.Coords): string {
        return this.plugin.getImagePath(this.imagePath);
    }
}

Real-World Map

Using OpenStreetMap or other tile servers:

const map: StoryMap = {
    type: 'real',
    lat: 51.505,
    lng: -0.09,
    zoom: 13,
    tileUrl: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
};

// Standard Leaflet tile layer
L.tileLayer(map.tileUrl, {
    attribution: '© OpenStreetMap contributors'
}).addTo(leafletMap);

Map Configuration

Frontmatter Fields

name: "World Map"
id: "map-001"
type: "image"
scale: "world"
backgroundImagePath: "maps/world.png"
width: 4096
height: 2048
zoom: 2
minZoom: 1
maxZoom: 5
correspondingLocationId: "loc-world"
parentMapId: null
childMapIds:
  - "map-002"  # Northern Kingdom
  - "map-003"  # Southern Empire
markers:
  - id: "marker-001"
    entityType: "location"
    entityId: "loc-001"
    lat: 1024
    lng: 512
    icon: "castle"
    color: "#gold"
    popup: "Castle Stormhaven - Capital"
layers:
  - id: "layer-001"
    name: "Political Borders"
    visible: true
    geojson: "maps/borders.json"

Entity Markers

MapMarker Interface

interface MapMarker {
    id: string;
    entityType: 'character' | 'location' | 'event' | 'item';
    entityId: string;
    lat: number;
    lng: number;
    icon?: string;
    color?: string;
    popup?: string;
    tooltip?: string;
}

Marker Discovery

EntityMarkerDiscovery.ts auto-discovers markers from:

  1. Frontmatter - Entities with location or mapmarkers field
  2. DataView - Query results (requires DataView plugin)
class EntityMarkerDiscovery {
    async discoverMarkers(mapId: string): Promise<MapMarker[]> {
        const markers: MapMarker[] = [];
        
        // From frontmatter
        if (this.settings.enableFrontmatterMarkers) {
            const locations = await this.plugin.listLocations();
            for (const loc of locations) {
                if (loc.coordinates) {
                    markers.push(this.locationToMarker(loc));
                }
            }
        }
        
        // From DataView
        if (this.settings.enableDataViewMarkers && this.hasDataView()) {
            const dvMarkers = await this.queryDataViewMarkers(mapId);
            markers.push(...dvMarkers);
        }
        
        return markers;
    }
}

Marker Rendering

MapEntityRenderer.ts handles marker display:

class MapEntityRenderer {
    renderMarker(marker: MapMarker, map: L.Map): L.Marker {
        const icon = this.getIcon(marker.icon, marker.color);
        const leafletMarker = L.marker([marker.lat, marker.lng], { icon });
        
        // Popup with entity details
        if (marker.popup) {
            leafletMarker.bindPopup(marker.popup);
        }
        
        // Tooltip on hover
        if (marker.tooltip) {
            leafletMarker.bindTooltip(marker.tooltip);
        }
        
        // Click to open entity
        leafletMarker.on('click', () => {
            this.openEntity(marker.entityType, marker.entityId);
        });
        
        return leafletMarker;
    }
    
    getIcon(iconName: string, color: string): L.Icon {
        // Custom icon rendering
        return L.divIcon({
            className: 'storyteller-marker',
            html: `<span style="color: ${color}">${this.getIconHtml(iconName)}</span>`,
        });
    }
}

Map Hierarchy

Scale Levels

Scale Description Example
world Entire world Planet map
region Large area Kingdom, continent
city Urban area City, town
building Single structure Castle, dungeon
custom User-defined Any scale

MapHierarchyManager.ts

class MapHierarchyManager {
    // Get path from root to map
    async getMapPath(mapId: string): Promise<StoryMap[]> {
        const path: StoryMap[] = [];
        let current = await this.getMap(mapId);
        
        while (current) {
            path.unshift(current);
            if (current.parentMapId) {
                current = await this.getMap(current.parentMapId);
            } else {
                break;
            }
        }
        
        return path;
    }
    
    // Get all child maps
    async getChildMaps(mapId: string): Promise<StoryMap[]> {
        const allMaps = await this.listMaps();
        return allMaps.filter(m => m.parentMapId === mapId);
    }
    
    // Sync with location hierarchy
    async syncMapLocationHierarchy(mapId: string): Promise<void> {
        // Ensure map parent/child matches location parent/child
    }
}

Location-Map Correspondence

Every map has a corresponding location:

// Map → Location
map.correspondingLocationId = 'loc-kingdom';

// Location → Map
location.correspondingMapId = 'map-kingdom';

Auto-linking

// MapHierarchyManager.ts
async autoLinkMapsToLocations(): Promise<void> {
    const maps = await this.listMaps();
    
    for (const map of maps) {
        if (!map.correspondingLocationId) {
            // Create or find matching location
            const location = await this.findOrCreateLocation(map);
            map.correspondingLocationId = location.id;
            location.correspondingMapId = map.id;
            
            await this.saveMap(map);
            await this.saveLocation(location);
        }
    }
}

Map Modal (MapModal.ts)

Create/edit maps:

class MapModal extends ResponsiveModal {
    private map: StoryMap;
    
    onOpen(): void {
        // Name and description
        this.addTextField('name');
        this.addTextArea('description');
        
        // Map type
        this.addDropdown('type', ['image', 'real']);
        
        // Scale
        this.addDropdown('scale', ['world', 'region', 'city', 'building', 'custom']);
        
        // Corresponding location
        this.addLocationPicker('correspondingLocationId');
        
        // Image map settings
        if (this.map.type === 'image') {
            this.addImagePicker('backgroundImagePath');
            this.addNumberField('width');
            this.addNumberField('height');
        }
        
        // Real-world map settings
        if (this.map.type === 'real') {
            this.addNumberField('lat');
            this.addNumberField('lng');
            this.addNumberField('zoom');
        }
        
        // Markers section
        this.renderMarkerList();
    }
}

Entity Linking

EntityLinker.ts manages entity-marker connections:

class EntityLinker {
    // Add entity to map
    async addEntityToMap(
        mapId: string, 
        entityType: string, 
        entityId: string, 
        coordinates: { lat: number; lng: number }
    ): Promise<void> {
        const map = await this.getMap(mapId);
        
        map.markers = map.markers || [];
        map.markers.push({
            id: `marker-${Date.now()}`,
            entityType,
            entityId,
            lat: coordinates.lat,
            lng: coordinates.lng,
        });
        
        await this.saveMap(map);
    }
    
    // Remove entity from map
    async removeEntityFromMap(mapId: string, markerId: string): Promise<void> {
        const map = await this.getMap(mapId);
        map.markers = map.markers?.filter(m => m.id !== markerId);
        await this.saveMap(map);
    }
}

Tile Generation

TileGenerator.ts for large images:

class TileGenerator {
    // Split large image into tiles for performance
    async generateTiles(imagePath: string, options: TileOptions): Promise<void> {
        // Creates pyramid of tiles at different zoom levels
    }
}

RasterCoords Utility

RasterCoords.ts converts between pixel and lat/lng:

class RasterCoords {
    constructor(map: L.Map, imgSize: [number, number]) {
        this.imgWidth = imgSize[0];
        this.imgHeight = imgSize[1];
    }
    
    // Pixel coordinates to lat/lng
    unproject(point: L.Point): L.LatLng {
        return this.map.unproject(point, this.map.getMaxZoom());
    }
    
    // Lat/lng to pixel coordinates
    project(latlng: L.LatLng): L.Point {
        return this.map.project(latlng, this.map.getMaxZoom());
    }
    
    // Set view to show entire image
    setMaxBounds(): void {
        const southWest = this.unproject([0, this.imgHeight]);
        const northEast = this.unproject([this.imgWidth, 0]);
        this.map.setMaxBounds(new L.LatLngBounds(southWest, northEast));
    }
}

Map Config Parser

parser.ts parses YAML map configurations:

function parseMapConfig(source: string): MapConfig {
    const yaml = parseYaml(source);
    
    return {
        id: yaml.id,
        name: yaml.name,
        type: yaml.type || 'image',
        image: yaml.image || yaml.backgroundImagePath,
        width: yaml.width,
        height: yaml.height,
        lat: yaml.lat,
        lng: yaml.lng,
        zoom: yaml.zoom || 2,
        minZoom: yaml.minZoom || 0,
        maxZoom: yaml.maxZoom || 5,
        markers: yaml.markers || [],
        layers: yaml.layers || [],
    };
}

AddEntityToLocationModal

Picker for adding entities to maps:

class AddEntityToLocationModal extends ResponsiveModal {
    constructor(
        app: App,
        plugin: StorytellerSuitePlugin,
        map: StoryMap,
        coordinates: { lat: number; lng: number },
        onAdd: (marker: MapMarker) => void
    ) { ... }
    
    onOpen(): void {
        // Entity type selector
        this.addDropdown('entityType', ['character', 'location', 'event', 'item']);
        
        // Entity picker (filtered by type)
        this.addEntityPicker();
        
        // Marker customization
        this.addDropdown('icon', iconOptions);
        this.addColorPicker('color');
        
        // Confirm button
        this.addButton('Add to Map', () => this.addMarker());
    }
}

Settings

Map-related settings:

Setting Type Default Description
enableFrontmatterMarkers boolean true Auto-detect from frontmatter
enableDataViewMarkers boolean false Query DataView for markers
mapsFolder string 'Maps' Map file location