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.
- package/README.md +102 -1
- package/demo/demo-minimal.ts +531 -0
- package/demo/demo-theory.ts +1679 -0
- package/demo/index.html +11 -0
- package/demo/minimal.html +11 -0
- package/demo/package-lock.json +1162 -0
- package/demo/package.json +11 -0
- package/demo/vite.config.ts +9 -0
- package/dist/coordinate-layer.d.ts +1 -11
- package/dist/coordinate-layer.d.ts.map +1 -1
- package/dist/coordinate-layer.js +5 -5
- package/dist/coordinate-layer.js.map +1 -1
- package/dist/coordinate-positioner.d.ts +1 -17
- package/dist/coordinate-positioner.d.ts.map +1 -1
- package/dist/coordinate-positioner.js +7 -7
- package/dist/coordinate-positioner.js.map +1 -1
- package/dist/coordinate-range-fill.d.ts +1 -20
- package/dist/coordinate-range-fill.d.ts.map +1 -1
- package/dist/coordinate-range-fill.js +12 -10
- package/dist/coordinate-range-fill.js.map +1 -1
- package/dist/coordinate-range.d.ts +16 -7
- package/dist/coordinate-range.d.ts.map +1 -1
- package/dist/coordinate-range.js +30 -13
- package/dist/coordinate-range.js.map +1 -1
- package/dist/geometry.d.ts +1 -25
- package/dist/geometry.d.ts.map +1 -1
- package/dist/geometry.js +7 -7
- package/dist/geometry.js.map +1 -1
- package/dist/h-g-bounds.d.ts +8 -5
- package/dist/h-g-bounds.d.ts.map +1 -1
- package/dist/h-g-bounds.js +12 -10
- package/dist/h-g-bounds.js.map +1 -1
- package/dist/h-g-hexagon.d.ts +21 -7
- package/dist/h-g-hexagon.d.ts.map +1 -1
- package/dist/h-g-hexagon.js +34 -12
- package/dist/h-g-hexagon.js.map +1 -1
- package/dist/hex-grid.d.ts +5 -1
- package/dist/hex-grid.d.ts.map +1 -1
- package/dist/hex-grid.js +32 -15
- package/dist/hex-grid.js.map +1 -1
- package/dist/neighbours.d.ts +1 -23
- package/dist/neighbours.d.ts.map +1 -1
- package/dist/neighbours.js +7 -7
- package/dist/neighbours.js.map +1 -1
- package/docs/images/minimal-x.png +0 -0
- package/docs/images/minimal-y.png +0 -0
- package/docs/images/theory-x.png +0 -0
- package/docs/images/theory-y.png +0 -0
- 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
|
+
}
|