fuzzyworld

tower / files / src / views / map.ts

map.ts

Blame: the commit that last changed each line · file · history

09a64747 2026-09-07 Pavel Safronov1/**
2 * The 2D map: the inner surface unrolled. See design.md §1.7, §3.
3 *
4 * Drawn axis-horizontal (sun cap at the left) and circumference-vertical, which
5 * fits a 540:314 world onto a screen without scrolling. The surface is shown once,
6 * bounded, so its extent is readable — the cut falls at a slat centre, where there
7 * is no geography to split (see MAP_THETA_ORIGIN).
8 *
9 * Unrolling a cylinder is exact — no projection distortion anywhere on this map.
10 */
11import {
59c12827 2026-09-28 Pavel Safronov12 CIRCUMFERENCE_M, LENGTH_M, RADIUS_M,
09a64747 2026-09-07 Pavel Safronov13} from '../canon/constants.js'
14import { landStripCount, slatCount, slatWidth } from '../canon/slats.js'
15import {
16 type SurfacePoint, fromMap, landStripCentre, slatCentre, toMap,
17} from '../canon/geometry.js'
376f2ce6 2026-09-16 Pavel Safronov18import { type Feature, isAreaKind } from '../world/schema.js'
19import { terrain } from '../terrain/service.js'
09a64747 2026-09-07 Pavel Safronov20import { KIND_COLOR } from './palette.js'
21
22export interface MapCallbacks {
376f2ce6 2026-09-16 Pavel Safronov23 onPick: (p: SurfacePoint, hit: Feature | null, shiftKey: boolean) => void
09a64747 2026-09-07 Pavel Safronov24 onHover: (p: SurfacePoint | null) => void
25}
26
27const MARKER_R = 5
28
29export class TowerMap {
30 private ctx: CanvasRenderingContext2D
31 /** Pixels per metre. */
32 private scale = 1
33 private offsetX = 0
34 private offsetY = 0
35
376f2ce6 2026-09-16 Pavel Safronov36 places: readonly Feature[] = []
09a64747 2026-09-07 Pavel Safronov37 selectedId: string | null = null
38 probe: { from: SurfacePoint; to: SurfacePoint | null } | null = null
39
376f2ce6 2026-09-16 Pavel Safronov40 /** The generated terrain, drawn under everything. Null until the first world is drawn. */
41 private terrain: CanvasImageSource | null = null
09a64747 2026-09-07 Pavel Safronov42 showTerrain = true
1c0284d0 2026-09-27 Pavel Safronov43 /** Place names and the caps' captions. The scale bar stays: it is a measure, not a label. */
44 showLabels = true
59c12827 2026-09-28 Pavel Safronov45 /**
46 * Where Tower's sun is, metres from the sun cap, or null at night (sky.ts). The map
47 * shades the world by the light it throws, with the same curve the 3D views light by.
48 */
49 sunZ: number | null = null
42b4554e 2026-09-29 Pavel Safronov50 /** Tower's moon by night (D-088): where it is, and how much of it is lit. */
51 moon: { z: number; fraction: number } | null = null
09a64747 2026-09-07 Pavel Safronov52
53 private panning: { x: number; y: number; ox: number; oy: number } | null = null
54 private moved = false
55
56 constructor(private canvas: HTMLCanvasElement, private cb: MapCallbacks) {
57 const ctx = canvas.getContext('2d')
58 if (!ctx) throw new Error('2D canvas unavailable')
59 this.ctx = ctx
60 this.attach()
61 this.resize()
62 this.loadTerrain()
63 }
64
65 /**
376f2ce6 2026-09-16 Pavel Safronov66 * Draw the terrain the service renders from the world (world-generation.md §9). It is
67 * rendered against `mapThetaOrigin()`, the same cut this map uses, and replaced
68 * whenever the world's terrain changes. Until the first one arrives the map shows
69 * flat ground.
09a64747 2026-09-07 Pavel Safronov70 */
71 private loadTerrain(): void {
376f2ce6 2026-09-16 Pavel Safronov72 const adopt = (): void => {
73 this.terrain = terrain().mapCanvas
09a64747 2026-09-07 Pavel Safronov74 this.draw()
75 }
376f2ce6 2026-09-16 Pavel Safronov76 terrain().onMap(adopt)
77 adopt()
09a64747 2026-09-07 Pavel Safronov78 }
79
80 // ------------------------------------------------------------------- transform
81
82 private worldToScreen(x: number, y: number): [number, number] {
83 return [x * this.scale + this.offsetX, y * this.scale + this.offsetY]
84 }
85
86 private screenToWorld(px: number, py: number): [number, number] {
87 return [(px - this.offsetX) / this.scale, (py - this.offsetY) / this.scale]
88 }
89
90 /** Frame the whole world with a margin. */
91 fit(): void {
92 const { width, height } = this.canvas.getBoundingClientRect()
93 const m = 24
94 this.scale = Math.min((width - m * 2) / LENGTH_M, (height - m * 2) / CIRCUMFERENCE_M)
95 this.offsetX = (width - LENGTH_M * this.scale) / 2
96 this.offsetY = (height - CIRCUMFERENCE_M * this.scale) / 2
97 this.draw()
98 }
99
100 /** Keep some of the surface on screen; without tiling, it can otherwise be lost. */
101 private clampPan(): void {
102 const { width, height } = this.canvas.getBoundingClientRect()
103 const w = LENGTH_M * this.scale
104 const h = CIRCUMFERENCE_M * this.scale
105 const keep = 100
106 this.offsetX = Math.min(Math.max(this.offsetX, keep - w), width - keep)
107 this.offsetY = Math.min(Math.max(this.offsetY, keep - h), height - keep)
108 }
109
110 /** Centre the view on a surface point, zooming in if the map is fully zoomed out. */
111 centreOn(p: SurfacePoint, minScale = 6e-3): void {
112 const { width, height } = this.canvas.getBoundingClientRect()
113 if (this.scale < minScale) this.scale = minScale
114 const m = toMap(p)
115 this.offsetX = width / 2 - m.x * this.scale
116 this.offsetY = height / 2 - m.y * this.scale
117 this.clampPan()
118 this.draw()
119 }
120
121 resize(): void {
122 const dpr = window.devicePixelRatio || 1
123 const { width, height } = this.canvas.getBoundingClientRect()
124 this.canvas.width = Math.round(width * dpr)
125 this.canvas.height = Math.round(height * dpr)
126 this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
127 this.draw()
128 }
129
130 // ----------------------------------------------------------------- interaction
131
132 private eventPoint(e: PointerEvent | WheelEvent): { px: number; py: number } {
133 const r = this.canvas.getBoundingClientRect()
134 return { px: e.clientX - r.left, py: e.clientY - r.top }
135 }
136
137 private surfaceAt(px: number, py: number): SurfacePoint {
138 const [x, y] = this.screenToWorld(px, py)
139 return fromMap({ x, y })
140 }
141
142 private attach(): void {
143 this.canvas.addEventListener('pointerdown', (e) => {
144 const { px, py } = this.eventPoint(e)
145 this.moved = false
9d5a061c 2026-09-27 Pavel Safronov146 this.panning = { x: px, y: py, ox: this.offsetX, oy: this.offsetY }
147 this.canvas.setPointerCapture(e.pointerId)
09a64747 2026-09-07 Pavel Safronov148 })
149
150 this.canvas.addEventListener('pointermove', (e) => {
151 const { px, py } = this.eventPoint(e)
9d5a061c 2026-09-27 Pavel Safronov152 if (this.panning) {
09a64747 2026-09-07 Pavel Safronov153 if (Math.hypot(px - this.panning.x, py - this.panning.y) > 3) this.moved = true
154 this.offsetX = this.panning.ox + (px - this.panning.x)
155 this.offsetY = this.panning.oy + (py - this.panning.y)
156 this.clampPan()
157 this.draw()
158 } else {
159 this.cb.onHover(this.surfaceAt(px, py))
160 }
161 })
162
163 const end = (e: PointerEvent) => {
164 const { px, py } = this.eventPoint(e)
165 this.panning = null
166 if (this.canvas.hasPointerCapture(e.pointerId)) this.canvas.releasePointerCapture(e.pointerId)
9d5a061c 2026-09-27 Pavel Safronov167 // A summit clicked selects its range, like clicking the range.
168 if (!this.moved) {
e7a9f9dd 2026-09-17 Pavel Safronov169 this.cb.onPick(this.surfaceAt(px, py), this.hitPeak(px, py)?.feature ?? this.hitTest(px, py), e.shiftKey)
170 }
09a64747 2026-09-07 Pavel Safronov171 }
172 this.canvas.addEventListener('pointerup', end)
173 this.canvas.addEventListener('pointercancel', () => {
174 this.panning = null
175 })
176 this.canvas.addEventListener('pointerleave', () => this.cb.onHover(null))
177
178 this.canvas.addEventListener('wheel', (e) => {
179 e.preventDefault()
180 const { px, py } = this.eventPoint(e)
181 const [wx, wy] = this.screenToWorld(px, py)
182 const factor = Math.exp(-e.deltaY * 0.0015)
183 const fitScale = this.canvas.getBoundingClientRect().width / LENGTH_M
184 this.scale = Math.min(Math.max(this.scale * factor, fitScale * 0.5), fitScale * 400)
185 this.offsetX = px - wx * this.scale
186 this.offsetY = py - wy * this.scale
187 this.clampPan()
188 this.draw()
189 }, { passive: false })
190 }
191
e7a9f9dd 2026-09-17 Pavel Safronov192 /** The summit under the cursor, if any, and which of its range's summits it is. */
193 hitPeak(px: number, py: number): { feature: Feature; index: number } | null {
194 for (let i = this.places.length - 1; i >= 0; i--) {
195 const f = this.places[i]!
196 if (f.kind !== 'mountains') continue
197 for (let k = f.peaks.length - 1; k >= 0; k--) {
198 const m = toMap({ theta: f.peaks[k]!.theta, z: f.peaks[k]!.z })
199 const [sx, sy] = this.worldToScreen(m.x, m.y)
200 if (Math.hypot(px - sx, py - (sy - 1)) <= 9) return { feature: f, index: k }
201 }
202 }
203 return null
204 }
205
09a64747 2026-09-07 Pavel Safronov206 /** Topmost place under the cursor, markers taking precedence over areas. */
376f2ce6 2026-09-16 Pavel Safronov207 hitTest(px: number, py: number): Feature | null {
09a64747 2026-09-07 Pavel Safronov208 for (let i = this.places.length - 1; i >= 0; i--) {
209 const p = this.places[i]!
210 if (p.geometry.type !== 'point') continue
211 const m = toMap({ theta: p.geometry.theta, z: p.geometry.z })
212 const [sx, sy] = this.worldToScreen(m.x, m.y)
213 if (Math.hypot(px - sx, py - sy) <= MARKER_R + 5) return p
214 }
215 for (let i = this.places.length - 1; i >= 0; i--) {
216 const p = this.places[i]!
217 if (p.geometry.type !== 'polyline') continue
218 this.ctx.beginPath()
219 this.tracePath(p.geometry.points)
220 this.ctx.lineWidth = 10
221 if (this.ctx.isPointInStroke(px, py)) return p
222 }
04d6779e 2026-09-07 Pavel Safronov223 for (let i = this.places.length - 1; i >= 0; i--) {
224 const p = this.places[i]!
225 if (p.geometry.type !== 'band') continue
226 const [x0] = this.worldToScreen(p.geometry.zFrom, 0)
227 const [x1] = this.worldToScreen(p.geometry.zTo, 0)
228 if (px >= x0 && px <= x1) return p
229 }
09a64747 2026-09-07 Pavel Safronov230 for (let i = this.places.length - 1; i >= 0; i--) {
231 const p = this.places[i]!
232 if (p.geometry.type !== 'polygon') continue
233 this.ctx.beginPath()
234 this.tracePolygon(p.geometry.points)
235 if (this.ctx.isPointInPath(px, py)) return p
236 }
237 return null
238 }
239
240 // -------------------------------------------------------------------- drawing
241
242 /** Trace an open path. Segments crossing the seam are split, not drawn the long way. */
243 private tracePath(points: readonly [number, number][]): void {
244 let prevY: number | null = null
245 points.forEach(([theta, z], i) => {
246 const m = toMap({ theta, z })
247 const [sx, sy] = this.worldToScreen(m.x, m.y)
248 const jumped = prevY !== null && Math.abs(m.y - prevY) > CIRCUMFERENCE_M / 2
249 if (i === 0 || jumped) this.ctx.moveTo(sx, sy)
250 else this.ctx.lineTo(sx, sy)
251 prevY = m.y
252 })
253 }
254
255 private tracePolygon(points: readonly [number, number][]): void {
256 points.forEach(([theta, z], i) => {
257 const m = toMap({ theta, z })
258 const [sx, sy] = this.worldToScreen(m.x, m.y)
259 if (i === 0) this.ctx.moveTo(sx, sy)
260 else this.ctx.lineTo(sx, sy)
261 })
262 this.ctx.closePath()
263 }
264
265 draw(): void {
266 const ctx = this.ctx
267 const { width, height } = this.canvas.getBoundingClientRect()
268 ctx.clearRect(0, 0, width, height)
269 ctx.fillStyle = '#0b0d12'
270 ctx.fillRect(0, 0, width, height)
271
272 this.drawSurface()
59c12827 2026-09-28 Pavel Safronov273 this.drawDaylight()
09a64747 2026-09-07 Pavel Safronov274 this.drawCaps()
275 this.drawPlaces()
276 this.drawProbe()
277 this.drawScaleBar()
278 }
279
280 /** A round-number scale bar, because every distance here is counter-intuitive. */
281 private drawScaleBar(): void {
282 const ctx = this.ctx
283 const { height } = this.canvas.getBoundingClientRect()
284 const targetPx = 160
285 const candidates = [1e3, 5e3, 1e4, 2.5e4, 5e4, 1e5, 2e5]
286 const metres = candidates.find((c) => c * this.scale > targetPx) ?? candidates.at(-1)!
287 const px = metres * this.scale
288 const x = 16
289 const y = height - 22
290
291 ctx.strokeStyle = 'rgba(230,238,250,0.75)'
292 ctx.lineWidth = 1.5
293 ctx.beginPath()
294 ctx.moveTo(x, y - 4)
295 ctx.lineTo(x, y)
296 ctx.lineTo(x + px, y)
297 ctx.lineTo(x + px, y - 4)
298 ctx.stroke()
299 ctx.font = '10px ui-monospace, SFMono-Regular, Menlo, monospace'
300 ctx.fillStyle = 'rgba(230,238,250,0.8)'
301 ctx.fillText(metres >= 1000 ? `${metres / 1000} km` : `${metres} m`, x, y - 8)
302 }
303
304 private drawSurface(): void {
305 const ctx = this.ctx
306 const [x0, y0] = this.worldToScreen(0, 0)
307 const w = LENGTH_M * this.scale
308 const h = CIRCUMFERENCE_M * this.scale
309
310 if (this.showTerrain && this.terrain) {
311 ctx.imageSmoothingEnabled = true
312 ctx.drawImage(this.terrain, x0, y0, w, h)
313 } else {
314 ctx.fillStyle = '#1c2b22'
315 ctx.fillRect(x0, y0, w, h)
316 }
317
318 // Everything below is clipped to the surface, so the slat straddling the seam
319 // draws as two halves at the top and bottom edges rather than spilling out.
320 ctx.save()
321 ctx.beginPath()
322 ctx.rect(x0, y0, w, h)
323 ctx.clip()
324
325 // Slats: transparent floor over open space. Tower's "water" (D-002).
326 const slatH = slatWidth() * this.scale
327 for (let i = 0; i < slatCount(); i++) {
328 const yc = toMap({ theta: slatCentre(i), z: 0 }).y
329 for (const band of [yc, yc + CIRCUMFERENCE_M]) {
330 const [, sy] = this.worldToScreen(0, band)
331 ctx.fillStyle = '#05070d'
332 ctx.fillRect(x0, sy - slatH / 2, w, slatH)
333 ctx.strokeStyle = 'rgba(120,170,220,0.28)'
334 ctx.lineWidth = 1
335 ctx.strokeRect(x0, sy - slatH / 2, w, slatH)
336 this.drawStars(x0, sy - slatH / 2, w, slatH, i)
337 }
338 }
339
1c0284d0 2026-09-27 Pavel Safronov340 if (!this.showLabels) {
341 // Nothing to caption.
342 } else if (slatCount() === 0) {
09a64747 2026-09-07 Pavel Safronov343 // The single strip is centred on the map edge, so label it in the middle
344 // where it can actually be read.
345 ctx.fillText('NO SLATS — ONE UNBROKEN SURFACE', x0 + 6, y0 + h / 2)
346 } else {
347 for (let i = 0; i < landStripCount(); i++) {
348 const [, sy] = this.worldToScreen(0, toMap({ theta: landStripCentre(i), z: 0 }).y)
349 ctx.fillText(`STRIP ${i}`, x0 + 6, sy - 3)
350 }
351 }
352 ctx.restore()
353
354 ctx.strokeStyle = 'rgba(150,180,210,0.30)'
355 ctx.lineWidth = 1
356 ctx.strokeRect(x0, y0, w, h)
357 }
358
359 /** Deterministic star field so slats do not shimmer while panning. */
360 private drawStars(x: number, y: number, w: number, h: number, seed: number): void {
361 if (h < 2) return
362 const ctx = this.ctx
363 ctx.fillStyle = 'rgba(220,235,255,0.75)'
364 let s = seed * 9781 + 1
365 const n = Math.min(400, Math.max(20, Math.round(w / 6)))
366 for (let i = 0; i < n; i++) {
367 s = (s * 1103515245 + 12345) & 0x7fffffff
368 const fx = ((s >> 8) % 10000) / 10000
369 s = (s * 1103515245 + 12345) & 0x7fffffff
370 const fy = ((s >> 8) % 10000) / 10000
371 ctx.fillRect(x + fx * w, y + fy * h, 1, 1)
372 }
373 }
374
59c12827 2026-09-28 Pavel Safronov375 /**
376 * The day on the map: a soft darkening wherever the sun is low, and a line where it is
377 * overhead. Ground in the 3D views is lit as 0.34 + 0.8·sin(elevation) of its noon, so the
378 * shade here follows the same curve, held to a third so the map stays a map.
379 */
380 private drawDaylight(): void {
381 const ctx = this.ctx
382 const [x0, y0] = this.worldToScreen(0, 0)
383 const w = LENGTH_M * this.scale
384 const h = CIRCUMFERENCE_M * this.scale
385 if (this.sunZ === null) {
386 ctx.fillStyle = 'rgba(4,6,14,0.34)'
387 ctx.fillRect(x0, y0, w, h)
42b4554e 2026-09-29 Pavel Safronov388 this.drawMoon(y0, h)
59c12827 2026-09-28 Pavel Safronov389 return
390 }
391 const grad = ctx.createLinearGradient(x0, 0, x0 + w, 0)
392 for (let i = 0; i <= 24; i++) {
393 const z = (i / 24) * LENGTH_M
394 const sin = RADIUS_M / Math.hypot(RADIUS_M, z - this.sunZ)
395 const light = (0.34 + 0.8 * sin) / 1.14
396 grad.addColorStop(i / 24, `rgba(4,6,14,${(0.34 * (1 - light)).toFixed(3)})`)
397 }
398 ctx.fillStyle = grad
399 ctx.fillRect(x0, y0, w, h)
400
401 const [sx] = this.worldToScreen(this.sunZ, 0)
402 const glow = ctx.createLinearGradient(sx - 40, 0, sx + 40, 0)
403 glow.addColorStop(0, 'rgba(255,236,190,0)')
404 glow.addColorStop(0.5, 'rgba(255,236,190,0.22)')
405 glow.addColorStop(1, 'rgba(255,236,190,0)')
406 ctx.fillStyle = glow
407 ctx.fillRect(sx - 40, y0, 80, h)
408 ctx.lineWidth = 2
409 ctx.strokeStyle = 'rgba(255,240,205,0.9)'
410 ctx.beginPath()
411 ctx.moveTo(sx, y0)
412 ctx.lineTo(sx, y0 + h)
413 ctx.stroke()
414 if (!this.showLabels) return
415 ctx.font = '11px ui-monospace, SFMono-Regular, Menlo, monospace'
416 ctx.fillStyle = 'rgba(255,240,205,0.95)'
417 ctx.textAlign = 'center'
418 ctx.fillText('SUN', sx, y0 + h + 14)
419 ctx.textAlign = 'left'
420 }
421
42b4554e 2026-09-29 Pavel Safronov422 /** The moon's line, where it is overhead: pale, and as strong as the moon is lit. */
423 private drawMoon(y0: number, h: number): void {
424 const moon = this.moon
425 if (!moon || moon.fraction < 0.01) return
426 const ctx = this.ctx
427 const [mx] = this.worldToScreen(moon.z, 0)
428 const a = 0.25 + 0.6 * moon.fraction
429 ctx.lineWidth = 1.5
430 ctx.strokeStyle = `rgba(205,220,255,${a.toFixed(2)})`
431 ctx.setLineDash([6, 5])
432 ctx.beginPath()
433 ctx.moveTo(mx, y0)
434 ctx.lineTo(mx, y0 + h)
435 ctx.stroke()
436 ctx.setLineDash([])
437 if (!this.showLabels) return
438 ctx.font = '11px ui-monospace, SFMono-Regular, Menlo, monospace'
439 ctx.fillStyle = `rgba(215,228,255,${Math.min(1, a + 0.1).toFixed(2)})`
440 ctx.textAlign = 'center'
441 ctx.fillText(`MOON ${Math.round(moon.fraction * 100)}%`, mx, y0 + h + 14)
442 ctx.textAlign = 'left'
443 }
444
09a64747 2026-09-07 Pavel Safronov445 private drawCaps(): void {
446 const ctx = this.ctx
447 const [, y0] = this.worldToScreen(0, 0)
448 const h = CIRCUMFERENCE_M * this.scale
449 const [sunX] = this.worldToScreen(0, 0)
450 const [farX] = this.worldToScreen(LENGTH_M, 0)
451
452 const grad = ctx.createLinearGradient(sunX, 0, sunX + 90, 0)
453 grad.addColorStop(0, 'rgba(255,214,140,0.30)')
454 grad.addColorStop(1, 'rgba(255,214,140,0)')
455 ctx.fillStyle = grad
456 ctx.fillRect(sunX, y0, 90, h)
457
458 ctx.lineWidth = 2
459 ctx.strokeStyle = 'rgba(255,214,140,0.65)'
460 ctx.beginPath()
461 ctx.moveTo(sunX, y0)
462 ctx.lineTo(sunX, y0 + h)
463 ctx.stroke()
464
465 ctx.strokeStyle = 'rgba(150,170,200,0.45)'
466 ctx.beginPath()
467 ctx.moveTo(farX, y0)
468 ctx.lineTo(farX, y0 + h)
469 ctx.stroke()
470
1c0284d0 2026-09-27 Pavel Safronov471 if (!this.showLabels) return
09a64747 2026-09-07 Pavel Safronov472 ctx.font = '11px ui-monospace, SFMono-Regular, Menlo, monospace'
473 ctx.fillStyle = 'rgba(255,214,140,0.85)'
474 ctx.fillText('SUN CAP', sunX + 8, y0 + 14)
475 ctx.fillStyle = 'rgba(170,190,215,0.8)'
476 ctx.textAlign = 'right'
477 ctx.fillText('FAR CAP', farX - 8, y0 + 14)
478 ctx.textAlign = 'left'
479 }
480
481 /** Rank for label priority: settlements and landmarks beat broad regions. */
482 private static readonly LABEL_RANK: Record<string, number> = {
376f2ce6 2026-09-16 Pavel Safronov483 settlement: 0, tower: 1, embassy: 3, landmark: 4, mountains: 5,
99a2821b 2026-09-29 Pavel Safronov484 river: 7, lake: 8, sea: 8, note: 9, road: 10, path: 10, railway: 10, forest: 10, desert: 11, region: 12,
09a64747 2026-09-07 Pavel Safronov485 }
486
487 private drawPlaces(): void {
488 const ctx = this.ctx
489 ctx.font = '12px system-ui, -apple-system, sans-serif'
490 // Labels are placed most-important first and skipped where they would collide.
491 // Without this the mountain settlements pile into an unreadable smear.
492 this.labelBoxes = []
493 const order = [...this.places].sort(
494 (a, b) => (TowerMap.LABEL_RANK[a.kind] ?? 99) - (TowerMap.LABEL_RANK[b.kind] ?? 99))
495 for (const p of order) {
496 const color = KIND_COLOR[p.kind] ?? '#ccc'
497 const selected = p.id === this.selectedId
498 ctx.globalAlpha = p.public ? 1 : 0.55
499
04d6779e 2026-09-07 Pavel Safronov500 if (p.geometry.type === 'band') {
501 const [x0, y0] = this.worldToScreen(p.geometry.zFrom, 0)
502 const [x1] = this.worldToScreen(p.geometry.zTo, 0)
503 const h = CIRCUMFERENCE_M * this.scale
504 const overTerrain = this.showTerrain && this.terrain
505 if (!overTerrain || selected) {
506 ctx.fillStyle = color + (selected ? '2e' : '30')
507 ctx.fillRect(x0, y0, x1 - x0, h)
508 }
509 ctx.strokeStyle = overTerrain && !selected ? color + '66' : color
510 ctx.lineWidth = selected ? 2.5 : 1.5
511 ctx.setLineDash(p.public ? [] : [5, 4])
512 ctx.strokeRect(x0, y0, x1 - x0, h)
513 ctx.setLineDash([])
514 this.label(p.name, (x0 + x1) / 2, y0 + h - 10, color, true)
e7a9f9dd 2026-09-17 Pavel Safronov515 if (p.kind === 'mountains') {
516 for (const pk of p.peaks) {
517 const m = toMap({ theta: pk.theta, z: pk.z })
518 const [sx, sy] = this.worldToScreen(m.x, m.y)
519 this.peak(sx, sy, color, selected)
520 // An unnamed summit is a triangle and nothing more: the range's name on it
521 // would read as a second summit of that name.
522 if (pk.name) this.label(pk.name, sx + 8, sy + 4, color, false)
523 }
524 }
04d6779e 2026-09-07 Pavel Safronov525 } else if (p.geometry.type === 'polyline') {
09a64747 2026-09-07 Pavel Safronov526 ctx.beginPath()
527 this.tracePath(p.geometry.points)
528 ctx.strokeStyle = color
529 ctx.lineWidth = selected ? 3.5 : 2
530 ctx.lineJoin = 'round'
531 ctx.lineCap = 'round'
532 ctx.setLineDash(p.public ? [] : [6, 5])
533 ctx.stroke()
534 ctx.setLineDash([])
535 const mid = p.geometry.points[Math.floor(p.geometry.points.length / 2)]!
536 const mm = toMap({ theta: mid[0], z: mid[1] })
537 const [sx, sy] = this.worldToScreen(mm.x, mm.y)
538 this.label(p.name, sx, sy - 8, color, true)
539 } else if (p.geometry.type === 'polygon') {
540 ctx.beginPath()
541 this.tracePolygon(p.geometry.points)
542 const overTerrain = this.showTerrain && this.terrain
543 if (!overTerrain || selected) {
544 ctx.fillStyle = color + (selected ? '2e' : isAreaKind(p.kind) ? '3a' : '22')
545 ctx.fill()
546 }
547 ctx.strokeStyle = overTerrain && !selected ? color + '77' : color
548 ctx.lineWidth = selected ? 2.5 : 1.5
549 ctx.setLineDash(p.public ? [] : [5, 4])
550 ctx.stroke()
551 ctx.setLineDash([])
376f2ce6 2026-09-16 Pavel Safronov552 if (p.kind === 'mountains' && p.peaks.length) {
553 // A range is read by its summits, so they carry the labels.
554 for (const pk of p.peaks) {
555 const m = toMap({ theta: pk.theta, z: pk.z })
556 const [sx, sy] = this.worldToScreen(m.x, m.y)
557 this.peak(sx, sy, color, selected)
e7a9f9dd 2026-09-17 Pavel Safronov558 // An unnamed summit is a triangle and nothing more: the range's name on it
559 // would read as a second summit of that name.
560 if (pk.name) this.label(pk.name, sx + 8, sy + 4, color, false)
376f2ce6 2026-09-16 Pavel Safronov561 }
562 } else {
563 const c = centroid(p.geometry.points)
564 const m = toMap({ theta: c[0], z: c[1] })
565 const [sx, sy] = this.worldToScreen(m.x, m.y)
566 this.label(p.name, sx, sy, color, true)
567 }
09a64747 2026-09-07 Pavel Safronov568 } else {
569 const m = toMap({ theta: p.geometry.theta, z: p.geometry.z })
570 const [sx, sy] = this.worldToScreen(m.x, m.y)
51c39d8b 2026-09-17 Pavel Safronov571 /**
572 * A settlement is a point with an extent: the marker says where it stands, the ring
573 * says how far it reaches. Drawn only when it is bigger than the marker, so a
574 * hamlet is a dot rather than a dot inside a circle.
575 */
576 if (p.kind === 'settlement') {
577 const r = p.radiusM * this.scale
578 if (r > MARKER_R + 2) {
579 ctx.beginPath()
580 ctx.arc(sx, sy, r, 0, Math.PI * 2)
581 const overTerrain = this.showTerrain && this.terrain
582 if (!overTerrain || selected) {
583 ctx.fillStyle = color + (selected ? '2e' : '22')
584 ctx.fill()
585 }
586 ctx.strokeStyle = overTerrain && !selected ? color + '77' : color
587 ctx.lineWidth = selected ? 2.5 : 1.5
588 ctx.setLineDash(p.public ? [] : [5, 4])
589 ctx.stroke()
590 ctx.setLineDash([])
591 }
592 }
09a64747 2026-09-07 Pavel Safronov593 ctx.beginPath()
594 ctx.arc(sx, sy, MARKER_R, 0, Math.PI * 2)
595 ctx.fillStyle = color
596 ctx.fill()
597 ctx.strokeStyle = selected ? '#fff' : 'rgba(0,0,0,0.6)'
598 ctx.lineWidth = selected ? 2.5 : 1.5
599 ctx.stroke()
600 this.label(p.name, sx + MARKER_R + 5, sy + 4, color, false)
601 }
602 ctx.globalAlpha = 1
603 }
604 }
605
606 private labelBoxes: { x: number; y: number; w: number; h: number }[] = []
607
376f2ce6 2026-09-16 Pavel Safronov608 /** A summit: a small triangle, pointing up the way contour maps draw one. */
609 private peak(x: number, y: number, color: string, selected: boolean): void {
610 const ctx = this.ctx
611 ctx.beginPath()
612 ctx.moveTo(x, y - 6)
613 ctx.lineTo(x + 5, y + 3)
614 ctx.lineTo(x - 5, y + 3)
615 ctx.closePath()
616 ctx.fillStyle = color
617 ctx.fill()
618 ctx.strokeStyle = selected ? '#fff' : 'rgba(0,0,0,0.6)'
619 ctx.lineWidth = selected ? 2 : 1.2
620 ctx.stroke()
621 }
622
09a64747 2026-09-07 Pavel Safronov623 private label(text: string, x: number, y: number, color: string, centred: boolean): void {
1c0284d0 2026-09-27 Pavel Safronov624 if (!this.showLabels) return
09a64747 2026-09-07 Pavel Safronov625 const ctx = this.ctx
626 const w = ctx.measureText(text).width
627 const box = { x: centred ? x - w / 2 : x, y: y - 11, w, h: 14 }
628 const { width, height } = this.canvas.getBoundingClientRect()
629 if (box.x < -w || box.x > width || box.y < -14 || box.y > height) return
630 for (const b of this.labelBoxes) {
631 if (box.x < b.x + b.w + 3 && box.x + box.w + 3 > b.x &&
632 box.y < b.y + b.h + 2 && box.y + box.h + 2 > b.y) return
633 }
634 this.labelBoxes.push(box)
635
636 if (centred) ctx.textAlign = 'center'
637 ctx.lineWidth = 3
638 ctx.strokeStyle = 'rgba(0,0,0,0.8)'
639 ctx.strokeText(text, x, y)
640 ctx.fillStyle = color
641 ctx.fillText(text, x, y)
642 ctx.textAlign = 'left'
643 }
644
645 private drawProbe(): void {
646 if (!this.probe) return
647 const ctx = this.ctx
648 const dot = (p: SurfacePoint, filled: boolean) => {
649 const mp = toMap(p)
650 const [sx, sy] = this.worldToScreen(mp.x, mp.y)
651 ctx.beginPath()
652 ctx.arc(sx, sy, 6, 0, Math.PI * 2)
653 ctx.strokeStyle = '#7fd4ff'
654 ctx.lineWidth = 2
655 if (filled) {
656 ctx.fillStyle = '#7fd4ff'
657 ctx.fill()
658 }
659 ctx.stroke()
660 }
661 dot(this.probe.from, true)
662 if (!this.probe.to) return
663 dot(this.probe.to, false)
664
665 const a = toMap(this.probe.from)
666 const b = toMap(this.probe.to)
667 // The instrument measures the short way round, so the line must show that
668 // route — which means splitting it where it leaves one edge and re-enters
669 // at the other, rather than drawing a straight line the long way.
670 let dy = b.y - a.y
671 if (dy > CIRCUMFERENCE_M / 2) dy -= CIRCUMFERENCE_M
672 if (dy < -CIRCUMFERENCE_M / 2) dy += CIRCUMFERENCE_M
673 const end = a.y + dy
674
675 ctx.strokeStyle = '#7fd4ff'
676 ctx.lineWidth = 1.5
677 ctx.setLineDash([6, 4])
678 if (end >= 0 && end <= CIRCUMFERENCE_M) {
679 this.segment(a.x, a.y, b.x, b.y)
680 } else {
681 const edge = end < 0 ? 0 : CIRCUMFERENCE_M
682 const t = (edge - a.y) / dy
683 const xCross = a.x + (b.x - a.x) * t
684 this.segment(a.x, a.y, xCross, edge)
685 this.segment(xCross, edge === 0 ? CIRCUMFERENCE_M : 0, b.x, b.y)
686 }
687 ctx.setLineDash([])
688 }
689
690 private segment(x1: number, y1: number, x2: number, y2: number): void {
691 const [ax, ay] = this.worldToScreen(x1, y1)
692 const [bx, by] = this.worldToScreen(x2, y2)
693 this.ctx.beginPath()
694 this.ctx.moveTo(ax, ay)
695 this.ctx.lineTo(bx, by)
696 this.ctx.stroke()
697 }
698}
699
700function centroid(points: readonly [number, number][]): [number, number] {
701 let t = 0
702 let z = 0
703 for (const p of points) {
704 t += p[0]
705 z += p[1]
706 }
707 return [t / points.length, z / points.length]
708}