fuzzyworld

tower / cascadia / files / src / views / map.ts

Branch cascadia · default branch · all branches

map.ts

21.2 KB · history · blame

1/**
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 {
12 CIRCUMFERENCE_M, LENGTH_M,
13} 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'
18import { type Place, isAreaKind } from '../world/schema.js'
19import { KIND_COLOR } from './palette.js'
20import { type Surface, rasterSet } from './raster.js'
21
22export interface MapCallbacks {
23 onPick: (p: SurfacePoint, hit: Place | null, shiftKey: boolean) => void
24 onDragPlace: (id: string, p: SurfacePoint) => void
25 onHover: (p: SurfacePoint | null) => void
26}
27
28const MARKER_R = 5
29
30export class TowerMap {
31 private ctx: CanvasRenderingContext2D
32 /** Pixels per metre. */
33 private scale = 1
34 private offsetX = 0
35 private offsetY = 0
36
37 places: readonly Place[] = []
38 selectedId: string | null = null
39 probe: { from: SurfacePoint; to: SurfacePoint | null } | null = null
40 draftPolygon: [number, number][] = []
41 /**
42 * Whether pressing on a marker grabs it. False while a creation tool is active,
43 * so that clicking on top of an existing place creates a new one rather than
44 * silently dragging the old one.
45 */
46 dragEnabled = true
47
48 /** The rendered terrain, drawn under everything. Null until it loads, or if absent. */
49 private terrain: HTMLImageElement | null = null
50 showTerrain = true
51
52 private surface: Surface = 'canon'
53 private dragging: { id: string } | null = null
54 private panning: { x: number; y: number; ox: number; oy: number } | null = null
55 private moved = false
56
57 constructor(private canvas: HTMLCanvasElement, private cb: MapCallbacks) {
58 const ctx = canvas.getContext('2d')
59 if (!ctx) throw new Error('2D canvas unavailable')
60 this.ctx = ctx
61 this.attach()
62 this.resize()
63 this.loadTerrain()
64 }
65
66 /**
67 * Load the terrain raster produced by `npm run terrain`. It is drawn against
68 * `mapThetaOrigin()`, which content/geography.json must match — see the note on
69 * MAP_CUT_WITHOUT_SLATS. Absence is fine; the map falls back to flat ground.
70 */
71 private loadTerrain(): void {
72 const img = new Image()
73 const want = this.surface
74 img.onload = () => {
75 // A slow load of the surface the reader has since switched away from must
76 // not paint over the one they are looking at.
77 if (want !== this.surface) return
78 this.terrain = img
79 this.draw()
80 }
81 img.src = `${import.meta.env.BASE_URL}${rasterSet(this.surface).color}`
82 }
83
84 /** Swap the ground under the map. See raster.ts for what a surface is. */
85 setSurface(surface: Surface): void {
86 if (surface === this.surface) return
87 this.surface = surface
88 this.terrain = null
89 this.draw()
90 this.loadTerrain()
91 }
92
93 // ------------------------------------------------------------------- transform
94
95 private worldToScreen(x: number, y: number): [number, number] {
96 return [x * this.scale + this.offsetX, y * this.scale + this.offsetY]
97 }
98
99 private screenToWorld(px: number, py: number): [number, number] {
100 return [(px - this.offsetX) / this.scale, (py - this.offsetY) / this.scale]
101 }
102
103 /** Frame the whole world with a margin. */
104 fit(): void {
105 const { width, height } = this.canvas.getBoundingClientRect()
106 const m = 24
107 this.scale = Math.min((width - m * 2) / LENGTH_M, (height - m * 2) / CIRCUMFERENCE_M)
108 this.offsetX = (width - LENGTH_M * this.scale) / 2
109 this.offsetY = (height - CIRCUMFERENCE_M * this.scale) / 2
110 this.draw()
111 }
112
113 /** Keep some of the surface on screen; without tiling, it can otherwise be lost. */
114 private clampPan(): void {
115 const { width, height } = this.canvas.getBoundingClientRect()
116 const w = LENGTH_M * this.scale
117 const h = CIRCUMFERENCE_M * this.scale
118 const keep = 100
119 this.offsetX = Math.min(Math.max(this.offsetX, keep - w), width - keep)
120 this.offsetY = Math.min(Math.max(this.offsetY, keep - h), height - keep)
121 }
122
123 /** Centre the view on a surface point, zooming in if the map is fully zoomed out. */
124 centreOn(p: SurfacePoint, minScale = 6e-3): void {
125 const { width, height } = this.canvas.getBoundingClientRect()
126 if (this.scale < minScale) this.scale = minScale
127 const m = toMap(p)
128 this.offsetX = width / 2 - m.x * this.scale
129 this.offsetY = height / 2 - m.y * this.scale
130 this.clampPan()
131 this.draw()
132 }
133
134 resize(): void {
135 const dpr = window.devicePixelRatio || 1
136 const { width, height } = this.canvas.getBoundingClientRect()
137 this.canvas.width = Math.round(width * dpr)
138 this.canvas.height = Math.round(height * dpr)
139 this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
140 this.draw()
141 }
142
143 // ----------------------------------------------------------------- interaction
144
145 private eventPoint(e: PointerEvent | WheelEvent): { px: number; py: number } {
146 const r = this.canvas.getBoundingClientRect()
147 return { px: e.clientX - r.left, py: e.clientY - r.top }
148 }
149
150 private surfaceAt(px: number, py: number): SurfacePoint {
151 const [x, y] = this.screenToWorld(px, py)
152 return fromMap({ x, y })
153 }
154
155 private attach(): void {
156 this.canvas.addEventListener('pointerdown', (e) => {
157 const { px, py } = this.eventPoint(e)
158 this.moved = false
159 const hit = this.hitTest(px, py)
160 if (this.dragEnabled && hit && hit.geometry.type === 'point' && !e.shiftKey) {
161 this.dragging = { id: hit.id }
162 this.canvas.setPointerCapture(e.pointerId)
163 } else {
164 this.panning = { x: px, y: py, ox: this.offsetX, oy: this.offsetY }
165 this.canvas.setPointerCapture(e.pointerId)
166 }
167 })
168
169 this.canvas.addEventListener('pointermove', (e) => {
170 const { px, py } = this.eventPoint(e)
171 if (this.dragging) {
172 this.moved = true
173 this.cb.onDragPlace(this.dragging.id, this.surfaceAt(px, py))
174 } else if (this.panning) {
175 if (Math.hypot(px - this.panning.x, py - this.panning.y) > 3) this.moved = true
176 this.offsetX = this.panning.ox + (px - this.panning.x)
177 this.offsetY = this.panning.oy + (py - this.panning.y)
178 this.clampPan()
179 this.draw()
180 } else {
181 this.cb.onHover(this.surfaceAt(px, py))
182 }
183 })
184
185 const end = (e: PointerEvent) => {
186 const { px, py } = this.eventPoint(e)
187 const wasDragging = this.dragging
188 this.dragging = null
189 this.panning = null
190 if (this.canvas.hasPointerCapture(e.pointerId)) this.canvas.releasePointerCapture(e.pointerId)
191 if (!this.moved && !wasDragging) this.cb.onPick(this.surfaceAt(px, py), this.hitTest(px, py), e.shiftKey)
192 }
193 this.canvas.addEventListener('pointerup', end)
194 this.canvas.addEventListener('pointercancel', () => {
195 this.dragging = null
196 this.panning = null
197 })
198 this.canvas.addEventListener('pointerleave', () => this.cb.onHover(null))
199
200 this.canvas.addEventListener('wheel', (e) => {
201 e.preventDefault()
202 const { px, py } = this.eventPoint(e)
203 const [wx, wy] = this.screenToWorld(px, py)
204 const factor = Math.exp(-e.deltaY * 0.0015)
205 const fitScale = this.canvas.getBoundingClientRect().width / LENGTH_M
206 this.scale = Math.min(Math.max(this.scale * factor, fitScale * 0.5), fitScale * 400)
207 this.offsetX = px - wx * this.scale
208 this.offsetY = py - wy * this.scale
209 this.clampPan()
210 this.draw()
211 }, { passive: false })
212 }
213
214 /** Topmost place under the cursor, markers taking precedence over areas. */
215 hitTest(px: number, py: number): Place | null {
216 for (let i = this.places.length - 1; i >= 0; i--) {
217 const p = this.places[i]!
218 if (p.geometry.type !== 'point') continue
219 const m = toMap({ theta: p.geometry.theta, z: p.geometry.z })
220 const [sx, sy] = this.worldToScreen(m.x, m.y)
221 if (Math.hypot(px - sx, py - sy) <= MARKER_R + 5) return p
222 }
223 for (let i = this.places.length - 1; i >= 0; i--) {
224 const p = this.places[i]!
225 if (p.geometry.type !== 'polyline') continue
226 this.ctx.beginPath()
227 this.tracePath(p.geometry.points)
228 this.ctx.lineWidth = 10
229 if (this.ctx.isPointInStroke(px, py)) return p
230 }
231 for (let i = this.places.length - 1; i >= 0; i--) {
232 const p = this.places[i]!
233 if (p.geometry.type !== 'band') continue
234 const [x0] = this.worldToScreen(p.geometry.zFrom, 0)
235 const [x1] = this.worldToScreen(p.geometry.zTo, 0)
236 if (px >= x0 && px <= x1) return p
237 }
238 for (let i = this.places.length - 1; i >= 0; i--) {
239 const p = this.places[i]!
240 if (p.geometry.type !== 'polygon') continue
241 this.ctx.beginPath()
242 this.tracePolygon(p.geometry.points)
243 if (this.ctx.isPointInPath(px, py)) return p
244 }
245 return null
246 }
247
248 // -------------------------------------------------------------------- drawing
249
250 /** Trace an open path. Segments crossing the seam are split, not drawn the long way. */
251 private tracePath(points: readonly [number, number][]): void {
252 let prevY: number | null = null
253 points.forEach(([theta, z], i) => {
254 const m = toMap({ theta, z })
255 const [sx, sy] = this.worldToScreen(m.x, m.y)
256 const jumped = prevY !== null && Math.abs(m.y - prevY) > CIRCUMFERENCE_M / 2
257 if (i === 0 || jumped) this.ctx.moveTo(sx, sy)
258 else this.ctx.lineTo(sx, sy)
259 prevY = m.y
260 })
261 }
262
263 private tracePolygon(points: readonly [number, number][]): void {
264 points.forEach(([theta, z], i) => {
265 const m = toMap({ theta, z })
266 const [sx, sy] = this.worldToScreen(m.x, m.y)
267 if (i === 0) this.ctx.moveTo(sx, sy)
268 else this.ctx.lineTo(sx, sy)
269 })
270 this.ctx.closePath()
271 }
272
273 draw(): void {
274 const ctx = this.ctx
275 const { width, height } = this.canvas.getBoundingClientRect()
276 ctx.clearRect(0, 0, width, height)
277 ctx.fillStyle = '#0b0d12'
278 ctx.fillRect(0, 0, width, height)
279
280 this.drawSurface()
281 this.drawCaps()
282 this.drawPlaces()
283 this.drawProbe()
284 this.drawDraft()
285 this.drawScaleBar()
286 }
287
288 /** A round-number scale bar, because every distance here is counter-intuitive. */
289 private drawScaleBar(): void {
290 const ctx = this.ctx
291 const { height } = this.canvas.getBoundingClientRect()
292 const targetPx = 160
293 const candidates = [1e3, 5e3, 1e4, 2.5e4, 5e4, 1e5, 2e5]
294 const metres = candidates.find((c) => c * this.scale > targetPx) ?? candidates.at(-1)!
295 const px = metres * this.scale
296 const x = 16
297 const y = height - 22
298
299 ctx.strokeStyle = 'rgba(230,238,250,0.75)'
300 ctx.lineWidth = 1.5
301 ctx.beginPath()
302 ctx.moveTo(x, y - 4)
303 ctx.lineTo(x, y)
304 ctx.lineTo(x + px, y)
305 ctx.lineTo(x + px, y - 4)
306 ctx.stroke()
307 ctx.font = '10px ui-monospace, SFMono-Regular, Menlo, monospace'
308 ctx.fillStyle = 'rgba(230,238,250,0.8)'
309 ctx.fillText(metres >= 1000 ? `${metres / 1000} km` : `${metres} m`, x, y - 8)
310 }
311
312 private drawSurface(): void {
313 const ctx = this.ctx
314 const [x0, y0] = this.worldToScreen(0, 0)
315 const w = LENGTH_M * this.scale
316 const h = CIRCUMFERENCE_M * this.scale
317
318 if (this.showTerrain && this.terrain) {
319 ctx.imageSmoothingEnabled = true
320 ctx.drawImage(this.terrain, x0, y0, w, h)
321 } else {
322 ctx.fillStyle = '#1c2b22'
323 ctx.fillRect(x0, y0, w, h)
324 }
325
326 // Everything below is clipped to the surface, so the slat straddling the seam
327 // draws as two halves at the top and bottom edges rather than spilling out.
328 ctx.save()
329 ctx.beginPath()
330 ctx.rect(x0, y0, w, h)
331 ctx.clip()
332
333 // Slats: transparent floor over open space. Tower's "water" (D-002).
334 const slatH = slatWidth() * this.scale
335 for (let i = 0; i < slatCount(); i++) {
336 const yc = toMap({ theta: slatCentre(i), z: 0 }).y
337 for (const band of [yc, yc + CIRCUMFERENCE_M]) {
338 const [, sy] = this.worldToScreen(0, band)
339 ctx.fillStyle = '#05070d'
340 ctx.fillRect(x0, sy - slatH / 2, w, slatH)
341 ctx.strokeStyle = 'rgba(120,170,220,0.28)'
342 ctx.lineWidth = 1
343 ctx.strokeRect(x0, sy - slatH / 2, w, slatH)
344 this.drawStars(x0, sy - slatH / 2, w, slatH, i)
345 }
346 }
347
348 if (slatCount() === 0) {
349 // The single strip is centred on the map edge, so label it in the middle
350 // where it can actually be read.
351 ctx.fillText('NO SLATS — ONE UNBROKEN SURFACE', x0 + 6, y0 + h / 2)
352 } else {
353 for (let i = 0; i < landStripCount(); i++) {
354 const [, sy] = this.worldToScreen(0, toMap({ theta: landStripCentre(i), z: 0 }).y)
355 ctx.fillText(`STRIP ${i}`, x0 + 6, sy - 3)
356 }
357 }
358 ctx.restore()
359
360 ctx.strokeStyle = 'rgba(150,180,210,0.30)'
361 ctx.lineWidth = 1
362 ctx.strokeRect(x0, y0, w, h)
363 }
364
365 /** Deterministic star field so slats do not shimmer while panning. */
366 private drawStars(x: number, y: number, w: number, h: number, seed: number): void {
367 if (h < 2) return
368 const ctx = this.ctx
369 ctx.fillStyle = 'rgba(220,235,255,0.75)'
370 let s = seed * 9781 + 1
371 const n = Math.min(400, Math.max(20, Math.round(w / 6)))
372 for (let i = 0; i < n; i++) {
373 s = (s * 1103515245 + 12345) & 0x7fffffff
374 const fx = ((s >> 8) % 10000) / 10000
375 s = (s * 1103515245 + 12345) & 0x7fffffff
376 const fy = ((s >> 8) % 10000) / 10000
377 ctx.fillRect(x + fx * w, y + fy * h, 1, 1)
378 }
379 }
380
381 private drawCaps(): void {
382 const ctx = this.ctx
383 const [, y0] = this.worldToScreen(0, 0)
384 const h = CIRCUMFERENCE_M * this.scale
385 const [sunX] = this.worldToScreen(0, 0)
386 const [farX] = this.worldToScreen(LENGTH_M, 0)
387
388 const grad = ctx.createLinearGradient(sunX, 0, sunX + 90, 0)
389 grad.addColorStop(0, 'rgba(255,214,140,0.30)')
390 grad.addColorStop(1, 'rgba(255,214,140,0)')
391 ctx.fillStyle = grad
392 ctx.fillRect(sunX, y0, 90, h)
393
394 ctx.lineWidth = 2
395 ctx.strokeStyle = 'rgba(255,214,140,0.65)'
396 ctx.beginPath()
397 ctx.moveTo(sunX, y0)
398 ctx.lineTo(sunX, y0 + h)
399 ctx.stroke()
400
401 ctx.strokeStyle = 'rgba(150,170,200,0.45)'
402 ctx.beginPath()
403 ctx.moveTo(farX, y0)
404 ctx.lineTo(farX, y0 + h)
405 ctx.stroke()
406
407 ctx.font = '11px ui-monospace, SFMono-Regular, Menlo, monospace'
408 ctx.fillStyle = 'rgba(255,214,140,0.85)'
409 ctx.fillText('SUN CAP', sunX + 8, y0 + 14)
410 ctx.fillStyle = 'rgba(170,190,215,0.8)'
411 ctx.textAlign = 'right'
412 ctx.fillText('FAR CAP', farX - 8, y0 + 14)
413 ctx.textAlign = 'left'
414 }
415
416 /** Rank for label priority: settlements and landmarks beat broad regions. */
417 private static readonly LABEL_RANK: Record<string, number> = {
418 city: 0, tower: 1, town: 2, embassy: 3, landmark: 4, mountain: 5,
419 canyon: 6, river: 7, lake: 8, note: 9, forest: 10, desert: 11, region: 12,
420 }
421
422 private drawPlaces(): void {
423 const ctx = this.ctx
424 ctx.font = '12px system-ui, -apple-system, sans-serif'
425 // Labels are placed most-important first and skipped where they would collide.
426 // Without this the mountain settlements pile into an unreadable smear.
427 this.labelBoxes = []
428 const order = [...this.places].sort(
429 (a, b) => (TowerMap.LABEL_RANK[a.kind] ?? 99) - (TowerMap.LABEL_RANK[b.kind] ?? 99))
430 for (const p of order) {
431 const color = KIND_COLOR[p.kind] ?? '#ccc'
432 const selected = p.id === this.selectedId
433 ctx.globalAlpha = p.public ? 1 : 0.55
434
435 if (p.geometry.type === 'band') {
436 const [x0, y0] = this.worldToScreen(p.geometry.zFrom, 0)
437 const [x1] = this.worldToScreen(p.geometry.zTo, 0)
438 const h = CIRCUMFERENCE_M * this.scale
439 const overTerrain = this.showTerrain && this.terrain
440 if (!overTerrain || selected) {
441 ctx.fillStyle = color + (selected ? '2e' : '30')
442 ctx.fillRect(x0, y0, x1 - x0, h)
443 }
444 ctx.strokeStyle = overTerrain && !selected ? color + '66' : color
445 ctx.lineWidth = selected ? 2.5 : 1.5
446 ctx.setLineDash(p.public ? [] : [5, 4])
447 ctx.strokeRect(x0, y0, x1 - x0, h)
448 ctx.setLineDash([])
449 this.label(p.name, (x0 + x1) / 2, y0 + h - 10, color, true)
450 } else if (p.geometry.type === 'polyline') {
451 ctx.beginPath()
452 this.tracePath(p.geometry.points)
453 ctx.strokeStyle = color
454 ctx.lineWidth = selected ? 3.5 : 2
455 ctx.lineJoin = 'round'
456 ctx.lineCap = 'round'
457 ctx.setLineDash(p.public ? [] : [6, 5])
458 ctx.stroke()
459 ctx.setLineDash([])
460 const mid = p.geometry.points[Math.floor(p.geometry.points.length / 2)]!
461 const mm = toMap({ theta: mid[0], z: mid[1] })
462 const [sx, sy] = this.worldToScreen(mm.x, mm.y)
463 this.label(p.name, sx, sy - 8, color, true)
464 } else if (p.geometry.type === 'polygon') {
465 ctx.beginPath()
466 this.tracePolygon(p.geometry.points)
467 const overTerrain = this.showTerrain && this.terrain
468 if (!overTerrain || selected) {
469 ctx.fillStyle = color + (selected ? '2e' : isAreaKind(p.kind) ? '3a' : '22')
470 ctx.fill()
471 }
472 ctx.strokeStyle = overTerrain && !selected ? color + '77' : color
473 ctx.lineWidth = selected ? 2.5 : 1.5
474 ctx.setLineDash(p.public ? [] : [5, 4])
475 ctx.stroke()
476 ctx.setLineDash([])
477 const c = centroid(p.geometry.points)
478 const m = toMap({ theta: c[0], z: c[1] })
479 const [sx, sy] = this.worldToScreen(m.x, m.y)
480 this.label(p.name, sx, sy, color, true)
481 } else {
482 const m = toMap({ theta: p.geometry.theta, z: p.geometry.z })
483 const [sx, sy] = this.worldToScreen(m.x, m.y)
484 ctx.beginPath()
485 ctx.arc(sx, sy, MARKER_R, 0, Math.PI * 2)
486 ctx.fillStyle = color
487 ctx.fill()
488 ctx.strokeStyle = selected ? '#fff' : 'rgba(0,0,0,0.6)'
489 ctx.lineWidth = selected ? 2.5 : 1.5
490 ctx.stroke()
491 this.label(p.name, sx + MARKER_R + 5, sy + 4, color, false)
492 }
493 ctx.globalAlpha = 1
494 }
495 }
496
497 private labelBoxes: { x: number; y: number; w: number; h: number }[] = []
498
499 private label(text: string, x: number, y: number, color: string, centred: boolean): void {
500 const ctx = this.ctx
501 const w = ctx.measureText(text).width
502 const box = { x: centred ? x - w / 2 : x, y: y - 11, w, h: 14 }
503 const { width, height } = this.canvas.getBoundingClientRect()
504 if (box.x < -w || box.x > width || box.y < -14 || box.y > height) return
505 for (const b of this.labelBoxes) {
506 if (box.x < b.x + b.w + 3 && box.x + box.w + 3 > b.x &&
507 box.y < b.y + b.h + 2 && box.y + box.h + 2 > b.y) return
508 }
509 this.labelBoxes.push(box)
510
511 if (centred) ctx.textAlign = 'center'
512 ctx.lineWidth = 3
513 ctx.strokeStyle = 'rgba(0,0,0,0.8)'
514 ctx.strokeText(text, x, y)
515 ctx.fillStyle = color
516 ctx.fillText(text, x, y)
517 ctx.textAlign = 'left'
518 }
519
520 private drawProbe(): void {
521 if (!this.probe) return
522 const ctx = this.ctx
523 const dot = (p: SurfacePoint, filled: boolean) => {
524 const mp = toMap(p)
525 const [sx, sy] = this.worldToScreen(mp.x, mp.y)
526 ctx.beginPath()
527 ctx.arc(sx, sy, 6, 0, Math.PI * 2)
528 ctx.strokeStyle = '#7fd4ff'
529 ctx.lineWidth = 2
530 if (filled) {
531 ctx.fillStyle = '#7fd4ff'
532 ctx.fill()
533 }
534 ctx.stroke()
535 }
536 dot(this.probe.from, true)
537 if (!this.probe.to) return
538 dot(this.probe.to, false)
539
540 const a = toMap(this.probe.from)
541 const b = toMap(this.probe.to)
542 // The instrument measures the short way round, so the line must show that
543 // route — which means splitting it where it leaves one edge and re-enters
544 // at the other, rather than drawing a straight line the long way.
545 let dy = b.y - a.y
546 if (dy > CIRCUMFERENCE_M / 2) dy -= CIRCUMFERENCE_M
547 if (dy < -CIRCUMFERENCE_M / 2) dy += CIRCUMFERENCE_M
548 const end = a.y + dy
549
550 ctx.strokeStyle = '#7fd4ff'
551 ctx.lineWidth = 1.5
552 ctx.setLineDash([6, 4])
553 if (end >= 0 && end <= CIRCUMFERENCE_M) {
554 this.segment(a.x, a.y, b.x, b.y)
555 } else {
556 const edge = end < 0 ? 0 : CIRCUMFERENCE_M
557 const t = (edge - a.y) / dy
558 const xCross = a.x + (b.x - a.x) * t
559 this.segment(a.x, a.y, xCross, edge)
560 this.segment(xCross, edge === 0 ? CIRCUMFERENCE_M : 0, b.x, b.y)
561 }
562 ctx.setLineDash([])
563 }
564
565 private segment(x1: number, y1: number, x2: number, y2: number): void {
566 const [ax, ay] = this.worldToScreen(x1, y1)
567 const [bx, by] = this.worldToScreen(x2, y2)
568 this.ctx.beginPath()
569 this.ctx.moveTo(ax, ay)
570 this.ctx.lineTo(bx, by)
571 this.ctx.stroke()
572 }
573
574 private drawDraft(): void {
575 if (this.draftPolygon.length === 0) return
576 const ctx = this.ctx
577 ctx.beginPath()
578 this.tracePath(this.draftPolygon)
579 ctx.strokeStyle = '#7fd4ff'
580 ctx.lineWidth = 2
581 ctx.setLineDash([5, 4])
582 ctx.stroke()
583 ctx.setLineDash([])
584 for (const [theta, z] of this.draftPolygon) {
585 const m = toMap({ theta, z })
586 const [sx, sy] = this.worldToScreen(m.x, m.y)
587 ctx.beginPath()
588 ctx.arc(sx, sy, 3.5, 0, Math.PI * 2)
589 ctx.fillStyle = '#7fd4ff'
590 ctx.fill()
591 }
592 }
593}
594
595function centroid(points: readonly [number, number][]): [number, number] {
596 let t = 0
597 let z = 0
598 for (const p of points) {
599 t += p[0]
600 z += p[1]
601 }
602 return [t / points.length, z / points.length]
603}