fix scaling when drawing
This commit is contained in:
parent
77c21ee07d
commit
c22a69f8b1
102
frontend/data/countries.json
Normal file
102
frontend/data/countries.json
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "Switzerland",
|
||||||
|
"file": "switzerland.json",
|
||||||
|
"hint": "Alpine country in Central Europe",
|
||||||
|
"cities": [
|
||||||
|
{ "name": "Bern", "lon": 7.4474, "lat": 46.948 },
|
||||||
|
{ "name": "Zurich", "lon": 8.5417, "lat": 47.3769 },
|
||||||
|
{ "name": "Geneva", "lon": 6.1432, "lat": 46.2044 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Norway",
|
||||||
|
"file": "norway.json",
|
||||||
|
"hint": "Scandinavian country with long coastline",
|
||||||
|
"cities": [
|
||||||
|
{ "name": "Oslo", "lon": 10.7522, "lat": 59.9139 },
|
||||||
|
{ "name": "Bergen", "lon": 5.3221, "lat": 60.39299 },
|
||||||
|
{ "name": "Tromso", "lon": 18.9553, "lat": 69.6492 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Italy",
|
||||||
|
"file": "italy.json",
|
||||||
|
"hint": "Boot-shaped peninsula in Southern Europe",
|
||||||
|
"cities": [
|
||||||
|
{ "name": "Rome", "lon": 12.4964, "lat": 41.9028 },
|
||||||
|
{ "name": "Milan", "lon": 9.19, "lat": 45.4642 },
|
||||||
|
{ "name": "Naples", "lon": 14.2681, "lat": 40.8518 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Japan",
|
||||||
|
"file": "japan.json",
|
||||||
|
"hint": "Island nation in East Asia",
|
||||||
|
"cities": [
|
||||||
|
{ "name": "Tokyo", "lon": 139.6503, "lat": 35.6762 },
|
||||||
|
{ "name": "Osaka", "lon": 135.5023, "lat": 34.6937 },
|
||||||
|
{ "name": "Sapporo", "lon": 141.3545, "lat": 43.0618 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Brazil",
|
||||||
|
"file": "brazil.json",
|
||||||
|
"hint": "Largest country in South America",
|
||||||
|
"cities": [
|
||||||
|
{ "name": "Brasilia", "lon": -47.8825, "lat": -15.7942 },
|
||||||
|
{ "name": "Sao Paulo", "lon": -46.6333, "lat": -23.5505 },
|
||||||
|
{ "name": "Manaus", "lon": -60.0217, "lat": -3.119 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Australia",
|
||||||
|
"file": "australia.json",
|
||||||
|
"hint": "Continent and country in the Southern Hemisphere",
|
||||||
|
"cities": [
|
||||||
|
{ "name": "Canberra", "lon": 149.13, "lat": -35.2809 },
|
||||||
|
{ "name": "Sydney", "lon": 151.2093, "lat": -33.8688 },
|
||||||
|
{ "name": "Perth", "lon": 115.8613, "lat": -31.9523 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "France",
|
||||||
|
"file": "france.json",
|
||||||
|
"hint": "Western Europe, roughly hexagonal shape",
|
||||||
|
"cities": [
|
||||||
|
{ "name": "Paris", "lon": 2.3522, "lat": 48.8566 },
|
||||||
|
{ "name": "Lyon", "lon": 4.8357, "lat": 45.764 },
|
||||||
|
{ "name": "Marseille", "lon": 5.3698, "lat": 43.2965 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "India",
|
||||||
|
"file": "india.json",
|
||||||
|
"hint": "Large peninsula in South Asia",
|
||||||
|
"cities": [
|
||||||
|
{ "name": "New Delhi", "lon": 77.209, "lat": 28.6139 },
|
||||||
|
{ "name": "Mumbai", "lon": 72.8777, "lat": 19.076 },
|
||||||
|
{ "name": "Chennai", "lon": 80.2707, "lat": 13.0827 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Canada",
|
||||||
|
"file": "canada.json",
|
||||||
|
"hint": "Second largest country in the world",
|
||||||
|
"cities": [
|
||||||
|
{ "name": "Ottawa", "lon": -75.6972, "lat": 45.4215 },
|
||||||
|
{ "name": "Vancouver", "lon": -123.1207, "lat": 49.2827 },
|
||||||
|
{ "name": "Toronto", "lon": -79.3832, "lat": 43.6532 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Germany",
|
||||||
|
"file": "germany.json",
|
||||||
|
"hint": "Central European country",
|
||||||
|
"cities": [
|
||||||
|
{ "name": "Berlin", "lon": 13.405, "lat": 52.52 },
|
||||||
|
{ "name": "Munich", "lon": 11.582, "lat": 48.1351 },
|
||||||
|
{ "name": "Hamburg", "lon": 9.9937, "lat": 53.5511 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -9,317 +9,781 @@
|
|||||||
},
|
},
|
||||||
"outline": {
|
"outline": {
|
||||||
"type": "Polygon",
|
"type": "Polygon",
|
||||||
"bounds": {
|
"geoBounds": {
|
||||||
"minLon": 5.954809204000128,
|
"minLon": 5.954809204000128,
|
||||||
"maxLon": 10.466626831000013,
|
"maxLon": 10.466626831000013,
|
||||||
"minLat": 45.82071848599999,
|
"minLat": 45.82071848599999,
|
||||||
"maxLat": 47.801166077000076
|
"maxLat": 47.801166077000076
|
||||||
},
|
},
|
||||||
|
"projectedBounds": {
|
||||||
|
"minX": 0.103931,
|
||||||
|
"maxX": 0.182677,
|
||||||
|
"minY": 0.901778,
|
||||||
|
"maxY": 0.952291
|
||||||
|
},
|
||||||
|
"projection": {
|
||||||
|
"padding": 6,
|
||||||
|
"scale": 1117.516047,
|
||||||
|
"xOffset": 6,
|
||||||
|
"yOffset": 21.775893
|
||||||
|
},
|
||||||
"rings": [
|
"rings": [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"x": 99.72,
|
"x": 93.75,
|
||||||
"y": 47.3
|
"y": 48.73
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 97.84,
|
"x": 93.04,
|
||||||
"y": 57
|
"y": 50.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 99.5,
|
"x": 93.26,
|
||||||
"y": 63.8
|
"y": 51.83
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 96.2,
|
"x": 92.1,
|
||||||
"y": 63.12
|
"y": 54.2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 93.92,
|
"x": 92.62,
|
||||||
"y": 59.3
|
"y": 55.16
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 90.38,
|
"x": 93.85,
|
||||||
"y": 64.04
|
"y": 55.59
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 90.24,
|
"x": 93.56,
|
||||||
"y": 68.41
|
"y": 58.03
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 92.78,
|
"x": 90.66,
|
||||||
"y": 70.6
|
"y": 57.65
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 92.27,
|
"x": 89.48,
|
||||||
"y": 79.28
|
"y": 56.96
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 90.6,
|
"x": 89.15,
|
||||||
"y": 79.81
|
"y": 55.49
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 87.85,
|
"x": 86.61,
|
||||||
"y": 72.21
|
"y": 56.13
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 83.45,
|
"x": 85.54,
|
||||||
"y": 73.23
|
"y": 58.16
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 82.44,
|
"x": 85.76,
|
||||||
"y": 76.21
|
"y": 60.03
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 79.38,
|
"x": 85.41,
|
||||||
"y": 75.87
|
"y": 60.62
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 77.13,
|
"x": 87.64,
|
||||||
|
"y": 61.84
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 86.7,
|
||||||
|
"y": 63.68
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 88,
|
||||||
|
"y": 65.81
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 87.2,
|
||||||
|
"y": 66.7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 85.73,
|
||||||
|
"y": 67
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 85.52,
|
||||||
|
"y": 65.88
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 84.46,
|
||||||
|
"y": 64.81
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 84.2,
|
||||||
|
"y": 63.17
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 83.31,
|
||||||
|
"y": 62.74
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 80.38,
|
||||||
|
"y": 63.66
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 79.44,
|
||||||
|
"y": 63.31
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 79.21,
|
||||||
|
"y": 64.42
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 78.55,
|
||||||
|
"y": 64.98
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 75.86,
|
||||||
|
"y": 64.79
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 74.06,
|
||||||
|
"y": 62.63
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 73.87,
|
||||||
|
"y": 59.14
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 72.9,
|
||||||
|
"y": 60.05
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 71.85,
|
||||||
|
"y": 59.05
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 70.53,
|
||||||
|
"y": 59.52
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 70.04,
|
||||||
|
"y": 60.89
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 70.76,
|
||||||
|
"y": 63.87
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 69.78,
|
||||||
|
"y": 66.69
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 68.58,
|
||||||
|
"y": 68.35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 67.16,
|
||||||
|
"y": 69.31
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 66.55,
|
||||||
|
"y": 71.46
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 65.35,
|
||||||
|
"y": 72.41
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 65.7,
|
||||||
|
"y": 73.39
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 65.01,
|
||||||
|
"y": 74.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 66.63,
|
||||||
|
"y": 76.03
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 65.44,
|
||||||
|
"y": 78.22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 63.44,
|
||||||
|
"y": 78.07
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 63.68,
|
||||||
|
"y": 76.47
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 62.88,
|
||||||
|
"y": 74.68
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 60.87,
|
||||||
|
"y": 73.67
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 62.16,
|
||||||
|
"y": 71.33
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 61.67,
|
||||||
|
"y": 70.68
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 60.01,
|
||||||
|
"y": 70.12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 59.1,
|
||||||
|
"y": 70.51
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 57.63,
|
||||||
|
"y": 69.75
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 55.84,
|
||||||
|
"y": 67.35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 54.44,
|
||||||
|
"y": 66.58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 54.15,
|
||||||
|
"y": 65.43
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 54.58,
|
||||||
|
"y": 61.58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 54.24,
|
||||||
|
"y": 60.55
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 53.68,
|
||||||
|
"y": 60.45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 52.06,
|
||||||
|
"y": 60.97
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 51.38,
|
||||||
|
"y": 62.77
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 49.65,
|
||||||
|
"y": 64.49
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 47.59,
|
||||||
|
"y": 65.55
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 47.32,
|
||||||
|
"y": 66.06
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 48.42,
|
||||||
|
"y": 67.68
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 48.05,
|
||||||
|
"y": 69.63
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 46.38,
|
||||||
|
"y": 70.64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 45.61,
|
||||||
|
"y": 73.22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 43.62,
|
||||||
|
"y": 73.93
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 42.6,
|
||||||
|
"y": 75.6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 39.92,
|
||||||
|
"y": 75.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 38.93,
|
||||||
|
"y": 74.14
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 36.94,
|
||||||
|
"y": 73.64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 33.44,
|
||||||
|
"y": 75.78
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 31.98,
|
||||||
|
"y": 75.63
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 29.38,
|
||||||
|
"y": 76.66
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 27.69,
|
||||||
|
"y": 76.28
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 26.68,
|
||||||
|
"y": 75.07
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 26.15,
|
||||||
|
"y": 73.39
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 24.73,
|
||||||
|
"y": 71.83
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 23.48,
|
||||||
|
"y": 71.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 23.84,
|
||||||
|
"y": 70.04
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 21.98,
|
||||||
|
"y": 69.41
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 21.99,
|
||||||
|
"y": 67.97
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 23.02,
|
||||||
|
"y": 65.61
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 21.52,
|
||||||
|
"y": 63.47
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 22.23,
|
||||||
|
"y": 61.53
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 18.85,
|
||||||
|
"y": 60.34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 16.3,
|
||||||
|
"y": 60.55
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 14.64,
|
||||||
|
"y": 61.7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 12.76,
|
||||||
|
"y": 62.08
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 11.06,
|
||||||
|
"y": 64.32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 11.52,
|
||||||
|
"y": 65.66
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 12.27,
|
||||||
"y": 65.79
|
"y": 65.79
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 73.33,
|
"x": 12.37,
|
||||||
"y": 66.45
|
"y": 66.44
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 72.48,
|
"x": 8.99,
|
||||||
"y": 79.27
|
"y": 69.3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 67.44,
|
"x": 7.43,
|
||||||
"y": 89.54
|
"y": 69.04
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 67.55,
|
"x": 6.08,
|
||||||
"y": 100
|
"y": 69.53
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 65.28,
|
"x": 6.55,
|
||||||
"y": 99.71
|
"y": 68.39
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 64.63,
|
"x": 6.07,
|
||||||
"y": 93.62
|
"y": 67.24
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 62.35,
|
"x": 8.63,
|
||||||
"y": 91.8
|
"y": 66.27
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 63.26,
|
"x": 9.54,
|
||||||
"y": 86.42
|
"y": 63.08
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 58.67,
|
"x": 8.99,
|
||||||
"y": 84.75
|
"y": 62.03
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 55.04,
|
"x": 7.94,
|
||||||
"y": 79.06
|
"y": 61.38
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 54.18,
|
"x": 8.13,
|
||||||
"y": 68.12
|
"y": 59.91
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 47.27,
|
"x": 9.72,
|
||||||
"y": 77.22
|
"y": 57.63
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 47.78,
|
"x": 9.19,
|
||||||
"y": 84.54
|
"y": 56.73
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 41.59,
|
"x": 15.25,
|
||||||
"y": 95.27
|
"y": 51.69
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 35.16,
|
"x": 15.51,
|
||||||
"y": 91.75
|
"y": 46.46
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 31.18,
|
"x": 18.56,
|
||||||
"y": 95.6
|
"y": 45.25
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 24.65,
|
"x": 19.86,
|
||||||
"y": 96.49
|
"y": 44.25
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 18.16,
|
"x": 20.33,
|
||||||
"y": 84.14
|
"y": 42.62
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 18.45,
|
"x": 25.53,
|
||||||
"y": 70.03
|
"y": 37.83
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 14.6,
|
"x": 25.58,
|
||||||
"y": 67.93
|
"y": 36.53
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 7.69,
|
"x": 27.1,
|
||||||
"y": 71.03
|
"y": 35.41
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 5.75,
|
"x": 26.46,
|
||||||
"y": 75.02
|
"y": 34.3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 7.23,
|
"x": 23.78,
|
||||||
"y": 78.83
|
"y": 34.7
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 3.39,
|
"x": 24.94,
|
||||||
"y": 83.95
|
"y": 32.66
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 0.09,
|
"x": 26.21,
|
||||||
"y": 84.36
|
"y": 31.87
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 4.02,
|
"x": 25.86,
|
||||||
"y": 72.8
|
"y": 30.81
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 2.2,
|
"x": 29.91,
|
||||||
"y": 69.77
|
"y": 30.83
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 3.63,
|
"x": 29.67,
|
||||||
"y": 61.49
|
"y": 32.12
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 10.51,
|
"x": 31.03,
|
||||||
"y": 52.53
|
"y": 32.9
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 10.81,
|
"x": 34.31,
|
||||||
"y": 43.27
|
"y": 32.28
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 15.75,
|
"x": 34.76,
|
||||||
"y": 39.38
|
"y": 31.5
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 23.98,
|
"x": 34.47,
|
||||||
"y": 23.82
|
"y": 30.78
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 23.25,
|
"x": 35.84,
|
||||||
"y": 21.86
|
"y": 30.7
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 20.21,
|
"x": 35.69,
|
||||||
"y": 22.57
|
"y": 30.07
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 22.57,
|
"x": 36.24,
|
||||||
"y": 15.76
|
"y": 29.54
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 27.17,
|
"x": 35.8,
|
||||||
"y": 15.8
|
"y": 29.27
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 28.44,
|
"x": 38.81,
|
||||||
"y": 19.41
|
"y": 27.75
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 32.17,
|
"x": 39.25,
|
||||||
"y": 18.33
|
"y": 27.7
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 37.28,
|
"x": 39,
|
||||||
"y": 10.41
|
"y": 28.43
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 38.31,
|
"x": 38.28,
|
||||||
"y": 12.97
|
"y": 28.63
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 41.33,
|
"x": 39.72,
|
||||||
"y": 10.39
|
"y": 29.22
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 46.27,
|
"x": 41.34,
|
||||||
"y": 12.15
|
"y": 28.88
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 50.49,
|
"x": 42.37,
|
||||||
"y": 9.05
|
"y": 27.74
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 57.76,
|
"x": 43.9,
|
||||||
"y": 10.69
|
"y": 27.96
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 58.79,
|
"x": 44.18,
|
||||||
"y": 7.32
|
"y": 28.75
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 54,
|
"x": 46.71,
|
||||||
"y": 6.85
|
"y": 28.75
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 57.7,
|
"x": 50.43,
|
||||||
"y": 0
|
"y": 26.97
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 61.14,
|
"x": 51.52,
|
||||||
"y": 2.21
|
"y": 27.15
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 61.22,
|
"x": 51.87,
|
||||||
"y": 5.38
|
"y": 27.83
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 63.01,
|
"x": 52.8,
|
||||||
"y": 4.1
|
"y": 28.15
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 65.41,
|
"x": 54.64,
|
||||||
"y": 7.54
|
"y": 28.06
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 71.56,
|
"x": 54.9,
|
||||||
"y": 6.6
|
"y": 27.43
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 79.63,
|
"x": 56.08,
|
||||||
"y": 13.46
|
"y": 26.97
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 81.89,
|
"x": 56.83,
|
||||||
"y": 19.77
|
"y": 27.91
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 78.07,
|
"x": 57.74,
|
||||||
"y": 37.23
|
"y": 25.98
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 86.51,
|
"x": 56.97,
|
||||||
"y": 39.67
|
"y": 25.78
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 86.89,
|
"x": 54.83,
|
||||||
"y": 44.12
|
"y": 26.45
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 92.12,
|
"x": 53.52,
|
||||||
"y": 48.17
|
"y": 25.71
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 97.93,
|
"x": 53.54,
|
||||||
"y": 40.64
|
"y": 24.72
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 99.66,
|
"x": 54.43,
|
||||||
"y": 46.22
|
"y": 24.04
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 99.72,
|
"x": 54.93,
|
||||||
"y": 47.3
|
"y": 22.86
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 56.65,
|
||||||
|
"y": 22.41
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 56.78,
|
||||||
|
"y": 21.78
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 57.62,
|
||||||
|
"y": 21.97
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 57.93,
|
||||||
|
"y": 23.05
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 58.45,
|
||||||
|
"y": 22.07
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 59.19,
|
||||||
|
"y": 23.01
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 59.8,
|
||||||
|
"y": 23.05
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 59.88,
|
||||||
|
"y": 24.87
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 60.91,
|
||||||
|
"y": 24.85
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 60.92,
|
||||||
|
"y": 24.11
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 61.45,
|
||||||
|
"y": 24.13
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 63.09,
|
||||||
|
"y": 25.98
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 64.33,
|
||||||
|
"y": 26.03
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 65.72,
|
||||||
|
"y": 25.32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 70.72,
|
||||||
|
"y": 26.16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 76.07,
|
||||||
|
"y": 29.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 76.79,
|
||||||
|
"y": 31.05
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 78.08,
|
||||||
|
"y": 31.88
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 77.87,
|
||||||
|
"y": 33.54
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 74.9,
|
||||||
|
"y": 38.84
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 75.39,
|
||||||
|
"y": 41.77
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 74.7,
|
||||||
|
"y": 43.03
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 78.44,
|
||||||
|
"y": 43.25
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 82.13,
|
||||||
|
"y": 44.42
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 82.46,
|
||||||
|
"y": 46.94
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 87.07,
|
||||||
|
"y": 49.23
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 88.83,
|
||||||
|
"y": 48.67
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 89.48,
|
||||||
|
"y": 47.05
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 90.67,
|
||||||
|
"y": 47.07
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 91.02,
|
||||||
|
"y": 45.88
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 92.18,
|
||||||
|
"y": 44.97
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 93.84,
|
||||||
|
"y": 46.67
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 93.7,
|
||||||
|
"y": 48.13
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 93.75,
|
||||||
|
"y": 48.73
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|||||||
@ -114,7 +114,10 @@
|
|||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
<button id="btn-submit" class="btn btn--primary">
|
<button id="btn-submit" class="btn btn--primary">
|
||||||
Submit & Next Round →
|
Submit
|
||||||
|
</button>
|
||||||
|
<button id="btn-next" class="btn btn--primary" disabled>
|
||||||
|
Next Round →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"format": "biome format --write",
|
"format": "biome format --write",
|
||||||
"lint": "biome lint",
|
"lint": "biome lint",
|
||||||
"lint:fix": "biome lint --write"
|
"lint:fix": "biome lint --write",
|
||||||
|
"test:scoring": "node scripts/scoring.test.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.3.14"
|
"@biomejs/biome": "2.3.14"
|
||||||
|
|||||||
@ -1,115 +1,127 @@
|
|||||||
// countries.js — country data and helpers
|
// countries.js - country data and helpers
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @typedef {{ name: string, lon: number, lat: number }} CitySource
|
||||||
* @typedef {{ name: string, x: number, y: number }} City
|
* @typedef {{ name: string, x: number, y: number }} City
|
||||||
* @typedef {{ name: string, hint: string, cities: City[] }} Country
|
* @typedef {{ minLon: number, maxLon: number, minLat: number, maxLat: number }} Bounds
|
||||||
|
* @typedef {{ minX: number, maxX: number, minY: number, maxY: number }} ProjectedBounds
|
||||||
|
* @typedef {{ padding: number, scale: number, xOffset: number, yOffset: number }} Projection
|
||||||
|
* @typedef {{ type: string, geoBounds?: Bounds, projectedBounds?: ProjectedBounds, bounds?: Bounds, projection?: Projection, rings: { x: number, y: number }[][] }} CountryOutline
|
||||||
|
* @typedef {{ name: string, hint: string, file: string, cities: CitySource[] }} CountrySource
|
||||||
|
* @typedef {{ name: string, hint: string, cities: City[], outline: CountryOutline }} Country
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @type {Country[]} */
|
const MAX_MERCATOR_LAT = 85.05112878;
|
||||||
const COUNTRIES_DATA = [
|
|
||||||
{
|
|
||||||
name: "Switzerland",
|
|
||||||
hint: "Alpine country in Central Europe",
|
|
||||||
cities: [
|
|
||||||
{ name: "Bern", x: 48, y: 58 },
|
|
||||||
{ name: "Zürich", x: 58, y: 38 },
|
|
||||||
{ name: "Geneva", x: 22, y: 72 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Norway",
|
|
||||||
hint: "Scandinavian country with long coastline",
|
|
||||||
cities: [
|
|
||||||
{ name: "Oslo", x: 55, y: 72 },
|
|
||||||
{ name: "Bergen", x: 32, y: 60 },
|
|
||||||
{ name: "Tromsø", x: 62, y: 18 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Italy",
|
|
||||||
hint: "Boot-shaped peninsula in Southern Europe",
|
|
||||||
cities: [
|
|
||||||
{ name: "Rome", x: 52, y: 58 },
|
|
||||||
{ name: "Milan", x: 42, y: 22 },
|
|
||||||
{ name: "Naples", x: 58, y: 72 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Japan",
|
|
||||||
hint: "Island nation in East Asia",
|
|
||||||
cities: [
|
|
||||||
{ name: "Tokyo", x: 72, y: 48 },
|
|
||||||
{ name: "Osaka", x: 58, y: 58 },
|
|
||||||
{ name: "Sapporo", x: 70, y: 22 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Brazil",
|
|
||||||
hint: "Largest country in South America",
|
|
||||||
cities: [
|
|
||||||
{ name: "Brasília", x: 58, y: 52 },
|
|
||||||
{ name: "São Paulo", x: 60, y: 68 },
|
|
||||||
{ name: "Manaus", x: 38, y: 38 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Australia",
|
|
||||||
hint: "Continent and country in the Southern Hemisphere",
|
|
||||||
cities: [
|
|
||||||
{ name: "Canberra", x: 72, y: 72 },
|
|
||||||
{ name: "Sydney", x: 78, y: 68 },
|
|
||||||
{ name: "Perth", x: 22, y: 65 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "France",
|
|
||||||
hint: "Western Europe, roughly hexagonal shape",
|
|
||||||
cities: [
|
|
||||||
{ name: "Paris", x: 50, y: 32 },
|
|
||||||
{ name: "Lyon", x: 58, y: 55 },
|
|
||||||
{ name: "Marseille", x: 58, y: 72 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "India",
|
|
||||||
hint: "Large peninsula in South Asia",
|
|
||||||
cities: [
|
|
||||||
{ name: "New Delhi", x: 46, y: 28 },
|
|
||||||
{ name: "Mumbai", x: 32, y: 55 },
|
|
||||||
{ name: "Chennai", x: 52, y: 72 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Canada",
|
|
||||||
hint: "Second largest country in the world",
|
|
||||||
cities: [
|
|
||||||
{ name: "Ottawa", x: 62, y: 52 },
|
|
||||||
{ name: "Vancouver", x: 22, y: 55 },
|
|
||||||
{ name: "Toronto", x: 60, y: 58 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Germany",
|
|
||||||
hint: "Central European country",
|
|
||||||
cities: [
|
|
||||||
{ name: "Berlin", x: 58, y: 28 },
|
|
||||||
{ name: "Munich", x: 48, y: 68 },
|
|
||||||
{ name: "Hamburg", x: 42, y: 18 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/** @type {Country[]} */
|
/** @type {Country[]} */
|
||||||
let data = [];
|
let data = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load country data into memory. Safe to call multiple times.
|
* Load country data and outlines into memory. Safe to call multiple times.
|
||||||
* Returns a Promise for future compatibility with a real API fetch.
|
|
||||||
* @returns {Promise<Country[]>}
|
* @returns {Promise<Country[]>}
|
||||||
*/
|
*/
|
||||||
export function loadCountries() {
|
export async function loadCountries() {
|
||||||
if (!data.length) data = COUNTRIES_DATA;
|
if (data.length) return data;
|
||||||
return Promise.resolve(data);
|
|
||||||
|
const countries = await loadCountrySources();
|
||||||
|
data = await Promise.all(
|
||||||
|
countries.map(async (country) => {
|
||||||
|
const response = await fetch(
|
||||||
|
new URL(`../data/outlines/${country.file}`, import.meta.url),
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Could not load outline for ${country.name}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outlineData = await response.json();
|
||||||
|
const outline = outlineData.outline;
|
||||||
|
return {
|
||||||
|
name: country.name,
|
||||||
|
hint: country.hint,
|
||||||
|
cities: projectCities(country.cities, outline),
|
||||||
|
outline,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and validate country metadata from managed JSON.
|
||||||
|
* @returns {Promise<CountrySource[]>}
|
||||||
|
*/
|
||||||
|
async function loadCountrySources() {
|
||||||
|
const response = await fetch(new URL("../data/countries.json", import.meta.url));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Could not load country metadata.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const countries = await response.json();
|
||||||
|
if (!Array.isArray(countries)) {
|
||||||
|
throw new Error("Country metadata must be an array.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return countries.map(validateCountrySource);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} value
|
||||||
|
* @param {number} index
|
||||||
|
* @returns {CountrySource}
|
||||||
|
*/
|
||||||
|
function validateCountrySource(value, index) {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
throw new Error(`Country metadata entry ${index + 1} must be an object.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const country = /** @type {Record<string, unknown>} */ (value);
|
||||||
|
if (typeof country.name !== "string" || country.name.trim() === "") {
|
||||||
|
throw new Error(`Country metadata entry ${index + 1} is missing a name.`);
|
||||||
|
}
|
||||||
|
if (typeof country.file !== "string" || country.file.trim() === "") {
|
||||||
|
throw new Error(`Country metadata entry ${country.name} is missing a file.`);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(country.cities)) {
|
||||||
|
throw new Error(`Country metadata entry ${country.name} is missing cities.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: country.name,
|
||||||
|
file: country.file,
|
||||||
|
hint: typeof country.hint === "string" ? country.hint : "",
|
||||||
|
cities: country.cities.map((city, cityIndex) =>
|
||||||
|
validateCitySource(city, country.name, cityIndex),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} value
|
||||||
|
* @param {string} countryName
|
||||||
|
* @param {number} index
|
||||||
|
* @returns {CitySource}
|
||||||
|
*/
|
||||||
|
function validateCitySource(value, countryName, index) {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
throw new Error(`City ${index + 1} for ${countryName} must be an object.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const city = /** @type {Record<string, unknown>} */ (value);
|
||||||
|
if (typeof city.name !== "string" || city.name.trim() === "") {
|
||||||
|
throw new Error(`City ${index + 1} for ${countryName} is missing a name.`);
|
||||||
|
}
|
||||||
|
if (typeof city.lon !== "number" || !Number.isFinite(city.lon)) {
|
||||||
|
throw new Error(`${city.name} for ${countryName} is missing a numeric lon.`);
|
||||||
|
}
|
||||||
|
if (typeof city.lat !== "number" || !Number.isFinite(city.lat)) {
|
||||||
|
throw new Error(`${city.name} for ${countryName} is missing a numeric lat.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: city.name,
|
||||||
|
lon: city.lon,
|
||||||
|
lat: city.lat,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -130,3 +142,86 @@ export function getCities(countryName) {
|
|||||||
const country = data.find((c) => c.name === countryName);
|
const country = data.find((c) => c.name === countryName);
|
||||||
return country ? country.cities : [];
|
return country ? country.cities : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {CitySource[]} cities
|
||||||
|
* @param {CountryOutline} outline
|
||||||
|
* @returns {City[]}
|
||||||
|
*/
|
||||||
|
function projectCities(cities, outline) {
|
||||||
|
const { bounds, geoBounds, projectedBounds, projection } = outline;
|
||||||
|
if (projectedBounds && projection) {
|
||||||
|
return cities.map((city) => {
|
||||||
|
const projected = mercatorProject(city.lon, city.lat);
|
||||||
|
return {
|
||||||
|
name: city.name,
|
||||||
|
x: clampPercent(projectProjectedX(projected.x, projectedBounds, projection)),
|
||||||
|
y: clampPercent(projectProjectedY(projected.y, projectedBounds, projection)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bounds && !geoBounds) return [];
|
||||||
|
const fallbackBounds = bounds || geoBounds;
|
||||||
|
if (!fallbackBounds) return [];
|
||||||
|
const lonSpan = fallbackBounds.maxLon - fallbackBounds.minLon;
|
||||||
|
const latSpan = fallbackBounds.maxLat - fallbackBounds.minLat;
|
||||||
|
|
||||||
|
return cities.map((city) => ({
|
||||||
|
name: city.name,
|
||||||
|
x: clampPercent(
|
||||||
|
lonSpan === 0 ? 50 : ((city.lon - fallbackBounds.minLon) / lonSpan) * 100,
|
||||||
|
),
|
||||||
|
y: clampPercent(
|
||||||
|
latSpan === 0
|
||||||
|
? 50
|
||||||
|
: (1 - (city.lat - fallbackBounds.minLat) / latSpan) * 100,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} lon
|
||||||
|
* @param {number} lat
|
||||||
|
* @returns {{ x: number, y: number }}
|
||||||
|
*/
|
||||||
|
function mercatorProject(lon, lat) {
|
||||||
|
const clampedLat = Math.max(
|
||||||
|
-MAX_MERCATOR_LAT,
|
||||||
|
Math.min(MAX_MERCATOR_LAT, lat),
|
||||||
|
);
|
||||||
|
const lonRad = (lon * Math.PI) / 180;
|
||||||
|
const latRad = (clampedLat * Math.PI) / 180;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: lonRad,
|
||||||
|
y: Math.log(Math.tan(Math.PI / 4 + latRad / 2)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} x
|
||||||
|
* @param {ProjectedBounds} bounds
|
||||||
|
* @param {Projection} projection
|
||||||
|
*/
|
||||||
|
function projectProjectedX(x, bounds, projection) {
|
||||||
|
return projection.scale === 0
|
||||||
|
? 50
|
||||||
|
: projection.xOffset + (x - bounds.minX) * projection.scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} y
|
||||||
|
* @param {ProjectedBounds} bounds
|
||||||
|
* @param {Projection} projection
|
||||||
|
*/
|
||||||
|
function projectProjectedY(y, bounds, projection) {
|
||||||
|
return projection.scale === 0
|
||||||
|
? 50
|
||||||
|
: projection.yOffset + (bounds.maxY - y) * projection.scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {number} value */
|
||||||
|
function clampPercent(value) {
|
||||||
|
return Math.max(0, Math.min(100, Math.round(value * 100) / 100));
|
||||||
|
}
|
||||||
|
|||||||
@ -7,9 +7,13 @@ let ctx;
|
|||||||
|
|
||||||
let isDrawing = false;
|
let isDrawing = false;
|
||||||
/** @type {{ x: number, y: number }[]} */
|
/** @type {{ x: number, y: number }[]} */
|
||||||
let points = [];
|
let currentStroke = [];
|
||||||
|
/** @type {{ x: number, y: number }[][]} */
|
||||||
|
let strokes = [];
|
||||||
/** @type {{ name: string, x: number, y: number }[]} */
|
/** @type {{ name: string, x: number, y: number }[]} */
|
||||||
let cities = [];
|
let cities = [];
|
||||||
|
/** @type {{ rings: { x: number, y: number }[][] } | null} */
|
||||||
|
let referenceOutline = null;
|
||||||
|
|
||||||
const STROKE_COLOR = "#1a7fc4";
|
const STROKE_COLOR = "#1a7fc4";
|
||||||
const STROKE_WIDTH = 2.5;
|
const STROKE_WIDTH = 2.5;
|
||||||
@ -57,7 +61,8 @@ function onDown(e) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
isDrawing = true;
|
isDrawing = true;
|
||||||
const p = pos(e);
|
const p = pos(e);
|
||||||
points.push(p);
|
currentStroke = [p];
|
||||||
|
strokes.push(currentStroke);
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(p.x, p.y);
|
ctx.moveTo(p.x, p.y);
|
||||||
}
|
}
|
||||||
@ -67,7 +72,7 @@ function onMove(e) {
|
|||||||
if (!isDrawing) return;
|
if (!isDrawing) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const p = pos(e);
|
const p = pos(e);
|
||||||
points.push(p);
|
currentStroke.push(p);
|
||||||
ctx.lineTo(p.x, p.y);
|
ctx.lineTo(p.x, p.y);
|
||||||
ctx.strokeStyle = STROKE_COLOR;
|
ctx.strokeStyle = STROKE_COLOR;
|
||||||
ctx.lineWidth = STROKE_WIDTH;
|
ctx.lineWidth = STROKE_WIDTH;
|
||||||
@ -80,12 +85,15 @@ function onMove(e) {
|
|||||||
function onUp(e) {
|
function onUp(e) {
|
||||||
if (!isDrawing) return;
|
if (!isDrawing) return;
|
||||||
isDrawing = false;
|
isDrawing = false;
|
||||||
|
currentStroke = [];
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clear the canvas and redraw city markers. */
|
/** Clear the canvas and redraw city markers. */
|
||||||
export function clear() {
|
export function clear() {
|
||||||
points = [];
|
strokes = [];
|
||||||
|
currentStroke = [];
|
||||||
|
referenceOutline = null;
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
const { width, height } = canvas.getBoundingClientRect();
|
const { width, height } = canvas.getBoundingClientRect();
|
||||||
ctx.clearRect(0, 0, width, height);
|
ctx.clearRect(0, 0, width, height);
|
||||||
@ -104,11 +112,10 @@ export function setCities(cityList) {
|
|||||||
/** Render all city markers with labels. */
|
/** Render all city markers with labels. */
|
||||||
function drawCities() {
|
function drawCities() {
|
||||||
if (!ctx || !cities.length) return;
|
if (!ctx || !cities.length) return;
|
||||||
const { width, height } = canvas.getBoundingClientRect();
|
const viewport = getCanvasViewport();
|
||||||
|
|
||||||
cities.forEach((city) => {
|
cities.forEach((city) => {
|
||||||
const cx = (city.x / 100) * width;
|
const { x: cx, y: cy } = toCanvasPoint(city, viewport);
|
||||||
const cy = (city.y / 100) * height;
|
|
||||||
|
|
||||||
// Dot
|
// Dot
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@ -139,31 +146,138 @@ function drawCities() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Redraw the full stroke from stored points. */
|
/** Redraw all stored strokes. */
|
||||||
function redraw() {
|
function redraw() {
|
||||||
drawCities();
|
drawCities();
|
||||||
if (!points.length) return;
|
for (const stroke of strokes) {
|
||||||
ctx.beginPath();
|
if (!stroke.length) continue;
|
||||||
ctx.strokeStyle = STROKE_COLOR;
|
ctx.beginPath();
|
||||||
ctx.lineWidth = STROKE_WIDTH;
|
ctx.strokeStyle = STROKE_COLOR;
|
||||||
ctx.lineJoin = "round";
|
ctx.lineWidth = STROKE_WIDTH;
|
||||||
ctx.lineCap = "round";
|
ctx.lineJoin = "round";
|
||||||
points.forEach((p, i) => {
|
ctx.lineCap = "round";
|
||||||
if (i === 0) {
|
stroke.forEach((p, i) => {
|
||||||
ctx.moveTo(p.x, p.y);
|
if (i === 0) {
|
||||||
} else {
|
ctx.moveTo(p.x, p.y);
|
||||||
ctx.lineTo(p.x, p.y);
|
} else {
|
||||||
}
|
ctx.lineTo(p.x, p.y);
|
||||||
});
|
}
|
||||||
ctx.stroke();
|
});
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
drawReferenceOutline();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return a copy of the current drawn points.
|
* Return a flattened copy of all drawn points.
|
||||||
* @returns {{ x: number, y: number }[]}
|
* @returns {{ x: number, y: number }[]}
|
||||||
*/
|
*/
|
||||||
export function getPoints() {
|
export function getPoints() {
|
||||||
return [...points];
|
return strokes.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return drawn points in the same 0-100 space used by country outlines.
|
||||||
|
* @returns {{ x: number, y: number }[]}
|
||||||
|
*/
|
||||||
|
export function getNormalizedPoints() {
|
||||||
|
return getNormalizedRings().flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return drawn strokes in the same 0-100 space used by country outlines.
|
||||||
|
* @returns {{ x: number, y: number }[][]}
|
||||||
|
*/
|
||||||
|
export function getNormalizedRings() {
|
||||||
|
if (!canvas) return [];
|
||||||
|
const viewport = getCanvasViewport();
|
||||||
|
if (viewport.size === 0) return [];
|
||||||
|
|
||||||
|
return strokes
|
||||||
|
.map((stroke) => stroke.map((point) => toNormalizedPoint(point, viewport)))
|
||||||
|
.filter((stroke) => stroke.length > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the reference outline over the player's drawing.
|
||||||
|
* @param {{ rings: { x: number, y: number }[][] } | null} outline
|
||||||
|
*/
|
||||||
|
export function showReferenceOutline(outline) {
|
||||||
|
referenceOutline = outline;
|
||||||
|
redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draw the current reference outline if one is set. */
|
||||||
|
function drawReferenceOutline() {
|
||||||
|
if (!ctx || !referenceOutline?.rings?.length) return;
|
||||||
|
const viewport = getCanvasViewport();
|
||||||
|
if (viewport.size === 0) return;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = "rgba(224,92,92,0.95)";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.lineJoin = "round";
|
||||||
|
ctx.lineCap = "round";
|
||||||
|
ctx.setLineDash([8, 5]);
|
||||||
|
|
||||||
|
for (const ring of referenceOutline.rings) {
|
||||||
|
if (!ring.length) continue;
|
||||||
|
ctx.beginPath();
|
||||||
|
ring.forEach((point, index) => {
|
||||||
|
const { x, y } = toCanvasPoint(point, viewport);
|
||||||
|
if (index === 0) {
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
} else {
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the square drawing viewport used for normalized 0-100 country space.
|
||||||
|
* @returns {{ x: number, y: number, size: number }}
|
||||||
|
*/
|
||||||
|
function getCanvasViewport() {
|
||||||
|
const { width, height } = canvas.getBoundingClientRect();
|
||||||
|
const size = Math.min(width, height);
|
||||||
|
return {
|
||||||
|
x: (width - size) / 2,
|
||||||
|
y: (height - size) / 2,
|
||||||
|
size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ x: number, y: number }} point
|
||||||
|
* @param {{ x: number, y: number, size: number }} viewport
|
||||||
|
* @returns {{ x: number, y: number }}
|
||||||
|
*/
|
||||||
|
function toCanvasPoint(point, viewport) {
|
||||||
|
return {
|
||||||
|
x: viewport.x + (point.x / 100) * viewport.size,
|
||||||
|
y: viewport.y + (point.y / 100) * viewport.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ x: number, y: number }} point
|
||||||
|
* @param {{ x: number, y: number, size: number }} viewport
|
||||||
|
* @returns {{ x: number, y: number }}
|
||||||
|
*/
|
||||||
|
function toNormalizedPoint(point, viewport) {
|
||||||
|
return {
|
||||||
|
x: clampPercent(((point.x - viewport.x) / viewport.size) * 100),
|
||||||
|
y: clampPercent(((point.y - viewport.y) / viewport.size) * 100),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {number} value */
|
||||||
|
function clampPercent(value) {
|
||||||
|
return Math.max(0, Math.min(100, Math.round(value * 100) / 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove event listeners and clean up. */
|
/** Remove event listeners and clean up. */
|
||||||
|
|||||||
@ -2,7 +2,13 @@
|
|||||||
|
|
||||||
import { submitScore } from "./api.js";
|
import { submitScore } from "./api.js";
|
||||||
import { getRandomCountries, loadCountries } from "./countries.js";
|
import { getRandomCountries, loadCountries } from "./countries.js";
|
||||||
import { clear, getPoints, init as initDrawing, setCities } from "./drawing.js";
|
import {
|
||||||
|
clear,
|
||||||
|
getNormalizedRings,
|
||||||
|
init as initDrawing,
|
||||||
|
setCities,
|
||||||
|
showReferenceOutline,
|
||||||
|
} from "./drawing.js";
|
||||||
import { calculateScore, getGrade } from "./scoring.js";
|
import { calculateScore, getGrade } from "./scoring.js";
|
||||||
import { getLobbyName, getPlayerName, saveGameState } from "./storage.js";
|
import { getLobbyName, getPlayerName, saveGameState } from "./storage.js";
|
||||||
|
|
||||||
@ -25,6 +31,8 @@ let elTimerBar;
|
|||||||
let elTimerWrap;
|
let elTimerWrap;
|
||||||
let elBtnClear;
|
let elBtnClear;
|
||||||
let elBtnSubmit;
|
let elBtnSubmit;
|
||||||
|
let elBtnNext;
|
||||||
|
let roundSubmitted = false;
|
||||||
|
|
||||||
/** Initialise DOM refs, drawing, events, countries, and the first round. */
|
/** Initialise DOM refs, drawing, events, countries, and the first round. */
|
||||||
async function initGame() {
|
async function initGame() {
|
||||||
@ -36,6 +44,7 @@ async function initGame() {
|
|||||||
elTimerWrap = document.querySelector(".game-timer");
|
elTimerWrap = document.querySelector(".game-timer");
|
||||||
elBtnClear = document.getElementById("btn-clear");
|
elBtnClear = document.getElementById("btn-clear");
|
||||||
elBtnSubmit = document.getElementById("btn-submit");
|
elBtnSubmit = document.getElementById("btn-submit");
|
||||||
|
elBtnNext = document.getElementById("btn-next");
|
||||||
|
|
||||||
const canvas = document.getElementById("draw-canvas");
|
const canvas = document.getElementById("draw-canvas");
|
||||||
const wrap = document.getElementById("canvas-wrap");
|
const wrap = document.getElementById("canvas-wrap");
|
||||||
@ -48,6 +57,7 @@ async function initGame() {
|
|||||||
|
|
||||||
elBtnClear.addEventListener("click", () => clear());
|
elBtnClear.addEventListener("click", () => clear());
|
||||||
elBtnSubmit.addEventListener("click", () => submitRound(false));
|
elBtnSubmit.addEventListener("click", () => submitRound(false));
|
||||||
|
elBtnNext.addEventListener("click", goToNextRound);
|
||||||
|
|
||||||
await loadCountries();
|
await loadCountries();
|
||||||
roundCountries = getRandomCountries(TOTAL_ROUNDS);
|
roundCountries = getRandomCountries(TOTAL_ROUNDS);
|
||||||
@ -67,9 +77,11 @@ function startRound() {
|
|||||||
updateRoundPips(currentRound + 1);
|
updateRoundPips(currentRound + 1);
|
||||||
|
|
||||||
document.getElementById("canvas-wrap")?.classList.remove("has-drawing");
|
document.getElementById("canvas-wrap")?.classList.remove("has-drawing");
|
||||||
|
hideScoreFeedback();
|
||||||
|
|
||||||
setCities(country.cities || []);
|
setCities(country.cities || []);
|
||||||
clear();
|
clear();
|
||||||
|
roundSubmitted = false;
|
||||||
|
|
||||||
timeLeft = ROUND_DURATION;
|
timeLeft = ROUND_DURATION;
|
||||||
updateTimerUI();
|
updateTimerUI();
|
||||||
@ -77,10 +89,12 @@ function startRound() {
|
|||||||
timerInterval = setInterval(tickTimer, 1000);
|
timerInterval = setInterval(tickTimer, 1000);
|
||||||
|
|
||||||
elBtnSubmit.disabled = false;
|
elBtnSubmit.disabled = false;
|
||||||
elBtnSubmit.textContent =
|
elBtnSubmit.hidden = false;
|
||||||
currentRound < TOTAL_ROUNDS - 1
|
elBtnSubmit.textContent = "Submit";
|
||||||
? "Submit & Next Round →"
|
elBtnClear.disabled = false;
|
||||||
: "Submit & See Results →";
|
elBtnNext.disabled = true;
|
||||||
|
elBtnNext.textContent =
|
||||||
|
currentRound < TOTAL_ROUNDS - 1 ? "Next Round →" : "See Results →";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Decrement timer by one second and auto-submit when time runs out. */
|
/** Decrement timer by one second and auto-submit when time runs out. */
|
||||||
@ -109,35 +123,51 @@ function updateTimerUI() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Submit the current round, record the score, and advance or finish.
|
* Submit the current round, record the score, and reveal the reference outline.
|
||||||
* @param {boolean} [auto=false] - True when triggered by timer expiry.
|
* @param {boolean} [auto=false] - True when triggered by timer expiry.
|
||||||
*/
|
*/
|
||||||
function submitRound(auto = false) {
|
function submitRound(auto = false) {
|
||||||
clearInterval(timerInterval);
|
if (roundSubmitted) return;
|
||||||
elBtnSubmit.disabled = true;
|
|
||||||
|
|
||||||
const points = getPoints();
|
clearInterval(timerInterval);
|
||||||
const score = calculateScore(points);
|
roundSubmitted = true;
|
||||||
|
elBtnSubmit.disabled = true;
|
||||||
|
elBtnClear.disabled = true;
|
||||||
|
|
||||||
|
const country = roundCountries[currentRound];
|
||||||
|
const rings = getNormalizedRings();
|
||||||
|
const score = calculateScore(rings, country.outline);
|
||||||
scores.push(score);
|
scores.push(score);
|
||||||
|
|
||||||
|
showReferenceOutline(country.outline);
|
||||||
updateScoreDisplay(currentRound, score);
|
updateScoreDisplay(currentRound, score);
|
||||||
showScoreFeedback(score);
|
showScoreFeedback(score);
|
||||||
|
|
||||||
setTimeout(
|
elBtnNext.disabled = false;
|
||||||
() => {
|
elBtnNext.focus();
|
||||||
if (currentRound < TOTAL_ROUNDS - 1) {
|
if (auto) {
|
||||||
currentRound++;
|
elBtnNext.textContent =
|
||||||
startRound();
|
currentRound < TOTAL_ROUNDS - 1
|
||||||
} else {
|
? "Time's up - Next Round"
|
||||||
finishGame();
|
: "Time's up - See Results";
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
auto ? 400 : 1200,
|
|
||||||
);
|
/** Move to the next round or finish the game after a submitted round. */
|
||||||
|
function goToNextRound() {
|
||||||
|
if (!roundSubmitted) return;
|
||||||
|
|
||||||
|
elBtnNext.disabled = true;
|
||||||
|
if (currentRound < TOTAL_ROUNDS - 1) {
|
||||||
|
currentRound++;
|
||||||
|
startRound();
|
||||||
|
} else {
|
||||||
|
finishGame();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Briefly display the score grade overlay on the canvas.
|
* Display the score grade overlay on the canvas until the next round starts.
|
||||||
* @param {number} score
|
* @param {number} score
|
||||||
*/
|
*/
|
||||||
function showScoreFeedback(score) {
|
function showScoreFeedback(score) {
|
||||||
@ -147,10 +177,13 @@ function showScoreFeedback(score) {
|
|||||||
el.style.color = grade.color;
|
el.style.color = grade.color;
|
||||||
el.style.opacity = "1";
|
el.style.opacity = "1";
|
||||||
el.style.transform = "translateY(0)";
|
el.style.transform = "translateY(0)";
|
||||||
setTimeout(() => {
|
}
|
||||||
el.style.opacity = "0";
|
|
||||||
el.style.transform = "translateY(-10px)";
|
/** Hide the score grade overlay. */
|
||||||
}, 900);
|
function hideScoreFeedback() {
|
||||||
|
const el = document.getElementById("score-feedback");
|
||||||
|
el.style.opacity = "0";
|
||||||
|
el.style.transform = "translateY(-10px)";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persist game state, update leaderboard, and navigate to results. */
|
/** Persist game state, update leaderboard, and navigate to results. */
|
||||||
|
|||||||
@ -1,31 +1,69 @@
|
|||||||
// scoring.js - accuracy calculation
|
// scoring.js - accuracy calculation
|
||||||
|
|
||||||
|
const GRID_SIZE = 96;
|
||||||
|
const MIN_DRAWN_POINTS = 10;
|
||||||
|
const MIN_FILLED_CELLS = 8;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate a score from the drawn path points.
|
* @typedef {{ x: number, y: number }} Point
|
||||||
* NOTE: Currently uses an effort-based approximation.
|
* @typedef {{ rings: Point[][] }} CountryOutline
|
||||||
* TODO: Replace with real polygon comparison (IoU or Hausdorff distance).
|
*/
|
||||||
* @param {{ x: number, y: number }[]} drawnPoints
|
|
||||||
|
/**
|
||||||
|
* Calculate a deterministic shape score from a drawn polygon and reference outline.
|
||||||
|
* @param {Point[] | Point[][]} drawnShape - Points or rings normalized to 0-100 canvas space.
|
||||||
|
* @param {CountryOutline | undefined | null} referenceOutline
|
||||||
* @returns {number} Score between 0 and 100.
|
* @returns {number} Score between 0 and 100.
|
||||||
*/
|
*/
|
||||||
export function calculateScore(drawnPoints) {
|
export function calculateScore(drawnShape, referenceOutline) {
|
||||||
if (!drawnPoints || drawnPoints.length < 10) return 0;
|
return compareShapes(drawnShape, referenceOutline);
|
||||||
|
|
||||||
const effort = Math.min(drawnPoints.length / 300, 1); // 0-1
|
|
||||||
const base = 40 + Math.round(effort * 45); // 40-85
|
|
||||||
const jitter = Math.round((Math.random() - 0.5) * 14); // +/-7
|
|
||||||
return Math.max(0, Math.min(100, base + jitter));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compare a drawn polygon against a reference polygon.
|
* Compare a drawn polygon against one or more reference rings using rasterized IoU.
|
||||||
* Stub - reserved for future IoU / Hausdorff implementation.
|
* @param {Point[] | Point[][]} drawnShape - Points or rings normalized to 0-100 canvas space.
|
||||||
* @param {{ x: number, y: number }[]} _drawnPoints
|
* @param {CountryOutline | undefined | null} referenceOutline
|
||||||
* @param {{ x: number, y: number }[]} _referencePolygon - Normalised 0-1 coords.
|
|
||||||
* @returns {number} Score between 0 and 100.
|
* @returns {number} Score between 0 and 100.
|
||||||
*/
|
*/
|
||||||
export function compareShapes(_drawnPoints, _referencePolygon) {
|
export function compareShapes(drawnShape, referenceOutline) {
|
||||||
// TODO: implement real shape comparison
|
if (!referenceOutline?.rings?.length) return 0;
|
||||||
return 0;
|
|
||||||
|
const drawnRings = normalizeDrawnRings(drawnShape);
|
||||||
|
const referenceRings = referenceOutline.rings.filter(isValidReferenceRing).map(closeRing);
|
||||||
|
if (!drawnRings.length || !referenceRings.length) return 0;
|
||||||
|
|
||||||
|
let intersection = 0;
|
||||||
|
let union = 0;
|
||||||
|
let drawnCells = 0;
|
||||||
|
|
||||||
|
for (let row = 0; row < GRID_SIZE; row++) {
|
||||||
|
for (let col = 0; col < GRID_SIZE; col++) {
|
||||||
|
const point = cellCenter(col, row);
|
||||||
|
const inDrawn = isInsideAnyRing(point, drawnRings);
|
||||||
|
const inReference = isInsideAnyRing(point, referenceRings);
|
||||||
|
|
||||||
|
if (inDrawn) drawnCells++;
|
||||||
|
if (inDrawn && inReference) intersection++;
|
||||||
|
if (inDrawn || inReference) union++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drawnCells < MIN_FILLED_CELLS || union === 0) return 0;
|
||||||
|
return Math.round((intersection / union) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Point[] | Point[][]} drawnShape
|
||||||
|
* @returns {Point[][]}
|
||||||
|
*/
|
||||||
|
function normalizeDrawnRings(drawnShape) {
|
||||||
|
if (!Array.isArray(drawnShape) || !drawnShape.length) return [];
|
||||||
|
if (isPoint(drawnShape[0])) {
|
||||||
|
return isValidRing(drawnShape) ? [closeRing(drawnShape)] : [];
|
||||||
|
}
|
||||||
|
return drawnShape
|
||||||
|
.filter(isValidRing)
|
||||||
|
.map(closeRing);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -40,3 +78,79 @@ export function getGrade(score) {
|
|||||||
if (score >= 40) return { label: "C", color: "#7a9aaa" };
|
if (score >= 40) return { label: "C", color: "#7a9aaa" };
|
||||||
return { label: "D", color: "#e05c5c" };
|
return { label: "D", color: "#e05c5c" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} col
|
||||||
|
* @param {number} row
|
||||||
|
* @returns {Point}
|
||||||
|
*/
|
||||||
|
function cellCenter(col, row) {
|
||||||
|
return {
|
||||||
|
x: ((col + 0.5) / GRID_SIZE) * 100,
|
||||||
|
y: ((row + 0.5) / GRID_SIZE) * 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Point} point
|
||||||
|
* @param {Point[][]} rings
|
||||||
|
*/
|
||||||
|
function isInsideAnyRing(point, rings) {
|
||||||
|
return rings.some((ring) => pointInRing(point, ring));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {unknown} value */
|
||||||
|
function isPoint(value) {
|
||||||
|
return (
|
||||||
|
!!value &&
|
||||||
|
typeof value === "object" &&
|
||||||
|
Number.isFinite(value.x) &&
|
||||||
|
Number.isFinite(value.y)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Point} point
|
||||||
|
* @param {Point[]} ring
|
||||||
|
*/
|
||||||
|
function pointInRing(point, ring) {
|
||||||
|
let inside = false;
|
||||||
|
|
||||||
|
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||||
|
const a = ring[i];
|
||||||
|
const b = ring[j];
|
||||||
|
const intersects =
|
||||||
|
a.y > point.y !== b.y > point.y &&
|
||||||
|
point.x < ((b.x - a.x) * (point.y - a.y)) / (b.y - a.y) + a.x;
|
||||||
|
|
||||||
|
if (intersects) inside = !inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {Point[]} ring */
|
||||||
|
function isValidRing(ring) {
|
||||||
|
return (
|
||||||
|
Array.isArray(ring) &&
|
||||||
|
ring.length >= MIN_DRAWN_POINTS &&
|
||||||
|
ring.every((point) => Number.isFinite(point.x) && Number.isFinite(point.y))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {Point[]} ring */
|
||||||
|
function isValidReferenceRing(ring) {
|
||||||
|
return (
|
||||||
|
Array.isArray(ring) &&
|
||||||
|
ring.length >= 4 &&
|
||||||
|
ring.every((point) => Number.isFinite(point.x) && Number.isFinite(point.y))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {Point[]} ring */
|
||||||
|
function closeRing(ring) {
|
||||||
|
const first = ring[0];
|
||||||
|
const last = ring[ring.length - 1];
|
||||||
|
if (first.x === last.x && first.y === last.y) return ring;
|
||||||
|
return [...ring, { ...first }];
|
||||||
|
}
|
||||||
|
|||||||
101
frontend/scripts/scoring.test.mjs
Normal file
101
frontend/scripts/scoring.test.mjs
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { calculateScore } from "./scoring.js";
|
||||||
|
|
||||||
|
const square = [
|
||||||
|
{ x: 20, y: 20 },
|
||||||
|
{ x: 40, y: 20 },
|
||||||
|
{ x: 60, y: 20 },
|
||||||
|
{ x: 80, y: 20 },
|
||||||
|
{ x: 80, y: 40 },
|
||||||
|
{ x: 80, y: 60 },
|
||||||
|
{ x: 80, y: 80 },
|
||||||
|
{ x: 60, y: 80 },
|
||||||
|
{ x: 40, y: 80 },
|
||||||
|
{ x: 20, y: 80 },
|
||||||
|
{ x: 20, y: 60 },
|
||||||
|
{ x: 20, y: 40 },
|
||||||
|
{ x: 20, y: 20 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const shiftedSquare = square.map((point) => ({
|
||||||
|
x: Math.min(100, point.x + 12),
|
||||||
|
y: point.y,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const smallSquare = square.map((point) => ({
|
||||||
|
x: 35 + (point.x - 20) * 0.5,
|
||||||
|
y: 35 + (point.y - 20) * 0.5,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const secondIsland = [
|
||||||
|
{ x: 5, y: 5 },
|
||||||
|
{ x: 10, y: 5 },
|
||||||
|
{ x: 15, y: 5 },
|
||||||
|
{ x: 15, y: 10 },
|
||||||
|
{ x: 15, y: 15 },
|
||||||
|
{ x: 10, y: 15 },
|
||||||
|
{ x: 5, y: 15 },
|
||||||
|
{ x: 5, y: 10 },
|
||||||
|
{ x: 5, y: 7 },
|
||||||
|
{ x: 5, y: 5 },
|
||||||
|
{ x: 7, y: 5 },
|
||||||
|
{ x: 10, y: 5 },
|
||||||
|
{ x: 5, y: 5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const firstIsland = [
|
||||||
|
{ x: 5, y: 70 },
|
||||||
|
{ x: 12, y: 70 },
|
||||||
|
{ x: 20, y: 70 },
|
||||||
|
{ x: 20, y: 77 },
|
||||||
|
{ x: 20, y: 85 },
|
||||||
|
{ x: 12, y: 85 },
|
||||||
|
{ x: 5, y: 85 },
|
||||||
|
{ x: 5, y: 78 },
|
||||||
|
{ x: 5, y: 74 },
|
||||||
|
{ x: 5, y: 70 },
|
||||||
|
{ x: 8, y: 70 },
|
||||||
|
{ x: 12, y: 70 },
|
||||||
|
{ x: 5, y: 70 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const tinyNoise = [
|
||||||
|
{ x: 90, y: 90 },
|
||||||
|
{ x: 91, y: 90 },
|
||||||
|
{ x: 91, y: 91 },
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.equal(calculateScore(square, { rings: [square] }), 100);
|
||||||
|
assert.equal(calculateScore([], { rings: [square] }), 0);
|
||||||
|
assert.equal(calculateScore(square.slice(0, 4), { rings: [square] }), 0);
|
||||||
|
assert.ok(calculateScore(shiftedSquare, { rings: [square] }) < 100);
|
||||||
|
assert.ok(calculateScore(smallSquare, { rings: [square] }) < 60);
|
||||||
|
assert.ok(calculateScore(square, { rings: [square, secondIsland] }) >= 95);
|
||||||
|
assert.ok(
|
||||||
|
calculateScore([square, secondIsland], { rings: [square, secondIsland] }) >=
|
||||||
|
95,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
calculateScore([tinyNoise, square], { rings: [square] }),
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
calculateScore([firstIsland, secondIsland], {
|
||||||
|
rings: [firstIsland, secondIsland],
|
||||||
|
}) > 90,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
calculateScore([firstIsland, secondIsland], {
|
||||||
|
rings: [
|
||||||
|
[
|
||||||
|
{ x: 5, y: 5 },
|
||||||
|
{ x: 20, y: 5 },
|
||||||
|
{ x: 20, y: 85 },
|
||||||
|
{ x: 5, y: 85 },
|
||||||
|
{ x: 5, y: 5 },
|
||||||
|
],
|
||||||
|
],
|
||||||
|
}) < 50,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("scoring tests passed");
|
||||||
@ -141,10 +141,14 @@
|
|||||||
grid-template-columns: 1fr 220px;
|
grid-template-columns: 1fr 220px;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvas-wrap {
|
.canvas-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
justify-self: center;
|
||||||
|
width: min(100%, 68vh);
|
||||||
|
aspect-ratio: 1;
|
||||||
background: var(--white);
|
background: var(--white);
|
||||||
border: 1.5px solid var(--line);
|
border: 1.5px solid var(--line);
|
||||||
border-radius: var(--r-xl);
|
border-radius: var(--r-xl);
|
||||||
@ -284,7 +288,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.canvas-wrap {
|
.canvas-wrap {
|
||||||
min-height: 340px;
|
width: min(100%, 72vh);
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-topbar {
|
.game-topbar {
|
||||||
@ -315,7 +319,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.canvas-wrap {
|
.canvas-wrap {
|
||||||
min-height: 280px;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-controls {
|
.game-controls {
|
||||||
@ -334,6 +338,6 @@
|
|||||||
font-size: 1.6rem;
|
font-size: 1.6rem;
|
||||||
}
|
}
|
||||||
.canvas-wrap {
|
.canvas-wrap {
|
||||||
min-height: 240px;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,9 @@ import { mkdir, readdir, writeFile } from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import shapefile from "shapefile";
|
import shapefile from "shapefile";
|
||||||
|
|
||||||
const DEFAULT_TOLERANCE = 1.5;
|
const DEFAULT_TOLERANCE = 0.3;
|
||||||
|
const DEFAULT_PADDING = 6;
|
||||||
|
const MAX_MERCATOR_LAT = 85.05112878;
|
||||||
const SHAPEFILE_BASENAME = "ne_10m_admin_0_countries";
|
const SHAPEFILE_BASENAME = "ne_10m_admin_0_countries";
|
||||||
const GAME_COUNTRIES = [
|
const GAME_COUNTRIES = [
|
||||||
"Switzerland",
|
"Switzerland",
|
||||||
@ -18,14 +20,22 @@ const GAME_COUNTRIES = [
|
|||||||
"Canada",
|
"Canada",
|
||||||
"Germany",
|
"Germany",
|
||||||
];
|
];
|
||||||
|
const COUNTRY_OPTIONS = {
|
||||||
|
France: { keepLargestRingOnly: true },
|
||||||
|
Norway: { keepLargestRingOnly: true },
|
||||||
|
};
|
||||||
|
|
||||||
function usage() {
|
function usage() {
|
||||||
return `Usage:
|
return `Usage:
|
||||||
node convert-geojson-outlines.js <input-dir> <output-dir> [options]
|
node convert-geojson-outlines.js <input-dir> <output-dir> [options]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--tolerance <number> Douglas-Peucker tolerance in 0..100 units. Default: 1.5
|
--tolerance <number> Douglas-Peucker tolerance in 0..100 units. Default: 0.3
|
||||||
|
--padding <number> Padding on each side in 0..100 units. Default: 6
|
||||||
--max-points <number> Simplify each ring until it has at most this many points.
|
--max-points <number> Simplify each ring until it has at most this many points.
|
||||||
|
--min-ring-area-ratio <number>
|
||||||
|
Drop rings smaller than this ratio of the largest ring. Default: 0.001
|
||||||
|
--mainland-only Keep only the largest ring for every country.
|
||||||
--include-holes Include inner rings from Polygon/MultiPolygon geometries.
|
--include-holes Include inner rings from Polygon/MultiPolygon geometries.
|
||||||
--pretty Write indented JSON instead of compact JSON.
|
--pretty Write indented JSON instead of compact JSON.
|
||||||
`;
|
`;
|
||||||
@ -35,7 +45,10 @@ function parseArgs(argv) {
|
|||||||
const positionals = [];
|
const positionals = [];
|
||||||
const options = {
|
const options = {
|
||||||
tolerance: DEFAULT_TOLERANCE,
|
tolerance: DEFAULT_TOLERANCE,
|
||||||
|
padding: DEFAULT_PADDING,
|
||||||
maxPoints: null,
|
maxPoints: null,
|
||||||
|
minRingAreaRatio: 0.001,
|
||||||
|
mainlandOnly: false,
|
||||||
includeHoles: false,
|
includeHoles: false,
|
||||||
pretty: false,
|
pretty: false,
|
||||||
};
|
};
|
||||||
@ -45,8 +58,14 @@ function parseArgs(argv) {
|
|||||||
|
|
||||||
if (arg === "--tolerance") {
|
if (arg === "--tolerance") {
|
||||||
options.tolerance = Number(argv[++i]);
|
options.tolerance = Number(argv[++i]);
|
||||||
|
} else if (arg === "--padding") {
|
||||||
|
options.padding = Number(argv[++i]);
|
||||||
} else if (arg === "--max-points") {
|
} else if (arg === "--max-points") {
|
||||||
options.maxPoints = Number(argv[++i]);
|
options.maxPoints = Number(argv[++i]);
|
||||||
|
} else if (arg === "--min-ring-area-ratio") {
|
||||||
|
options.minRingAreaRatio = Number(argv[++i]);
|
||||||
|
} else if (arg === "--mainland-only") {
|
||||||
|
options.mainlandOnly = true;
|
||||||
} else if (arg === "--include-holes") {
|
} else if (arg === "--include-holes") {
|
||||||
options.includeHoles = true;
|
options.includeHoles = true;
|
||||||
} else if (arg === "--pretty") {
|
} else if (arg === "--pretty") {
|
||||||
@ -65,11 +84,27 @@ function parseArgs(argv) {
|
|||||||
if (!Number.isFinite(options.tolerance) || options.tolerance < 0) {
|
if (!Number.isFinite(options.tolerance) || options.tolerance < 0) {
|
||||||
throw new Error("--tolerance must be a number greater than or equal to 0.");
|
throw new Error("--tolerance must be a number greater than or equal to 0.");
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
!Number.isFinite(options.padding) ||
|
||||||
|
options.padding < 0 ||
|
||||||
|
options.padding >= 50
|
||||||
|
) {
|
||||||
|
throw new Error("--padding must be a number greater than or equal to 0 and less than 50.");
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
options.maxPoints !== null &&
|
options.maxPoints !== null &&
|
||||||
(!Number.isInteger(options.maxPoints) || options.maxPoints < 4)
|
(!Number.isInteger(options.maxPoints) || options.maxPoints < 4)
|
||||||
) {
|
) {
|
||||||
throw new Error("--max-points must be an integer greater than or equal to 4.");
|
throw new Error(
|
||||||
|
"--max-points must be an integer greater than or equal to 4.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Number.isFinite(options.minRingAreaRatio) ||
|
||||||
|
options.minRingAreaRatio < 0 ||
|
||||||
|
options.minRingAreaRatio > 1
|
||||||
|
) {
|
||||||
|
throw new Error("--min-ring-area-ratio must be a number between 0 and 1.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -85,7 +120,10 @@ function stripNulls(value) {
|
|||||||
|
|
||||||
function cleanProperties(properties) {
|
function cleanProperties(properties) {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(properties || {}).map(([key, value]) => [key, stripNulls(value)]),
|
Object.entries(properties || {}).map(([key, value]) => [
|
||||||
|
key,
|
||||||
|
stripNulls(value),
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,7 +175,47 @@ function cleanRing(ring) {
|
|||||||
return ring.filter(isValidCoordinate).map(([lon, lat]) => [lon, lat]);
|
return ring.filter(isValidCoordinate).map(([lon, lat]) => [lon, lat]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBounds(rings) {
|
function mercatorProject([lon, lat]) {
|
||||||
|
const clampedLat = Math.max(
|
||||||
|
-MAX_MERCATOR_LAT,
|
||||||
|
Math.min(MAX_MERCATOR_LAT, lat),
|
||||||
|
);
|
||||||
|
const lonRad = (lon * Math.PI) / 180;
|
||||||
|
const latRad = (clampedLat * Math.PI) / 180;
|
||||||
|
|
||||||
|
return [
|
||||||
|
lonRad,
|
||||||
|
Math.log(Math.tan(Math.PI / 4 + latRad / 2)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ringArea(ring) {
|
||||||
|
if (!Array.isArray(ring) || ring.length < 4) return 0;
|
||||||
|
let area = 0;
|
||||||
|
for (let i = 0; i < ring.length; i++) {
|
||||||
|
const [x1, y1] = ring[i];
|
||||||
|
const [x2, y2] = ring[(i + 1) % ring.length];
|
||||||
|
area += x1 * y2 - x2 * y1;
|
||||||
|
}
|
||||||
|
return Math.abs(area / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterGameplayRings(rings, options) {
|
||||||
|
const ranked = rings
|
||||||
|
.map((ring) => ({ ring, area: ringArea(ring) }))
|
||||||
|
.filter(({ area }) => area > 0)
|
||||||
|
.sort((a, b) => b.area - a.area);
|
||||||
|
|
||||||
|
if (!ranked.length) return rings;
|
||||||
|
if (options.mainlandOnly || options.keepLargestRingOnly) {
|
||||||
|
return [ranked[0].ring];
|
||||||
|
}
|
||||||
|
|
||||||
|
const minArea = ranked[0].area * options.minRingAreaRatio;
|
||||||
|
return ranked.filter(({ area }) => area >= minArea).map(({ ring }) => ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGeoBounds(rings) {
|
||||||
let minLon = Infinity;
|
let minLon = Infinity;
|
||||||
let maxLon = -Infinity;
|
let maxLon = -Infinity;
|
||||||
let minLat = Infinity;
|
let minLat = Infinity;
|
||||||
@ -159,18 +237,64 @@ function getBounds(rings) {
|
|||||||
return { minLon, maxLon, minLat, maxLat };
|
return { minLon, maxLon, minLat, maxLat };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getProjectedBounds(rings) {
|
||||||
|
let minX = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
|
||||||
|
for (const ring of rings) {
|
||||||
|
for (const [x, y] of ring) {
|
||||||
|
minX = Math.min(minX, x);
|
||||||
|
maxX = Math.max(maxX, x);
|
||||||
|
minY = Math.min(minY, y);
|
||||||
|
maxY = Math.max(maxY, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (![minX, maxX, minY, maxY].every(Number.isFinite)) {
|
||||||
|
throw new Error("Could not calculate projected bounds from geometry.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { minX, maxX, minY, maxY };
|
||||||
|
}
|
||||||
|
|
||||||
function roundCoord(value) {
|
function roundCoord(value) {
|
||||||
return Math.round(value * 100) / 100;
|
return Math.round(value * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePoint([lon, lat], bounds) {
|
function roundMeta(value) {
|
||||||
const lonSpan = bounds.maxLon - bounds.minLon;
|
return Math.round(value * 1_000_000) / 1_000_000;
|
||||||
const latSpan = bounds.maxLat - bounds.minLat;
|
}
|
||||||
|
|
||||||
|
function createProjection(bounds, padding) {
|
||||||
|
const xSpan = bounds.maxX - bounds.minX;
|
||||||
|
const ySpan = bounds.maxY - bounds.minY;
|
||||||
|
const usableSize = 100 - padding * 2;
|
||||||
|
const maxSpan = Math.max(xSpan, ySpan);
|
||||||
|
const scale = maxSpan === 0 ? 0 : usableSize / maxSpan;
|
||||||
|
const width = xSpan * scale;
|
||||||
|
const height = ySpan * scale;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x: roundCoord(lonSpan === 0 ? 50 : ((lon - bounds.minLon) / lonSpan) * 100),
|
padding,
|
||||||
|
scale,
|
||||||
|
xOffset: (100 - width) / 2,
|
||||||
|
yOffset: (100 - height) / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePoint([x, y], bounds, projection) {
|
||||||
|
return {
|
||||||
|
x: roundCoord(
|
||||||
|
projection.scale === 0
|
||||||
|
? 50
|
||||||
|
: projection.xOffset + (x - bounds.minX) * projection.scale,
|
||||||
|
),
|
||||||
y: roundCoord(
|
y: roundCoord(
|
||||||
latSpan === 0 ? 50 : (1 - (lat - bounds.minLat) / latSpan) * 100,
|
projection.scale === 0
|
||||||
|
? 50
|
||||||
|
: projection.yOffset + (bounds.maxY - y) * projection.scale,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -205,7 +329,8 @@ function squaredDistanceToSegment(point, start, end) {
|
|||||||
0,
|
0,
|
||||||
Math.min(
|
Math.min(
|
||||||
1,
|
1,
|
||||||
((point.x - start.x) * dx + (point.y - start.y) * dy) / (dx * dx + dy * dy),
|
((point.x - start.x) * dx + (point.y - start.y) * dy) /
|
||||||
|
(dx * dx + dy * dy),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const projection = {
|
const projection = {
|
||||||
@ -224,7 +349,11 @@ function douglasPeucker(points, tolerance) {
|
|||||||
const lastIndex = points.length - 1;
|
const lastIndex = points.length - 1;
|
||||||
|
|
||||||
for (let i = 1; i < lastIndex; i++) {
|
for (let i = 1; i < lastIndex; i++) {
|
||||||
const distance = squaredDistanceToSegment(points[i], points[0], points[lastIndex]);
|
const distance = squaredDistanceToSegment(
|
||||||
|
points[i],
|
||||||
|
points[0],
|
||||||
|
points[lastIndex],
|
||||||
|
);
|
||||||
if (distance > maxDistance) {
|
if (distance > maxDistance) {
|
||||||
maxDistance = distance;
|
maxDistance = distance;
|
||||||
splitIndex = i;
|
splitIndex = i;
|
||||||
@ -259,17 +388,25 @@ function normalizeGeometry(geometry, options) {
|
|||||||
throw new Error("No Polygon or MultiPolygon geometry found.");
|
throw new Error("No Polygon or MultiPolygon geometry found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const rings = extractRings(geometry, options.includeHoles)
|
const geoRings = filterGameplayRings(
|
||||||
.map(cleanRing)
|
extractRings(geometry, options.includeHoles)
|
||||||
.filter((ring) => ring.length >= 4);
|
.map(cleanRing)
|
||||||
|
.filter((ring) => ring.length >= 4),
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
if (!rings.length) {
|
if (!geoRings.length) {
|
||||||
throw new Error("No valid polygon rings found.");
|
throw new Error("No valid polygon rings found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const bounds = getBounds(rings);
|
const projectedRings = geoRings.map((ring) => ring.map(mercatorProject));
|
||||||
const normalizedRings = rings
|
const geoBounds = getGeoBounds(geoRings);
|
||||||
.map((ring) => ring.map((point) => normalizePoint(point, bounds)))
|
const projectedBounds = getProjectedBounds(projectedRings);
|
||||||
|
const projection = createProjection(projectedBounds, options.padding);
|
||||||
|
const normalizedRings = projectedRings
|
||||||
|
.map((ring) =>
|
||||||
|
ring.map((point) => normalizePoint(point, projectedBounds, projection)),
|
||||||
|
)
|
||||||
.map(closeRing)
|
.map(closeRing)
|
||||||
.map((ring) => simplifyRing(ring, options.tolerance, options.maxPoints))
|
.map((ring) => simplifyRing(ring, options.tolerance, options.maxPoints))
|
||||||
.filter((ring) => ring.length >= 4);
|
.filter((ring) => ring.length >= 4);
|
||||||
@ -279,8 +416,20 @@ function normalizeGeometry(geometry, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: geometry.type,
|
type: geoRings.length > 1 ? "MultiPolygon" : "Polygon",
|
||||||
bounds,
|
geoBounds,
|
||||||
|
projectedBounds: {
|
||||||
|
minX: roundMeta(projectedBounds.minX),
|
||||||
|
maxX: roundMeta(projectedBounds.maxX),
|
||||||
|
minY: roundMeta(projectedBounds.minY),
|
||||||
|
maxY: roundMeta(projectedBounds.maxY),
|
||||||
|
},
|
||||||
|
projection: {
|
||||||
|
padding: roundMeta(projection.padding),
|
||||||
|
scale: roundMeta(projection.scale),
|
||||||
|
xOffset: roundMeta(projection.xOffset),
|
||||||
|
yOffset: roundMeta(projection.yOffset),
|
||||||
|
},
|
||||||
rings: normalizedRings,
|
rings: normalizedRings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -288,7 +437,10 @@ function normalizeGeometry(geometry, options) {
|
|||||||
async function findShapefile(inputDir) {
|
async function findShapefile(inputDir) {
|
||||||
const entries = await readdir(inputDir, { withFileTypes: true });
|
const entries = await readdir(inputDir, { withFileTypes: true });
|
||||||
const shpFiles = entries
|
const shpFiles = entries
|
||||||
.filter((entry) => entry.isFile() && path.extname(entry.name).toLowerCase() === ".shp")
|
.filter(
|
||||||
|
(entry) =>
|
||||||
|
entry.isFile() && path.extname(entry.name).toLowerCase() === ".shp",
|
||||||
|
)
|
||||||
.map((entry) => entry.name)
|
.map((entry) => entry.name)
|
||||||
.sort((a, b) => a.localeCompare(b));
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
@ -305,7 +457,9 @@ async function findShapefile(inputDir) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function readTargetFeatures(shpPath, targets) {
|
async function readTargetFeatures(shpPath, targets) {
|
||||||
const source = await shapefile.open(shpPath, undefined, { encoding: "utf-8" });
|
const source = await shapefile.open(shpPath, undefined, {
|
||||||
|
encoding: "utf-8",
|
||||||
|
});
|
||||||
const features = new Map();
|
const features = new Map();
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
@ -327,7 +481,17 @@ async function readTargetFeatures(shpPath, targets) {
|
|||||||
return features;
|
return features;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeCountryOutline(outputDir, countryName, feature, sourceFile, options) {
|
async function writeCountryOutline(
|
||||||
|
outputDir,
|
||||||
|
countryName,
|
||||||
|
feature,
|
||||||
|
sourceFile,
|
||||||
|
options,
|
||||||
|
) {
|
||||||
|
const countryOptions = {
|
||||||
|
...options,
|
||||||
|
...(COUNTRY_OPTIONS[countryName] || {}),
|
||||||
|
};
|
||||||
const output = {
|
const output = {
|
||||||
source: sourceFile,
|
source: sourceFile,
|
||||||
country: {
|
country: {
|
||||||
@ -337,7 +501,7 @@ async function writeCountryOutline(outputDir, countryName, feature, sourceFile,
|
|||||||
continent: feature.properties.CONTINENT,
|
continent: feature.properties.CONTINENT,
|
||||||
subregion: feature.properties.SUBREGION,
|
subregion: feature.properties.SUBREGION,
|
||||||
},
|
},
|
||||||
outline: normalizeGeometry(feature.geometry, options),
|
outline: normalizeGeometry(feature.geometry, countryOptions),
|
||||||
};
|
};
|
||||||
const fileName = `${slugify(countryName)}.json`;
|
const fileName = `${slugify(countryName)}.json`;
|
||||||
const json = JSON.stringify(output, null, options.pretty ? 2 : 0);
|
const json = JSON.stringify(output, null, options.pretty ? 2 : 0);
|
||||||
@ -351,7 +515,9 @@ async function main() {
|
|||||||
const sourceFile = path.basename(shpPath);
|
const sourceFile = path.basename(shpPath);
|
||||||
const targets = new Set(GAME_COUNTRIES);
|
const targets = new Set(GAME_COUNTRIES);
|
||||||
const features = await readTargetFeatures(shpPath, targets);
|
const features = await readTargetFeatures(shpPath, targets);
|
||||||
const missing = GAME_COUNTRIES.filter((countryName) => !features.has(countryName));
|
const missing = GAME_COUNTRIES.filter(
|
||||||
|
(countryName) => !features.has(countryName),
|
||||||
|
);
|
||||||
|
|
||||||
if (missing.length) {
|
if (missing.length) {
|
||||||
throw new Error(`Missing country feature(s): ${missing.join(", ")}`);
|
throw new Error(`Missing country feature(s): ${missing.join(", ")}`);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user