diff --git a/CONTEXT.md b/CONTEXT.md
new file mode 100644
index 0000000000..b8598415c2
--- /dev/null
+++ b/CONTEXT.md
@@ -0,0 +1,97 @@
+# Chronicle Map
+
+A first-class chronicle system inside Wiki.js for pinning historical (or fictional) events onto era-specific maps, with page embeds and a master overview.
+
+## Language
+
+**Chronicle**:
+A first-class aggregate that owns era maps, events, and its own tags. Wiki pages embed or link to it; they do not own its data.
+_Avoid_: Map collection, project, atlas (as the top-level container)
+
+**Event**:
+A pin-able record inside a Chronicle — title, summary, when/where it occurred, and tags. May optionally link to one or more Wiki Pages for long-form detail; the pin itself is not a Page.
+_Avoid_: Page (for the pin), marker-only stub with no domain identity
+
+**Era Map**:
+A dated basemap (2D imagery or 3D scene) owned by a Chronicle, covering a time range. Multiple Era Maps coexist; each can be aligned to a shared spatial reference so the same Event can appear across eras despite scale and position drift.
+_Avoid_: Layer (as the map entity), basemap-only asset with no era identity
+
+**Canonical Position**:
+The Event's single authoritative location in the Chronicle's shared spatial reference (typically modern lat/lng, or a custom CRS for fictional worlds). Default source for projecting pins onto every Era Map.
+_Avoid_: Per-map coordinates (as the primary store), "roughly around here" without a stored point
+
+**Alignment**:
+A transform on an Era Map (control points / warp) that maps Canonical Positions onto that basemap's pixel or local space, correcting scale and position drift between eras.
+_Avoid_: Manual re-pinning every Event per map, ad-hoc UI pan/zoom offsets
+
+**Pin Override**:
+An optional per–Event-per–Era-Map position that replaces the Alignment projection when the default transform is wrong for that pin. Escape hatch only — Canonical Position remains the default.
+_Avoid_: Making per-map pins the normal path for every Event
+
+**Occurrence**:
+When an Event happened — a point or interval on the Chronicle timeline (may be approximate). Used to decide default Era Map visibility by intersection with each Era Map's time range.
+_Avoid_: "Date" alone when an interval or fuzzy span is meant; publication date of the wiki page
+
+**Visibility Override**:
+An optional per–Event-per–Era-Map force include or force exclude that replaces the default Occurrence∩Era-Map time-range rule.
+_Avoid_: Manually listing every Era Map for every Event as the normal path
+
+**Chronicle Tag**:
+A Chronicle-scoped label for filtering and grouping Events on maps (e.g. haunt, traffic death, urban, outlying islands). Independent of Wiki.js Page Tags.
+_Avoid_: Page Tag (for map filters), global taxonomy shared across unrelated Chronicles
+
+**Page Tag**:
+The existing Wiki.js flat tag on a Wiki Page. Used only for wiki content; not the primary map filter. An Event's linked Pages keep their own Page Tags.
+_Avoid_: Reusing Page Tags as the Chronicle's event taxonomy
+
+**Chronicle Overlay**:
+An optional map view mode that shows Events (and optionally Era Maps) from more than one Chronicle on the same canvas, with each Chronicle toggleable. Chronicles remain separate aggregates; overlay is a view concern, not a merge of data.
+_Avoid_: World Layer inside a single Chronicle; permanently merging taxonomies or alignments across Chronicles
+
+**Map View**:
+The single interactive map experience for exploring Chronicles — Era Maps, Event pins, filters, overlays, and (for editors) alignment tools. Reused everywhere; not duplicated per surface.
+_Avoid_: Separate “simple embed map” and “full map” products with different data rules
+
+**Chronicle Embed**:
+A Map View instance placed inside a Wiki Page, usually with a locked or narrowed scope (one Chronicle, Era Map, Tag set, or Event focus). Same underlying Map View as the master route.
+_Avoid_: Static thumbnail that is not a Map View; treating the embed as a second data store
+
+**Event Draft**:
+A proposed Event (from Ingest API or AI Analysis) that is not yet a live pin. Becomes an Event only after human confirmation. Same fields as Event, different lifecycle.
+_Avoid_: Writing AI/API output straight to live Events; a separate parallel data model unrelated to Event
+
+**Ingest API**:
+An HTTP/GraphQL API for submitting structured Event or Event Draft payloads from external systems. Does not bypass confirmation when the payload is marked as draft/AI-sourced.
+_Avoid_: Ad-hoc DB inserts; scraping hooks that publish live pins with no review path
+
+**AI Analysis**:
+A process that turns unstructured input (text, URL, bulk notes) into one or more Event Drafts — suggested title, Occurrence, Canonical Position, Chronicle Tags — for human review.
+_Avoid_: AI as the source of truth; AI that only fills a form with no draft record
+
+**Map Mode**:
+How an Era Map is presented in Map View: `2d`, `3d`, or `both` (user-switchable). Events still use a 2D Canonical Position by default; 3D is primarily a presentation concern, with optional elevation later.
+_Avoid_: Separate 2D-only and 3D-only Era Map entity types; treating 3D as a different aggregate
+
+**Basemap Source**:
+Where an Era Map's imagery or tiles come from. One of: Public Basemap, Uploaded Basemap, or Derived Basemap. Source type lives on the Era Map; it is not a separate aggregate.
+_Avoid_: Iframe-only external maps outside the Chronicle model; one-off assets with no source metadata
+
+**Public Basemap**:
+A Basemap Source that connects to an open or configured tile/map provider (e.g. OSM, satellite, government open layers) by provider id or URL. Preferred for real-world geographies already in a known CRS.
+_Avoid_: Re-uploading public tiles as static images when a provider works
+
+**Uploaded Basemap**:
+A Basemap Source from manually uploaded imagery or tile packs — fantasy maps, scanned historical maps, custom art. Usually requires Alignment.
+_Avoid_: Treating uploads as already georeferenced without Alignment
+
+**Derived Basemap**:
+A Basemap Source produced by AI/API conversion from an image or other map (e.g. suggested Alignment control points, cleaned tile asset). Stored as a draft asset until a human confirms and attaches it to an Era Map.
+_Avoid_: AI silently publishing live basemaps; calling this a third aggregate beside Era Map
+
+**Footprint**:
+Optional line or polygon geometry on an Event that shows extent (building, road stretch, district). Canonical Position remains required; Footprint does not replace it for alignment, overrides, or default AI geocoding.
+_Avoid_: Polygon-only Events with no point; treating Footprint as the primary pin
+
+**Chronicle Permission**:
+Access rules owned by the Chronicle domain — read map data, write Events, approve Event Drafts / Derived Basemaps, and manage Era Maps / Alignment. Independent of Wiki Page permissions; may be global or per-Chronicle via groups.
+_Avoid_: Inferring Chronicle edit rights from “can edit this wiki page”; admin-only as the only write model
diff --git a/client/client-app.js b/client/client-app.js
index f3dc05a8b6..88f40853f3 100644
--- a/client/client-app.js
+++ b/client/client-app.js
@@ -170,6 +170,7 @@ Vue.component('Register', () => import(/* webpackChunkName: "register" */ './com
Vue.component('SearchResults', () => import(/* webpackPrefetch: true, webpackChunkName: "ui-extra" */ './components/common/search-results.vue'))
Vue.component('SocialSharing', () => import(/* webpackPrefetch: true, webpackChunkName: "ui-extra" */ './components/common/social-sharing.vue'))
Vue.component('Tags', () => import(/* webpackChunkName: "tags" */ './components/tags.vue'))
+Vue.component('ChronicleMap', () => import(/* webpackChunkName: "chronicle" */ './components/chronicle-map.vue'))
Vue.component('Unauthorized', () => import(/* webpackChunkName: "unauthorized" */ './components/unauthorized.vue'))
Vue.component('VCardChin', () => import(/* webpackPrefetch: true, webpackChunkName: "ui-extra" */ './components/common/v-card-chin.vue'))
Vue.component('VCardInfo', () => import(/* webpackPrefetch: true, webpackChunkName: "ui-extra" */ './components/common/v-card-info.vue'))
diff --git a/client/components/admin.vue b/client/components/admin.vue
index aa6ad23c01..6721398309 100644
--- a/client/components/admin.vue
+++ b/client/components/admin.vue
@@ -39,6 +39,9 @@
:dark='pendingReviewsCount > 0'
)
.caption(:class='pendingReviewsCount > 0 ? `` : `grey--text`') {{ pendingReviewsCount }}
+ v-list-item(to='/chronicles', color='primary', v-if='hasPermission([`manage:system`, `manage:chronicles`])')
+ v-list-item-avatar(size='24', tile): v-icon mdi-map-clock-outline
+ v-list-item-title Chronicles
v-list-item(to='/tags', v-if='hasPermission([`manage:system`])')
v-list-item-avatar(size='24', tile): v-icon mdi-tag-multiple
v-list-item-title {{ $t('admin:tags.title') }}
@@ -163,6 +166,7 @@ const router = new VueRouter({
{ path: '/pages/:id(\\d+)', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-pages-edit.vue') },
{ path: '/pages/visualize', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-pages-visualize.vue') },
{ path: '/page-reviews', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-page-reviews.vue') },
+ { path: '/chronicles', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-chronicles.vue') },
{ path: '/tags', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-tags.vue') },
{ path: '/theme', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-theme.vue') },
{ path: '/groups', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-groups.vue') },
diff --git a/client/components/admin/admin-chronicles.vue b/client/components/admin/admin-chronicles.vue
new file mode 100644
index 0000000000..a61f50d356
--- /dev/null
+++ b/client/components/admin/admin-chronicles.vue
@@ -0,0 +1,574 @@
+
+ v-container(fluid, grid-list-lg)
+ v-layout(row wrap)
+ v-flex(xs12)
+ .admin-header
+ img.animated.fadeInUp(src='/_assets/svg/icon-file.svg', alt='Chronicles', style='width: 80px;')
+ .admin-header-title
+ .headline.blue--text.text--darken-2.animated.fadeInLeft Chronicles
+ .subtitle-1.grey--text.animated.fadeInLeft.wait-p2s Era maps, events, AI drafts
+ v-spacer
+ v-btn.mr-2.animated.fadeInDown(outlined, color='primary', href='/chronicle', target='_blank')
+ v-icon(left) mdi-map
+ span Open map
+ v-btn.animated.fadeInDown(color='primary', dark, @click='openCreate')
+ v-icon(left) mdi-plus
+ span New Chronicle
+ v-card.mt-3.animated.fadeInUp
+ v-data-table(
+ :items='chronicles'
+ :headers='headers'
+ :loading='loading'
+ hide-default-footer
+ :items-per-page='50'
+ )
+ template(slot='item', slot-scope='props')
+ tr.is-clickable(@click='openEdit(props.item)')
+ td {{ props.item.id }}
+ td
+ .body-2: strong {{ props.item.title }}
+ .caption /chronicle/{{ props.item.slug }}
+ td
+ v-chip(small, :color='props.item.isPublished ? `green` : `grey`', dark) {{ props.item.isPublished ? 'published' : 'draft' }}
+ td {{ props.item.updatedAt | moment('calendar') }}
+ template(slot='no-data')
+ v-alert.ma-3(icon='mdi-map-outline', :value='true', outlined) No chronicles yet.
+
+ //- Chronicle editor
+ v-dialog(v-model='dialog', max-width='960', scrollable)
+ v-card(v-if='form')
+ v-card-title {{ form.id ? 'Edit Chronicle' : 'New Chronicle' }}
+ v-divider
+ v-card-text
+ v-text-field(v-model='form.title', label='Title', outlined, dense)
+ v-text-field(v-model='form.slug', label='Slug', outlined, dense, hint='Used in /chronicle/{slug}', persistent-hint)
+ v-textarea(v-model='form.description', label='Description', outlined, dense, rows='2')
+ v-switch(v-model='form.isPublished', label='Published', inset)
+ template(v-if='form.id')
+ v-tabs(v-model='tab')
+ v-tab Era Maps
+ v-tab Events
+ v-tab AI / Text ingest
+ v-tab-item.pt-3
+ v-list(dense)
+ v-list-item(v-for='em in eraMaps', :key='em.id', @click='openEraMap(em)')
+ v-list-item-content
+ v-list-item-title {{ em.title }}
+ v-list-item-subtitle {{ em.timeStart || '…' }} → {{ em.timeEnd || '…' }} · {{ em.basemapSource }} · {{ em.mapMode }} · {{ em.status }}
+ v-list-item-action
+ v-btn(icon, small, @click.stop='deleteEraMap(em.id)')
+ v-icon mdi-delete-outline
+ v-btn.mt-2(small, outlined, color='primary', @click='openEraMap(null)') Add Era Map
+ v-tab-item.pt-3
+ v-list(dense)
+ v-list-item(v-for='ev in events', :key='ev.id', @click='openEvent(ev)')
+ v-list-item-content
+ v-list-item-title {{ ev.title }}
+ v-list-item-subtitle {{ ev.occurrenceStart || '?' }} · {{ ev.lat }}, {{ ev.lng }} · {{ ev.status }}
+ v-list-item-action
+ v-btn(v-if='ev.status === `draft`', icon, small, color='green', @click.stop='approveDraft(ev.id)')
+ v-icon mdi-check
+ v-btn(icon, small, @click.stop='deleteEvent(ev.id)')
+ v-icon mdi-delete-outline
+ v-btn.mt-2.mr-2(small, outlined, color='primary', @click='openEvent(null)') Add Event
+ v-btn.mt-2(small, text, @click='addSampleEvent') Sample HK pin
+ v-tab-item.pt-3
+ .caption.grey--text.mb-2 Heuristic analysis (no external LLM). Parses places/years from text into Event Drafts.
+ v-textarea(v-model='aiText', label='Paste notes / articles (one event per line)', outlined, rows='6')
+ v-btn(color='primary', dark, :loading='acting', :disabled='!aiText', @click='runAnalyze') Analyze → Drafts
+ v-divider
+ v-card-actions
+ v-btn(v-if='form.id', text, color='error', :loading='acting', @click='remove') Delete
+ v-spacer
+ v-btn(text, @click='dialog = false') Close
+ v-btn(color='primary', dark, :loading='acting', @click='save') Save Chronicle
+
+ //- Era Map editor
+ v-dialog(v-model='eraDialog', max-width='720', scrollable)
+ v-card(v-if='eraForm')
+ v-card-title {{ eraForm.id ? 'Edit Era Map' : 'New Era Map' }}
+ v-divider
+ v-card-text
+ v-text-field(v-model='eraForm.title', label='Title', outlined, dense)
+ v-layout(row, wrap)
+ v-flex(xs6, class='pr-2')
+ v-text-field(v-model='eraForm.timeStart', label='Time start (year)', outlined, dense)
+ v-flex(xs6, class='pl-2')
+ v-text-field(v-model='eraForm.timeEnd', label='Time end (year)', outlined, dense)
+ v-select(v-model='eraForm.mapMode', :items='mapModes', label='Map Mode', outlined, dense)
+ v-select(v-model='eraForm.basemapSource', :items='basemapSources', label='Basemap Source', outlined, dense, @change='onBasemapSourceChange')
+ v-select(v-model='eraForm.status', :items='["live", "draft"]', label='Status', outlined, dense)
+ template(v-if='eraForm.basemapSource === "public"')
+ v-text-field(v-model='eraForm.tileUrl', label='Tile URL template', outlined, dense)
+ v-text-field(v-model='eraForm.centerLat', label='Center lat', outlined, dense, type='number', step='any')
+ v-text-field(v-model='eraForm.centerLng', label='Center lng', outlined, dense, type='number', step='any')
+ v-text-field(v-model='eraForm.zoom', label='Zoom', outlined, dense, type='number')
+ template(v-else)
+ v-text-field(v-model='eraForm.imageUrl', label='Image URL (uploaded / derived asset)', outlined, dense)
+ v-text-field(v-model='eraForm.boundSouth', label='Bounds south', outlined, dense, type='number', step='any')
+ v-text-field(v-model='eraForm.boundWest', label='Bounds west', outlined, dense, type='number', step='any')
+ v-text-field(v-model='eraForm.boundNorth', label='Bounds north', outlined, dense, type='number', step='any')
+ v-text-field(v-model='eraForm.boundEast', label='Bounds east', outlined, dense, type='number', step='any')
+ v-select(v-model='eraForm.alignmentType', :items='alignmentTypes', label='Alignment', outlined, dense)
+ v-textarea(
+ v-if='eraForm.alignmentType === "controlPoints"'
+ v-model='eraForm.alignmentJson'
+ label='Alignment JSON (control points)'
+ outlined
+ dense
+ rows='4'
+ hint='{"type":"controlPoints","points":[{"canonical":{"lat":22.3,"lng":114.2},"map":{"x":0.5,"y":0.4}}]}'
+ persistent-hint
+ )
+ v-divider
+ v-card-actions
+ v-spacer
+ v-btn(text, @click='eraDialog = false') Cancel
+ v-btn(color='primary', dark, :loading='acting', @click='saveEraMap') Save Era Map
+
+ //- Event editor
+ v-dialog(v-model='eventDialog', max-width='720', scrollable)
+ v-card(v-if='eventForm')
+ v-card-title {{ eventForm.id ? 'Edit Event' : 'New Event' }}
+ v-divider
+ v-card-text
+ v-text-field(v-model='eventForm.title', label='Title', outlined, dense)
+ v-textarea(v-model='eventForm.summary', label='Summary', outlined, dense, rows='3')
+ v-layout(row, wrap)
+ v-flex(xs6, class='pr-2')
+ v-text-field(v-model='eventForm.occurrenceStart', label='Occurrence start', outlined, dense)
+ v-flex(xs6, class='pl-2')
+ v-text-field(v-model='eventForm.occurrenceEnd', label='Occurrence end', outlined, dense)
+ v-switch(v-model='eventForm.occurrenceFuzzy', label='Fuzzy date', inset)
+ v-layout(row, wrap)
+ v-flex(xs6, class='pr-2')
+ v-text-field(v-model.number='eventForm.lat', label='Canonical lat', outlined, dense, type='number', step='any')
+ v-flex(xs6, class='pl-2')
+ v-text-field(v-model.number='eventForm.lng', label='Canonical lng', outlined, dense, type='number', step='any')
+ v-combobox(v-model='eventForm.tags', label='Chronicle Tags', multiple, chips, small-chips, outlined, dense, hint='haunt, urban, outlying…', persistent-hint)
+ v-select(v-model='eventForm.status', :items='["live", "draft"]', label='Status', outlined, dense)
+ v-divider
+ v-card-actions
+ v-spacer
+ v-btn(text, @click='eventDialog = false') Cancel
+ v-btn(color='primary', dark, :loading='acting', @click='saveEvent') Save Event
+
+
+
+
+
diff --git a/client/components/admin/admin-groups-edit-permissions.vue b/client/components/admin/admin-groups-edit-permissions.vue
index 0e955c7053..5f5d9cabd9 100644
--- a/client/components/admin/admin-groups-edit-permissions.vue
+++ b/client/components/admin/admin-groups-edit-permissions.vue
@@ -155,6 +155,34 @@ export default {
warning: false,
restrictedForSystem: true,
disabled: false
+ },
+ {
+ permission: 'read:chronicles',
+ hint: 'Can view Chronicle maps and event pins',
+ warning: false,
+ restrictedForSystem: false,
+ disabled: false
+ },
+ {
+ permission: 'write:chronicles',
+ hint: 'Can create and edit Chronicle events (and submit drafts)',
+ warning: false,
+ restrictedForSystem: true,
+ disabled: false
+ },
+ {
+ permission: 'approve:chronicles',
+ hint: 'Can approve Event Drafts and Derived Basemaps',
+ warning: true,
+ restrictedForSystem: true,
+ disabled: false
+ },
+ {
+ permission: 'manage:chronicles',
+ hint: 'Can create Chronicles, Era Maps, Alignment, and manage all chronicle data',
+ warning: true,
+ restrictedForSystem: true,
+ disabled: false
}
]
},
diff --git a/client/components/chronicle-map.vue b/client/components/chronicle-map.vue
new file mode 100644
index 0000000000..0935c552b3
--- /dev/null
+++ b/client/components/chronicle-map.vue
@@ -0,0 +1,346 @@
+
+ component(:is='embedded ? "div" : "v-app"', :dark='!embedded && $vuetify.theme.dark')
+ nav-header(v-if='!embedded')
+ .chronicle-map-view(:class='{ "chronicle-map-view--page": !embedded }')
+ .chronicle-map-toolbar
+ .chronicle-map-title {{ title }}
+ v-select.chronicle-select(
+ v-if='chronicleOptions.length'
+ v-model='slug'
+ :items='chronicleOptions'
+ item-text='title'
+ item-value='slug'
+ dense
+ outlined
+ hide-details
+ label='Chronicle'
+ style='max-width: 280px;'
+ @change='reload'
+ )
+ v-select.chronicle-select(
+ v-if='eraMapOptions.length'
+ v-model='eraMapId'
+ :items='eraMapOptions'
+ item-text='title'
+ item-value='id'
+ dense
+ outlined
+ hide-details
+ label='Era Map'
+ style='max-width: 240px;'
+ @change='reload'
+ )
+ v-select.chronicle-select(
+ v-if='!embedded && overlayChoices.length'
+ v-model='activeOverlays'
+ :items='overlayChoices'
+ item-text='title'
+ item-value='slug'
+ dense
+ outlined
+ hide-details
+ multiple
+ chips
+ small-chips
+ label='Overlay Chronicles'
+ style='max-width: 320px;'
+ @change='reload'
+ )
+ v-chip-group(v-if='tagOptions.length', column)
+ v-chip(
+ v-for='t in tagOptions'
+ :key='t'
+ filter
+ :input-value='selectedTags.includes(t)'
+ @click='toggleTag(t)'
+ ) {{ t }}
+ .chronicle-map-canvas(ref='mapEl')
+ .chronicle-map-sidebar(v-if='selected')
+ .subtitle-1 {{ selected.title }}
+ .caption.grey--text {{ selected.occurrenceStart }}{{ selected.occurrenceEnd && selected.occurrenceEnd !== selected.occurrenceStart ? ' – ' + selected.occurrenceEnd : '' }}
+ p.body-2.mt-2 {{ selected.summary }}
+ .caption(v-if='selected.tags && selected.tags.length') Tags: {{ selected.tags.map(t => t.tag).join(', ') }}
+ v-btn.mt-3(v-if='embedded', text, small, color='primary', :href='`/chronicle/${slug}`') Open full map
+
+
+
+
+
diff --git a/client/themes/default/components/chronicle-embed.vue b/client/themes/default/components/chronicle-embed.vue
new file mode 100644
index 0000000000..4547b53cb4
--- /dev/null
+++ b/client/themes/default/components/chronicle-embed.vue
@@ -0,0 +1,31 @@
+
+ chronicle-map(
+ :initial-slug='slug'
+ :initial-tags='tagList'
+ :initial-era-map-id='eraMapIdNum'
+ :embedded='true'
+ :height='height || "420px"'
+ )
+
+
+
diff --git a/client/themes/default/components/page.vue b/client/themes/default/components/page.vue
index 73f314f671..d3836bb314 100644
--- a/client/themes/default/components/page.vue
+++ b/client/themes/default/components/page.vue
@@ -393,6 +393,7 @@