fuzzyworld

tower / interior-flight / files / src / views / map.ts

Branch interior-flight · default branch · all branches

map.ts

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