Storyteller Suite - Map System
Overview
The map system provides interactive maps for worldbuilding using Leaflet.js. Features include:
- Image-based maps (custom artwork)
- Real-world maps (tile servers)
- Entity markers (characters, locations, events)
- Marker clustering
- Map hierarchy (world → region → city → building)
- Location-map correspondence
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:
- Frontmatter - Entities with
locationormapmarkersfield - 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 |