midgard-hex-grid 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +102 -1
  2. package/demo/demo-minimal.ts +531 -0
  3. package/demo/demo-theory.ts +1679 -0
  4. package/demo/index.html +11 -0
  5. package/demo/minimal.html +11 -0
  6. package/demo/package-lock.json +1162 -0
  7. package/demo/package.json +11 -0
  8. package/demo/vite.config.ts +9 -0
  9. package/dist/coordinate-layer.d.ts +1 -11
  10. package/dist/coordinate-layer.d.ts.map +1 -1
  11. package/dist/coordinate-layer.js +5 -5
  12. package/dist/coordinate-layer.js.map +1 -1
  13. package/dist/coordinate-positioner.d.ts +1 -17
  14. package/dist/coordinate-positioner.d.ts.map +1 -1
  15. package/dist/coordinate-positioner.js +7 -7
  16. package/dist/coordinate-positioner.js.map +1 -1
  17. package/dist/coordinate-range-fill.d.ts +1 -20
  18. package/dist/coordinate-range-fill.d.ts.map +1 -1
  19. package/dist/coordinate-range-fill.js +12 -10
  20. package/dist/coordinate-range-fill.js.map +1 -1
  21. package/dist/coordinate-range.d.ts +16 -7
  22. package/dist/coordinate-range.d.ts.map +1 -1
  23. package/dist/coordinate-range.js +30 -13
  24. package/dist/coordinate-range.js.map +1 -1
  25. package/dist/geometry.d.ts +1 -25
  26. package/dist/geometry.d.ts.map +1 -1
  27. package/dist/geometry.js +7 -7
  28. package/dist/geometry.js.map +1 -1
  29. package/dist/h-g-bounds.d.ts +8 -5
  30. package/dist/h-g-bounds.d.ts.map +1 -1
  31. package/dist/h-g-bounds.js +12 -10
  32. package/dist/h-g-bounds.js.map +1 -1
  33. package/dist/h-g-hexagon.d.ts +21 -7
  34. package/dist/h-g-hexagon.d.ts.map +1 -1
  35. package/dist/h-g-hexagon.js +34 -12
  36. package/dist/h-g-hexagon.js.map +1 -1
  37. package/dist/hex-grid.d.ts +5 -1
  38. package/dist/hex-grid.d.ts.map +1 -1
  39. package/dist/hex-grid.js +32 -15
  40. package/dist/hex-grid.js.map +1 -1
  41. package/dist/neighbours.d.ts +1 -23
  42. package/dist/neighbours.d.ts.map +1 -1
  43. package/dist/neighbours.js +7 -7
  44. package/dist/neighbours.js.map +1 -1
  45. package/docs/images/minimal-x.png +0 -0
  46. package/docs/images/minimal-y.png +0 -0
  47. package/docs/images/theory-x.png +0 -0
  48. package/docs/images/theory-y.png +0 -0
  49. package/package.json +1 -1
