tower / commit
feat(map): unrolled surface map with editing, terrain and permalinks
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', +}