fuzzyworld

tower / commit

feat(map): unrolled surface map with editing, terrain and permalinks

09a64747b965543d0784d6cef2f7dccd2f69b45c · Pavel Safronov · 2026-09-06 20:14:04 -0700 · parents e5555695f909

feat(map): unrolled surface map with editing, terrain and permalinks

A cylinder unrolls exactly, so the map has no projection distortion
anywhere. It shows the surface once rather than tiling, which means it needs
an edge — and the edge falls opposite the capital, the antimeridian
principle applied to habitation instead of geology. Sightlines whose short
route crosses that edge are drawn in two segments so the line always matches
the distance reported.

The rendered terrain is drawn underneath, and area regions become outlines
rather than fills when it is showing: the raster already says where the
desert is. Labels are placed by priority and dropped on collision, or the
mountain settlements are an unreadable smear.

Writer mode is a UI mode, not a security boundary — with no backend an
editor can only change the visitor's own copy. It is the default for now
while the world is still being built; ?reader=1 previews the read-only view.
Readers get their own detail card rather than a disabled editor, because
un-hiding the edit panel on selection had been leaking the private notes
field to them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 index.html           |  94 +++++++++
 src/main.ts          |  10 +
 src/style.css        |  78 +++++++
 src/ui/app.ts        | 531 ++++++++++++++++++++++++++++++++++++++++++++++
 src/views/map.ts     | 585 +++++++++++++++++++++++++++++++++++++++++++++++++++
 src/views/palette.ts |  18 ++
 6 files changed, 1316 insertions(+)