@@ -0,0 +1,1679 @@
1
+ import { CoordinateLayer, HexGrid } from '../src/index.ts'
2
+
3
+ import type { Coordinate, Point } from '../src/index.ts'
4
+
5
+ const theoryStyles = `
6
+ :root {
7
+ --page-navy: #0e1c2e;
8
+ --panel-parchment: #c7a47f;
9
+ --panel-shadow: 0 16px 36px rgba(0, 0, 0, 0.28);
10
+ --ink-strong: #2a1a0e;
11
+ --ink-body: #3d2918;
12
+ --ink-soft: #4a3120;
13
+ --rule-brown: #7a4e2d;
14
+
15
+ --fill-valid: #f4ead8;
16
+ --fill-edge: #909090;
17
+ --fill-mixed: #505050;
18
+ --fill-neighbour: #788d42;
19
+ --fill-center: #a9b965;
20
+ --fill-forbidden-cell: #e5a3a5;
21
+
22
+ --stroke-boundary: #8e2e2a;
23
+ --stroke-axis: #4a433a;
24
+ --stroke-neighbour-mark: #1b6840;
25
+ --stroke-overlay: #75c9f1;
26
+ --fill-movement: #102b20;
27
+ --fill-forbidden: #7a2020;
28
+ --stroke-forbidden: #ff2020;
29
+ --fill-center-mark: #ffe44d;
30
+ --label-marked: #d10f0f;
31
+ --stroke-offset-mark: #d52c36;
32
+
33
+ --label-origin: #fff1ea;
34
+ --label-near-origin: #1a120c;
35
+ --label-outer-pair: #a33b48;
36
+ --label-neighbour: #143f24;
37
+ --label-scattered: #7a2f68;
38
+ --label-far-corner: #ffe7a3;
39
+
40
+ --label-hex-axis: #4a5614;
41
+ --label-hex-diagonal: #1a4c84;
42
+ --label-hex-row: #143f24;
43
+ }
44
+
45
+ html {
46
+ font-size: 17px;
47
+ }
48
+
49
+ body {
50
+ margin: 0;
51
+ min-height: 100vh;
52
+ background: var(--page-navy);
53
+ color: var(--ink-body);
54
+ font-family: Palatino, 'Palatino Linotype', 'Iowan Old Style', Georgia, serif;
55
+ line-height: 1.45;
56
+ }
57
+
58
+ .page {
59
+ padding: 32px 20px 48px;
60
+ }
61
+
62
+ .sheet {
63
+ width: 100%;
64
+ max-width: 1200px;
65
+ margin: 0 auto;
66
+ padding: 48px 40px 56px;
67
+ background: var(--panel-parchment);
68
+ box-shadow: var(--panel-shadow);
69
+ }
70
+
71
+ .sheet-section {
72
+ display: flow-root;
73
+ width: 100%;
74
+ margin: 0;
75
+ }
76
+
77
+ .sheet__intro {
78
+ margin-bottom: 36px;
79
+ }
80
+
81
+ #y-dominated {
82
+ padding-top: 36px;
83
+ }
84
+
85
+ h1 {
86
+ margin: 0;
87
+ color: var(--ink-strong);
88
+ font-size: 40px;
89
+ font-weight: 600;
90
+ line-height: 1.15;
91
+ text-align: center;
92
+ }
93
+
94
+ .sheet__minimal {
95
+ display: block;
96
+ margin: 8px 0 0;
97
+ color: var(--ink-soft);
98
+ font-size: 18px;
99
+ font-weight: 600;
100
+ line-height: 1.25;
101
+ text-align: center;
102
+ text-decoration-line: underline;
103
+ text-decoration-thickness: 1px;
104
+ text-underline-offset: 0.2em;
105
+ }
106
+
107
+ .principle {
108
+ max-width: 820px;
109
+ margin: 36px auto;
110
+ }
111
+
112
+ .principle h2 {
113
+ margin: 0 0 16px;
114
+ padding: 0;
115
+ border: 0;
116
+ color: var(--ink-strong);
117
+ font-size: 26px;
118
+ font-weight: 600;
119
+ line-height: 1.25;
120
+ text-align: center;
121
+ }
122
+
123
+ .principle p {
124
+ margin: 0 0 16px;
125
+ color: var(--ink-body);
126
+ line-height: 1.6;
127
+ text-align: left;
128
+ }
129
+
130
+ .principle p:last-child {
131
+ margin-bottom: 0;
132
+ }
133
+
134
+ .principle strong {
135
+ color: var(--ink-strong);
136
+ font-weight: 700;
137
+ }
138
+
139
+ .diagrams {
140
+ display: flex;
141
+ align-items: flex-start;
142
+ justify-content: center;
143
+ gap: 32px;
144
+ }
145
+
146
+ .diagram {
147
+ display: flex;
148
+ flex: 0 0 auto;
149
+ flex-direction: column;
150
+ align-items: center;
151
+ min-width: 0;
152
+ }
153
+
154
+ h2 {
155
+ width: 100%;
156
+ margin: 0 0 16px;
157
+ padding-bottom: 8px;
158
+ border-bottom: 1px solid var(--rule-brown);
159
+ color: var(--ink-strong);
160
+ font-size: 26px;
161
+ font-weight: 600;
162
+ line-height: 1.25;
163
+ text-align: center;
164
+ }
165
+
166
+ .stage {
167
+ position: relative;
168
+ max-width: 100%;
169
+ }
170
+
171
+ .grid,
172
+ .hexes {
173
+ display: block;
174
+ max-width: none;
175
+ height: auto;
176
+ }
177
+
178
+ .overlay {
179
+ position: absolute;
180
+ inset: 0;
181
+ z-index: 2;
182
+ width: 100%;
183
+ height: 100%;
184
+ pointer-events: none;
185
+ }
186
+
187
+ .cell-label,
188
+ .hex-label {
189
+ font-family: Palatino, 'Palatino Linotype', 'Iowan Old Style', Georgia, serif;
190
+ font-weight: 700;
191
+ }
192
+
193
+ @media (max-width: 980px) {
194
+ .sheet {
195
+ padding: 36px 22px 40px;
196
+ }
197
+
198
+ h1 {
199
+ font-size: 32px;
200
+ }
201
+
202
+ .diagrams {
203
+ flex-direction: column;
204
+ align-items: center;
205
+ gap: 36px;
206
+ }
207
+
208
+ .diagram {
209
+ width: min(100%, 640px);
210
+ }
211
+ }
212
+ `
213
+
214
+ installTheoryStyles()
215
+ createTheoryLayout()
216
+
217
+ const theory = requireHtmlElement(document, '#theory')
218
+ const xSection = requireHtmlElement(document, '#x-dominated')
219
+ const ySection = requireHtmlElement(document, '#y-dominated')
220
+
221
+ // Both illustrations are authored at this cell size. The wider
222
+ // Y-dominated row decides the one scale that makes them visible.
223
+ const sharedCellSize = 76
224
+
225
+ renderTheory(theory)
226
+ renderXDominated(xSection)
227
+ renderYDominated(ySection)
228
+
229
+ function installTheoryStyles() {
230
+ if (document.getElementById('midgard-theory-styles') !== null) {
231
+ return
232
+ }
233
+
234
+ const style = document.createElement('style')
235
+ style.id = 'midgard-theory-styles'
236
+ style.textContent = theoryStyles
237
+ document.head.append(style)
238
+ }
239
+
240
+ function createTheoryLayout() {
241
+ if (document.getElementById('theory') !== null) {
242
+ return
243
+ }
244
+
245
+ const page = document.createElement('main')
246
+ page.className = 'page'
247
+
248
+ const sheet = document.createElement('article')
249
+ sheet.className = 'sheet'
250
+ sheet.append(
251
+ createTheorySection(),
252
+ createDemonstrationSection(
253
+ 'x-dominated',
254
+ 'In the X-dominated grid',
255
+ 'xdom',
256
+ 'Y-movement is not allowed',
257
+ 'x-hexgrid'
258
+ ),
259
+ createDemonstrationSection(
260
+ 'y-dominated',
261
+ 'In the Y-dominated grid',
262
+ 'ydom',
263
+ 'X-movement is not allowed',
264
+ 'y-hexgrid'
265
+ )
266
+ )
267
+
268
+ page.append(sheet)
269
+ document.body.prepend(page)
270
+ }
271
+
272
+ function createTheorySection() {
273
+ const section = document.createElement('div')
274
+ section.id = 'theory'
275
+ section.className = 'sheet-section'
276
+
277
+ const header = document.createElement('header')
278
+ header.className = 'sheet__intro'
279
+
280
+ const heading = document.createElement('h1')
281
+ heading.textContent = 'Midgard Hex Grid Theory Demonstration'
282
+
283
+ const minimal = document.createElement('a')
284
+ minimal.className = 'sheet__minimal'
285
+ minimal.href = '/minimal.html'
286
+ minimal.textContent = 'Minimal Demonstration'
287
+
288
+ header.append(heading, minimal)
289
+ section.append(header, createPrincipleSection())
290
+ return section
291
+ }
292
+
293
+ function createPrincipleSection() {
294
+ const section = document.createElement('section')
295
+ section.className = 'principle'
296
+
297
+ const heading = document.createElement('h2')
298
+ heading.textContent = '⬡ The Parity Principle & Grid Orientation'
299
+
300
+ const parity = document.createElement('p')
301
+ parity.append(
302
+ 'Midgard Hex Grid is built on a simple mathematical principle called ',
303
+ strong('coordinate parity'),
304
+ '. Every valid hexagon coordinate consists of two numbers that are either both even or both odd. The even coordinates, such as (2,2), (4,2), (2,4), and (4,4), form the underlying ',
305
+ strong('skeleton grid'),
306
+ ', while the odd coordinates, such as (3,3), (1,3), and (3,5), identify the surrounding hexagons that complete the grid. Mixed coordinates, such as (3,4) or (2,5), are never valid because their numbers have different parity.'
307
+ )
308
+
309
+ const foundation = document.createElement('p')
310
+ foundation.append(
311
+ strong('The foundation of every grid is the skeleton hexagon at coordinate (2,2).'),
312
+ ' This is always the top-left skeleton hexagon and the fixed starting point from which the skeleton grid is constructed. Regardless of the grid\'s dimensions or orientation, the skeleton begins at (2,2). For example, when skeletonWidth: 1 and skeletonHeight: 1, the skeleton consists of exactly one hexagon: (2,2). Increasing the skeleton width or height extends the skeleton from this fixed origin, using even coordinates such as (4,2), (2,4), and (4,4). This makes (2,2) the permanent anchor of the Midgard coordinate system.'
313
+ )
314
+
315
+ const orientation = document.createElement('p')
316
+ orientation.append(
317
+ 'The library supports two orientations: ',
318
+ strong('X-dominated'),
319
+ ' and ',
320
+ strong('Y-dominated'),
321
+ '. X-dominated grids use hexagons with pointed tops and bottoms, while Y-dominated grids use hexagons with flat tops and bottoms. Both orientations follow exactly the same parity rules and coordinate system; only the geometric arrangement changes. This means that a hexagon at coordinate (2,2) retains its logical identity regardless of orientation.'
322
+ )
323
+
324
+ const separation = document.createElement('p')
325
+ separation.append(
326
+ 'By separating ',
327
+ strong('logical coordinates from visual geometry'),
328
+ ', Midgard Hex Grid provides a consistent foundation for creating, positioning, and navigating hexagonal grids in either orientation.'
329
+ )
330
+
331
+ section.append(heading, parity, foundation, orientation, separation)
332
+ return section
333
+ }
334
+
335
+ function strong(text: string) {
336
+ const element = document.createElement('strong')
337
+ element.textContent = text
338
+ return element
339
+ }
340
+
341
+ function createDemonstrationSection(
342
+ id: string,
343
+ leftHeading: string,
344
+ leftStageId: string,
345
+ rightHeading: string,
346
+ rightStageId: string
347
+ ) {
348
+ const section = document.createElement('div')
349
+ section.id = id
350
+ section.className = 'sheet-section'
351
+
352
+ const diagrams = document.createElement('div')
353
+ diagrams.className = 'diagrams'
354
+ diagrams.append(
355
+ createDiagram(leftHeading, leftStageId),
356
+ createDiagram(rightHeading, rightStageId)
357
+ )
358
+
359
+ section.append(diagrams)
360
+ return section
361
+ }
362
+
363
+ function createDiagram(heading: string, stageId: string) {
364
+ const diagram = document.createElement('section')
365
+ diagram.className = 'diagram'
366
+
367
+ const title = document.createElement('h2')
368
+ title.textContent = heading
369
+
370
+ const stage = document.createElement('div')
371
+ stage.id = stageId
372
+ stage.className = 'stage'
373
+
374
+ diagram.append(title, stage)
375
+ return diagram
376
+ }
377
+
378
+ function renderTheory(_root: HTMLElement) {
379
+ }
380
+
381
+ function requireHtmlElement(scope: ParentNode, selector: string) {
382
+ const element = scope.querySelector(selector)
383
+
384
+ if (!(element instanceof HTMLElement)) {
385
+ throw new Error('Demo containers are missing.')
386
+ }
387
+
388
+ return element
389
+ }
390
+
391
+ function sharedDemonstrationScale() {
392
+ const diagrams = ySection.querySelector('.diagrams')
393
+
394
+ if (!(diagrams instanceof HTMLElement)) {
395
+ return 1
396
+ }
397
+
398
+ const sideBySide = getComputedStyle(diagrams).flexDirection !== 'column'
399
+ const gap = Number.parseFloat(getComputedStyle(diagrams).columnGap) || 0
400
+ const available = diagrams.clientWidth
401
+ const fitted = yDominatedUserWidth()
402
+ const naturalWidth = sideBySide
403
+ ? fitted.leftWidth + fitted.rightWidth + gap
404
+ : Math.max(fitted.leftWidth, fitted.rightWidth)
405
+
406
+ if (naturalWidth <= 0 || available <= 0) {
407
+ return 1
408
+ }
409
+
410
+ return Math.min(1, available / naturalWidth)
411
+ }
412
+
413
+ function yDominatedUserWidth() {
414
+ const coordinates: Coordinate[] = []
415
+
416
+ for (let x = 1; x <= 5; x += 1) {
417
+ for (let y = 0; y <= 6; y += 1) {
418
+ if ((x + y) % 2 === 0) {
419
+ coordinates.push({ x, y })
420
+ }
421
+ }
422
+ }
423
+
424
+ const bounds = new HexGrid('y-dominated').getGridBounds(
425
+ coordinates,
426
+ sharedCellSize * 2
427
+ )
428
+ const pad = 8
429
+
430
+ return {
431
+ leftWidth: 7 * sharedCellSize * Math.sqrt(3),
432
+ rightWidth: bounds.width + pad * 2
433
+ }
434
+ }
435
+
436
+ function renderXDominated(root: HTMLElement) {
437
+ const svgNs = 'http://www.w3.org/2000/svg'
438
+ const sqrt3 = Math.sqrt(3)
439
+ const columns = 7
440
+ const rows = 7
441
+ const cellWidth = sharedCellSize
442
+ const cellHeight = cellWidth * sqrt3
443
+
444
+ // One x-dominated step of 2 is a horizontal neighbour.
445
+ // That distance is the hexagon width, and it equals two rectangle columns.
446
+ const hexDiameter = cellWidth * 2
447
+
448
+ const grid = new HexGrid('x-dominated')
449
+ const centerCoordinate = { x: 3, y: 3 }
450
+ const neighbourKeys = new Set(
451
+ grid.getNeighbours(centerCoordinate).map((coordinate) => keyOf(coordinate))
452
+ )
453
+
454
+ const labeledXDom = new Set([
455
+ '0,0',
456
+ '1,1',
457
+ '5,1',
458
+ '0,2',
459
+ '2,2',
460
+ '4,2',
461
+ '6,2',
462
+ '1,3',
463
+ '3,1',
464
+ '3,3',
465
+ '3,5',
466
+ '5,3',
467
+ '0,4',
468
+ '2,4',
469
+ '4,4',
470
+ '6,4',
471
+ '1,5',
472
+ '5,5',
473
+ '6,6'
474
+ ])
475
+
476
+ const xDomLabelColor: Record<string, string> = {
477
+ '0,0': 'var(--label-origin)',
478
+ '1,1': 'var(--label-near-origin)',
479
+ '5,1': 'var(--label-scattered)',
480
+ '0,2': 'var(--label-outer-pair)',
481
+ '6,2': 'var(--label-outer-pair)',
482
+ '2,2': 'var(--label-neighbour)',
483
+ '4,2': 'var(--label-neighbour)',
484
+ '1,3': 'var(--label-neighbour)',
485
+ '3,1': 'var(--label-marked)',
486
+ '3,3': 'var(--label-neighbour)',
487
+ '3,5': 'var(--label-marked)',
488
+ '5,3': 'var(--label-neighbour)',
489
+ '0,4': 'var(--label-scattered)',
490
+ '2,4': 'var(--label-neighbour)',
491
+ '4,4': 'var(--label-neighbour)',
492
+ '6,4': 'var(--label-scattered)',
493
+ '1,5': 'var(--label-scattered)',
494
+ '5,5': 'var(--label-scattered)',
495
+ '6,6': 'var(--label-far-corner)'
496
+ }
497
+
498
+ const neighbourCircleRadius = cellWidth * 0.34
499
+ const arrowTail = neighbourCircleRadius + 1 + 1.5
500
+ const arrowTip = 122
501
+ const arrowHeadLength = 36
502
+ const movementArrow = movementArrowTo(arrowTip)
503
+ const overlayCoordinates = [
504
+ centerCoordinate,
505
+ ...grid.getNeighbours(centerCoordinate)
506
+ ]
507
+ const forbiddenMoves = [
508
+ { x: 3, y: 1 },
509
+ { x: 3, y: 5 }
510
+ ]
511
+
512
+ const xdom = requireHtmlElement(root, '#xdom')
513
+ const hexgrid = requireHtmlElement(root, '#x-hexgrid')
514
+
515
+ drawXDom(xdom)
516
+ drawHexGrid(hexgrid)
517
+ alignHexGrid()
518
+ window.addEventListener('resize', alignHexGrid)
519
+
520
+ function drawXDom(container: Element) {
521
+ const width = columns * cellWidth
522
+ const height = rows * cellHeight
523
+ const board = createSvg(width, height)
524
+ board.setAttribute('class', 'grid')
525
+
526
+ for (let y = 0; y < rows; y += 1) {
527
+ for (let x = 0; x < columns; x += 1) {
528
+ const rect = createSvgElement('rect')
529
+ rect.setAttribute('x', String(x * cellWidth))
530
+ rect.setAttribute('y', String(y * cellHeight))
531
+ rect.setAttribute('width', String(cellWidth))
532
+ rect.setAttribute('height', String(cellHeight))
533
+ rect.setAttribute('fill', rectangleFill(x, y))
534
+ rect.setAttribute('stroke', 'var(--stroke-boundary)')
535
+ rect.setAttribute('stroke-width', '2')
536
+ board.append(rect)
537
+ }
538
+ }
539
+
540
+ board.append(createNeighbourCircle(cellCenter(centerCoordinate.x, centerCoordinate.y)))
541
+
542
+ for (let y = 0; y < rows; y += 1) {
543
+ for (let x = 0; x < columns; x += 1) {
544
+ const id = keyOf({ x, y })
545
+
546
+ if (!labeledXDom.has(id)) {
547
+ continue
548
+ }
549
+
550
+ const center = cellCenter(x, y)
551
+ const color = xDomLabelColor[id] ?? 'var(--ink-strong)'
552
+
553
+ if (neighbourKeys.has(id) || isForbiddenCoordinate({ x, y })) {
554
+ board.append(createNeighbourLabel(`${x},${y}`, { x, y }, center, color))
555
+ continue
556
+ }
557
+
558
+ board.append(createLabel(`${x},${y}`, center, color))
559
+ }
560
+ }
561
+
562
+ const overlay = createSvg(width, height)
563
+ overlay.setAttribute('class', 'overlay')
564
+ const shifted = createSvgElement('g')
565
+ shifted.setAttribute(
566
+ 'transform',
567
+ `translate(${cellWidth / 2} ${cellHeight / 2})`
568
+ )
569
+
570
+ for (const coordinate of overlayCoordinates) {
571
+ const center = grid.getCenterPosition(coordinate, hexDiameter)
572
+ const polygon = createPolygon(grid.getHexagonPoints(center, hexDiameter))
573
+ polygon.setAttribute('fill', 'none')
574
+ polygon.setAttribute('stroke', 'var(--stroke-overlay)')
575
+ polygon.setAttribute('stroke-width', '3')
576
+ polygon.setAttribute('stroke-linejoin', 'round')
577
+ shifted.append(polygon)
578
+ }
579
+
580
+ overlay.append(shifted)
581
+ container.append(board, overlay)
582
+ }
583
+
584
+ function drawHexGrid(container: Element) {
585
+ const coordinates = hexCoordinates()
586
+ const layered = new CoordinateLayer(coordinates).getLayeredCoordinates()
587
+ const bounds = grid.getGridBounds(coordinates, hexDiameter)
588
+ const pad = 8
589
+ const width = bounds.width + pad * 2
590
+ const height = bounds.height + pad * 2
591
+ const board = createSvg(width, height)
592
+ board.setAttribute('class', 'hexes')
593
+ board.setAttribute(
594
+ 'viewBox',
595
+ `${bounds.minX - pad} ${bounds.minY - pad} ${width} ${height}`
596
+ )
597
+
598
+ const surrounding = layered.filter((coordinate) => !isCentralHex(coordinate))
599
+ const central = layered.filter((coordinate) => isCentralHex(coordinate))
600
+
601
+ for (const coordinate of [...surrounding, ...central]) {
602
+ const center = grid.getCenterPosition(coordinate, hexDiameter)
603
+ const polygon = createPolygon(grid.getHexagonPoints(center, hexDiameter))
604
+ polygon.setAttribute('fill', hexFill(coordinate))
605
+ polygon.setAttribute('stroke', hexStroke(coordinate))
606
+ polygon.setAttribute('stroke-width', isCentralHex(coordinate) ? '3' : '2')
607
+ polygon.setAttribute('stroke-linejoin', 'round')
608
+ board.append(polygon)
609
+ }
610
+
611
+ drawMovementArrows(board)
612
+ drawForbiddenArrows(board)
613
+ board.append(createNeighbourCircle(
614
+ grid.getCenterPosition(centerCoordinate, hexDiameter)
615
+ ))
616
+
617
+ for (const coordinate of layered) {
618
+ if (!isLabeledHex(coordinate)) {
619
+ continue
620
+ }
621
+
622
+ const center = grid.getCenterPosition(coordinate, hexDiameter)
623
+ const color = hexLabelColor(coordinate)
624
+
625
+ if (isForbiddenCoordinate(coordinate)) {
626
+ board.append(createNeighbourLabel(
627
+ `${coordinate.x},${coordinate.y}`,
628
+ coordinate,
629
+ center,
630
+ color
631
+ ))
632
+ continue
633
+ }
634
+
635
+ board.append(createLabel(
636
+ `${coordinate.x},${coordinate.y}`,
637
+ center,
638
+ color
639
+ ))
640
+ }
641
+
642
+ container.append(board)
643
+ }
644
+
645
+ function hexCoordinates() {
646
+ const coordinates: Coordinate[] = []
647
+
648
+ for (let y = 1; y <= 5; y += 1) {
649
+ for (let x = 0; x <= 6; x += 1) {
650
+ if ((x + y) % 2 === 0) {
651
+ coordinates.push({ x, y })
652
+ }
653
+ }
654
+ }
655
+
656
+ return coordinates
657
+ }
658
+
659
+ function rectangleFill(x: number, y: number) {
660
+ const id = keyOf({ x, y })
661
+
662
+ if (id === '3,3') {
663
+ return 'var(--fill-center)'
664
+ }
665
+
666
+ if (isForbiddenCoordinate({ x, y })) {
667
+ return 'var(--fill-forbidden-cell)'
668
+ }
669
+
670
+ if (neighbourKeys.has(id)) {
671
+ return 'var(--fill-neighbour)'
672
+ }
673
+
674
+ if ((x + y) % 2 === 0) {
675
+ if (y === 0 || y === 6) {
676
+ return 'var(--fill-edge)'
677
+ }
678
+
679
+ return 'var(--fill-valid)'
680
+ }
681
+
682
+ return 'var(--fill-mixed)'
683
+ }
684
+
685
+ function isCentralHex(coordinate: Coordinate) {
686
+ return keyOf(coordinate) === '3,3' || neighbourKeys.has(keyOf(coordinate))
687
+ }
688
+
689
+ function hexStroke(coordinate: Coordinate) {
690
+ if (isCentralHex(coordinate)) {
691
+ return 'var(--stroke-overlay)'
692
+ }
693
+
694
+ return 'var(--stroke-boundary)'
695
+ }
696
+
697
+ function hexFill(coordinate: Coordinate) {
698
+ const id = keyOf(coordinate)
699
+
700
+ if (id === '3,3') {
701
+ return 'var(--fill-center)'
702
+ }
703
+
704
+ if (isForbiddenCoordinate(coordinate)) {
705
+ return 'var(--fill-forbidden-cell)'
706
+ }
707
+
708
+ if (neighbourKeys.has(id)) {
709
+ return 'var(--fill-neighbour)'
710
+ }
711
+
712
+ return 'var(--fill-valid)'
713
+ }
714
+
715
+ function hexLabelColor(coordinate: Coordinate) {
716
+ if (coordinate.x === 3 && (coordinate.y === 1 || coordinate.y === 5)) {
717
+ return 'var(--label-marked)'
718
+ }
719
+
720
+ if (coordinate.y === 1 || coordinate.y === 5) {
721
+ return 'var(--label-hex-axis)'
722
+ }
723
+
724
+ if (coordinate.x === 2 || coordinate.x === 4) {
725
+ return 'var(--label-hex-diagonal)'
726
+ }
727
+
728
+ return 'var(--label-hex-row)'
729
+ }
730
+
731
+ function isLabeledHex(coordinate: Coordinate) {
732
+ if (coordinate.x === 3 && coordinate.y % 2 === 1) {
733
+ return coordinate.y >= 1 && coordinate.y <= 5
734
+ }
735
+
736
+ if (neighbourKeys.has(keyOf(coordinate))) {
737
+ return true
738
+ }
739
+
740
+ return false
741
+ }
742
+
743
+ function createNeighbourCircle(center: Point, radius = neighbourCircleRadius) {
744
+ const circle = createSvgElement('circle')
745
+ circle.setAttribute('cx', String(center.x))
746
+ circle.setAttribute('cy', String(center.y))
747
+ circle.setAttribute('r', String(radius))
748
+ circle.setAttribute('fill', 'var(--fill-center-mark)')
749
+ circle.setAttribute('stroke', 'var(--stroke-neighbour-mark)')
750
+ circle.setAttribute('stroke-width', '2')
751
+ return circle
752
+ }
753
+
754
+ function createNeighbourLabel(
755
+ text: string,
756
+ coordinate: Coordinate,
757
+ center: Point,
758
+ color: string
759
+ ) {
760
+ const group = createSvgElement('g')
761
+ const coordinateLabel = createLabel(
762
+ text,
763
+ { x: center.x, y: center.y - 11 },
764
+ color
765
+ )
766
+ coordinateLabel.setAttribute('font-size', '20')
767
+ const offsetLabel = createLabel(
768
+ formatOffset(coordinate),
769
+ { x: center.x, y: center.y + 11 },
770
+ color
771
+ )
772
+ offsetLabel.setAttribute('font-size', '14')
773
+ group.append(coordinateLabel, offsetLabel)
774
+
775
+ if (isForbiddenCoordinate(coordinate)) {
776
+ group.append(createForbiddenOffsetMark({ x: center.x, y: center.y + 11 }))
777
+ }
778
+
779
+ return group
780
+ }
781
+
782
+ function isForbiddenCoordinate(coordinate: Coordinate) {
783
+ return forbiddenMoves.some((move) => keyOf(move) === keyOf(coordinate))
784
+ }
785
+
786
+ function createForbiddenOffsetMark(center: Point) {
787
+ const mark = createSvgElement('g')
788
+ mark.setAttribute('class', 'offset-mark')
789
+ const scale = 14 / 24
790
+ const strokes: ReadonlyArray<readonly [number, number, number, number]> = [
791
+ [-17, -22, 12, 7],
792
+ [9, -22, -17, 7]
793
+ ]
794
+
795
+ for (const [x1, y1, x2, y2] of strokes) {
796
+ const line = createSvgElement('line')
797
+ line.setAttribute('x1', String(center.x + x1 * scale))
798
+ line.setAttribute('y1', String(center.y + y1 * scale))
799
+ line.setAttribute('x2', String(center.x + x2 * scale))
800
+ line.setAttribute('y2', String(center.y + y2 * scale))
801
+ line.setAttribute('stroke', 'var(--stroke-offset-mark)')
802
+ line.setAttribute('stroke-width', String(2.6 * scale))
803
+ line.setAttribute('stroke-linecap', 'round')
804
+ mark.append(line)
805
+ }
806
+
807
+ return mark
808
+ }
809
+
810
+ function formatOffset(coordinate: Coordinate) {
811
+ const dx = coordinate.x - centerCoordinate.x
812
+ const dy = coordinate.y - centerCoordinate.y
813
+ return `(${signed(dx)},${signed(dy)})`
814
+ }
815
+
816
+ function signed(value: number) {
817
+ return value > 0 ? `+${value}` : String(value)
818
+ }
819
+
820
+ function drawMovementArrows(layer: SVGElement) {
821
+ const origin = grid.getCenterPosition(centerCoordinate, hexDiameter)
822
+
823
+ for (const neighbour of grid.getNeighbours(centerCoordinate)) {
824
+ const target = grid.getCenterPosition(neighbour, hexDiameter)
825
+ layer.append(createMovementArrow(origin, target))
826
+ }
827
+ }
828
+
829
+ function drawForbiddenArrows(layer: SVGElement) {
830
+ const origin = grid.getCenterPosition(centerCoordinate, hexDiameter)
831
+ const tip = forbiddenArrowTip(origin)
832
+ const points = movementArrowTo(tip)
833
+
834
+ for (const coordinate of forbiddenMoves) {
835
+ const target = grid.getCenterPosition(coordinate, hexDiameter)
836
+ layer.append(createMovementArrow(origin, target, 'var(--fill-forbidden)', points))
837
+ }
838
+
839
+ for (const coordinate of forbiddenMoves) {
840
+ const target = grid.getCenterPosition(coordinate, hexDiameter)
841
+ layer.append(createForbiddenCross(origin, target, tip))
842
+ }
843
+ }
844
+
845
+ function forbiddenArrowTip(origin: Point) {
846
+ const neighbour = grid.getNeighbours(centerCoordinate)[0]
847
+ const blocked = forbiddenMoves[0]
848
+
849
+ if (neighbour === undefined || blocked === undefined) {
850
+ return arrowTip
851
+ }
852
+
853
+ const neighbourDistance = distance(
854
+ origin,
855
+ grid.getCenterPosition(neighbour, hexDiameter)
856
+ )
857
+ const blockedDistance = distance(
858
+ origin,
859
+ grid.getCenterPosition(blocked, hexDiameter)
860
+ )
861
+ return blockedDistance - (neighbourDistance - arrowTip)
862
+ }
863
+
864
+ function movementArrowTo(tip: number) {
865
+ const headBase = tip - arrowHeadLength
866
+ return [
867
+ { x: arrowTail, y: -4 },
868
+ { x: headBase, y: -4 },
869
+ { x: headBase, y: -12.8 },
870
+ { x: tip, y: 0 },
871
+ { x: headBase, y: 12.8 },
872
+ { x: headBase, y: 4 },
873
+ { x: arrowTail, y: 4 }
874
+ ]
875
+ }
876
+
877
+ function createMovementArrow(
878
+ origin: Point,
879
+ target: Point,
880
+ fill = 'var(--fill-movement)',
881
+ points = movementArrow
882
+ ) {
883
+ const arrow = createSvgElement('g')
884
+ arrow.setAttribute('class', 'movement-arrow')
885
+ arrow.setAttribute('transform', movementTransform(origin, target))
886
+ const polygon = createPolygon(points)
887
+ polygon.setAttribute('fill', fill)
888
+ arrow.append(polygon)
889
+ return arrow
890
+ }
891
+
892
+ function createForbiddenCross(origin: Point, target: Point, tip: number) {
893
+ const cross = createSvgElement('g')
894
+ cross.setAttribute('class', 'forbidden-cross')
895
+ cross.setAttribute('transform', movementTransform(origin, target))
896
+ const center = (arrowTail + tip) / 2
897
+ const arm = 18
898
+ cross.append(
899
+ createForbiddenLine(center - arm, -arm, center + arm, arm),
900
+ createForbiddenLine(center + arm, -arm, center - arm, arm)
901
+ )
902
+ return cross
903
+ }
904
+
905
+ function createForbiddenLine(x1: number, y1: number, x2: number, y2: number) {
906
+ const line = createSvgElement('line')
907
+ line.setAttribute('x1', String(x1))
908
+ line.setAttribute('y1', String(y1))
909
+ line.setAttribute('x2', String(x2))
910
+ line.setAttribute('y2', String(y2))
911
+ line.setAttribute('stroke', 'var(--stroke-forbidden)')
912
+ line.setAttribute('stroke-width', '5')
913
+ line.setAttribute('stroke-linecap', 'round')
914
+ return line
915
+ }
916
+
917
+ function distance(from: Point, to: Point) {
918
+ return Math.hypot(to.x - from.x, to.y - from.y)
919
+ }
920
+
921
+ function movementTransform(origin: Point, target: Point) {
922
+ const degrees = Math.atan2(target.y - origin.y, target.x - origin.x) * 180 / Math.PI
923
+ return `translate(${origin.x} ${origin.y}) rotate(${degrees})`
924
+ }
925
+
926
+ function createLabel(text: string, center: Point, color: string) {
927
+ const label = createSvgElement('text')
928
+ label.setAttribute('class', 'cell-label')
929
+ label.setAttribute('font-size', '17')
930
+ label.setAttribute('x', String(center.x))
931
+ label.setAttribute('y', String(center.y))
932
+ label.setAttribute('fill', color)
933
+ label.setAttribute('text-anchor', 'middle')
934
+ label.setAttribute('dominant-baseline', 'central')
935
+ label.textContent = text
936
+ return label
937
+ }
938
+
939
+ function createPolygon(points: Point[]) {
940
+ const polygon = createSvgElement('polygon')
941
+ polygon.setAttribute(
942
+ 'points',
943
+ points.map((point) => `${point.x},${point.y}`).join(' ')
944
+ )
945
+ return polygon
946
+ }
947
+
948
+ function cellCenter(x: number, y: number): Point {
949
+ return {
950
+ x: (x + 0.5) * cellWidth,
951
+ y: (y + 0.5) * cellHeight
952
+ }
953
+ }
954
+
955
+ function createSvg(width: number, height: number) {
956
+ const svg = createSvgElement('svg')
957
+ svg.setAttribute('width', String(width))
958
+ svg.setAttribute('height', String(height))
959
+ svg.setAttribute('viewBox', `0 0 ${width} ${height}`)
960
+ return svg
961
+ }
962
+
963
+ function createSvgElement<Name extends keyof SVGElementTagNameMap>(name: Name) {
964
+ return document.createElementNS(svgNs, name)
965
+ }
966
+
967
+ function keyOf(coordinate: Coordinate) {
968
+ return `${coordinate.x},${coordinate.y}`
969
+ }
970
+
971
+ function alignHexGrid() {
972
+ const diagrams = root.querySelector('.diagrams')
973
+ const leftSvg = xdom.querySelector('.grid')
974
+ const rightSvg = hexgrid.querySelector('.hexes')
975
+
976
+ if (
977
+ !(diagrams instanceof HTMLElement) ||
978
+ !(leftSvg instanceof SVGSVGElement) ||
979
+ !(rightSvg instanceof SVGSVGElement)
980
+ ) {
981
+ return
982
+ }
983
+
984
+ const sideBySide = getComputedStyle(diagrams).flexDirection !== 'column'
985
+ const leftWidth = leftSvg.width.baseVal.value
986
+ const leftHeight = leftSvg.height.baseVal.value
987
+ const rightWidth = rightSvg.width.baseVal.value
988
+ const rightHeight = rightSvg.height.baseVal.value
989
+ const scale = sharedDemonstrationScale()
990
+
991
+ leftSvg.style.width = `${leftWidth * scale}px`
992
+ leftSvg.style.height = `${leftHeight * scale}px`
993
+ rightSvg.style.width = `${rightWidth * scale}px`
994
+ rightSvg.style.height = `${rightHeight * scale}px`
995
+
996
+ hexgrid.style.marginTop = '0'
997
+
998
+ if (!sideBySide) {
999
+ return
1000
+ }
1001
+
1002
+ const leftCenter = cellCenter(centerCoordinate.x, centerCoordinate.y)
1003
+ const rightCenter = grid.getCenterPosition(centerCoordinate, hexDiameter)
1004
+ const leftPoint = toScreen(leftSvg, leftCenter)
1005
+ const rightPoint = toScreen(rightSvg, rightCenter)
1006
+
1007
+ if (leftPoint === null || rightPoint === null) {
1008
+ return
1009
+ }
1010
+
1011
+ hexgrid.style.marginTop = `${leftPoint.y - rightPoint.y}px`
1012
+ }
1013
+
1014
+ function toScreen(svg: SVGSVGElement, point: Point) {
1015
+ const matrix = svg.getScreenCTM()
1016
+
1017
+ if (matrix === null) {
1018
+ return null
1019
+ }
1020
+
1021
+ const svgPoint = svg.createSVGPoint()
1022
+ svgPoint.x = point.x
1023
+ svgPoint.y = point.y
1024
+ return svgPoint.matrixTransform(matrix)
1025
+ }
1026
+ }
1027
+
1028
+ function renderYDominated(root: HTMLElement) {
1029
+ const svgNs = 'http://www.w3.org/2000/svg'
1030
+ const sqrt3 = Math.sqrt(3)
1031
+ const columns = 7
1032
+ const rows = 7
1033
+ const cellHeight = sharedCellSize
1034
+ const cellWidth = cellHeight * sqrt3
1035
+
1036
+ // One y-dominated step of 2 is a vertical neighbour.
1037
+ // That distance is the hexagon height, and it equals two rectangle rows.
1038
+ const hexDiameter = cellHeight * 2
1039
+
1040
+ const grid = new HexGrid('y-dominated')
1041
+ const centerCoordinate = { x: 3, y: 3 }
1042
+ const neighbourKeys = new Set(
1043
+ grid.getNeighbours(centerCoordinate).map((coordinate) => keyOf(coordinate))
1044
+ )
1045
+
1046
+ const labeledYDom = new Set([
1047
+ '0,0',
1048
+ '1,1',
1049
+ '1,5',
1050
+ '2,0',
1051
+ '2,2',
1052
+ '2,4',
1053
+ '2,6',
1054
+ '3,1',
1055
+ '1,3',
1056
+ '3,3',
1057
+ '5,3',
1058
+ '3,5',
1059
+ '4,0',
1060
+ '4,2',
1061
+ '4,4',
1062
+ '4,6',
1063
+ '5,1',
1064
+ '5,5',
1065
+ '6,6'
1066
+ ])
1067
+
1068
+ const yDomLabelColor: Record<string, string> = {
1069
+ '0,0': 'var(--label-origin)',
1070
+ '1,1': 'var(--label-near-origin)',
1071
+ '1,5': 'var(--label-scattered)',
1072
+ '2,0': 'var(--label-outer-pair)',
1073
+ '2,6': 'var(--label-outer-pair)',
1074
+ '2,2': 'var(--label-neighbour)',
1075
+ '2,4': 'var(--label-neighbour)',
1076
+ '3,1': 'var(--label-neighbour)',
1077
+ '1,3': 'var(--label-marked)',
1078
+ '3,3': 'var(--label-neighbour)',
1079
+ '5,3': 'var(--label-marked)',
1080
+ '3,5': 'var(--label-neighbour)',
1081
+ '4,0': 'var(--label-scattered)',
1082
+ '4,2': 'var(--label-neighbour)',
1083
+ '4,4': 'var(--label-neighbour)',
1084
+ '4,6': 'var(--label-scattered)',
1085
+ '5,1': 'var(--label-scattered)',
1086
+ '5,5': 'var(--label-scattered)',
1087
+ '6,6': 'var(--label-far-corner)'
1088
+ }
1089
+
1090
+ const neighbourCircleRadius = cellHeight * 0.34
1091
+ const arrowTail = neighbourCircleRadius + 1 + 1.5
1092
+ const arrowTip = 122
1093
+ const arrowHeadLength = 36
1094
+ const movementArrow = movementArrowTo(arrowTip)
1095
+ const overlayCoordinates = [
1096
+ centerCoordinate,
1097
+ ...grid.getNeighbours(centerCoordinate)
1098
+ ]
1099
+ const forbiddenMoves = [
1100
+ { x: 1, y: 3 },
1101
+ { x: 5, y: 3 }
1102
+ ]
1103
+
1104
+ const ydom = requireHtmlElement(root, '#ydom')
1105
+ const hexgrid = requireHtmlElement(root, '#y-hexgrid')
1106
+
1107
+ drawYDom(ydom)
1108
+ drawHexGrid(hexgrid)
1109
+ alignHexGrid()
1110
+ window.addEventListener('resize', alignHexGrid)
1111
+
1112
+ function drawYDom(container: Element) {
1113
+ const width = columns * cellWidth
1114
+ const height = rows * cellHeight
1115
+ const board = createSvg(width, height)
1116
+ board.setAttribute('class', 'grid')
1117
+
1118
+ for (let y = 0; y < rows; y += 1) {
1119
+ for (let x = 0; x < columns; x += 1) {
1120
+ const rect = createSvgElement('rect')
1121
+ rect.setAttribute('x', String(x * cellWidth))
1122
+ rect.setAttribute('y', String(y * cellHeight))
1123
+ rect.setAttribute('width', String(cellWidth))
1124
+ rect.setAttribute('height', String(cellHeight))
1125
+ rect.setAttribute('fill', rectangleFill(x, y))
1126
+ rect.setAttribute('stroke', 'var(--stroke-boundary)')
1127
+ rect.setAttribute('stroke-width', '2')
1128
+ board.append(rect)
1129
+ }
1130
+ }
1131
+
1132
+ board.append(createNeighbourCircle(cellCenter(centerCoordinate.x, centerCoordinate.y)))
1133
+
1134
+ for (let y = 0; y < rows; y += 1) {
1135
+ for (let x = 0; x < columns; x += 1) {
1136
+ const id = keyOf({ x, y })
1137
+
1138
+ if (!labeledYDom.has(id)) {
1139
+ continue
1140
+ }
1141
+
1142
+ const center = cellCenter(x, y)
1143
+ const color = yDomLabelColor[id] ?? 'var(--ink-strong)'
1144
+
1145
+ if (neighbourKeys.has(id) || isForbiddenCoordinate({ x, y })) {
1146
+ board.append(createNeighbourLabel(`${x},${y}`, { x, y }, center, color))
1147
+ continue
1148
+ }
1149
+
1150
+ board.append(createLabel(`${x},${y}`, center, color))
1151
+ }
1152
+ }
1153
+
1154
+ const overlay = createSvg(width, height)
1155
+ overlay.setAttribute('class', 'overlay')
1156
+ const shifted = createSvgElement('g')
1157
+ shifted.setAttribute(
1158
+ 'transform',
1159
+ `translate(${cellWidth / 2} ${cellHeight / 2})`
1160
+ )
1161
+
1162
+ for (const coordinate of overlayCoordinates) {
1163
+ const center = grid.getCenterPosition(coordinate, hexDiameter)
1164
+ const polygon = createPolygon(grid.getHexagonPoints(center, hexDiameter))
1165
+ polygon.setAttribute('fill', 'none')
1166
+ polygon.setAttribute('stroke', 'var(--stroke-overlay)')
1167
+ polygon.setAttribute('stroke-width', '3')
1168
+ polygon.setAttribute('stroke-linejoin', 'round')
1169
+ shifted.append(polygon)
1170
+ }
1171
+
1172
+ overlay.append(shifted)
1173
+ container.append(board, overlay)
1174
+ }
1175
+
1176
+ function drawHexGrid(container: Element) {
1177
+ const coordinates = hexCoordinates()
1178
+ const layered = new CoordinateLayer(coordinates).getLayeredCoordinates()
1179
+ const bounds = grid.getGridBounds(coordinates, hexDiameter)
1180
+ const pad = 8
1181
+ const width = bounds.width + pad * 2
1182
+ const height = bounds.height + pad * 2
1183
+ const board = createSvg(width, height)
1184
+ board.setAttribute('class', 'hexes')
1185
+ board.setAttribute(
1186
+ 'viewBox',
1187
+ `${bounds.minX - pad} ${bounds.minY - pad} ${width} ${height}`
1188
+ )
1189
+
1190
+ const surrounding = layered.filter((coordinate) => !isCentralHex(coordinate))
1191
+ const central = layered.filter((coordinate) => isCentralHex(coordinate))
1192
+
1193
+ for (const coordinate of [...surrounding, ...central]) {
1194
+ const center = grid.getCenterPosition(coordinate, hexDiameter)
1195
+ const polygon = createPolygon(grid.getHexagonPoints(center, hexDiameter))
1196
+ polygon.setAttribute('fill', hexFill(coordinate))
1197
+ polygon.setAttribute('stroke', hexStroke(coordinate))
1198
+ polygon.setAttribute('stroke-width', isCentralHex(coordinate) ? '3' : '2')
1199
+ polygon.setAttribute('stroke-linejoin', 'round')
1200
+ board.append(polygon)
1201
+ }
1202
+
1203
+ drawMovementArrows(board)
1204
+ drawForbiddenArrows(board)
1205
+ board.append(createNeighbourCircle(
1206
+ grid.getCenterPosition(centerCoordinate, hexDiameter)
1207
+ ))
1208
+
1209
+ for (const coordinate of layered) {
1210
+ if (!isLabeledHex(coordinate)) {
1211
+ continue
1212
+ }
1213
+
1214
+ const center = grid.getCenterPosition(coordinate, hexDiameter)
1215
+ const color = hexLabelColor(coordinate)
1216
+
1217
+ if (isForbiddenCoordinate(coordinate)) {
1218
+ board.append(createNeighbourLabel(
1219
+ `${coordinate.x},${coordinate.y}`,
1220
+ coordinate,
1221
+ center,
1222
+ color
1223
+ ))
1224
+ continue
1225
+ }
1226
+
1227
+ board.append(createLabel(
1228
+ `${coordinate.x},${coordinate.y}`,
1229
+ center,
1230
+ color
1231
+ ))
1232
+ }
1233
+
1234
+ container.append(board)
1235
+ }
1236
+
1237
+ function hexCoordinates() {
1238
+ const coordinates: Coordinate[] = []
1239
+
1240
+ for (let x = 1; x <= 5; x += 1) {
1241
+ for (let y = 0; y <= 6; y += 1) {
1242
+ if ((x + y) % 2 === 0) {
1243
+ coordinates.push({ x, y })
1244
+ }
1245
+ }
1246
+ }
1247
+
1248
+ return coordinates
1249
+ }
1250
+
1251
+ function rectangleFill(x: number, y: number) {
1252
+ const id = keyOf({ x, y })
1253
+
1254
+ if (id === '3,3') {
1255
+ return 'var(--fill-center)'
1256
+ }
1257
+
1258
+ if (isForbiddenCoordinate({ x, y })) {
1259
+ return 'var(--fill-forbidden-cell)'
1260
+ }
1261
+
1262
+ if (neighbourKeys.has(id)) {
1263
+ return 'var(--fill-neighbour)'
1264
+ }
1265
+
1266
+ if ((x + y) % 2 === 0) {
1267
+ if (x === 0 || x === 6) {
1268
+ return 'var(--fill-edge)'
1269
+ }
1270
+
1271
+ return 'var(--fill-valid)'
1272
+ }
1273
+
1274
+ return 'var(--fill-mixed)'
1275
+ }
1276
+
1277
+ function isCentralHex(coordinate: Coordinate) {
1278
+ return keyOf(coordinate) === '3,3' || neighbourKeys.has(keyOf(coordinate))
1279
+ }
1280
+
1281
+ function hexStroke(coordinate: Coordinate) {
1282
+ if (isCentralHex(coordinate)) {
1283
+ return 'var(--stroke-overlay)'
1284
+ }
1285
+
1286
+ return 'var(--stroke-boundary)'
1287
+ }
1288
+
1289
+ function hexFill(coordinate: Coordinate) {
1290
+ const id = keyOf(coordinate)
1291
+
1292
+ if (id === '3,3') {
1293
+ return 'var(--fill-center)'
1294
+ }
1295
+
1296
+ if (isForbiddenCoordinate(coordinate)) {
1297
+ return 'var(--fill-forbidden-cell)'
1298
+ }
1299
+
1300
+ if (neighbourKeys.has(id)) {
1301
+ return 'var(--fill-neighbour)'
1302
+ }
1303
+
1304
+ return 'var(--fill-valid)'
1305
+ }
1306
+
1307
+ function hexLabelColor(coordinate: Coordinate) {
1308
+ if (coordinate.y === 3 && (coordinate.x === 1 || coordinate.x === 5)) {
1309
+ return 'var(--label-marked)'
1310
+ }
1311
+
1312
+ if (coordinate.x === 1 || coordinate.x === 5) {
1313
+ return 'var(--label-hex-axis)'
1314
+ }
1315
+
1316
+ if (coordinate.y === 2 || coordinate.y === 4) {
1317
+ return 'var(--label-hex-diagonal)'
1318
+ }
1319
+
1320
+ return 'var(--label-hex-row)'
1321
+ }
1322
+
1323
+ function isLabeledHex(coordinate: Coordinate) {
1324
+ if (coordinate.y === 3 && coordinate.x % 2 === 1) {
1325
+ return coordinate.x >= 1 && coordinate.x <= 5
1326
+ }
1327
+
1328
+ if (neighbourKeys.has(keyOf(coordinate))) {
1329
+ return true
1330
+ }
1331
+
1332
+ return false
1333
+ }
1334
+
1335
+ function createNeighbourCircle(center: Point, radius = neighbourCircleRadius) {
1336
+ const circle = createSvgElement('circle')
1337
+ circle.setAttribute('cx', String(center.x))
1338
+ circle.setAttribute('cy', String(center.y))
1339
+ circle.setAttribute('r', String(radius))
1340
+ circle.setAttribute('fill', 'var(--fill-center-mark)')
1341
+ circle.setAttribute('stroke', 'var(--stroke-neighbour-mark)')
1342
+ circle.setAttribute('stroke-width', '2')
1343
+ return circle
1344
+ }
1345
+
1346
+ function createNeighbourLabel(
1347
+ text: string,
1348
+ coordinate: Coordinate,
1349
+ center: Point,
1350
+ color: string
1351
+ ) {
1352
+ const group = createSvgElement('g')
1353
+ const coordinateLabel = createLabel(
1354
+ text,
1355
+ { x: center.x, y: center.y - 11 },
1356
+ color
1357
+ )
1358
+ coordinateLabel.setAttribute('font-size', '20')
1359
+ const offsetLabel = createLabel(
1360
+ formatOffset(coordinate),
1361
+ { x: center.x, y: center.y + 11 },
1362
+ color
1363
+ )
1364
+ offsetLabel.setAttribute('font-size', '14')
1365
+ group.append(coordinateLabel, offsetLabel)
1366
+
1367
+ if (isForbiddenCoordinate(coordinate)) {
1368
+ group.append(createForbiddenOffsetMark({ x: center.x, y: center.y + 11 }))
1369
+ }
1370
+
1371
+ return group
1372
+ }
1373
+
1374
+ function isForbiddenCoordinate(coordinate: Coordinate) {
1375
+ return forbiddenMoves.some((move) => keyOf(move) === keyOf(coordinate))
1376
+ }
1377
+
1378
+ function createForbiddenOffsetMark(center: Point) {
1379
+ const mark = createSvgElement('g')
1380
+ mark.setAttribute('class', 'offset-mark')
1381
+ const scale = 14 / 24
1382
+ const strokes: ReadonlyArray<readonly [number, number, number, number]> = [
1383
+ [-17, -22, 12, 7],
1384
+ [9, -22, -17, 7]
1385
+ ]
1386
+
1387
+ for (const [x1, y1, x2, y2] of strokes) {
1388
+ const line = createSvgElement('line')
1389
+ line.setAttribute('x1', String(center.x + x1 * scale))
1390
+ line.setAttribute('y1', String(center.y + y1 * scale))
1391
+ line.setAttribute('x2', String(center.x + x2 * scale))
1392
+ line.setAttribute('y2', String(center.y + y2 * scale))
1393
+ line.setAttribute('stroke', 'var(--stroke-offset-mark)')
1394
+ line.setAttribute('stroke-width', String(2.6 * scale))
1395
+ line.setAttribute('stroke-linecap', 'round')
1396
+ mark.append(line)
1397
+ }
1398
+
1399
+ return mark
1400
+ }
1401
+
1402
+ function formatOffset(coordinate: Coordinate) {
1403
+ const dx = coordinate.x - centerCoordinate.x
1404
+ const dy = coordinate.y - centerCoordinate.y
1405
+ return `(${signed(dx)},${signed(dy)})`
1406
+ }
1407
+
1408
+ function signed(value: number) {
1409
+ return value > 0 ? `+${value}` : String(value)
1410
+ }
1411
+
1412
+ function drawMovementArrows(layer: SVGElement) {
1413
+ const origin = grid.getCenterPosition(centerCoordinate, hexDiameter)
1414
+
1415
+ for (const neighbour of grid.getNeighbours(centerCoordinate)) {
1416
+ const target = grid.getCenterPosition(neighbour, hexDiameter)
1417
+ layer.append(createMovementArrow(origin, target))
1418
+ }
1419
+ }
1420
+
1421
+ function drawForbiddenArrows(layer: SVGElement) {
1422
+ const origin = grid.getCenterPosition(centerCoordinate, hexDiameter)
1423
+ const tip = forbiddenArrowTip(origin)
1424
+ const points = movementArrowTo(tip)
1425
+
1426
+ for (const coordinate of forbiddenMoves) {
1427
+ const target = grid.getCenterPosition(coordinate, hexDiameter)
1428
+ layer.append(createMovementArrow(origin, target, 'var(--fill-forbidden)', points))
1429
+ }
1430
+
1431
+ for (const coordinate of forbiddenMoves) {
1432
+ const target = grid.getCenterPosition(coordinate, hexDiameter)
1433
+ layer.append(createForbiddenCross(origin, target, tip))
1434
+ }
1435
+ }
1436
+
1437
+ function forbiddenArrowTip(origin: Point) {
1438
+ const neighbour = grid.getNeighbours(centerCoordinate)[0]
1439
+ const blocked = forbiddenMoves[0]
1440
+
1441
+ if (neighbour === undefined || blocked === undefined) {
1442
+ return arrowTip
1443
+ }
1444
+
1445
+ const neighbourDistance = distance(
1446
+ origin,
1447
+ grid.getCenterPosition(neighbour, hexDiameter)
1448
+ )
1449
+ const blockedDistance = distance(
1450
+ origin,
1451
+ grid.getCenterPosition(blocked, hexDiameter)
1452
+ )
1453
+ return blockedDistance - (neighbourDistance - arrowTip)
1454
+ }
1455
+
1456
+ function movementArrowTo(tip: number) {
1457
+ const headBase = tip - arrowHeadLength
1458
+ return [
1459
+ { x: arrowTail, y: -4 },
1460
+ { x: headBase, y: -4 },
1461
+ { x: headBase, y: -12.8 },
1462
+ { x: tip, y: 0 },
1463
+ { x: headBase, y: 12.8 },
1464
+ { x: headBase, y: 4 },
1465
+ { x: arrowTail, y: 4 }
1466
+ ]
1467
+ }
1468
+
1469
+ function createMovementArrow(
1470
+ origin: Point,
1471
+ target: Point,
1472
+ fill = 'var(--fill-movement)',
1473
+ points = movementArrow
1474
+ ) {
1475
+ const arrow = createSvgElement('g')
1476
+ arrow.setAttribute('class', 'movement-arrow')
1477
+ arrow.setAttribute('transform', movementTransform(origin, target))
1478
+ const polygon = createPolygon(points)
1479
+ polygon.setAttribute('fill', fill)
1480
+ arrow.append(polygon)
1481
+ return arrow
1482
+ }
1483
+
1484
+ function createForbiddenCross(origin: Point, target: Point, tip: number) {
1485
+ const cross = createSvgElement('g')
1486
+ cross.setAttribute('class', 'forbidden-cross')
1487
+ cross.setAttribute('transform', movementTransform(origin, target))
1488
+ const center = (arrowTail + tip) / 2
1489
+ const arm = 18
1490
+ cross.append(
1491
+ createForbiddenLine(center - arm, -arm, center + arm, arm),
1492
+ createForbiddenLine(center + arm, -arm, center - arm, arm)
1493
+ )
1494
+ return cross
1495
+ }
1496
+
1497
+ function createForbiddenLine(x1: number, y1: number, x2: number, y2: number) {
1498
+ const line = createSvgElement('line')
1499
+ line.setAttribute('x1', String(x1))
1500
+ line.setAttribute('y1', String(y1))
1501
+ line.setAttribute('x2', String(x2))
1502
+ line.setAttribute('y2', String(y2))
1503
+ line.setAttribute('stroke', 'var(--stroke-forbidden)')
1504
+ line.setAttribute('stroke-width', '5')
1505
+ line.setAttribute('stroke-linecap', 'round')
1506
+ return line
1507
+ }
1508
+
1509
+ function distance(from: Point, to: Point) {
1510
+ return Math.hypot(to.x - from.x, to.y - from.y)
1511
+ }
1512
+
1513
+ function movementTransform(origin: Point, target: Point) {
1514
+ const degrees = Math.atan2(target.y - origin.y, target.x - origin.x) * 180 / Math.PI
1515
+ return `translate(${origin.x} ${origin.y}) rotate(${degrees})`
1516
+ }
1517
+
1518
+ function createLabel(text: string, center: Point, color: string) {
1519
+ const label = createSvgElement('text')
1520
+ label.setAttribute('class', 'cell-label')
1521
+ label.setAttribute('font-size', '17')
1522
+ label.setAttribute('x', String(center.x))
1523
+ label.setAttribute('y', String(center.y))
1524
+ label.setAttribute('fill', color)
1525
+ label.setAttribute('text-anchor', 'middle')
1526
+ label.setAttribute('dominant-baseline', 'central')
1527
+ label.textContent = text
1528
+ return label
1529
+ }
1530
+
1531
+ function createPolygon(points: Point[]) {
1532
+ const polygon = createSvgElement('polygon')
1533
+ polygon.setAttribute(
1534
+ 'points',
1535
+ points.map((point) => `${point.x},${point.y}`).join(' ')
1536
+ )
1537
+ return polygon
1538
+ }
1539
+
1540
+ function cellCenter(x: number, y: number): Point {
1541
+ return {
1542
+ x: (x + 0.5) * cellWidth,
1543
+ y: (y + 0.5) * cellHeight
1544
+ }
1545
+ }
1546
+
1547
+ function createSvg(width: number, height: number) {
1548
+ const svg = createSvgElement('svg')
1549
+ svg.setAttribute('width', String(width))
1550
+ svg.setAttribute('height', String(height))
1551
+ svg.setAttribute('viewBox', `0 0 ${width} ${height}`)
1552
+ return svg
1553
+ }
1554
+
1555
+ function createSvgElement<Name extends keyof SVGElementTagNameMap>(name: Name) {
1556
+ return document.createElementNS(svgNs, name)
1557
+ }
1558
+
1559
+ function keyOf(coordinate: Coordinate) {
1560
+ return `${coordinate.x},${coordinate.y}`
1561
+ }
1562
+
1563
+ function alignHexGrid() {
1564
+ const diagrams = root.querySelector('.diagrams')
1565
+ const leftSvg = ydom.querySelector('.grid')
1566
+ const rightSvg = hexgrid.querySelector('.hexes')
1567
+
1568
+ if (
1569
+ !(diagrams instanceof HTMLElement) ||
1570
+ !(leftSvg instanceof SVGSVGElement) ||
1571
+ !(rightSvg instanceof SVGSVGElement)
1572
+ ) {
1573
+ return
1574
+ }
1575
+
1576
+ const sideBySide = getComputedStyle(diagrams).flexDirection !== 'column'
1577
+ const leftWidth = leftSvg.width.baseVal.value
1578
+ const leftHeight = leftSvg.height.baseVal.value
1579
+ const rightWidth = rightSvg.width.baseVal.value
1580
+ const rightHeight = rightSvg.height.baseVal.value
1581
+ const scale = sharedDemonstrationScale()
1582
+
1583
+ leftSvg.style.width = `${leftWidth * scale}px`
1584
+ leftSvg.style.height = `${leftHeight * scale}px`
1585
+ rightSvg.style.width = `${rightWidth * scale}px`
1586
+ rightSvg.style.height = `${rightHeight * scale}px`
1587
+
1588
+ ydom.style.marginTop = '0'
1589
+ hexgrid.style.marginTop = '0'
1590
+
1591
+ if (!sideBySide) {
1592
+ return
1593
+ }
1594
+
1595
+ const leftCenter = cellCenter(centerCoordinate.x, centerCoordinate.y)
1596
+ const rightCenter = grid.getCenterPosition(centerCoordinate, hexDiameter)
1597
+ const leftPoint = toScreen(leftSvg, leftCenter)
1598
+ const rightPoint = toScreen(rightSvg, rightCenter)
1599
+
1600
+ if (leftPoint === null || rightPoint === null) {
1601
+ return
1602
+ }
1603
+
1604
+ // Flat-top hexes rise above the rectangle when the centers are level.
1605
+ // Drop both diagrams by that overhang so each stays below its heading rule.
1606
+ const centerOffset = leftPoint.y - rightPoint.y
1607
+ const shift = sharedTopShift(leftSvg, rightSvg, centerOffset)
1608
+
1609
+ ydom.style.marginTop = `${shift}px`
1610
+ hexgrid.style.marginTop = `${centerOffset + shift}px`
1611
+ }
1612
+
1613
+ function sharedTopShift(
1614
+ leftSvg: SVGSVGElement,
1615
+ rightSvg: SVGSVGElement,
1616
+ centerOffset: number
1617
+ ) {
1618
+ const leftHeading = ydom.closest('.diagram')?.querySelector('h2')
1619
+ const rightHeading = hexgrid.closest('.diagram')?.querySelector('h2')
1620
+ const rightTop = hexagonOutlineTop(rightSvg)
1621
+
1622
+ if (
1623
+ !(leftHeading instanceof HTMLElement) ||
1624
+ !(rightHeading instanceof HTMLElement) ||
1625
+ rightTop === null
1626
+ ) {
1627
+ return 0
1628
+ }
1629
+
1630
+ const leftGap = leftSvg.getBoundingClientRect().top - leftHeading.getBoundingClientRect().bottom
1631
+ const rightGap = rightTop + centerOffset - rightHeading.getBoundingClientRect().bottom
1632
+
1633
+ return Math.max(0, leftGap - rightGap)
1634
+ }
1635
+
1636
+ function hexagonOutlineTop(svg: SVGSVGElement) {
1637
+ const matrix = svg.getScreenCTM()
1638
+
1639
+ if (matrix === null) {
1640
+ return null
1641
+ }
1642
+
1643
+ let top = Infinity
1644
+
1645
+ for (const polygon of svg.querySelectorAll(':scope > polygon')) {
1646
+ const points = polygon.getAttribute('points')
1647
+
1648
+ if (points === null) {
1649
+ continue
1650
+ }
1651
+
1652
+ const stroke = Number(polygon.getAttribute('stroke-width')) || 0
1653
+ const outset = (stroke / 2) * matrix.d
1654
+
1655
+ for (const pair of points.trim().split(/\s+/)) {
1656
+ const [x, y] = pair.split(',')
1657
+ const point = svg.createSVGPoint()
1658
+ point.x = Number(x)
1659
+ point.y = Number(y)
1660
+ top = Math.min(top, point.matrixTransform(matrix).y - outset)
1661
+ }
1662
+ }
1663
+
1664
+ return Number.isFinite(top) ? top : null
1665
+ }
1666
+
1667
+ function toScreen(svg: SVGSVGElement, point: Point) {
1668
+ const matrix = svg.getScreenCTM()
1669
+
1670
+ if (matrix === null) {
1671
+ return null
1672
+ }
1673
+
1674
+ const svgPoint = svg.createSVGPoint()
1675
+ svgPoint.x = point.x
1676
+ svgPoint.y = point.y
1677
+ return svgPoint.matrixTransform(matrix)
1678
+ }
1679
+ }