diff --git a/index.html b/index.html
new file mode 100644
index 0000000..baeab99
--- /dev/null
+++ b/index.html
@@ -0,0 +1,94 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1" />
+<title>Tower — Map</title>
+<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='14' font-size='14'>🛰</text></svg>" />
+</head>
+<body>
+<div id="app">
+  <main>
+    <canvas id="map"></canvas>
+    <div id="cursor" class="overlay hidden"></div>
+    <div id="sightline" class="overlay hidden"></div>
+  </main>
+  <aside>
+    <header>
+      <h1>Tower</h1>
+      <span id="mode" class="pill"></span>
+    </header>
+
+    <section id="tools-section">
+      <h2>Place</h2>
+      <div id="tools" class="tools"></div>
+      <p class="hint" id="tool-hint">Click a marker to select. Drag to move. Shift-click two points to measure a sightline.</p>
+    </section>
+
+    <section>
+      <h2>World <span id="counts" class="dim"></span></h2>
+      <ul id="list" class="list"></ul>
+    </section>
+
+    <section id="detail" class="hidden">
+      <h2>Selected</h2>
+      <p id="d-name"></p>
+      <p id="d-blurb" class="dim"></p>
+      <p id="d-where" class="dim"></p>
+    </section>
+
+    <section id="editor" class="hidden">
+      <h2>Selected</h2>
+      <label>Name <input id="f-name" type="text" /></label>
+      <label>Kind <select id="f-kind"></select></label>
+      <label>ID <span class="dim">— permalink target; follows the name until you edit it</span>
+        <input id="f-id" type="text" /></label>
+      <label>Public blurb <textarea id="f-blurb" rows="2" placeholder="Shown to readers."></textarea></label>
+      <label>Private notes <textarea id="f-notes" rows="3" placeholder="Never leaves your machine unless you export a backup."></textarea></label>
+      <label class="row"><input id="f-public" type="checkbox" /> Publish to readers</label>
+      <p class="dim" id="f-where"></p>
+      <button id="f-delete" class="danger">Delete</button>
+    </section>
+
+    <section id="canon-section">
+      <h2>Canon</h2>
+      <label>Star slats <span id="slat-value" class="dim"></span>
+        <input id="f-slats" type="range" min="0" max="16" step="1" /></label>
+      <p class="hint" id="slat-derived"></p>
+    </section>
+
+    <section id="about-section">
+      <h2>About</h2>
+      <p class="hint">Tower is the setting of a novel. This map is its companion piece —
+        the canon behind it is worked out in the open.</p>
+      <p class="links">
+        <a href="https://gitlab.com/PavelSafronov/tower" target="_blank" rel="noopener">Source on GitLab</a>
+        <a href="https://gitlab.com/PavelSafronov/tower/-/blob/main/design.md" target="_blank" rel="noopener">Canon</a>
+        <a href="https://gitlab.com/PavelSafronov/tower/-/blob/main/decisions.md" target="_blank" rel="noopener">Decisions</a>
+      </p>
+    </section>
+
+    <section id="view-section">
+      <h2>View</h2>
+      <label class="row"><input id="f-terrain" type="checkbox" checked /> Show terrain</label>
+      <p class="hint">Rendered by <code>npm run terrain</code> from
+        <code>content/geography.json</code>.</p>
+    </section>
+
+    <section id="data-section">
+      <h2>Data</h2>
+      <div id="save" class="save"></div>
+      <button id="export-backup">Export backup <span class="dim">— everything</span></button>
+      <button id="export-static">Export static map content <span class="dim">— public only</span></button>
+      <button id="import">Import backup…</button>
+      <input id="import-file" type="file" accept="application/json" hidden />
+      <p class="hint">
+        Backups carry your private notes; keep them out of a public repo.
+        The static export is <code>public/world.json</code> — the default readers see.
+      </p>
+    </section>
+  </aside>
+</div>
+<script type="module" src="/src/main.ts"></script>
+</body>
+</html>
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..c091c91
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,10 @@
+import './style.css'
+import { App } from './ui/app.js'
+import { loadSlatCount } from './canon/slats.js'
+import { WorldStore } from './world/store.js'
+
+loadSlatCount()
+const store = new WorldStore()
+// The committed public world seeds a browser with no local draft yet.
+await store.load(`${import.meta.env.BASE_URL}world.json`)
+new App(store)
diff --git a/src/style.css b/src/style.css
new file mode 100644
index 0000000..1c5d33e
--- /dev/null
+++ b/src/style.css
@@ -0,0 +1,78 @@
+:root {
+  --bg: #0b0d12; --panel: #131722; --line: #232a3a;
+  --fg: #dde3ee; --dim: #8892a6; --accent: #7fd4ff; --danger: #e06c6c;
+  color-scheme: dark;
+}
+* { box-sizing: border-box; }
+html, body { height: 100%; margin: 0; }
+body {
+  background: var(--bg); color: var(--fg);
+  font: 13px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
+}
+#app { display: grid; grid-template-columns: 1fr 340px; height: 100%; }
+main { position: relative; overflow: hidden; }
+canvas { width: 100%; height: 100%; display: block; cursor: crosshair; touch-action: none; }
+
+.overlay {
+  position: absolute; bottom: 12px; left: 12px;
+  background: rgba(10,13,20,0.86); border: 1px solid var(--line); border-radius: 6px;
+  padding: 8px 10px; font: 11px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
+  pointer-events: none; white-space: pre;
+}
+#sightline { left: auto; right: 12px; }
+.hidden { display: none !important; }
+
+aside {
+  background: var(--panel); border-left: 1px solid var(--line);
+  overflow-y: auto; padding: 0 14px 24px;
+}
+header { display: flex; align-items: center; gap: 8px; padding: 14px 0 4px; }
+h1 { font-size: 15px; margin: 0; letter-spacing: 0.08em; text-transform: uppercase; }
+h2 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em;
+     color: var(--dim); margin: 18px 0 8px; }
+section { border-top: 1px solid var(--line); }
+section:first-of-type { border-top: none; }
+.pill { font-size: 10px; padding: 2px 7px; border-radius: 999px;
+        background: #26314a; color: var(--accent); letter-spacing: 0.06em; }
+.dim { color: var(--dim); font-weight: 400; }
+.hint { color: var(--dim); font-size: 11px; margin: 8px 0 14px; }
+code { background: #0b0d12; padding: 1px 4px; border-radius: 3px; }
+
+.tools { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
+.tools button { text-align: center; }
+button {
+  width: 100%; margin-bottom: 6px; padding: 7px 10px; border-radius: 6px;
+  border: 1px solid var(--line); background: #1b2130; color: var(--fg);
+  font: inherit; font-size: 12px; cursor: pointer;
+}
+button:hover { background: #222a3d; }
+button.active { border-color: var(--accent); color: var(--accent); background: #16233a; }
+button.danger { border-color: #4a2a2a; color: var(--danger); }
+button.danger:hover { background: #2a1a1a; }
+
+.list { list-style: none; margin: 0; padding: 0; max-height: 260px; overflow-y: auto; }
+.list li { display: flex; align-items: center; gap: 8px; padding: 5px 6px;
+           border-radius: 5px; cursor: pointer; }
+.list li:hover { background: #1b2130; }
+.list li.sel { background: #1d2942; }
+.list .swatch { width: 9px; height: 9px; border-radius: 50%; flex: none; }
+.list .nm { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.list .kd { color: var(--dim); font-size: 10px; }
+.list li.private .nm { opacity: 0.6; font-style: italic; }
+
+#detail p { margin: 0 0 8px; }
+#d-name { font-size: 14px; font-weight: 600; }
+label { display: block; margin-bottom: 9px; color: var(--dim); font-size: 11px; }
+label.row { display: flex; align-items: center; gap: 7px; color: var(--fg); font-size: 12px; }
+input[type=text], select, textarea {
+  width: 100%; margin-top: 3px; padding: 6px 8px; border-radius: 5px;
+  border: 1px solid var(--line); background: #0e1219; color: var(--fg);
+  font: inherit; font-size: 12px; resize: vertical;
+}
+input[type=range] { width: 100%; margin-top: 6px; accent-color: var(--accent); }
+.warn { color: #e8c46a; }
+.links { display: flex; flex-wrap: wrap; gap: 4px 12px; margin: 0 0 14px; font-size: 11px; }
+.links a { color: var(--accent); text-decoration: none; }
+.links a:hover { text-decoration: underline; }
+.save { font-size: 11px; color: var(--dim); margin-bottom: 10px; }
+.save.error { color: var(--danger); }
diff --git a/src/ui/app.ts b/src/ui/app.ts
new file mode 100644
index 0000000..4093618
--- /dev/null
+++ b/src/ui/app.ts
@@ -0,0 +1,531 @@
+/**
+ * Writer-mode UI. See design.md §3.
+ *
+ * The loop this serves: place and name things on the map, autosaved to localStorage
+ * continuously; export a full backup whenever; export the static public content
+ * once to seed `public/world.json` in the repo.
+ */
+import { SLAT_COUNT_MAX, SLAT_COUNT_MIN } from '../canon/constants.js'
+import { landStripWidth, slatCount, setSlatCount, slatWidth } from '../canon/slats.js'
+import { type SurfacePoint, landStripIndex, isSlat, surfaceDistance } from '../canon/geometry.js'
+import { sightline } from '../world/instruments.js'
+import {
+  type Place, type PlaceKind, PLACE_KINDS, isAreaKind, isAutoId, isTracedKind, makeId, parseWorld,
+} from '../world/schema.js'
+import { KIND_COLOR } from '../views/palette.js'
+import { WorldStore, backupFilename, download } from '../world/store.js'
+import { TowerMap } from '../views/map.js'
+
+type Tool = 'select' | PlaceKind
+
+const $ = <T extends HTMLElement>(id: string): T => {
+  const el = document.getElementById(id)
+  if (!el) throw new Error(`missing #${id}`)
+  return el as T
+}
+
+const km = (m: number): string => `${(m / 1000).toFixed(m < 10_000 ? 2 : 1)} km`
+
+/** Walked length of a traced path, following the surface (design.md §1.7). */
+const pathLength = (pts: readonly [number, number][]): number => {
+  let total = 0
+  for (let i = 1; i < pts.length; i++) {
+    total += surfaceDistance(
+      { theta: pts[i - 1]![0], z: pts[i - 1]![1] },
+      { theta: pts[i]![0], z: pts[i]![1] })
+  }
+  return total
+}
+const pct = (x: number): string => `${(x * 100).toFixed(1)}%`
+
+export class App {
+  private map: TowerMap
+  /**
+   * Writer mode is a UI mode, not a security boundary (D-011). It is guessable
+   * either way, and that is fine: with no backend an editor can only change the
+   * visitor's own copy.
+   *
+   * Default is WRITER for now — the world is still being built and the author is
+   * the only visitor. `?reader=1` previews what a reader sees. Flipping the
+   * default is a one-line change here when the map is ready to publish.
+   */
+  private readonly writer = !new URLSearchParams(location.search).has('reader')
+  private tool: Tool = 'select'
+  private selectedId: string | null = null
+  private draft: [number, number][] = []
+  private probeFrom: SurfacePoint | null = null
+
+  constructor(private store: WorldStore) {
+    this.map = new TowerMap($<HTMLCanvasElement>('map'), {
+      onPick: (p, hit, shift) => this.onPick(p, hit, shift),
+      onDragPlace: (id, p) => this.onDragPlace(id, p),
+      onHover: (p) => this.onHover(p),
+    })
+    this.buildTools()
+    this.buildKindSelect()
+    this.wireEditor()
+    this.wireCanon()
+    this.wireView()
+    this.wireData()
+    this.applyMode()
+    window.addEventListener('hashchange', () => this.readHash())
+    window.addEventListener('resize', () => this.map.resize())
+    window.addEventListener('keydown', (e) => this.onKey(e))
+    // A close mid-debounce must not lose the last edit.
+    window.addEventListener('pagehide', () => this.store.flush())
+    this.store.subscribe(() => this.render())
+    this.map.fit()
+    this.readHash()
+    this.render()
+  }
+
+  /** Readers get the map, the place list and the sightline probe — nothing else. */
+  private applyMode(): void {
+    $('mode').textContent = this.writer ? 'writer' : 'reader'
+    for (const id of ['tools-section', 'editor', 'data-section', 'canon-section']) {
+      $(id).classList.toggle('hidden', !this.writer)
+    }
+    if (!this.writer) {
+      $('tool-hint').textContent = 'Shift-click two points to measure a sightline.'
+    }
+  }
+
+  // --------------------------------------------------------------- permalinks
+
+  /**
+   * The link IS the deliverable for a companion piece (D-010): "stand here and look
+   * at this" is the whole value. A place id survives the map being redrawn; a raw
+   * point survives the place being renamed.
+   */
+  private writeHash(): void {
+    const params = new URLSearchParams()
+    if (this.selectedId) params.set('place', this.selectedId)
+    else if (this.map.probe?.from) {
+      const p = this.map.probe.from
+      params.set('at', `${p.theta.toFixed(5)},${Math.round(p.z)}`)
+    }
+    const next = params.toString()
+    const url = next ? `#${next}` : location.pathname + location.search
+    if (location.hash.slice(1) !== next) history.replaceState(null, '', url)
+  }
+
+  private readHash(): void {
+    const params = new URLSearchParams(location.hash.slice(1))
+    const id = params.get('place')
+    if (id && this.store.get(id)) {
+      this.selectedId = id
+      this.focusOn(id)
+      this.render()
+      return
+    }
+    const at = params.get('at')
+    if (at) {
+      const [theta, z] = at.split(',').map(Number)
+      if (Number.isFinite(theta) && Number.isFinite(z)) {
+        this.map.probe = { from: { theta: theta!, z: z! }, to: null }
+        this.render()
+      }
+    }
+  }
+
+  private focusOn(id: string): void {
+    const p = this.store.get(id)
+    if (!p) return
+    const g = p.geometry
+    const point = g.type === 'point'
+      ? { theta: g.theta, z: g.z }
+      : { theta: g.points[0]![0], z: g.points[0]![1] }
+    this.map.centreOn(point)
+  }
+
+  // ------------------------------------------------------------------- toolbar
+
+  private buildTools(): void {
+    const host = $('tools')
+    const mk = (tool: Tool, label: string) => {
+      const b = document.createElement('button')
+      b.textContent = label
+      b.dataset['tool'] = tool
+      b.onclick = () => this.setTool(tool)
+      host.append(b)
+      return b
+    }
+    mk('select', 'Select')
+    for (const k of PLACE_KINDS) mk(k, k[0]!.toUpperCase() + k.slice(1))
+  }
+
+  private setTool(tool: Tool): void {
+    this.tool = tool
+    this.draft = []
+    this.map.draftPolygon = []
+    this.probeFrom = null
+    this.map.probe = null
+    $('sightline').classList.add('hidden')
+    $('tool-hint').textContent =
+      tool === 'select'
+        ? 'Click a marker to select. Drag to move. Shift-click two points to measure a sightline.'
+        : isAreaKind(tool)
+          ? `Click to trace the outline of a ${tool}. Enter closes it; Esc cancels.`
+          : isTracedKind(tool)
+            ? `Click to trace the course of a ${tool}. Enter finishes it; Esc cancels.`
+            : `Click the map to place a ${tool}.`
+    this.render()
+  }
+
+  // -------------------------------------------------------------- map handlers
+
+  private onPick(p: SurfacePoint, hit: Place | null, shift: boolean): void {
+    if (shift) return this.onProbe(p)
+
+    if (this.tool === 'select') {
+      this.selectedId = hit?.id ?? null
+      return this.render()
+    }
+    if (isTracedKind(this.tool)) {
+      this.draft.push([p.theta, p.z])
+      this.map.draftPolygon = this.draft
+      return this.render()
+    }
+    this.createPlace(this.tool, { type: 'point', theta: p.theta, z: p.z })
+  }
+
+  private onProbe(p: SurfacePoint): void {
+    if (!this.probeFrom) {
+      this.probeFrom = p
+      this.map.probe = { from: p, to: null }
+      $('sightline').classList.add('hidden')
+    } else {
+      this.map.probe = { from: this.probeFrom, to: p }
+      this.showSightline(this.probeFrom, p)
+      this.probeFrom = null
+    }
+    this.render()
+  }
+
+  private showSightline(a: SurfacePoint, b: SurfacePoint): void {
+    const s = sightline(a, b)
+    const el = $('sightline')
+    el.classList.remove('hidden')
+    el.textContent = [
+      `walk       ${km(s.surfaceDistanceM)}`,
+      `line of sight ${km(s.directDistanceM)}`,
+      `rises to   ${km(s.apexHeightM)} above the floor`,
+      `optical depth ${s.opticalDepth.toFixed(2)}`,
+      `transmission  ${pct(s.transmission)}`,
+      `             ${s.clarity.toUpperCase()}`,
+    ].join('\n')
+  }
+
+  private onDragPlace(id: string, p: SurfacePoint): void {
+    const place = this.store.get(id)
+    if (place?.geometry.type !== 'point') return
+    this.store.update(id, { geometry: { type: 'point', theta: p.theta, z: p.z } })
+  }
+
+  private onHover(p: SurfacePoint | null): void {
+    const el = $('cursor')
+    if (!p) {
+      el.textContent = ''
+      el.classList.add('hidden')
+      return
+    }
+    el.classList.remove('hidden')
+    const strip = landStripIndex(p.theta)
+    el.textContent = [
+      `z ${km(p.z)} from sun cap`,
+      `θ ${((p.theta * 180) / Math.PI).toFixed(2)}°`,
+      strip === null ? 'on a star slat' : `land strip ${strip}`,
+    ].join('\n')
+  }
+
+  private onKey(e: KeyboardEvent): void {
+    if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
+    if (e.key === 'Escape') return this.setTool('select')
+    if (e.key === 'Enter' && isTracedKind(this.tool as PlaceKind)) {
+      const area = isAreaKind(this.tool as PlaceKind)
+      if (this.draft.length >= (area ? 3 : 2)) {
+        this.createPlace(this.tool as PlaceKind,
+          area ? { type: 'polygon', points: this.draft } : { type: 'polyline', points: this.draft })
+        this.draft = []
+        this.map.draftPolygon = []
+      }
+    }
+    if ((e.key === 'Backspace' || e.key === 'Delete') && this.selectedId) {
+      this.deleteSelected()
+    }
+    if (e.key === 'f') this.map.fit()
+  }
+
+  private createPlace(kind: PlaceKind, geometry: Place['geometry']): void {
+    const name = `New ${kind}`
+    const place: Place = {
+      id: makeId(name, this.store.takenIds),
+      name, kind, geometry, blurb: '', notes: '', public: true,
+    }
+    this.store.add(place)
+    this.selectedId = place.id
+    this.setTool('select')
+    // renderEditor leaves a focused field alone, so set it explicitly here —
+    // creating a place while the name box has focus would otherwise show the
+    // previous place's name.
+    const field = $<HTMLInputElement>('f-name')
+    field.value = place.name
+    field.select()
+  }
+
+  // --------------------------------------------------------------------- canon
+
+  /**
+   * Slat count is the one canon parameter the author can move at runtime — D-002
+   * calls it the most tunable number in canon, and seeing eight strips on a map is
+   * exactly the evidence needed to judge it.
+   */
+  private wireCanon(): void {
+    const slider = $<HTMLInputElement>('f-slats')
+    slider.min = String(SLAT_COUNT_MIN)
+    slider.max = String(SLAT_COUNT_MAX)
+    slider.value = String(slatCount())
+    slider.oninput = () => {
+      setSlatCount(Number(slider.value))
+      this.render()
+    }
+  }
+
+  private wireView(): void {
+    const box = $<HTMLInputElement>('f-terrain')
+    box.onchange = () => {
+      this.map.showTerrain = box.checked
+      this.map.draw()
+    }
+  }
+
+  private renderCanon(): void {
+    const n = slatCount()
+    $<HTMLInputElement>('f-slats').value = String(n)
+    $('slat-value').textContent = `— ${n}`
+    const derived = $('slat-derived')
+    derived.classList.toggle('warn', n === 0)
+    derived.textContent =
+      n === 0
+        ? 'CANON (D-017): sealed hull. One unbroken surface of 169,646 km² — '
+          + 'Wisconsin’s total area — and no view out except at the caps. All '
+          + 'weather is Tower deliberately aiming light.'
+        : `Exploring: ${n} slats, each ${km(slatWidth())} wide, land strips `
+          + `${km(landStripWidth())} wide. Total slat width is fixed, so land stays `
+          + 'exactly Wisconsin’s 140,268 km². Canon is 0.'
+  }
+
+  // -------------------------------------------------------------------- editor
+
+  private buildKindSelect(): void {
+    const sel = $<HTMLSelectElement>('f-kind')
+    for (const k of PLACE_KINDS) {
+      const o = document.createElement('option')
+      o.value = k
+      o.textContent = k
+      sel.append(o)
+    }
+  }
+
+  private wireEditor(): void {
+    const patch = (p: Partial<Omit<Place, 'id'>>) => {
+      if (this.selectedId) this.store.update(this.selectedId, p)
+    }
+    $<HTMLInputElement>('f-name').oninput = (e) =>
+      this.rename((e.target as HTMLInputElement).value)
+    $<HTMLInputElement>('f-id').oninput = (e) => this.setId((e.target as HTMLInputElement).value)
+    $<HTMLSelectElement>('f-kind').onchange = (e) =>
+      patch({ kind: (e.target as HTMLSelectElement).value as PlaceKind })
+    $<HTMLTextAreaElement>('f-blurb').oninput = (e) =>
+      patch({ blurb: (e.target as HTMLTextAreaElement).value })
+    $<HTMLTextAreaElement>('f-notes').oninput = (e) =>
+      patch({ notes: (e.target as HTMLTextAreaElement).value })
+    $<HTMLInputElement>('f-public').onchange = (e) =>
+      patch({ public: (e.target as HTMLInputElement).checked })
+    $('f-delete').onclick = () => this.deleteSelected()
+  }
+
+  /**
+   * Rename, letting the id follow along. Ids are permalink targets and must be
+   * stable once published, but during world-building names churn constantly and a
+   * file full of "new-city-7" is useless to read or hand-edit. So the id tracks the
+   * name only while it still matches it; editing the id field pins it for good.
+   */
+  private rename(name: string): void {
+    const place = this.selectedId ? this.store.get(this.selectedId) : undefined
+    if (!place) return
+    const idWasAuto = isAutoId(place.id, place.name)
+    this.store.update(place.id, { name })
+    if (!idWasAuto) return
+    this.reid(place.id, name)
+  }
+
+  /**
+   * Move a place to a new id. `selectedId` is updated *before* the store emits,
+   * because the store's re-render looks the selection up by id and would otherwise
+   * find nothing and blank the editor mid-rename.
+   */
+  private reid(from: string, source: string): void {
+    const taken = new Set([...this.store.takenIds].filter((i) => i !== from))
+    const next = makeId(source, taken)
+    if (next === from) return
+    this.selectedId = next
+    if (!this.store.setId(from, next)) this.selectedId = from
+  }
+
+  private setId(raw: string): void {
+    if (this.selectedId) this.reid(this.selectedId, raw)
+  }
+
+  private deleteSelected(): void {
+    if (!this.selectedId) return
+    const place = this.store.get(this.selectedId)
+    if (place && !confirm(`Delete "${place.name}"?`)) return
+    this.store.remove(this.selectedId)
+    this.selectedId = null
+    this.render()
+  }
+
+  // ---------------------------------------------------------------------- data
+
+  private wireData(): void {
+    $('export-backup').onclick = () => {
+      this.store.flush()
+      download(backupFilename(), this.store.fullJSON())
+    }
+    $('export-static').onclick = () => {
+      this.store.flush()
+      download('world.json', this.store.publicJSON())
+    }
+    const file = $<HTMLInputElement>('import-file')
+    $('import').onclick = () => file.click()
+    file.onchange = async () => {
+      const f = file.files?.[0]
+      if (!f) return
+      try {
+        const world = parseWorld(JSON.parse(await f.text()))
+        const n = this.store.places.length
+        if (n && !confirm(`Replace ${n} place(s) with ${world.places.length} from ${f.name}?`)) return
+        this.store.replaceAll(world)
+        this.selectedId = null
+      } catch (err) {
+        alert(`Could not read that file.\n\n${err instanceof Error ? err.message : String(err)}`)
+      } finally {
+        file.value = ''
+      }
+    }
+  }
+
+  // -------------------------------------------------------------------- render
+
+  private render(): void {
+    for (const b of document.querySelectorAll<HTMLButtonElement>('#tools button')) {
+      b.classList.toggle('active', b.dataset['tool'] === this.tool)
+    }
+
+    this.map.places = this.store.places
+    this.map.selectedId = this.selectedId
+    this.map.dragEnabled = this.tool === 'select'
+    this.map.draw()
+
+    const total = this.store.places.length
+    const pub = this.store.publicCount()
+    $('counts').textContent = `— ${total} place${total === 1 ? '' : 's'}, ${pub} public`
+
+    this.renderList()
+    this.renderEditor()
+    this.renderCanon()
+    this.renderSave()
+    this.writeHash()
+  }
+
+  private renderList(): void {
+    const ul = $('list')
+    ul.replaceChildren()
+    for (const p of [...this.store.places].sort((a, b) => a.name.localeCompare(b.name))) {
+      const li = document.createElement('li')
+      li.className = (p.id === this.selectedId ? 'sel ' : '') + (p.public ? '' : 'private')
+      li.onclick = () => {
+        this.selectedId = p.id
+        this.render()
+      }
+      const sw = document.createElement('span')
+      sw.className = 'swatch'
+      sw.style.background = KIND_COLOR[p.kind]
+      const nm = document.createElement('span')
+      nm.className = 'nm'
+      nm.textContent = p.name
+      const kd = document.createElement('span')
+      kd.className = 'kd'
+      kd.textContent = p.public ? p.kind : `${p.kind} · private`
+      li.append(sw, nm, kd)
+      ul.append(li)
+    }
+  }
+
+  private renderEditor(): void {
+    const editor = $('editor')
+    const detail = $('detail')
+    const p = this.selectedId ? this.store.get(this.selectedId) : undefined
+
+    // Readers get a read-only card. Without this branch renderEditor would happily
+    // un-hide the edit panel — including the private notes field — for everyone.
+    if (!this.writer) {
+      editor.classList.add('hidden')
+      detail.classList.toggle('hidden', !p)
+      if (p) {
+        $('d-name').textContent = p.name
+        $('d-blurb').textContent = p.blurb || `A ${p.kind}.`
+        $('d-where').textContent = this.whereText(p)
+      }
+      return
+    }
+
+    detail.classList.add('hidden')
+    if (!p) return editor.classList.add('hidden')
+    editor.classList.remove('hidden')
+
+    const name = $<HTMLInputElement>('f-name')
+    if (document.activeElement !== name) name.value = p.name
+    const id = $<HTMLInputElement>('f-id')
+    if (document.activeElement !== id) id.value = p.id
+    $<HTMLSelectElement>('f-kind').value = p.kind
+    const blurb = $<HTMLTextAreaElement>('f-blurb')
+    if (document.activeElement !== blurb) blurb.value = p.blurb
+    const notes = $<HTMLTextAreaElement>('f-notes')
+    if (document.activeElement !== notes) notes.value = p.notes
+    $<HTMLInputElement>('f-public').checked = p.public
+
+    $('f-where').textContent = this.whereText(p)
+  }
+
+  /** Human-readable location, shared by the editor and the reader's detail card. */
+  private whereText(p: Place): string {
+    const g = p.geometry
+    if (g.type === 'point') {
+      const where = isSlat(g.theta)
+        ? `on a star slat (${km(slatWidth())} wide)`
+        : slatCount() === 0
+          ? 'on the open surface'
+          : `land strip ${landStripIndex(g.theta)} (${km(landStripWidth())} wide)`
+      return `z ${km(g.z)} from the sun cap · ${where}`
+    }
+    if (g.type === 'polyline') {
+      return `${g.points.length} points, ${km(pathLength(g.points))} long`
+    }
+    return `${g.points.length} vertices`
+  }
+
+  private renderSave(): void {
+    const el = $('save')
+    el.classList.toggle('error', this.store.saveState === 'error')
+    if (this.store.storageError) {
+      el.textContent = `Not saving locally: ${this.store.storageError}. Export a backup.`
+    } else if (this.store.saveState === 'saving') {
+      el.textContent = 'Saving…'
+    } else {
+      const t = this.store.lastSaved
+      el.textContent = t ? `Saved locally at ${t.toLocaleTimeString()}` : 'Saved locally'
+    }
+  }
+}
diff --git a/src/views/map.ts b/src/views/map.ts
new file mode 100644
index 0000000..804e9e1
--- /dev/null
+++ b/src/views/map.ts
@@ -0,0 +1,585 @@
+/**
+ * The 2D map: the inner surface unrolled. See design.md §1.7, §3.
+ *
+ * Drawn axis-horizontal (sun cap at the left) and circumference-vertical, which
+ * fits a 540:314 world onto a screen without scrolling. The surface is shown once,
+ * bounded, so its extent is readable — the cut falls at a slat centre, where there
+ * is no geography to split (see MAP_THETA_ORIGIN).
+ *
+ * Unrolling a cylinder is exact — no projection distortion anywhere on this map.
+ */
+import {
+  CIRCUMFERENCE_M, LENGTH_M,
+} from '../canon/constants.js'
+import { landStripCount, slatCount, slatWidth } from '../canon/slats.js'
+import {
+  type SurfacePoint, fromMap, landStripCentre, slatCentre, toMap,
+} from '../canon/geometry.js'
+import { type Place, isAreaKind } from '../world/schema.js'
+import { KIND_COLOR } from './palette.js'
+
+export interface MapCallbacks {
+  onPick: (p: SurfacePoint, hit: Place | null, shiftKey: boolean) => void
+  onDragPlace: (id: string, p: SurfacePoint) => void
+  onHover: (p: SurfacePoint | null) => void
+}
+
+const MARKER_R = 5
+
+export class TowerMap {
+  private ctx: CanvasRenderingContext2D
+  /** Pixels per metre. */
+  private scale = 1
+  private offsetX = 0
+  private offsetY = 0
+
+  places: readonly Place[] = []
+  selectedId: string | null = null
+  probe: { from: SurfacePoint; to: SurfacePoint | null } | null = null
+  draftPolygon: [number, number][] = []
+  /**
+   * Whether pressing on a marker grabs it. False while a creation tool is active,
+   * so that clicking on top of an existing place creates a new one rather than
+   * silently dragging the old one.
+   */
+  dragEnabled = true
+
+  /** The rendered terrain, drawn under everything. Null until it loads, or if absent. */
+  private terrain: HTMLImageElement | null = null
+  showTerrain = true
+
+  private dragging: { id: string } | null = null
+  private panning: { x: number; y: number; ox: number; oy: number } | null = null
+  private moved = false
+
+  constructor(private canvas: HTMLCanvasElement, private cb: MapCallbacks) {
+    const ctx = canvas.getContext('2d')
+    if (!ctx) throw new Error('2D canvas unavailable')
+    this.ctx = ctx
+    this.attach()
+    this.resize()
+    this.loadTerrain()
+  }
+
+  /**
+   * Load the terrain raster produced by `npm run terrain`. It is drawn against
+   * `mapThetaOrigin()`, which content/geography.json must match — see the note on
+   * MAP_CUT_WITHOUT_SLATS. Absence is fine; the map falls back to flat ground.
+   */
+  private loadTerrain(): void {
+    const img = new Image()
+    img.onload = () => {
+      this.terrain = img
+      this.draw()
+    }
+    img.src = `${import.meta.env.BASE_URL}terrain-color.png`
+  }
+
+  // ------------------------------------------------------------------- transform
+
+  private worldToScreen(x: number, y: number): [number, number] {
+    return [x * this.scale + this.offsetX, y * this.scale + this.offsetY]
+  }
+
+  private screenToWorld(px: number, py: number): [number, number] {
+    return [(px - this.offsetX) / this.scale, (py - this.offsetY) / this.scale]
+  }
+
+  /** Frame the whole world with a margin. */
+  fit(): void {
+    const { width, height } = this.canvas.getBoundingClientRect()
+    const m = 24
+    this.scale = Math.min((width - m * 2) / LENGTH_M, (height - m * 2) / CIRCUMFERENCE_M)
+    this.offsetX = (width - LENGTH_M * this.scale) / 2
+    this.offsetY = (height - CIRCUMFERENCE_M * this.scale) / 2
+    this.draw()
+  }
+
+  /** Keep some of the surface on screen; without tiling, it can otherwise be lost. */
+  private clampPan(): void {
+    const { width, height } = this.canvas.getBoundingClientRect()
+    const w = LENGTH_M * this.scale
+    const h = CIRCUMFERENCE_M * this.scale
+    const keep = 100
+    this.offsetX = Math.min(Math.max(this.offsetX, keep - w), width - keep)
+    this.offsetY = Math.min(Math.max(this.offsetY, keep - h), height - keep)
+  }
+
+  /** Centre the view on a surface point, zooming in if the map is fully zoomed out. */
+  centreOn(p: SurfacePoint, minScale = 6e-3): void {
+    const { width, height } = this.canvas.getBoundingClientRect()
+    if (this.scale < minScale) this.scale = minScale
+    const m = toMap(p)
+    this.offsetX = width / 2 - m.x * this.scale
+    this.offsetY = height / 2 - m.y * this.scale
+    this.clampPan()
+    this.draw()
+  }
+
+  resize(): void {
+    const dpr = window.devicePixelRatio || 1
+    const { width, height } = this.canvas.getBoundingClientRect()
+    this.canvas.width = Math.round(width * dpr)
+    this.canvas.height = Math.round(height * dpr)
+    this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
+    this.draw()
+  }
+
+  // ----------------------------------------------------------------- interaction
+
+  private eventPoint(e: PointerEvent | WheelEvent): { px: number; py: number } {
+    const r = this.canvas.getBoundingClientRect()
+    return { px: e.clientX - r.left, py: e.clientY - r.top }
+  }
+
+  private surfaceAt(px: number, py: number): SurfacePoint {
+    const [x, y] = this.screenToWorld(px, py)
+    return fromMap({ x, y })
+  }
+
+  private attach(): void {
+    this.canvas.addEventListener('pointerdown', (e) => {
+      const { px, py } = this.eventPoint(e)
+      this.moved = false
+      const hit = this.hitTest(px, py)
+      if (this.dragEnabled && hit && hit.geometry.type === 'point' && !e.shiftKey) {
+        this.dragging = { id: hit.id }
+        this.canvas.setPointerCapture(e.pointerId)
+      } else {
+        this.panning = { x: px, y: py, ox: this.offsetX, oy: this.offsetY }
+        this.canvas.setPointerCapture(e.pointerId)
+      }
+    })
+
+    this.canvas.addEventListener('pointermove', (e) => {
+      const { px, py } = this.eventPoint(e)
+      if (this.dragging) {
+        this.moved = true
+        this.cb.onDragPlace(this.dragging.id, this.surfaceAt(px, py))
+      } else if (this.panning) {
+        if (Math.hypot(px - this.panning.x, py - this.panning.y) > 3) this.moved = true
+        this.offsetX = this.panning.ox + (px - this.panning.x)
+        this.offsetY = this.panning.oy + (py - this.panning.y)
+        this.clampPan()
+        this.draw()
+      } else {
+        this.cb.onHover(this.surfaceAt(px, py))
+      }
+    })
+
+    const end = (e: PointerEvent) => {
+      const { px, py } = this.eventPoint(e)
+      const wasDragging = this.dragging
+      this.dragging = null
+      this.panning = null
+      if (this.canvas.hasPointerCapture(e.pointerId)) this.canvas.releasePointerCapture(e.pointerId)
+      if (!this.moved && !wasDragging) this.cb.onPick(this.surfaceAt(px, py), this.hitTest(px, py), e.shiftKey)
+    }
+    this.canvas.addEventListener('pointerup', end)
+    this.canvas.addEventListener('pointercancel', () => {
+      this.dragging = null
+      this.panning = null
+    })
+    this.canvas.addEventListener('pointerleave', () => this.cb.onHover(null))
+
+    this.canvas.addEventListener('wheel', (e) => {
+      e.preventDefault()
+      const { px, py } = this.eventPoint(e)
+      const [wx, wy] = this.screenToWorld(px, py)
+      const factor = Math.exp(-e.deltaY * 0.0015)
+      const fitScale = this.canvas.getBoundingClientRect().width / LENGTH_M
+      this.scale = Math.min(Math.max(this.scale * factor, fitScale * 0.5), fitScale * 400)
+      this.offsetX = px - wx * this.scale
+      this.offsetY = py - wy * this.scale
+      this.clampPan()
+      this.draw()
+    }, { passive: false })
+  }
+
+  /** Topmost place under the cursor, markers taking precedence over areas. */
+  hitTest(px: number, py: number): Place | null {
+    for (let i = this.places.length - 1; i >= 0; i--) {
+      const p = this.places[i]!
+      if (p.geometry.type !== 'point') continue
+      const m = toMap({ theta: p.geometry.theta, z: p.geometry.z })
+      const [sx, sy] = this.worldToScreen(m.x, m.y)
+      if (Math.hypot(px - sx, py - sy) <= MARKER_R + 5) return p
+    }
+    for (let i = this.places.length - 1; i >= 0; i--) {
+      const p = this.places[i]!
+      if (p.geometry.type !== 'polyline') continue
+      this.ctx.beginPath()
+      this.tracePath(p.geometry.points)
+      this.ctx.lineWidth = 10
+      if (this.ctx.isPointInStroke(px, py)) return p
+    }
+    for (let i = this.places.length - 1; i >= 0; i--) {
+      const p = this.places[i]!
+      if (p.geometry.type !== 'polygon') continue
+      this.ctx.beginPath()
+      this.tracePolygon(p.geometry.points)
+      if (this.ctx.isPointInPath(px, py)) return p
+    }
+    return null
+  }
+
+  // -------------------------------------------------------------------- drawing
+
+  /** Trace an open path. Segments crossing the seam are split, not drawn the long way. */
+  private tracePath(points: readonly [number, number][]): void {
+    let prevY: number | null = null
+    points.forEach(([theta, z], i) => {
+      const m = toMap({ theta, z })
+      const [sx, sy] = this.worldToScreen(m.x, m.y)
+      const jumped = prevY !== null && Math.abs(m.y - prevY) > CIRCUMFERENCE_M / 2
+      if (i === 0 || jumped) this.ctx.moveTo(sx, sy)
+      else this.ctx.lineTo(sx, sy)
+      prevY = m.y
+    })
+  }
+
+  private tracePolygon(points: readonly [number, number][]): void {
+    points.forEach(([theta, z], i) => {
+      const m = toMap({ theta, z })
+      const [sx, sy] = this.worldToScreen(m.x, m.y)
+      if (i === 0) this.ctx.moveTo(sx, sy)
+      else this.ctx.lineTo(sx, sy)
+    })
+    this.ctx.closePath()
+  }
+
+  draw(): void {
+    const ctx = this.ctx
+    const { width, height } = this.canvas.getBoundingClientRect()
+    ctx.clearRect(0, 0, width, height)
+    ctx.fillStyle = '#0b0d12'
+    ctx.fillRect(0, 0, width, height)
+
+    this.drawSurface()
+    this.drawCaps()
+    this.drawPlaces()
+    this.drawProbe()
+    this.drawDraft()
+    this.drawScaleBar()
+  }
+
+  /** A round-number scale bar, because every distance here is counter-intuitive. */
+  private drawScaleBar(): void {
+    const ctx = this.ctx
+    const { height } = this.canvas.getBoundingClientRect()
+    const targetPx = 160
+    const candidates = [1e3, 5e3, 1e4, 2.5e4, 5e4, 1e5, 2e5]
+    const metres = candidates.find((c) => c * this.scale > targetPx) ?? candidates.at(-1)!
+    const px = metres * this.scale
+    const x = 16
+    const y = height - 22
+
+    ctx.strokeStyle = 'rgba(230,238,250,0.75)'
+    ctx.lineWidth = 1.5
+    ctx.beginPath()
+    ctx.moveTo(x, y - 4)
+    ctx.lineTo(x, y)
+    ctx.lineTo(x + px, y)
+    ctx.lineTo(x + px, y - 4)
+    ctx.stroke()
+    ctx.font = '10px ui-monospace, SFMono-Regular, Menlo, monospace'
+    ctx.fillStyle = 'rgba(230,238,250,0.8)'
+    ctx.fillText(metres >= 1000 ? `${metres / 1000} km` : `${metres} m`, x, y - 8)
+  }
+
+  private drawSurface(): void {
+    const ctx = this.ctx
+    const [x0, y0] = this.worldToScreen(0, 0)
+    const w = LENGTH_M * this.scale
+    const h = CIRCUMFERENCE_M * this.scale
+
+    if (this.showTerrain && this.terrain) {
+      ctx.imageSmoothingEnabled = true
+      ctx.drawImage(this.terrain, x0, y0, w, h)
+    } else {
+      ctx.fillStyle = '#1c2b22'
+      ctx.fillRect(x0, y0, w, h)
+    }
+
+    // Everything below is clipped to the surface, so the slat straddling the seam
+    // draws as two halves at the top and bottom edges rather than spilling out.
+    ctx.save()
+    ctx.beginPath()
+    ctx.rect(x0, y0, w, h)
+    ctx.clip()
+
+    // Slats: transparent floor over open space. Tower's "water" (D-002).
+    const slatH = slatWidth() * this.scale
+    for (let i = 0; i < slatCount(); i++) {
+      const yc = toMap({ theta: slatCentre(i), z: 0 }).y
+      for (const band of [yc, yc + CIRCUMFERENCE_M]) {
+        const [, sy] = this.worldToScreen(0, band)
+        ctx.fillStyle = '#05070d'
+        ctx.fillRect(x0, sy - slatH / 2, w, slatH)
+        ctx.strokeStyle = 'rgba(120,170,220,0.28)'
+        ctx.lineWidth = 1
+        ctx.strokeRect(x0, sy - slatH / 2, w, slatH)
+        this.drawStars(x0, sy - slatH / 2, w, slatH, i)
+      }
+    }
+
+    // 50 km graticule.
+    ctx.strokeStyle = 'rgba(255,255,255,0.055)'
+    ctx.lineWidth = 1
+    ctx.beginPath()
+    for (let z = 0; z <= LENGTH_M; z += 50_000) {
+      const [sx] = this.worldToScreen(z, 0)
+      ctx.moveTo(sx, y0)
+      ctx.lineTo(sx, y0 + h)
+    }
+    for (let i = 0; i < landStripCount(); i++) {
+      const [, sy] = this.worldToScreen(0, toMap({ theta: landStripCentre(i), z: 0 }).y)
+      ctx.moveTo(x0, sy)
+      ctx.lineTo(x0 + w, sy)
+    }
+    ctx.stroke()
+
+    // Naming each strip is what keeps the wrapped axis legible.
+    ctx.font = '10px ui-monospace, SFMono-Regular, Menlo, monospace'
+    ctx.fillStyle = 'rgba(160,200,170,0.5)'
+    if (slatCount() === 0) {
+      // The single strip is centred on the map edge, so label it in the middle
+      // where it can actually be read.
+      ctx.fillText('NO SLATS — ONE UNBROKEN SURFACE', x0 + 6, y0 + h / 2)
+    } else {
+      for (let i = 0; i < landStripCount(); i++) {
+        const [, sy] = this.worldToScreen(0, toMap({ theta: landStripCentre(i), z: 0 }).y)
+        ctx.fillText(`STRIP ${i}`, x0 + 6, sy - 3)
+      }
+    }
+    ctx.restore()
+
+    ctx.strokeStyle = 'rgba(150,180,210,0.30)'
+    ctx.lineWidth = 1
+    ctx.strokeRect(x0, y0, w, h)
+  }
+
+  /** Deterministic star field so slats do not shimmer while panning. */
+  private drawStars(x: number, y: number, w: number, h: number, seed: number): void {
+    if (h < 2) return
+    const ctx = this.ctx
+    ctx.fillStyle = 'rgba(220,235,255,0.75)'
+    let s = seed * 9781 + 1
+    const n = Math.min(400, Math.max(20, Math.round(w / 6)))
+    for (let i = 0; i < n; i++) {
+      s = (s * 1103515245 + 12345) & 0x7fffffff
+      const fx = ((s >> 8) % 10000) / 10000
+      s = (s * 1103515245 + 12345) & 0x7fffffff
+      const fy = ((s >> 8) % 10000) / 10000
+      ctx.fillRect(x + fx * w, y + fy * h, 1, 1)
+    }
+  }
+
+  private drawCaps(): void {
+    const ctx = this.ctx
+    const [, y0] = this.worldToScreen(0, 0)
+    const h = CIRCUMFERENCE_M * this.scale
+    const [sunX] = this.worldToScreen(0, 0)
+    const [farX] = this.worldToScreen(LENGTH_M, 0)
+
+    const grad = ctx.createLinearGradient(sunX, 0, sunX + 90, 0)
+    grad.addColorStop(0, 'rgba(255,214,140,0.30)')
+    grad.addColorStop(1, 'rgba(255,214,140,0)')
+    ctx.fillStyle = grad
+    ctx.fillRect(sunX, y0, 90, h)
+
+    ctx.lineWidth = 2
+    ctx.strokeStyle = 'rgba(255,214,140,0.65)'
+    ctx.beginPath()
+    ctx.moveTo(sunX, y0)
+    ctx.lineTo(sunX, y0 + h)
+    ctx.stroke()
+
+    ctx.strokeStyle = 'rgba(150,170,200,0.45)'
+    ctx.beginPath()
+    ctx.moveTo(farX, y0)
+    ctx.lineTo(farX, y0 + h)
+    ctx.stroke()
+
+    ctx.font = '11px ui-monospace, SFMono-Regular, Menlo, monospace'
+    ctx.fillStyle = 'rgba(255,214,140,0.85)'
+    ctx.fillText('SUN CAP', sunX + 8, y0 + 14)
+    ctx.fillStyle = 'rgba(170,190,215,0.8)'
+    ctx.textAlign = 'right'
+    ctx.fillText('FAR CAP', farX - 8, y0 + 14)
+    ctx.textAlign = 'left'
+  }
+
+  /** Rank for label priority: settlements and landmarks beat broad regions. */
+  private static readonly LABEL_RANK: Record<string, number> = {
+    city: 0, tower: 1, town: 2, embassy: 3, landmark: 4, mountain: 5,
+    canyon: 6, river: 7, lake: 8, note: 9, forest: 10, desert: 11, region: 12,
+  }
+
+  private drawPlaces(): void {
+    const ctx = this.ctx
+    ctx.font = '12px system-ui, -apple-system, sans-serif'
+    // Labels are placed most-important first and skipped where they would collide.
+    // Without this the mountain settlements pile into an unreadable smear.
+    this.labelBoxes = []
+    const order = [...this.places].sort(
+      (a, b) => (TowerMap.LABEL_RANK[a.kind] ?? 99) - (TowerMap.LABEL_RANK[b.kind] ?? 99))
+    for (const p of order) {
+      const color = KIND_COLOR[p.kind] ?? '#ccc'
+      const selected = p.id === this.selectedId
+      ctx.globalAlpha = p.public ? 1 : 0.55
+
+      if (p.geometry.type === 'polyline') {
+        ctx.beginPath()
+        this.tracePath(p.geometry.points)
+        ctx.strokeStyle = color
+        ctx.lineWidth = selected ? 3.5 : 2
+        ctx.lineJoin = 'round'
+        ctx.lineCap = 'round'
+        ctx.setLineDash(p.public ? [] : [6, 5])
+        ctx.stroke()
+        ctx.setLineDash([])
+        const mid = p.geometry.points[Math.floor(p.geometry.points.length / 2)]!
+        const mm = toMap({ theta: mid[0], z: mid[1] })
+        const [sx, sy] = this.worldToScreen(mm.x, mm.y)
+        this.label(p.name, sx, sy - 8, color, true)
+      } else if (p.geometry.type === 'polygon') {
+        ctx.beginPath()
+        this.tracePolygon(p.geometry.points)
+        const overTerrain = this.showTerrain && this.terrain
+        if (!overTerrain || selected) {
+          ctx.fillStyle = color + (selected ? '2e' : isAreaKind(p.kind) ? '3a' : '22')
+          ctx.fill()
+        }
+        ctx.strokeStyle = overTerrain && !selected ? color + '77' : color
+        ctx.lineWidth = selected ? 2.5 : 1.5
+        ctx.setLineDash(p.public ? [] : [5, 4])
+        ctx.stroke()
+        ctx.setLineDash([])
+        const c = centroid(p.geometry.points)
+        const m = toMap({ theta: c[0], z: c[1] })
+        const [sx, sy] = this.worldToScreen(m.x, m.y)
+        this.label(p.name, sx, sy, color, true)
+      } else {
+        const m = toMap({ theta: p.geometry.theta, z: p.geometry.z })
+        const [sx, sy] = this.worldToScreen(m.x, m.y)
+        ctx.beginPath()
+        ctx.arc(sx, sy, MARKER_R, 0, Math.PI * 2)
+        ctx.fillStyle = color
+        ctx.fill()
+        ctx.strokeStyle = selected ? '#fff' : 'rgba(0,0,0,0.6)'
+        ctx.lineWidth = selected ? 2.5 : 1.5
+        ctx.stroke()
+        this.label(p.name, sx + MARKER_R + 5, sy + 4, color, false)
+      }
+      ctx.globalAlpha = 1
+    }
+  }
+
+  private labelBoxes: { x: number; y: number; w: number; h: number }[] = []
+
+  private label(text: string, x: number, y: number, color: string, centred: boolean): void {
+    const ctx = this.ctx
+    const w = ctx.measureText(text).width
+    const box = { x: centred ? x - w / 2 : x, y: y - 11, w, h: 14 }
+    const { width, height } = this.canvas.getBoundingClientRect()
+    if (box.x < -w || box.x > width || box.y < -14 || box.y > height) return
+    for (const b of this.labelBoxes) {
+      if (box.x < b.x + b.w + 3 && box.x + box.w + 3 > b.x &&
+          box.y < b.y + b.h + 2 && box.y + box.h + 2 > b.y) return
+    }
+    this.labelBoxes.push(box)
+
+    if (centred) ctx.textAlign = 'center'
+    ctx.lineWidth = 3
+    ctx.strokeStyle = 'rgba(0,0,0,0.8)'
+    ctx.strokeText(text, x, y)
+    ctx.fillStyle = color
+    ctx.fillText(text, x, y)
+    ctx.textAlign = 'left'
+  }
+
+  private drawProbe(): void {
+    if (!this.probe) return
+    const ctx = this.ctx
+    const dot = (p: SurfacePoint, filled: boolean) => {
+      const mp = toMap(p)
+      const [sx, sy] = this.worldToScreen(mp.x, mp.y)
+      ctx.beginPath()
+      ctx.arc(sx, sy, 6, 0, Math.PI * 2)
+      ctx.strokeStyle = '#7fd4ff'
+      ctx.lineWidth = 2
+      if (filled) {
+        ctx.fillStyle = '#7fd4ff'
+        ctx.fill()
+      }
+      ctx.stroke()
+    }
+    dot(this.probe.from, true)
+    if (!this.probe.to) return
+    dot(this.probe.to, false)
+
+    const a = toMap(this.probe.from)
+    const b = toMap(this.probe.to)
+    // The instrument measures the short way round, so the line must show that
+    // route — which means splitting it where it leaves one edge and re-enters
+    // at the other, rather than drawing a straight line the long way.
+    let dy = b.y - a.y
+    if (dy > CIRCUMFERENCE_M / 2) dy -= CIRCUMFERENCE_M
+    if (dy < -CIRCUMFERENCE_M / 2) dy += CIRCUMFERENCE_M
+    const end = a.y + dy
+
+    ctx.strokeStyle = '#7fd4ff'
+    ctx.lineWidth = 1.5
+    ctx.setLineDash([6, 4])
+    if (end >= 0 && end <= CIRCUMFERENCE_M) {
+      this.segment(a.x, a.y, b.x, b.y)
+    } else {
+      const edge = end < 0 ? 0 : CIRCUMFERENCE_M
+      const t = (edge - a.y) / dy
+      const xCross = a.x + (b.x - a.x) * t
+      this.segment(a.x, a.y, xCross, edge)
+      this.segment(xCross, edge === 0 ? CIRCUMFERENCE_M : 0, b.x, b.y)
+    }
+    ctx.setLineDash([])
+  }
+
+  private segment(x1: number, y1: number, x2: number, y2: number): void {
+    const [ax, ay] = this.worldToScreen(x1, y1)
+    const [bx, by] = this.worldToScreen(x2, y2)
+    this.ctx.beginPath()
+    this.ctx.moveTo(ax, ay)
+    this.ctx.lineTo(bx, by)
+    this.ctx.stroke()
+  }
+
+  private drawDraft(): void {
+    if (this.draftPolygon.length === 0) return
+    const ctx = this.ctx
+    ctx.beginPath()
+    this.tracePath(this.draftPolygon)
+    ctx.strokeStyle = '#7fd4ff'
+    ctx.lineWidth = 2
+    ctx.setLineDash([5, 4])
+    ctx.stroke()
+    ctx.setLineDash([])
+    for (const [theta, z] of this.draftPolygon) {
+      const m = toMap({ theta, z })
+      const [sx, sy] = this.worldToScreen(m.x, m.y)
+      ctx.beginPath()
+      ctx.arc(sx, sy, 3.5, 0, Math.PI * 2)
+      ctx.fillStyle = '#7fd4ff'
+      ctx.fill()
+    }
+  }
+}
+
+function centroid(points: readonly [number, number][]): [number, number] {
+  let t = 0
+  let z = 0
+  for (const p of points) {
+    t += p[0]
+    z += p[1]
+  }
+  return [t / points.length, z / points.length]
+}
diff --git a/src/views/palette.ts b/src/views/palette.ts
new file mode 100644
index 0000000..84e5f4e
--- /dev/null
+++ b/src/views/palette.ts
@@ -0,0 +1,18 @@
+/** One colour per place kind, shared by the map, the list and the editor. */
+import type { PlaceKind } from '../world/schema.js'
+
+export const KIND_COLOR: Record<PlaceKind, string> = {
+  city: '#e8c46a',
+  town: '#d8b25e',
+  embassy: '#d98cc8',
+  tower: '#f0a0a0',
+  landmark: '#e8815a',
+  note: '#8a93a8',
+  mountain: '#cfd6e0',
+  lake: '#4a9fd4',
+  river: '#5cc0e8',
+  canyon: '#c58a5a',
+  forest: '#6fbf8a',
+  desert: '#d9c084',
+  region: '#9aa8bd',
+}