Add PHP backend scaffolding #4
3
.gitignore
vendored
3
.gitignore
vendored
@ -1 +1,2 @@
|
||||
node_modules
|
||||
node_modules
|
||||
backend/data/
|
||||
|
||||
16
README.md
16
README.md
@ -18,7 +18,13 @@ The game supports single-player sessions with a local leaderboard tracking top s
|
||||
|
||||
## How to start
|
||||
|
||||
Open the `frontend/index.html` file in a browser. That's it.
|
||||
From the project root, run:
|
||||
|
||||
```powershell
|
||||
php serve.php
|
||||
```
|
||||
Then open `http://localhost:8000/` in a browser.
|
||||
You can override the port, but the default will be `8000`.
|
||||
|
||||
---
|
||||
|
||||
@ -95,7 +101,13 @@ The drawing part will likely be handled by WebGL and a canvas.
|
||||
|
||||
Your browser must support **WebGL**. If you are uncertain, [check this website](https://get.webgl.org/).
|
||||
|
||||
Open the `frontend/index.html` file in a browser. That's it.
|
||||
Start the app from the project root:
|
||||
|
||||
```powershell
|
||||
php serve.php
|
||||
```
|
||||
|
||||
Then open `http://localhost:8000/` in a browser.
|
||||
|
||||
### Backend
|
||||
|
||||
|
||||
238
backend/index.php
Normal file
238
backend/index.php
Normal file
@ -0,0 +1,238 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
|
||||
$dataDir = __DIR__ . DIRECTORY_SEPARATOR . 'data';
|
||||
$dataFile = $dataDir . DIRECTORY_SEPARATOR . 'lobbies.json';
|
||||
|
||||
if (!is_dir($dataDir)) {
|
||||
mkdir($dataDir, 0777, true);
|
||||
}
|
||||
|
||||
if (!file_exists($dataFile)) {
|
||||
file_put_contents($dataFile, "{}\n");
|
||||
}
|
||||
|
||||
function respond(array $payload, int $status = 200): void
|
||||
{
|
||||
http_response_code($status);
|
||||
echo json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
function read_body(): array
|
||||
{
|
||||
$raw = file_get_contents('php://input');
|
||||
if ($raw === false || trim($raw) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
function clean_name(mixed $value, int $maxLength = 40): string
|
||||
{
|
||||
$name = trim((string) $value);
|
||||
$name = preg_replace('/\s+/', ' ', $name) ?? '';
|
||||
return substr($name, 0, $maxLength);
|
||||
}
|
||||
|
||||
function clean_scores(mixed $value): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_slice(array_map(static function ($score): int {
|
||||
return max(0, min(100, (int) $score));
|
||||
}, $value), 0, 3);
|
||||
}
|
||||
|
||||
function clean_countries(mixed $value): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_slice(array_map(static function ($country): string {
|
||||
return clean_name($country, 80);
|
||||
}, $value), 0, 3);
|
||||
}
|
||||
|
||||
function with_lobbies(callable $callback, bool $write = false): mixed
|
||||
{
|
||||
global $dataFile;
|
||||
|
||||
$handle = fopen($dataFile, 'c+');
|
||||
if ($handle === false) {
|
||||
respond(['ok' => false, 'error' => 'Could not open data file.'], 500);
|
||||
}
|
||||
|
||||
flock($handle, $write ? LOCK_EX : LOCK_SH);
|
||||
rewind($handle);
|
||||
$raw = stream_get_contents($handle);
|
||||
$lobbies = json_decode($raw ?: '{}', true);
|
||||
if (!is_array($lobbies)) {
|
||||
$lobbies = [];
|
||||
}
|
||||
|
||||
$result = $callback($lobbies);
|
||||
|
||||
if ($write) {
|
||||
rewind($handle);
|
||||
ftruncate($handle, 0);
|
||||
fwrite($handle, json_encode($lobbies, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
|
||||
fflush($handle);
|
||||
}
|
||||
|
||||
flock($handle, LOCK_UN);
|
||||
fclose($handle);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
function ensure_lobby(array &$lobbies, string $lobbyName): array
|
||||
{
|
||||
if (!isset($lobbies[$lobbyName]) || !is_array($lobbies[$lobbyName])) {
|
||||
$lobbies[$lobbyName] = [
|
||||
'name' => $lobbyName,
|
||||
'players' => [],
|
||||
'leaderboard' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$lobbies[$lobbyName]['players'] = array_values($lobbies[$lobbyName]['players'] ?? []);
|
||||
$lobbies[$lobbyName]['leaderboard'] = array_values($lobbies[$lobbyName]['leaderboard'] ?? []);
|
||||
|
||||
return $lobbies[$lobbyName];
|
||||
}
|
||||
|
||||
$action = $_GET['action'] ?? '';
|
||||
$body = read_body();
|
||||
|
||||
if ($action === 'createLobby') {
|
||||
$lobbyName = clean_name($body['lobbyName'] ?? '');
|
||||
if ($lobbyName === '') {
|
||||
respond(['ok' => false, 'error' => 'Lobby name is required.'], 400);
|
||||
}
|
||||
|
||||
$lobby = with_lobbies(static function (array &$lobbies) use ($lobbyName): array {
|
||||
return ensure_lobby($lobbies, $lobbyName);
|
||||
}, true);
|
||||
|
||||
respond(['ok' => true, 'lobby' => ['name' => $lobby['name']]]);
|
||||
}
|
||||
|
||||
if ($action === 'joinLobby') {
|
||||
$lobbyName = clean_name($body['lobbyName'] ?? '');
|
||||
$playerName = clean_name($body['playerName'] ?? '', 24);
|
||||
if ($lobbyName === '' || $playerName === '') {
|
||||
respond(['ok' => false, 'error' => 'Lobby name and player name are required.'], 400);
|
||||
}
|
||||
|
||||
$lobby = with_lobbies(static function (array &$lobbies) use ($lobbyName, $playerName): array {
|
||||
ensure_lobby($lobbies, $lobbyName);
|
||||
if (!in_array($playerName, $lobbies[$lobbyName]['players'], true)) {
|
||||
$lobbies[$lobbyName]['players'][] = $playerName;
|
||||
}
|
||||
return $lobbies[$lobbyName];
|
||||
}, true);
|
||||
|
||||
respond(['ok' => true, 'lobby' => ['name' => $lobby['name'], 'players' => $lobby['players']]]);
|
||||
}
|
||||
|
||||
if ($action === 'leaveLobby') {
|
||||
$lobbyName = clean_name($body['lobbyName'] ?? '');
|
||||
$playerName = clean_name($body['playerName'] ?? '', 24);
|
||||
|
||||
with_lobbies(static function (array &$lobbies) use ($lobbyName, $playerName): void {
|
||||
if ($lobbyName === '' || $playerName === '' || !isset($lobbies[$lobbyName])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lobbies[$lobbyName]['players'] = array_values(array_filter(
|
||||
$lobbies[$lobbyName]['players'] ?? [],
|
||||
static fn ($name): bool => $name !== $playerName,
|
||||
));
|
||||
}, true);
|
||||
|
||||
respond(['ok' => true]);
|
||||
}
|
||||
|
||||
if ($action === 'submitScore') {
|
||||
$lobbyName = clean_name($body['lobbyName'] ?? '');
|
||||
$playerName = clean_name($body['playerName'] ?? '', 24);
|
||||
if ($lobbyName === '' || $playerName === '') {
|
||||
respond(['ok' => false, 'error' => 'Lobby name and player name are required.'], 400);
|
||||
}
|
||||
|
||||
$entry = [
|
||||
'playerName' => $playerName,
|
||||
'totalScore' => max(0, min(300, (int) ($body['totalScore'] ?? 0))),
|
||||
'scores' => clean_scores($body['scores'] ?? []),
|
||||
'countries' => clean_countries($body['countries'] ?? []),
|
||||
'date' => gmdate('c'),
|
||||
];
|
||||
|
||||
with_lobbies(static function (array &$lobbies) use ($lobbyName, $playerName, $entry): void {
|
||||
ensure_lobby($lobbies, $lobbyName);
|
||||
if (!in_array($playerName, $lobbies[$lobbyName]['players'], true)) {
|
||||
$lobbies[$lobbyName]['players'][] = $playerName;
|
||||
}
|
||||
|
||||
$lobbies[$lobbyName]['leaderboard'][] = $entry;
|
||||
usort(
|
||||
$lobbies[$lobbyName]['leaderboard'],
|
||||
static fn (array $a, array $b): int => ($b['totalScore'] ?? 0) <=> ($a['totalScore'] ?? 0),
|
||||
);
|
||||
$lobbies[$lobbyName]['leaderboard'] = array_slice($lobbies[$lobbyName]['leaderboard'], 0, 20);
|
||||
}, true);
|
||||
|
||||
respond(['ok' => true, 'entry' => $entry]);
|
||||
}
|
||||
|
||||
if ($action === 'getLeaderboard') {
|
||||
$lobbyName = clean_name($_GET['lobbyName'] ?? '');
|
||||
if ($lobbyName === '') {
|
||||
respond(['ok' => false, 'error' => 'Lobby name is required.'], 400);
|
||||
}
|
||||
|
||||
$leaderboard = with_lobbies(static function (array &$lobbies) use ($lobbyName): array {
|
||||
return $lobbies[$lobbyName]['leaderboard'] ?? [];
|
||||
});
|
||||
|
||||
respond(['ok' => true, 'leaderboard' => $leaderboard]);
|
||||
}
|
||||
|
||||
if ($action === 'getLobby') {
|
||||
$lobbyName = clean_name($_GET['lobbyName'] ?? '');
|
||||
if ($lobbyName === '') {
|
||||
respond(['ok' => false, 'error' => 'Lobby name is required.'], 400);
|
||||
}
|
||||
|
||||
$lobby = with_lobbies(static function (array &$lobbies) use ($lobbyName): array {
|
||||
if (!isset($lobbies[$lobbyName])) {
|
||||
return ['name' => $lobbyName, 'players' => []];
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => $lobbies[$lobbyName]['name'] ?? $lobbyName,
|
||||
'players' => array_values($lobbies[$lobbyName]['players'] ?? []),
|
||||
];
|
||||
});
|
||||
|
||||
respond(['ok' => true, 'lobby' => $lobby]);
|
||||
}
|
||||
|
||||
respond(['ok' => false, 'error' => 'Unknown action.'], 404);
|
||||
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 }
|
||||
]
|
||||
}
|
||||
]
|
||||
1557
frontend/data/outlines/australia.json
Normal file
1557
frontend/data/outlines/australia.json
Normal file
File diff suppressed because it is too large
Load Diff
1317
frontend/data/outlines/brazil.json
Normal file
1317
frontend/data/outlines/brazil.json
Normal file
File diff suppressed because it is too large
Load Diff
6233
frontend/data/outlines/canada.json
Normal file
6233
frontend/data/outlines/canada.json
Normal file
File diff suppressed because it is too large
Load Diff
1435
frontend/data/outlines/france.json
Normal file
1435
frontend/data/outlines/france.json
Normal file
File diff suppressed because it is too large
Load Diff
1455
frontend/data/outlines/germany.json
Normal file
1455
frontend/data/outlines/germany.json
Normal file
File diff suppressed because it is too large
Load Diff
1361
frontend/data/outlines/india.json
Normal file
1361
frontend/data/outlines/india.json
Normal file
File diff suppressed because it is too large
Load Diff
1159
frontend/data/outlines/italy.json
Normal file
1159
frontend/data/outlines/italy.json
Normal file
File diff suppressed because it is too large
Load Diff
1481
frontend/data/outlines/japan.json
Normal file
1481
frontend/data/outlines/japan.json
Normal file
File diff suppressed because it is too large
Load Diff
3159
frontend/data/outlines/norway.json
Normal file
3159
frontend/data/outlines/norway.json
Normal file
File diff suppressed because it is too large
Load Diff
791
frontend/data/outlines/switzerland.json
Normal file
791
frontend/data/outlines/switzerland.json
Normal file
@ -0,0 +1,791 @@
|
||||
{
|
||||
"source": "ne_10m_admin_0_countries.shp",
|
||||
"country": {
|
||||
"name": "Switzerland",
|
||||
"isoA2": "CH",
|
||||
"isoA3": "CHE",
|
||||
"continent": "Europe",
|
||||
"subregion": "Western Europe"
|
||||
},
|
||||
"outline": {
|
||||
"type": "Polygon",
|
||||
"geoBounds": {
|
||||
"minLon": 5.954809204000128,
|
||||
"maxLon": 10.466626831000013,
|
||||
"minLat": 45.82071848599999,
|
||||
"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": [
|
||||
[
|
||||
{
|
||||
"x": 93.75,
|
||||
"y": 48.73
|
||||
},
|
||||
{
|
||||
"x": 93.04,
|
||||
"y": 50.6
|
||||
},
|
||||
{
|
||||
"x": 93.26,
|
||||
"y": 51.83
|
||||
},
|
||||
{
|
||||
"x": 92.1,
|
||||
"y": 54.2
|
||||
},
|
||||
{
|
||||
"x": 92.62,
|
||||
"y": 55.16
|
||||
},
|
||||
{
|
||||
"x": 93.85,
|
||||
"y": 55.59
|
||||
},
|
||||
{
|
||||
"x": 93.56,
|
||||
"y": 58.03
|
||||
},
|
||||
{
|
||||
"x": 90.66,
|
||||
"y": 57.65
|
||||
},
|
||||
{
|
||||
"x": 89.48,
|
||||
"y": 56.96
|
||||
},
|
||||
{
|
||||
"x": 89.15,
|
||||
"y": 55.49
|
||||
},
|
||||
{
|
||||
"x": 86.61,
|
||||
"y": 56.13
|
||||
},
|
||||
{
|
||||
"x": 85.54,
|
||||
"y": 58.16
|
||||
},
|
||||
{
|
||||
"x": 85.76,
|
||||
"y": 60.03
|
||||
},
|
||||
{
|
||||
"x": 85.41,
|
||||
"y": 60.62
|
||||
},
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"x": 12.37,
|
||||
"y": 66.44
|
||||
},
|
||||
{
|
||||
"x": 8.99,
|
||||
"y": 69.3
|
||||
},
|
||||
{
|
||||
"x": 7.43,
|
||||
"y": 69.04
|
||||
},
|
||||
{
|
||||
"x": 6.08,
|
||||
"y": 69.53
|
||||
},
|
||||
{
|
||||
"x": 6.55,
|
||||
"y": 68.39
|
||||
},
|
||||
{
|
||||
"x": 6.07,
|
||||
"y": 67.24
|
||||
},
|
||||
{
|
||||
"x": 8.63,
|
||||
"y": 66.27
|
||||
},
|
||||
{
|
||||
"x": 9.54,
|
||||
"y": 63.08
|
||||
},
|
||||
{
|
||||
"x": 8.99,
|
||||
"y": 62.03
|
||||
},
|
||||
{
|
||||
"x": 7.94,
|
||||
"y": 61.38
|
||||
},
|
||||
{
|
||||
"x": 8.13,
|
||||
"y": 59.91
|
||||
},
|
||||
{
|
||||
"x": 9.72,
|
||||
"y": 57.63
|
||||
},
|
||||
{
|
||||
"x": 9.19,
|
||||
"y": 56.73
|
||||
},
|
||||
{
|
||||
"x": 15.25,
|
||||
"y": 51.69
|
||||
},
|
||||
{
|
||||
"x": 15.51,
|
||||
"y": 46.46
|
||||
},
|
||||
{
|
||||
"x": 18.56,
|
||||
"y": 45.25
|
||||
},
|
||||
{
|
||||
"x": 19.86,
|
||||
"y": 44.25
|
||||
},
|
||||
{
|
||||
"x": 20.33,
|
||||
"y": 42.62
|
||||
},
|
||||
{
|
||||
"x": 25.53,
|
||||
"y": 37.83
|
||||
},
|
||||
{
|
||||
"x": 25.58,
|
||||
"y": 36.53
|
||||
},
|
||||
{
|
||||
"x": 27.1,
|
||||
"y": 35.41
|
||||
},
|
||||
{
|
||||
"x": 26.46,
|
||||
"y": 34.3
|
||||
},
|
||||
{
|
||||
"x": 23.78,
|
||||
"y": 34.7
|
||||
},
|
||||
{
|
||||
"x": 24.94,
|
||||
"y": 32.66
|
||||
},
|
||||
{
|
||||
"x": 26.21,
|
||||
"y": 31.87
|
||||
},
|
||||
{
|
||||
"x": 25.86,
|
||||
"y": 30.81
|
||||
},
|
||||
{
|
||||
"x": 29.91,
|
||||
"y": 30.83
|
||||
},
|
||||
{
|
||||
"x": 29.67,
|
||||
"y": 32.12
|
||||
},
|
||||
{
|
||||
"x": 31.03,
|
||||
"y": 32.9
|
||||
},
|
||||
{
|
||||
"x": 34.31,
|
||||
"y": 32.28
|
||||
},
|
||||
{
|
||||
"x": 34.76,
|
||||
"y": 31.5
|
||||
},
|
||||
{
|
||||
"x": 34.47,
|
||||
"y": 30.78
|
||||
},
|
||||
{
|
||||
"x": 35.84,
|
||||
"y": 30.7
|
||||
},
|
||||
{
|
||||
"x": 35.69,
|
||||
"y": 30.07
|
||||
},
|
||||
{
|
||||
"x": 36.24,
|
||||
"y": 29.54
|
||||
},
|
||||
{
|
||||
"x": 35.8,
|
||||
"y": 29.27
|
||||
},
|
||||
{
|
||||
"x": 38.81,
|
||||
"y": 27.75
|
||||
},
|
||||
{
|
||||
"x": 39.25,
|
||||
"y": 27.7
|
||||
},
|
||||
{
|
||||
"x": 39,
|
||||
"y": 28.43
|
||||
},
|
||||
{
|
||||
"x": 38.28,
|
||||
"y": 28.63
|
||||
},
|
||||
{
|
||||
"x": 39.72,
|
||||
"y": 29.22
|
||||
},
|
||||
{
|
||||
"x": 41.34,
|
||||
"y": 28.88
|
||||
},
|
||||
{
|
||||
"x": 42.37,
|
||||
"y": 27.74
|
||||
},
|
||||
{
|
||||
"x": 43.9,
|
||||
"y": 27.96
|
||||
},
|
||||
{
|
||||
"x": 44.18,
|
||||
"y": 28.75
|
||||
},
|
||||
{
|
||||
"x": 46.71,
|
||||
"y": 28.75
|
||||
},
|
||||
{
|
||||
"x": 50.43,
|
||||
"y": 26.97
|
||||
},
|
||||
{
|
||||
"x": 51.52,
|
||||
"y": 27.15
|
||||
},
|
||||
{
|
||||
"x": 51.87,
|
||||
"y": 27.83
|
||||
},
|
||||
{
|
||||
"x": 52.8,
|
||||
"y": 28.15
|
||||
},
|
||||
{
|
||||
"x": 54.64,
|
||||
"y": 28.06
|
||||
},
|
||||
{
|
||||
"x": 54.9,
|
||||
"y": 27.43
|
||||
},
|
||||
{
|
||||
"x": 56.08,
|
||||
"y": 26.97
|
||||
},
|
||||
{
|
||||
"x": 56.83,
|
||||
"y": 27.91
|
||||
},
|
||||
{
|
||||
"x": 57.74,
|
||||
"y": 25.98
|
||||
},
|
||||
{
|
||||
"x": 56.97,
|
||||
"y": 25.78
|
||||
},
|
||||
{
|
||||
"x": 54.83,
|
||||
"y": 26.45
|
||||
},
|
||||
{
|
||||
"x": 53.52,
|
||||
"y": 25.71
|
||||
},
|
||||
{
|
||||
"x": 53.54,
|
||||
"y": 24.72
|
||||
},
|
||||
{
|
||||
"x": 54.43,
|
||||
"y": 24.04
|
||||
},
|
||||
{
|
||||
"x": 54.93,
|
||||
"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
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -142,44 +145,6 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="scripts/storage.js"></script>
|
||||
<script src="scripts/countries.js"></script>
|
||||
<script src="scripts/scoring.js"></script>
|
||||
<script src="scripts/drawing.js"></script>
|
||||
<script>
|
||||
// Init drawing on canvas element
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
Drawing.init(document.getElementById("draw-canvas"));
|
||||
|
||||
// Show player name
|
||||
document.getElementById("player-name-display").textContent = Storage.getPlayerName();
|
||||
|
||||
// Track drawing for placeholder hide
|
||||
const wrap = document.getElementById("canvas-wrap");
|
||||
document.getElementById("draw-canvas").addEventListener("pointerdown", () => {
|
||||
wrap.classList.add("has-drawing");
|
||||
});
|
||||
|
||||
// Update round pips when round changes (game.js calls this)
|
||||
window.updateRoundPips = function(round) {
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const pip = document.getElementById(`pip-${i}`);
|
||||
pip.className = "round-pip" +
|
||||
(i < round ? " done" : "") +
|
||||
(i === round ? " active" : "");
|
||||
}
|
||||
};
|
||||
|
||||
// Update score display in sidebar
|
||||
window.updateScoreDisplay = function(roundIdx, score) {
|
||||
const el = document.getElementById(`score-r${roundIdx + 1}`);
|
||||
if (el) {
|
||||
el.textContent = score + "%";
|
||||
el.classList.add("filled");
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
<script src="scripts/game.js"></script>
|
||||
<script type="module" src="scripts/game.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
<nav class="nav" aria-label="Main navigation">
|
||||
<a href="#hero" class="nav__link">Play</a>
|
||||
<a href="#about" class="nav__link">How to play</a>
|
||||
<a href="#register" class="nav__cta">Login / Register</a>
|
||||
<a href="#register" class="nav__cta">Create lobby</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
@ -165,11 +165,6 @@
|
||||
<input id="username" type="text" placeholder="myLobby" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" type="password" placeholder="1234" autocomplete="new-password" />
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn--primary btn--full" id="reg-btn">
|
||||
Create game
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
@ -204,7 +199,6 @@
|
||||
|
||||
</div><!-- /wrap -->
|
||||
|
||||
<script src="scripts/storage.js"></script>
|
||||
<script src="scripts/index.js"></script>
|
||||
<script type="module" src="scripts/index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@ -89,7 +89,6 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="scripts/storage.js"></script>
|
||||
<script src="scripts/leaderboard.js"></script>
|
||||
<script type="module" src="scripts/leaderboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -114,7 +114,6 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="scripts/storage.js"></script>
|
||||
<script src="scripts/lobby.js"></script>
|
||||
<script type="module" src="scripts/lobby.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
354
frontend/package-lock.json
generated
354
frontend/package-lock.json
generated
@ -1,179 +1,179 @@
|
||||
{
|
||||
"name": "fs2026-frontend",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "fs2026-frontend",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/biome": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.14.tgz",
|
||||
"integrity": "sha512-QMT6QviX0WqXJCaiqVMiBUCr5WRQ1iFSjvOLoTk6auKukJMvnMzWucXpwZB0e8F00/1/BsS9DzcKgWH+CLqVuA==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"bin": {
|
||||
"biome": "bin/biome"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/biome"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@biomejs/cli-darwin-arm64": "2.3.14",
|
||||
"@biomejs/cli-darwin-x64": "2.3.14",
|
||||
"@biomejs/cli-linux-arm64": "2.3.14",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.3.14",
|
||||
"@biomejs/cli-linux-x64": "2.3.14",
|
||||
"@biomejs/cli-linux-x64-musl": "2.3.14",
|
||||
"@biomejs/cli-win32-arm64": "2.3.14",
|
||||
"@biomejs/cli-win32-x64": "2.3.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.14.tgz",
|
||||
"integrity": "sha512-UJGPpvWJMkLxSRtpCAKfKh41Q4JJXisvxZL8ChN1eNW3m/WlPFJ6EFDCE7YfUb4XS8ZFi3C1dFpxUJ0Ety5n+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-x64": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.14.tgz",
|
||||
"integrity": "sha512-PNkLNQG6RLo8lG7QoWe/hhnMxJIt1tEimoXpGQjwS/dkdNiKBLPv4RpeQl8o3s1OKI3ZOR5XPiYtmbGGHAOnLA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.14.tgz",
|
||||
"integrity": "sha512-KT67FKfzIw6DNnUNdYlBg+eU24Go3n75GWK6NwU4+yJmDYFe9i/MjiI+U/iEzKvo0g7G7MZqoyrhIYuND2w8QQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.14.tgz",
|
||||
"integrity": "sha512-LInRbXhYujtL3sH2TMCH/UBwJZsoGwfQjBrMfl84CD4hL/41C/EU5mldqf1yoFpsI0iPWuU83U+nB2TUUypWeg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.14.tgz",
|
||||
"integrity": "sha512-ZsZzQsl9U+wxFrGGS4f6UxREUlgHwmEfu1IrXlgNFrNnd5Th6lIJr8KmSzu/+meSa9f4rzFrbEW9LBBA6ScoMA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.14.tgz",
|
||||
"integrity": "sha512-KQU7EkbBBuHPW3/rAcoiVmhlPtDSGOGRPv9js7qJVpYTzjQmVR+C9Rfcz+ti8YCH+zT1J52tuBybtP4IodjxZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-arm64": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.14.tgz",
|
||||
"integrity": "sha512-+IKYkj/pUBbnRf1G1+RlyA3LWiDgra1xpS7H2g4BuOzzRbRB+hmlw0yFsLprHhbbt7jUzbzAbAjK/Pn0FDnh1A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-x64": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.14.tgz",
|
||||
"integrity": "sha512-oizCjdyQ3WJEswpb3Chdngeat56rIdSYK12JI3iI11Mt5T5EXcZ7WLuowzEaFPNJ3zmOQFliMN8QY1Pi+qsfdQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
"name": "fs2026-frontend",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "fs2026-frontend",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/biome": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.14.tgz",
|
||||
"integrity": "sha512-QMT6QviX0WqXJCaiqVMiBUCr5WRQ1iFSjvOLoTk6auKukJMvnMzWucXpwZB0e8F00/1/BsS9DzcKgWH+CLqVuA==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"bin": {
|
||||
"biome": "bin/biome"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/biome"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@biomejs/cli-darwin-arm64": "2.3.14",
|
||||
"@biomejs/cli-darwin-x64": "2.3.14",
|
||||
"@biomejs/cli-linux-arm64": "2.3.14",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.3.14",
|
||||
"@biomejs/cli-linux-x64": "2.3.14",
|
||||
"@biomejs/cli-linux-x64-musl": "2.3.14",
|
||||
"@biomejs/cli-win32-arm64": "2.3.14",
|
||||
"@biomejs/cli-win32-x64": "2.3.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.14.tgz",
|
||||
"integrity": "sha512-UJGPpvWJMkLxSRtpCAKfKh41Q4JJXisvxZL8ChN1eNW3m/WlPFJ6EFDCE7YfUb4XS8ZFi3C1dFpxUJ0Ety5n+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-x64": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.14.tgz",
|
||||
"integrity": "sha512-PNkLNQG6RLo8lG7QoWe/hhnMxJIt1tEimoXpGQjwS/dkdNiKBLPv4RpeQl8o3s1OKI3ZOR5XPiYtmbGGHAOnLA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.14.tgz",
|
||||
"integrity": "sha512-KT67FKfzIw6DNnUNdYlBg+eU24Go3n75GWK6NwU4+yJmDYFe9i/MjiI+U/iEzKvo0g7G7MZqoyrhIYuND2w8QQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.14.tgz",
|
||||
"integrity": "sha512-LInRbXhYujtL3sH2TMCH/UBwJZsoGwfQjBrMfl84CD4hL/41C/EU5mldqf1yoFpsI0iPWuU83U+nB2TUUypWeg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.14.tgz",
|
||||
"integrity": "sha512-ZsZzQsl9U+wxFrGGS4f6UxREUlgHwmEfu1IrXlgNFrNnd5Th6lIJr8KmSzu/+meSa9f4rzFrbEW9LBBA6ScoMA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.14.tgz",
|
||||
"integrity": "sha512-KQU7EkbBBuHPW3/rAcoiVmhlPtDSGOGRPv9js7qJVpYTzjQmVR+C9Rfcz+ti8YCH+zT1J52tuBybtP4IodjxZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-arm64": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.14.tgz",
|
||||
"integrity": "sha512-+IKYkj/pUBbnRf1G1+RlyA3LWiDgra1xpS7H2g4BuOzzRbRB+hmlw0yFsLprHhbbt7jUzbzAbAjK/Pn0FDnh1A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-x64": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.14.tgz",
|
||||
"integrity": "sha512-oizCjdyQ3WJEswpb3Chdngeat56rIdSYK12JI3iI11Mt5T5EXcZ7WLuowzEaFPNJ3zmOQFliMN8QY1Pi+qsfdQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,12 +4,13 @@
|
||||
"description": "",
|
||||
"license": "ISC",
|
||||
"author": "",
|
||||
"type": "commonjs",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"format": "biome format --write",
|
||||
"lint": "biome lint",
|
||||
"lint:fix": "biome lint --write"
|
||||
"lint:fix": "biome lint --write",
|
||||
"test:scoring": "node scripts/scoring.test.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.14"
|
||||
|
||||
@ -96,8 +96,6 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="scripts/storage.js"></script>
|
||||
<script src="scripts/scoring.js"></script>
|
||||
<script src="scripts/results.js"></script>
|
||||
<script type="module" src="scripts/results.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
57
frontend/scripts/api.js
Normal file
57
frontend/scripts/api.js
Normal file
@ -0,0 +1,57 @@
|
||||
// api.js - small backend client for the static frontend
|
||||
|
||||
const BASE_URL = `${window.location.origin}/backend/index.php`;
|
||||
|
||||
async function request(action, payload = null) {
|
||||
const options = payload
|
||||
? {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
: {};
|
||||
const response = await fetch(`${BASE_URL}?action=${action}`, options);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.ok) {
|
||||
throw new Error(data.error || "Backend request failed.");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function get(action, params = {}) {
|
||||
const query = new URLSearchParams({ action, ...params });
|
||||
return fetch(`${BASE_URL}?${query.toString()}`)
|
||||
.then((response) => response.json().then((data) => ({ response, data })))
|
||||
.then(({ response, data }) => {
|
||||
if (!response.ok || !data.ok) {
|
||||
throw new Error(data.error || "Backend request failed.");
|
||||
}
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
export function createLobby(lobbyName) {
|
||||
return request("createLobby", { lobbyName });
|
||||
}
|
||||
|
||||
export function joinLobby(lobbyName, playerName) {
|
||||
return request("joinLobby", { lobbyName, playerName });
|
||||
}
|
||||
|
||||
export function leaveLobby(lobbyName, playerName) {
|
||||
return request("leaveLobby", { lobbyName, playerName });
|
||||
}
|
||||
|
||||
export function submitScore(payload) {
|
||||
return request("submitScore", payload);
|
||||
}
|
||||
|
||||
export function getLeaderboard(lobbyName) {
|
||||
return get("getLeaderboard", { lobbyName });
|
||||
}
|
||||
|
||||
export function getLobby(lobbyName) {
|
||||
return get("getLobby", { lobbyName });
|
||||
}
|
||||
@ -1,136 +1,227 @@
|
||||
// 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, 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
|
||||
*/
|
||||
|
||||
const Countries = (() => {
|
||||
/** @type {Country[]} */
|
||||
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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
const MAX_MERCATOR_LAT = 85.05112878;
|
||||
|
||||
/** @type {Country[]} */
|
||||
let _data = [];
|
||||
/** @type {Country[]} */
|
||||
let data = [];
|
||||
|
||||
/**
|
||||
* Load country data into memory. Safe to call multiple times.
|
||||
* Returns a Promise for future compatibility with a real API fetch.
|
||||
* @returns {Promise<Country[]>}
|
||||
*/
|
||||
function loadCountries() {
|
||||
if (!_data.length) _data = COUNTRIES_DATA;
|
||||
return Promise.resolve(_data);
|
||||
/**
|
||||
* Load country data and outlines into memory. Safe to call multiple times.
|
||||
* @returns {Promise<Country[]>}
|
||||
*/
|
||||
export async function loadCountries() {
|
||||
if (data.length) return 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.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a random subset of countries.
|
||||
* @param {number} count
|
||||
* @returns {Country[]}
|
||||
*/
|
||||
function getRandomCountries(count = 3) {
|
||||
return [..._data].sort(() => Math.random() - 0.5).slice(0, count);
|
||||
const countries = await response.json();
|
||||
if (!Array.isArray(countries)) {
|
||||
throw new Error("Country metadata must be an array.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get city list for a specific country by name.
|
||||
* @param {string} countryName
|
||||
* @returns {City[]}
|
||||
*/
|
||||
function getCities(countryName) {
|
||||
const country = _data.find((c) => c.name === countryName);
|
||||
return country ? country.cities : [];
|
||||
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.`);
|
||||
}
|
||||
|
||||
return { loadCountries, getRandomCountries, getCities };
|
||||
})();
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a random subset of countries.
|
||||
* @param {number} count
|
||||
* @returns {Country[]}
|
||||
*/
|
||||
export function getRandomCountries(count = 3) {
|
||||
return [...data].sort(() => Math.random() - 0.5).slice(0, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get city list for a specific country by name.
|
||||
* @param {string} countryName
|
||||
* @returns {City[]}
|
||||
*/
|
||||
export function getCities(countryName) {
|
||||
const country = data.find((c) => c.name === countryName);
|
||||
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));
|
||||
}
|
||||
|
||||
@ -1,172 +1,286 @@
|
||||
// drawing.js — canvas drawing module
|
||||
|
||||
const Drawing = (() => {
|
||||
/** @type {HTMLCanvasElement} */
|
||||
let canvas;
|
||||
/** @type {CanvasRenderingContext2D} */
|
||||
let ctx;
|
||||
/** @type {HTMLCanvasElement} */
|
||||
let canvas;
|
||||
/** @type {CanvasRenderingContext2D} */
|
||||
let ctx;
|
||||
|
||||
let isDrawing = false;
|
||||
/** @type {{ x: number, y: number }[]} */
|
||||
let points = [];
|
||||
/** @type {{ name: string, x: number, y: number }[]} */
|
||||
let cities = [];
|
||||
let isDrawing = false;
|
||||
/** @type {{ x: number, y: number }[]} */
|
||||
let currentStroke = [];
|
||||
/** @type {{ x: number, y: number }[][]} */
|
||||
let strokes = [];
|
||||
/** @type {{ name: string, x: number, y: number }[]} */
|
||||
let cities = [];
|
||||
/** @type {{ rings: { x: number, y: number }[][] } | null} */
|
||||
let referenceOutline = null;
|
||||
|
||||
const STROKE_COLOR = "#1a7fc4";
|
||||
const STROKE_WIDTH = 2.5;
|
||||
const STROKE_COLOR = "#1a7fc4";
|
||||
const STROKE_WIDTH = 2.5;
|
||||
|
||||
/**
|
||||
* Initialise the drawing module on a canvas element.
|
||||
* @param {HTMLCanvasElement} canvasEl
|
||||
*/
|
||||
function init(canvasEl) {
|
||||
canvas = canvasEl;
|
||||
ctx = canvas.getContext("2d");
|
||||
/**
|
||||
* Initialise the drawing module on a canvas element.
|
||||
* @param {HTMLCanvasElement} canvasEl
|
||||
*/
|
||||
export function init(canvasEl) {
|
||||
canvas = canvasEl;
|
||||
ctx = canvas.getContext("2d");
|
||||
|
||||
canvas.addEventListener("pointerdown", onDown);
|
||||
canvas.addEventListener("pointermove", onMove);
|
||||
canvas.addEventListener("pointerup", onUp);
|
||||
canvas.addEventListener("pointerleave", onUp);
|
||||
canvas.style.touchAction = "none";
|
||||
canvas.addEventListener("pointerdown", onDown);
|
||||
canvas.addEventListener("pointermove", onMove);
|
||||
canvas.addEventListener("pointerup", onUp);
|
||||
canvas.addEventListener("pointerleave", onUp);
|
||||
canvas.style.touchAction = "none";
|
||||
|
||||
_resize();
|
||||
window.addEventListener("resize", _resize);
|
||||
}
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
}
|
||||
|
||||
/** Resize canvas to match its CSS size, accounting for device pixel ratio. */
|
||||
function _resize() {
|
||||
if (!canvas) return;
|
||||
const { width, height } = canvas.getBoundingClientRect();
|
||||
canvas.width = width * window.devicePixelRatio;
|
||||
canvas.height = height * window.devicePixelRatio;
|
||||
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
|
||||
_redraw();
|
||||
}
|
||||
/** Resize canvas to match its CSS size, accounting for device pixel ratio. */
|
||||
function resize() {
|
||||
if (!canvas) return;
|
||||
const { width, height } = canvas.getBoundingClientRect();
|
||||
canvas.width = width * window.devicePixelRatio;
|
||||
canvas.height = height * window.devicePixelRatio;
|
||||
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
|
||||
redraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a pointer event to canvas-local coordinates.
|
||||
* @param {PointerEvent} e
|
||||
* @returns {{ x: number, y: number }}
|
||||
*/
|
||||
function _pos(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||
}
|
||||
/**
|
||||
* Convert a pointer event to canvas-local coordinates.
|
||||
* @param {PointerEvent} e
|
||||
* @returns {{ x: number, y: number }}
|
||||
*/
|
||||
function pos(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||
}
|
||||
|
||||
/** @param {PointerEvent} e */
|
||||
function onDown(e) {
|
||||
e.preventDefault();
|
||||
isDrawing = true;
|
||||
const p = _pos(e);
|
||||
points.push(p);
|
||||
/** @param {PointerEvent} e */
|
||||
function onDown(e) {
|
||||
e.preventDefault();
|
||||
isDrawing = true;
|
||||
const p = pos(e);
|
||||
currentStroke = [p];
|
||||
strokes.push(currentStroke);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p.x, p.y);
|
||||
}
|
||||
|
||||
/** @param {PointerEvent} e */
|
||||
function onMove(e) {
|
||||
if (!isDrawing) return;
|
||||
e.preventDefault();
|
||||
const p = pos(e);
|
||||
currentStroke.push(p);
|
||||
ctx.lineTo(p.x, p.y);
|
||||
ctx.strokeStyle = STROKE_COLOR;
|
||||
ctx.lineWidth = STROKE_WIDTH;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineCap = "round";
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/** @param {PointerEvent} e */
|
||||
function onUp(e) {
|
||||
if (!isDrawing) return;
|
||||
isDrawing = false;
|
||||
currentStroke = [];
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
/** Clear the canvas and redraw city markers. */
|
||||
export function clear() {
|
||||
strokes = [];
|
||||
currentStroke = [];
|
||||
referenceOutline = null;
|
||||
if (!ctx) return;
|
||||
const { width, height } = canvas.getBoundingClientRect();
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
drawCities();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set city markers to display on the canvas.
|
||||
* @param {{ name: string, x: number, y: number }[]} cityList - Coords in percent (0–100).
|
||||
*/
|
||||
export function setCities(cityList) {
|
||||
cities = cityList || [];
|
||||
drawCities();
|
||||
}
|
||||
|
||||
/** Render all city markers with labels. */
|
||||
function drawCities() {
|
||||
if (!ctx || !cities.length) return;
|
||||
const viewport = getCanvasViewport();
|
||||
|
||||
cities.forEach((city) => {
|
||||
const { x: cx, y: cy } = toCanvasPoint(city, viewport);
|
||||
|
||||
// Dot
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p.x, p.y);
|
||||
}
|
||||
ctx.arc(cx, cy, 5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "rgba(240,180,40,0.9)";
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.9)";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
/** @param {PointerEvent} e */
|
||||
function onMove(e) {
|
||||
if (!isDrawing) return;
|
||||
e.preventDefault();
|
||||
const p = _pos(e);
|
||||
points.push(p);
|
||||
ctx.lineTo(p.x, p.y);
|
||||
// White pill label background
|
||||
ctx.font = "bold 11px 'DM Sans', sans-serif";
|
||||
const textW = ctx.measureText(city.name).width + 10;
|
||||
const textH = 16;
|
||||
const tx = cx;
|
||||
const ty = cy - 14;
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = "rgba(255,255,255,0.88)";
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(tx - textW / 2, ty - textH / 2 - 1, textW, textH, 4);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
ctx.fillStyle = "#0b1f2a";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(city.name, tx, ty + 4);
|
||||
});
|
||||
}
|
||||
|
||||
/** Redraw all stored strokes. */
|
||||
function redraw() {
|
||||
drawCities();
|
||||
for (const stroke of strokes) {
|
||||
if (!stroke.length) continue;
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = STROKE_COLOR;
|
||||
ctx.lineWidth = STROKE_WIDTH;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineCap = "round";
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/** @param {PointerEvent} e */
|
||||
function onUp(e) {
|
||||
if (!isDrawing) return;
|
||||
isDrawing = false;
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
/** Clear the canvas and redraw city markers. */
|
||||
function clear() {
|
||||
points = [];
|
||||
if (!ctx) return;
|
||||
const { width, height } = canvas.getBoundingClientRect();
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
_drawCities();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set city markers to display on the canvas.
|
||||
* @param {{ name: string, x: number, y: number }[]} cityList - Coords in percent (0–100).
|
||||
*/
|
||||
function setCities(cityList) {
|
||||
cities = cityList || [];
|
||||
_drawCities();
|
||||
}
|
||||
|
||||
/** Render all city markers with labels. */
|
||||
function _drawCities() {
|
||||
if (!ctx || !cities.length) return;
|
||||
const { width, height } = canvas.getBoundingClientRect();
|
||||
|
||||
cities.forEach((city) => {
|
||||
const cx = (city.x / 100) * width;
|
||||
const cy = (city.y / 100) * height;
|
||||
|
||||
// Dot
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, 5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "rgba(240,180,40,0.9)";
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.9)";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// White pill label background
|
||||
ctx.font = "bold 11px 'DM Sans', sans-serif";
|
||||
const textW = ctx.measureText(city.name).width + 10;
|
||||
const textH = 16;
|
||||
const tx = cx;
|
||||
const ty = cy - 14;
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = "rgba(255,255,255,0.88)";
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(tx - textW / 2, ty - textH / 2 - 1, textW, textH, 4);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
ctx.fillStyle = "#0b1f2a";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(city.name, tx, ty + 4);
|
||||
stroke.forEach((p, i) => {
|
||||
if (i === 0) {
|
||||
ctx.moveTo(p.x, p.y);
|
||||
} else {
|
||||
ctx.lineTo(p.x, p.y);
|
||||
}
|
||||
});
|
||||
ctx.stroke();
|
||||
}
|
||||
drawReferenceOutline();
|
||||
}
|
||||
|
||||
/** Redraw the full stroke from stored points. */
|
||||
function _redraw() {
|
||||
_drawCities();
|
||||
if (!points.length) return;
|
||||
/**
|
||||
* Return a flattened copy of all drawn points.
|
||||
* @returns {{ x: number, y: number }[]}
|
||||
*/
|
||||
export function getPoints() {
|
||||
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();
|
||||
ctx.strokeStyle = STROKE_COLOR;
|
||||
ctx.lineWidth = STROKE_WIDTH;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineCap = "round";
|
||||
points.forEach((p, i) =>
|
||||
i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y),
|
||||
);
|
||||
ring.forEach((point, index) => {
|
||||
const { x, y } = toCanvasPoint(point, viewport);
|
||||
if (index === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
});
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a copy of the current drawn points.
|
||||
* @returns {{ x: number, y: number }[]}
|
||||
*/
|
||||
function getPoints() {
|
||||
return [...points];
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/** Remove event listeners and clean up. */
|
||||
function destroy() {
|
||||
window.removeEventListener("resize", _resize);
|
||||
}
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
|
||||
return { init, clear, setCities, getPoints, destroy };
|
||||
})();
|
||||
/**
|
||||
* @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. */
|
||||
export function destroy() {
|
||||
window.removeEventListener("resize", resize);
|
||||
}
|
||||
|
||||
@ -1,4 +1,16 @@
|
||||
// game.js — round management, timer, submit
|
||||
// game.js - round management, timer, submit
|
||||
|
||||
import { submitScore } from "./api.js";
|
||||
import { getRandomCountries, loadCountries } from "./countries.js";
|
||||
import {
|
||||
clear,
|
||||
getNormalizedRings,
|
||||
init as initDrawing,
|
||||
setCities,
|
||||
showReferenceOutline,
|
||||
} from "./drawing.js";
|
||||
import { calculateScore, getGrade } from "./scoring.js";
|
||||
import { getLobbyName, getPlayerName, saveGameState } from "./storage.js";
|
||||
|
||||
const TOTAL_ROUNDS = 3;
|
||||
const ROUND_DURATION = 60; // seconds
|
||||
@ -11,29 +23,49 @@ let scores = [];
|
||||
let timerInterval = null;
|
||||
let timeLeft = ROUND_DURATION;
|
||||
|
||||
// ── DOM refs
|
||||
const elCountryName = document.getElementById("country-name");
|
||||
const elCountryHint = document.getElementById("country-hint");
|
||||
const elRoundNum = document.getElementById("round-num");
|
||||
const elTimerNum = document.getElementById("timer-num");
|
||||
const elTimerBar = document.getElementById("timer-bar");
|
||||
const elTimerWrap = document.querySelector(".game-timer");
|
||||
const elBtnClear = document.getElementById("btn-clear");
|
||||
const elBtnSubmit = document.getElementById("btn-submit");
|
||||
let elCountryName;
|
||||
let elCountryHint;
|
||||
let elRoundNum;
|
||||
let elTimerNum;
|
||||
let elTimerBar;
|
||||
let elTimerWrap;
|
||||
let elBtnClear;
|
||||
let elBtnSubmit;
|
||||
let elBtnNext;
|
||||
let roundSubmitted = false;
|
||||
|
||||
// ── Init
|
||||
|
||||
/** Load countries and start the first round. */
|
||||
/** Initialise DOM refs, drawing, events, countries, and the first round. */
|
||||
async function initGame() {
|
||||
await Countries.loadCountries();
|
||||
roundCountries = Countries.getRandomCountries(TOTAL_ROUNDS);
|
||||
elCountryName = document.getElementById("country-name");
|
||||
elCountryHint = document.getElementById("country-hint");
|
||||
elRoundNum = document.getElementById("round-num");
|
||||
elTimerNum = document.getElementById("timer-num");
|
||||
elTimerBar = document.getElementById("timer-bar");
|
||||
elTimerWrap = document.querySelector(".game-timer");
|
||||
elBtnClear = document.getElementById("btn-clear");
|
||||
elBtnSubmit = document.getElementById("btn-submit");
|
||||
elBtnNext = document.getElementById("btn-next");
|
||||
|
||||
const canvas = document.getElementById("draw-canvas");
|
||||
const wrap = document.getElementById("canvas-wrap");
|
||||
initDrawing(canvas);
|
||||
|
||||
document.getElementById("player-name-display").textContent = getPlayerName();
|
||||
canvas.addEventListener("pointerdown", () => {
|
||||
wrap.classList.add("has-drawing");
|
||||
});
|
||||
|
||||
elBtnClear.addEventListener("click", () => clear());
|
||||
elBtnSubmit.addEventListener("click", () => submitRound(false));
|
||||
elBtnNext.addEventListener("click", goToNextRound);
|
||||
|
||||
await loadCountries();
|
||||
roundCountries = getRandomCountries(TOTAL_ROUNDS);
|
||||
currentRound = 0;
|
||||
scores = [];
|
||||
startRound();
|
||||
}
|
||||
|
||||
// ── Round
|
||||
|
||||
/** Set up UI and timer for the current round. */
|
||||
function startRound() {
|
||||
const country = roundCountries[currentRound];
|
||||
@ -42,14 +74,14 @@ function startRound() {
|
||||
elCountryName.textContent = country.name;
|
||||
elCountryHint.textContent = country.hint || "";
|
||||
|
||||
if (typeof window.updateRoundPips === "function") {
|
||||
window.updateRoundPips(currentRound + 1);
|
||||
}
|
||||
updateRoundPips(currentRound + 1);
|
||||
|
||||
document.getElementById("canvas-wrap")?.classList.remove("has-drawing");
|
||||
hideScoreFeedback();
|
||||
|
||||
Drawing.clear();
|
||||
Drawing.setCities(country.cities || []);
|
||||
setCities(country.cities || []);
|
||||
clear();
|
||||
roundSubmitted = false;
|
||||
|
||||
timeLeft = ROUND_DURATION;
|
||||
updateTimerUI();
|
||||
@ -57,10 +89,12 @@ function startRound() {
|
||||
timerInterval = setInterval(tickTimer, 1000);
|
||||
|
||||
elBtnSubmit.disabled = false;
|
||||
elBtnSubmit.textContent =
|
||||
currentRound < TOTAL_ROUNDS - 1
|
||||
? "Submit & Next Round →"
|
||||
: "Submit & See Results →";
|
||||
elBtnSubmit.hidden = false;
|
||||
elBtnSubmit.textContent = "Submit";
|
||||
elBtnClear.disabled = false;
|
||||
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. */
|
||||
@ -89,55 +123,71 @@ 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.
|
||||
*/
|
||||
function submitRound(auto = false) {
|
||||
clearInterval(timerInterval);
|
||||
elBtnSubmit.disabled = true;
|
||||
if (roundSubmitted) return;
|
||||
|
||||
const points = Drawing.getPoints();
|
||||
const score = Scoring.calculateScore(points);
|
||||
clearInterval(timerInterval);
|
||||
roundSubmitted = true;
|
||||
elBtnSubmit.disabled = true;
|
||||
elBtnClear.disabled = true;
|
||||
|
||||
const country = roundCountries[currentRound];
|
||||
const rings = getNormalizedRings();
|
||||
const score = calculateScore(rings, country.outline);
|
||||
scores.push(score);
|
||||
|
||||
if (typeof window.updateScoreDisplay === "function") {
|
||||
window.updateScoreDisplay(currentRound, score);
|
||||
}
|
||||
|
||||
showReferenceOutline(country.outline);
|
||||
updateScoreDisplay(currentRound, score);
|
||||
showScoreFeedback(score);
|
||||
|
||||
setTimeout(
|
||||
() => {
|
||||
if (currentRound < TOTAL_ROUNDS - 1) {
|
||||
currentRound++;
|
||||
startRound();
|
||||
} else {
|
||||
finishGame();
|
||||
}
|
||||
},
|
||||
auto ? 400 : 1200,
|
||||
);
|
||||
elBtnNext.disabled = false;
|
||||
elBtnNext.focus();
|
||||
if (auto) {
|
||||
elBtnNext.textContent =
|
||||
currentRound < TOTAL_ROUNDS - 1
|
||||
? "Time's up - Next Round"
|
||||
: "Time's up - See Results";
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
*/
|
||||
function showScoreFeedback(score) {
|
||||
const grade = Scoring.getGrade(score);
|
||||
const grade = getGrade(score);
|
||||
const el = document.getElementById("score-feedback");
|
||||
el.textContent = `${score}% ${grade.label}`;
|
||||
el.style.color = grade.color;
|
||||
el.style.opacity = "1";
|
||||
el.style.transform = "translateY(0)";
|
||||
setTimeout(() => {
|
||||
el.style.opacity = "0";
|
||||
el.style.transform = "translateY(-10px)";
|
||||
}, 900);
|
||||
}
|
||||
|
||||
/** Hide the score grade overlay. */
|
||||
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. */
|
||||
function finishGame() {
|
||||
async function finishGame() {
|
||||
const totalScore = scores.reduce((sum, s) => sum + s, 0);
|
||||
const state = {
|
||||
currentRound: TOTAL_ROUNDS,
|
||||
@ -145,19 +195,43 @@ function finishGame() {
|
||||
totalScore,
|
||||
countries: roundCountries.map((c) => c.name),
|
||||
};
|
||||
Storage.saveGameState(state);
|
||||
Storage.saveLeaderboard({
|
||||
name: Storage.getPlayerName(),
|
||||
totalScore,
|
||||
scores,
|
||||
date: new Date().toISOString(),
|
||||
});
|
||||
saveGameState(state);
|
||||
|
||||
try {
|
||||
await submitScore({
|
||||
lobbyName: getLobbyName(),
|
||||
playerName: getPlayerName(),
|
||||
totalScore,
|
||||
scores,
|
||||
countries: state.countries,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("Could not submit score to backend.", error);
|
||||
}
|
||||
|
||||
location.href = "results.html";
|
||||
}
|
||||
|
||||
// ── Events
|
||||
elBtnClear.addEventListener("click", () => Drawing.clear());
|
||||
elBtnSubmit.addEventListener("click", () => submitRound(false));
|
||||
/** @param {number} round */
|
||||
function updateRoundPips(round) {
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const pip = document.getElementById(`pip-${i}`);
|
||||
pip.className = `round-pip${i < round ? " done" : ""}${
|
||||
i === round ? " active" : ""
|
||||
}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} roundIdx
|
||||
* @param {number} score
|
||||
*/
|
||||
function updateScoreDisplay(roundIdx, score) {
|
||||
const el = document.getElementById(`score-r${roundIdx + 1}`);
|
||||
if (el) {
|
||||
el.textContent = `${score}%`;
|
||||
el.classList.add("filled");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Boot
|
||||
window.addEventListener("DOMContentLoaded", initGame);
|
||||
|
||||
@ -1,24 +1,38 @@
|
||||
// index.js — landing page logic
|
||||
// index.js - landing page logic
|
||||
|
||||
import { createLobby } from "./api.js";
|
||||
import { saveLobbyName } from "./storage.js";
|
||||
|
||||
/**
|
||||
* Handle the "Create game" button click.
|
||||
* Validates the lobby name, persists it, and navigates to the lobby page.
|
||||
*/
|
||||
document.getElementById("reg-btn")?.addEventListener("click", () => {
|
||||
document.getElementById("reg-btn")?.addEventListener("click", async () => {
|
||||
const lobbyInput = /** @type {HTMLInputElement|null} */ (
|
||||
document.getElementById("username")
|
||||
);
|
||||
const button = /** @type {HTMLButtonElement|null} */ (
|
||||
document.getElementById("reg-btn")
|
||||
);
|
||||
const lobbyName = lobbyInput ? lobbyInput.value.trim() : "";
|
||||
|
||||
if (lobbyName) {
|
||||
Storage.saveLobbyName(lobbyName);
|
||||
window.location.href = "lobby.html";
|
||||
} else {
|
||||
if (!lobbyName) {
|
||||
lobbyInput?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (button) button.disabled = true;
|
||||
await createLobby(lobbyName);
|
||||
saveLobbyName(lobbyName);
|
||||
window.location.href = "lobby.html";
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : "Could not create lobby.");
|
||||
if (button) button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Scroll reveal — animate elements into view as they enter the viewport
|
||||
// Scroll reveal - animate elements into view as they enter the viewport
|
||||
const reveals = document.querySelectorAll(".reveal");
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
@ -32,4 +46,6 @@ const observer = new IntersectionObserver(
|
||||
{ threshold: 0.12 },
|
||||
);
|
||||
|
||||
reveals.forEach((el) => observer.observe(el));
|
||||
reveals.forEach((el) => {
|
||||
observer.observe(el);
|
||||
});
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
// leaderboard.js — leaderboard page logic
|
||||
// leaderboard.js - leaderboard page logic
|
||||
|
||||
import { getLeaderboard } from "./api.js";
|
||||
import { getLobbyName, getPlayerName } from "./storage.js";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const currentPlayer = Storage.getPlayerName();
|
||||
const currentPlayer = getPlayerName();
|
||||
const body = document.getElementById("lb-body");
|
||||
|
||||
/**
|
||||
@ -16,9 +19,22 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/** Render the leaderboard from localStorage data. */
|
||||
function render() {
|
||||
const board = Storage.getLeaderboard();
|
||||
/** Render the leaderboard from backend data. */
|
||||
async function render() {
|
||||
const lobbyName = getLobbyName();
|
||||
let board = [];
|
||||
try {
|
||||
const data = await getLeaderboard(lobbyName);
|
||||
board = data.leaderboard || [];
|
||||
} catch (_error) {
|
||||
body.innerHTML = `
|
||||
<div class="lb-empty">
|
||||
<p class="lb-empty__text">Could not load the leaderboard. Make sure the backend is running.</p>
|
||||
<a href="lobby.html" class="btn btn--primary btn--sm">Back to lobby</a>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
body.innerHTML = "";
|
||||
|
||||
if (!board.length) {
|
||||
@ -36,7 +52,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
board.forEach((entry, index) => {
|
||||
const rank = index + 1;
|
||||
const isYou = entry.name === currentPlayer;
|
||||
const playerName = entry.playerName || entry.name || "Anonymous";
|
||||
const isYou = playerName === currentPlayer;
|
||||
const date = entry.date
|
||||
? new Date(entry.date).toLocaleDateString("en-CH", {
|
||||
day: "2-digit",
|
||||
@ -51,7 +68,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
row.innerHTML = `
|
||||
<span class="lb-rank">${rank <= 3 ? `<span class="lb-medal">${medals[rank - 1]}</span>` : rank}</span>
|
||||
<span class="lb-name${isYou ? " is-you" : ""}">${escHtml(entry.name)}</span>
|
||||
<span class="lb-name${isYou ? " is-you" : ""}">${escHtml(playerName)}</span>
|
||||
<span class="lb-rounds hide-sm">${escHtml(rounds)}</span>
|
||||
<span class="lb-date hide-sm">${date}</span>
|
||||
<span class="lb-score${rank <= 3 ? ` ${scoreClasses[rank - 1]}` : ""}">${entry.totalScore}</span>
|
||||
@ -59,11 +76,13 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
body.appendChild(row);
|
||||
});
|
||||
|
||||
// Show the current player's latest score bar
|
||||
const latest = board.find((entry) => entry.name === currentPlayer);
|
||||
const latest = board.find(
|
||||
(entry) => (entry.playerName || entry.name) === currentPlayer,
|
||||
);
|
||||
if (latest) {
|
||||
document.getElementById("your-bar").style.display = "flex";
|
||||
document.getElementById("your-bar-name").textContent = latest.name;
|
||||
document.getElementById("your-bar-name").textContent =
|
||||
latest.playerName || latest.name;
|
||||
document.getElementById("your-bar-score").textContent = latest.totalScore;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,12 @@
|
||||
// lobby.js — lobby page logic
|
||||
// lobby.js - lobby page logic
|
||||
|
||||
import { joinLobby } from "./api.js";
|
||||
import {
|
||||
clearGameState,
|
||||
getLobbyName,
|
||||
getPlayerName,
|
||||
savePlayerName,
|
||||
} from "./storage.js";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const input = document.getElementById("username");
|
||||
@ -8,15 +16,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// Display the current lobby name
|
||||
const lobbyNameEl = document.getElementById("lobby-name-display");
|
||||
if (lobbyNameEl) {
|
||||
lobbyNameEl.textContent = Storage.getLobbyName();
|
||||
lobbyNameEl.textContent = getLobbyName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the username input and navigate to the game.
|
||||
* Shows an inline error message on invalid input.
|
||||
*/
|
||||
btn.addEventListener("click", () => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const name = input.value.trim();
|
||||
const lobbyName = getLobbyName();
|
||||
|
||||
if (!name) {
|
||||
errMsg.textContent = "Please enter a username to continue.";
|
||||
@ -31,9 +40,17 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
Storage.savePlayerName(name);
|
||||
Storage.clearGameState();
|
||||
location.href = "game.html";
|
||||
try {
|
||||
btn.disabled = true;
|
||||
await joinLobby(lobbyName, name);
|
||||
savePlayerName(name);
|
||||
clearGameState();
|
||||
location.href = "game.html";
|
||||
} catch (error) {
|
||||
errMsg.textContent =
|
||||
error instanceof Error ? error.message : "Could not join lobby.";
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Clear validation state on every keystroke
|
||||
@ -48,7 +65,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
|
||||
// Pre-fill username if the player has played before
|
||||
const existing = Storage.getPlayerName();
|
||||
const existing = getPlayerName();
|
||||
if (existing && existing !== "Anonymous") {
|
||||
input.value = existing;
|
||||
}
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
// results.js — results page logic
|
||||
// results.js - results page logic
|
||||
|
||||
import { getGrade } from "./scoring.js";
|
||||
import { getGameState, getPlayerName } from "./storage.js";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const state = Storage.getGameState();
|
||||
const name = Storage.getPlayerName();
|
||||
const state = getGameState();
|
||||
const name = getPlayerName();
|
||||
|
||||
document.getElementById("results-player").textContent = name;
|
||||
|
||||
@ -14,7 +17,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
const { scores, totalScore, countries } = state;
|
||||
|
||||
// Determine emoji and title based on average score
|
||||
const avg = totalScore / 3;
|
||||
let emoji = "🌍";
|
||||
let title = "Not bad!";
|
||||
@ -36,12 +38,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
document.getElementById("results-title").textContent = title;
|
||||
document.getElementById("total-score").textContent = totalScore;
|
||||
|
||||
// Grade badge
|
||||
const grade = Scoring.getGrade(avg);
|
||||
const grade = getGrade(avg);
|
||||
const gradeEl = document.getElementById("total-grade");
|
||||
gradeEl.textContent = `Grade ${grade.label}`;
|
||||
|
||||
// Round breakdown rows
|
||||
const rowsContainer = document.getElementById("round-rows");
|
||||
(scores || []).forEach((score, index) => {
|
||||
const row = document.createElement("div");
|
||||
@ -56,14 +56,12 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
rowsContainer.appendChild(row);
|
||||
});
|
||||
|
||||
// Animate score bars on next frame so CSS transition fires
|
||||
requestAnimationFrame(() => {
|
||||
document.querySelectorAll(".round-row__bar").forEach((bar) => {
|
||||
bar.style.width = `${bar.dataset.target}%`;
|
||||
});
|
||||
});
|
||||
|
||||
// Country tags
|
||||
const tagsContainer = document.getElementById("countries-row");
|
||||
(countries || []).forEach((country) => {
|
||||
const tag = document.createElement("span");
|
||||
|
||||
@ -1,46 +1,156 @@
|
||||
// scoring.js — accuracy calculation
|
||||
// scoring.js - accuracy calculation
|
||||
|
||||
const Scoring = (() => {
|
||||
/**
|
||||
* Calculate a score from the drawn path points.
|
||||
* NOTE: Currently uses an effort-based approximation.
|
||||
* TODO: Replace with real polygon comparison (IoU or Hausdorff distance).
|
||||
* @param {{ x: number, y: number }[]} drawnPoints
|
||||
* @returns {number} Score between 0 and 100.
|
||||
*/
|
||||
function calculateScore(drawnPoints) {
|
||||
if (!drawnPoints || drawnPoints.length < 10) return 0;
|
||||
const GRID_SIZE = 96;
|
||||
const MIN_DRAWN_POINTS = 10;
|
||||
const MIN_FILLED_CELLS = 8;
|
||||
|
||||
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));
|
||||
/**
|
||||
* @typedef {{ x: number, y: number }} Point
|
||||
* @typedef {{ rings: Point[][] }} CountryOutline
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function calculateScore(drawnShape, referenceOutline) {
|
||||
return compareShapes(drawnShape, referenceOutline);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a drawn polygon against one or more reference rings using rasterized IoU.
|
||||
* @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.
|
||||
*/
|
||||
export function compareShapes(drawnShape, referenceOutline) {
|
||||
if (!referenceOutline?.rings?.length) 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++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a drawn polygon against a reference polygon.
|
||||
* Stub — reserved for future IoU / Hausdorff implementation.
|
||||
* @param {{ x: number, y: number }[]} _drawnPoints
|
||||
* @param {{ x: number, y: number }[]} _referencePolygon - Normalised 0–1 coords.
|
||||
* @returns {number} Score between 0 and 100.
|
||||
*/
|
||||
function compareShapes(_drawnPoints, _referencePolygon) {
|
||||
// TODO: implement real shape comparison
|
||||
return 0;
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a numeric score to a letter grade with colour.
|
||||
* @param {number} score
|
||||
* @returns {{ label: string, color: string }}
|
||||
*/
|
||||
export function getGrade(score) {
|
||||
if (score >= 90) return { label: "S", color: "#f0b429" };
|
||||
if (score >= 75) return { label: "A", color: "#41b869" };
|
||||
if (score >= 60) return { label: "B", color: "#1a7fc4" };
|
||||
if (score >= 40) return { label: "C", color: "#7a9aaa" };
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a numeric score to a letter grade with colour.
|
||||
* @param {number} score
|
||||
* @returns {{ label: string, color: string }}
|
||||
*/
|
||||
function getGrade(score) {
|
||||
if (score >= 90) return { label: "S", color: "#f0b429" };
|
||||
if (score >= 75) return { label: "A", color: "#41b869" };
|
||||
if (score >= 60) return { label: "B", color: "#1a7fc4" };
|
||||
if (score >= 40) return { label: "C", color: "#7a9aaa" };
|
||||
return { label: "D", color: "#e05c5c" };
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
return { calculateScore, compareShapes, getGrade };
|
||||
})();
|
||||
/** @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");
|
||||
@ -1,105 +1,90 @@
|
||||
// storage.js — localStorage helpers
|
||||
// storage.js - localStorage helpers
|
||||
|
||||
const Storage = (() => {
|
||||
const KEYS = {
|
||||
PLAYER_NAME: "gd_playerName",
|
||||
LOBBY_NAME: "gd_lobbyName",
|
||||
GAME_STATE: "gd_gameState",
|
||||
LEADERBOARD: "gd_leaderboard",
|
||||
};
|
||||
const KEYS = {
|
||||
PLAYER_NAME: "gd_playerName",
|
||||
LOBBY_NAME: "gd_lobbyName",
|
||||
GAME_STATE: "gd_gameState",
|
||||
LEADERBOARD: "gd_leaderboard",
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely write a value to localStorage.
|
||||
* Silently fails if storage quota is exceeded or unavailable.
|
||||
* @param {string} key
|
||||
* @param {string} value
|
||||
*/
|
||||
function _set(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
console.warn(`Storage: could not write key "${key}"`);
|
||||
}
|
||||
/**
|
||||
* Safely write a value to localStorage.
|
||||
* Silently fails if storage quota is exceeded or unavailable.
|
||||
* @param {string} key
|
||||
* @param {string} value
|
||||
*/
|
||||
function setItem(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
console.warn(`Storage: could not write key "${key}"`);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} name */
|
||||
function savePlayerName(name) {
|
||||
_set(KEYS.PLAYER_NAME, name.trim());
|
||||
/** @param {string} name */
|
||||
export function savePlayerName(name) {
|
||||
setItem(KEYS.PLAYER_NAME, name.trim());
|
||||
}
|
||||
|
||||
/** @returns {string} */
|
||||
export function getPlayerName() {
|
||||
return localStorage.getItem(KEYS.PLAYER_NAME) || "Anonymous";
|
||||
}
|
||||
|
||||
/** @param {string} name */
|
||||
export function saveLobbyName(name) {
|
||||
setItem(KEYS.LOBBY_NAME, name.trim());
|
||||
}
|
||||
|
||||
/** @returns {string} */
|
||||
export function getLobbyName() {
|
||||
return localStorage.getItem(KEYS.LOBBY_NAME) || "My Lobby";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ scores: number[], totalScore: number, countries: string[] }} state
|
||||
*/
|
||||
export function saveGameState(state) {
|
||||
setItem(KEYS.GAME_STATE, JSON.stringify(state));
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {{ scores: number[], totalScore: number, countries: string[] } | null}
|
||||
*/
|
||||
export function getGameState() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(KEYS.GAME_STATE)) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {string} */
|
||||
function getPlayerName() {
|
||||
return localStorage.getItem(KEYS.PLAYER_NAME) || "Anonymous";
|
||||
export function clearGameState() {
|
||||
localStorage.removeItem(KEYS.GAME_STATE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an entry to the local fallback leaderboard and keep the top 20.
|
||||
* @param {{ name: string, totalScore: number, scores: number[], date: string }} entry
|
||||
*/
|
||||
export function saveLeaderboard(entry) {
|
||||
const board = getLeaderboard();
|
||||
board.push(entry);
|
||||
board.sort((a, b) => b.totalScore - a.totalScore);
|
||||
setItem(KEYS.LEADERBOARD, JSON.stringify(board.slice(0, 20)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {{ name: string, totalScore: number, scores: number[], date: string }[]}
|
||||
*/
|
||||
export function getLeaderboard() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(KEYS.LEADERBOARD)) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} name */
|
||||
function saveLobbyName(name) {
|
||||
_set(KEYS.LOBBY_NAME, name.trim());
|
||||
}
|
||||
|
||||
/** @returns {string} */
|
||||
function getLobbyName() {
|
||||
return localStorage.getItem(KEYS.LOBBY_NAME) || "My Lobby";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ scores: number[], totalScore: number, countries: string[] }} state
|
||||
*/
|
||||
function saveGameState(state) {
|
||||
_set(KEYS.GAME_STATE, JSON.stringify(state));
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {{ scores: number[], totalScore: number, countries: string[] } | null}
|
||||
*/
|
||||
function getGameState() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(KEYS.GAME_STATE)) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearGameState() {
|
||||
localStorage.removeItem(KEYS.GAME_STATE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an entry to the leaderboard and keep the top 20.
|
||||
* @param {{ name: string, totalScore: number, scores: number[], date: string }} entry
|
||||
*/
|
||||
function saveLeaderboard(entry) {
|
||||
const board = getLeaderboard();
|
||||
board.push(entry);
|
||||
board.sort((a, b) => b.totalScore - a.totalScore);
|
||||
_set(KEYS.LEADERBOARD, JSON.stringify(board.slice(0, 20)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {{ name: string, totalScore: number, scores: number[], date: string }[]}
|
||||
*/
|
||||
function getLeaderboard() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(KEYS.LEADERBOARD)) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function clearLeaderboard() {
|
||||
localStorage.removeItem(KEYS.LEADERBOARD);
|
||||
}
|
||||
|
||||
return {
|
||||
savePlayerName,
|
||||
getPlayerName,
|
||||
saveLobbyName,
|
||||
getLobbyName,
|
||||
saveGameState,
|
||||
getGameState,
|
||||
clearGameState,
|
||||
saveLeaderboard,
|
||||
getLeaderboard,
|
||||
clearLeaderboard,
|
||||
};
|
||||
})();
|
||||
export function clearLeaderboard() {
|
||||
localStorage.removeItem(KEYS.LEADERBOARD);
|
||||
}
|
||||
|
||||
@ -141,10 +141,14 @@
|
||||
grid-template-columns: 1fr 220px;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.canvas-wrap {
|
||||
position: relative;
|
||||
justify-self: center;
|
||||
width: min(100%, 68vh);
|
||||
aspect-ratio: 1;
|
||||
background: var(--white);
|
||||
border: 1.5px solid var(--line);
|
||||
border-radius: var(--r-xl);
|
||||
@ -284,7 +288,7 @@
|
||||
}
|
||||
|
||||
.canvas-wrap {
|
||||
min-height: 340px;
|
||||
width: min(100%, 72vh);
|
||||
}
|
||||
|
||||
.game-topbar {
|
||||
@ -315,7 +319,7 @@
|
||||
}
|
||||
|
||||
.canvas-wrap {
|
||||
min-height: 280px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.game-controls {
|
||||
@ -334,6 +338,6 @@
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
.canvas-wrap {
|
||||
min-height: 240px;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
38
router.php
Normal file
38
router.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$uriPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||
$root = __DIR__;
|
||||
|
||||
if (str_starts_with($uriPath, '/backend/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$frontendPath = $uriPath === '/' ? '/index.html' : $uriPath;
|
||||
$file = realpath($root . '/frontend' . $frontendPath);
|
||||
$frontendRoot = realpath($root . '/frontend');
|
||||
|
||||
if (
|
||||
$file !== false
|
||||
&& $frontendRoot !== false
|
||||
&& str_starts_with($file, $frontendRoot)
|
||||
&& is_file($file)
|
||||
) {
|
||||
$extension = strtolower(pathinfo($file, PATHINFO_EXTENSION));
|
||||
$contentTypes = [
|
||||
'css' => 'text/css; charset=utf-8',
|
||||
'html' => 'text/html; charset=utf-8',
|
||||
'js' => 'text/javascript; charset=utf-8',
|
||||
'json' => 'application/json; charset=utf-8',
|
||||
'svg' => 'image/svg+xml',
|
||||
];
|
||||
|
||||
header('Content-Type: ' . ($contentTypes[$extension] ?? 'application/octet-stream'));
|
||||
readfile($file);
|
||||
return true;
|
||||
}
|
||||
|
||||
http_response_code(404);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo "Not found\n";
|
||||
22
serve.php
Normal file
22
serve.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$host = 'localhost';
|
||||
$port = $argv[1] ?? '8000';
|
||||
$port = preg_match('/^\d{2,5}$/', $port) === 1 ? $port : '8000';
|
||||
$root = __DIR__;
|
||||
$router = __DIR__ . DIRECTORY_SEPARATOR . 'router.php';
|
||||
|
||||
echo "GeoDraw is running at http://{$host}:{$port}/\n";
|
||||
echo "Press Ctrl+C to stop the server.\n\n";
|
||||
|
||||
passthru(
|
||||
escapeshellarg(PHP_BINARY)
|
||||
. ' -S '
|
||||
. escapeshellarg("{$host}:{$port}")
|
||||
. ' -t '
|
||||
. escapeshellarg($root)
|
||||
. ' '
|
||||
. escapeshellarg($router),
|
||||
);
|
||||
545
tools/convert-geojson-outlines.js
Normal file
545
tools/convert-geojson-outlines.js
Normal file
@ -0,0 +1,545 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdir, readdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import shapefile from "shapefile";
|
||||
|
||||
const DEFAULT_TOLERANCE = 0.3;
|
||||
const DEFAULT_PADDING = 6;
|
||||
const MAX_MERCATOR_LAT = 85.05112878;
|
||||
const SHAPEFILE_BASENAME = "ne_10m_admin_0_countries";
|
||||
const GAME_COUNTRIES = [
|
||||
"Switzerland",
|
||||
"Norway",
|
||||
"Italy",
|
||||
"Japan",
|
||||
"Brazil",
|
||||
"Australia",
|
||||
"France",
|
||||
"India",
|
||||
"Canada",
|
||||
"Germany",
|
||||
];
|
||||
const COUNTRY_OPTIONS = {
|
||||
France: { keepLargestRingOnly: true },
|
||||
Norway: { keepLargestRingOnly: true },
|
||||
};
|
||||
|
||||
function usage() {
|
||||
return `Usage:
|
||||
node convert-geojson-outlines.js <input-dir> <output-dir> [options]
|
||||
|
||||
Options:
|
||||
--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.
|
||||
--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.
|
||||
--pretty Write indented JSON instead of compact JSON.
|
||||
`;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const positionals = [];
|
||||
const options = {
|
||||
tolerance: DEFAULT_TOLERANCE,
|
||||
padding: DEFAULT_PADDING,
|
||||
maxPoints: null,
|
||||
minRingAreaRatio: 0.001,
|
||||
mainlandOnly: false,
|
||||
includeHoles: false,
|
||||
pretty: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
|
||||
if (arg === "--tolerance") {
|
||||
options.tolerance = Number(argv[++i]);
|
||||
} else if (arg === "--padding") {
|
||||
options.padding = Number(argv[++i]);
|
||||
} else if (arg === "--max-points") {
|
||||
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") {
|
||||
options.includeHoles = true;
|
||||
} else if (arg === "--pretty") {
|
||||
options.pretty = true;
|
||||
} else if (arg === "--help" || arg === "-h") {
|
||||
console.log(usage());
|
||||
process.exit(0);
|
||||
} else {
|
||||
positionals.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
if (positionals.length !== 2) {
|
||||
throw new Error("Expected exactly <input-dir> and <output-dir>.");
|
||||
}
|
||||
if (!Number.isFinite(options.tolerance) || options.tolerance < 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 (
|
||||
options.maxPoints !== null &&
|
||||
(!Number.isInteger(options.maxPoints) || options.maxPoints < 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 {
|
||||
inputDir: positionals[0],
|
||||
outputDir: positionals[1],
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
function stripNulls(value) {
|
||||
return typeof value === "string" ? value.replaceAll("\0", "").trim() : value;
|
||||
}
|
||||
|
||||
function cleanProperties(properties) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(properties || {}).map(([key, value]) => [
|
||||
key,
|
||||
stripNulls(value),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function getCountryName(properties) {
|
||||
return (
|
||||
properties.NAME_EN ||
|
||||
properties.NAME_LONG ||
|
||||
properties.NAME ||
|
||||
properties.ADMIN ||
|
||||
properties.SOVEREIGNT ||
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||
.replaceAll(/(^-|-$)/g, "");
|
||||
}
|
||||
|
||||
function extractRings(geometry, includeHoles) {
|
||||
if (geometry.type === "Polygon") {
|
||||
return includeHoles
|
||||
? geometry.coordinates
|
||||
: [geometry.coordinates?.[0]].filter(Boolean);
|
||||
}
|
||||
|
||||
if (geometry.type === "MultiPolygon") {
|
||||
return geometry.coordinates.flatMap((polygon) =>
|
||||
includeHoles ? polygon : [polygon?.[0]].filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function isValidCoordinate(coord) {
|
||||
return (
|
||||
Array.isArray(coord) &&
|
||||
coord.length >= 2 &&
|
||||
Number.isFinite(coord[0]) &&
|
||||
Number.isFinite(coord[1])
|
||||
);
|
||||
}
|
||||
|
||||
function cleanRing(ring) {
|
||||
if (!Array.isArray(ring)) return [];
|
||||
return ring.filter(isValidCoordinate).map(([lon, lat]) => [lon, lat]);
|
||||
}
|
||||
|
||||
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 maxLon = -Infinity;
|
||||
let minLat = Infinity;
|
||||
let maxLat = -Infinity;
|
||||
|
||||
for (const ring of rings) {
|
||||
for (const [lon, lat] of ring) {
|
||||
minLon = Math.min(minLon, lon);
|
||||
maxLon = Math.max(maxLon, lon);
|
||||
minLat = Math.min(minLat, lat);
|
||||
maxLat = Math.max(maxLat, lat);
|
||||
}
|
||||
}
|
||||
|
||||
if (![minLon, maxLon, minLat, maxLat].every(Number.isFinite)) {
|
||||
throw new Error("Could not calculate bounds from geometry.");
|
||||
}
|
||||
|
||||
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) {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function roundMeta(value) {
|
||||
return Math.round(value * 1_000_000) / 1_000_000;
|
||||
}
|
||||
|
||||
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 {
|
||||
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(
|
||||
projection.scale === 0
|
||||
? 50
|
||||
: projection.yOffset + (bounds.maxY - y) * projection.scale,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function samePoint(a, b) {
|
||||
return a.x === b.x && a.y === b.y;
|
||||
}
|
||||
|
||||
function closeRing(points) {
|
||||
if (points.length < 2) return points;
|
||||
const first = points[0];
|
||||
const last = points[points.length - 1];
|
||||
return samePoint(first, last) ? points : [...points, { ...first }];
|
||||
}
|
||||
|
||||
function openRing(points) {
|
||||
if (points.length < 2) return points;
|
||||
const first = points[0];
|
||||
const last = points[points.length - 1];
|
||||
return samePoint(first, last) ? points.slice(0, -1) : points;
|
||||
}
|
||||
|
||||
function squaredDistanceToSegment(point, start, end) {
|
||||
const dx = end.x - start.x;
|
||||
const dy = end.y - start.y;
|
||||
|
||||
if (dx === 0 && dy === 0) {
|
||||
return (point.x - start.x) ** 2 + (point.y - start.y) ** 2;
|
||||
}
|
||||
|
||||
const t = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
((point.x - start.x) * dx + (point.y - start.y) * dy) /
|
||||
(dx * dx + dy * dy),
|
||||
),
|
||||
);
|
||||
const projection = {
|
||||
x: start.x + t * dx,
|
||||
y: start.y + t * dy,
|
||||
};
|
||||
|
||||
return (point.x - projection.x) ** 2 + (point.y - projection.y) ** 2;
|
||||
}
|
||||
|
||||
function douglasPeucker(points, tolerance) {
|
||||
if (points.length <= 2 || tolerance === 0) return points;
|
||||
|
||||
let maxDistance = 0;
|
||||
let splitIndex = 0;
|
||||
const lastIndex = points.length - 1;
|
||||
|
||||
for (let i = 1; i < lastIndex; i++) {
|
||||
const distance = squaredDistanceToSegment(
|
||||
points[i],
|
||||
points[0],
|
||||
points[lastIndex],
|
||||
);
|
||||
if (distance > maxDistance) {
|
||||
maxDistance = distance;
|
||||
splitIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (maxDistance <= tolerance * tolerance) {
|
||||
return [points[0], points[lastIndex]];
|
||||
}
|
||||
|
||||
const left = douglasPeucker(points.slice(0, splitIndex + 1), tolerance);
|
||||
const right = douglasPeucker(points.slice(splitIndex), tolerance);
|
||||
return [...left.slice(0, -1), ...right];
|
||||
}
|
||||
|
||||
function simplifyRing(points, initialTolerance, maxPoints) {
|
||||
let tolerance = initialTolerance;
|
||||
let simplified = closeRing(douglasPeucker(openRing(points), tolerance));
|
||||
let attempts = 0;
|
||||
|
||||
while (maxPoints && simplified.length > maxPoints && attempts < 30) {
|
||||
tolerance = tolerance === 0 ? 0.5 : tolerance * 1.25;
|
||||
simplified = closeRing(douglasPeucker(openRing(points), tolerance));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
return simplified;
|
||||
}
|
||||
|
||||
function normalizeGeometry(geometry, options) {
|
||||
if (!geometry || !["Polygon", "MultiPolygon"].includes(geometry.type)) {
|
||||
throw new Error("No Polygon or MultiPolygon geometry found.");
|
||||
}
|
||||
|
||||
const geoRings = filterGameplayRings(
|
||||
extractRings(geometry, options.includeHoles)
|
||||
.map(cleanRing)
|
||||
.filter((ring) => ring.length >= 4),
|
||||
options,
|
||||
);
|
||||
|
||||
if (!geoRings.length) {
|
||||
throw new Error("No valid polygon rings found.");
|
||||
}
|
||||
|
||||
const projectedRings = geoRings.map((ring) => ring.map(mercatorProject));
|
||||
const geoBounds = getGeoBounds(geoRings);
|
||||
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((ring) => simplifyRing(ring, options.tolerance, options.maxPoints))
|
||||
.filter((ring) => ring.length >= 4);
|
||||
|
||||
if (!normalizedRings.length) {
|
||||
throw new Error("No valid rings remained after simplification.");
|
||||
}
|
||||
|
||||
return {
|
||||
type: geoRings.length > 1 ? "MultiPolygon" : "Polygon",
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
async function findShapefile(inputDir) {
|
||||
const entries = await readdir(inputDir, { withFileTypes: true });
|
||||
const shpFiles = entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isFile() && path.extname(entry.name).toLowerCase() === ".shp",
|
||||
)
|
||||
.map((entry) => entry.name)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const preferred = shpFiles.find(
|
||||
(fileName) => path.basename(fileName, ".shp") === SHAPEFILE_BASENAME,
|
||||
);
|
||||
const fileName = preferred || shpFiles[0];
|
||||
|
||||
if (!fileName) {
|
||||
throw new Error(`No .shp file found in ${inputDir}.`);
|
||||
}
|
||||
|
||||
return path.join(inputDir, fileName);
|
||||
}
|
||||
|
||||
async function readTargetFeatures(shpPath, targets) {
|
||||
const source = await shapefile.open(shpPath, undefined, {
|
||||
encoding: "utf-8",
|
||||
});
|
||||
const features = new Map();
|
||||
|
||||
while (true) {
|
||||
const result = await source.read();
|
||||
if (result.done) break;
|
||||
|
||||
const feature = result.value;
|
||||
const properties = cleanProperties(feature.properties);
|
||||
const countryName = getCountryName(properties);
|
||||
|
||||
if (targets.has(countryName)) {
|
||||
features.set(countryName, {
|
||||
geometry: feature.geometry,
|
||||
properties,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
async function writeCountryOutline(
|
||||
outputDir,
|
||||
countryName,
|
||||
feature,
|
||||
sourceFile,
|
||||
options,
|
||||
) {
|
||||
const countryOptions = {
|
||||
...options,
|
||||
...(COUNTRY_OPTIONS[countryName] || {}),
|
||||
};
|
||||
const output = {
|
||||
source: sourceFile,
|
||||
country: {
|
||||
name: countryName,
|
||||
isoA2: feature.properties.ISO_A2,
|
||||
isoA3: feature.properties.ISO_A3,
|
||||
continent: feature.properties.CONTINENT,
|
||||
subregion: feature.properties.SUBREGION,
|
||||
},
|
||||
outline: normalizeGeometry(feature.geometry, countryOptions),
|
||||
};
|
||||
const fileName = `${slugify(countryName)}.json`;
|
||||
const json = JSON.stringify(output, null, options.pretty ? 2 : 0);
|
||||
await writeFile(path.join(outputDir, fileName), `${json}\n`, "utf8");
|
||||
console.log(`Converted ${countryName} -> ${fileName}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { inputDir, outputDir, options } = parseArgs(process.argv.slice(2));
|
||||
const shpPath = await findShapefile(inputDir);
|
||||
const sourceFile = path.basename(shpPath);
|
||||
const targets = new Set(GAME_COUNTRIES);
|
||||
const features = await readTargetFeatures(shpPath, targets);
|
||||
const missing = GAME_COUNTRIES.filter(
|
||||
(countryName) => !features.has(countryName),
|
||||
);
|
||||
|
||||
if (missing.length) {
|
||||
throw new Error(`Missing country feature(s): ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
for (const countryName of GAME_COUNTRIES) {
|
||||
await writeCountryOutline(
|
||||
outputDir,
|
||||
countryName,
|
||||
features.get(countryName),
|
||||
sourceFile,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Done. Converted ${GAME_COUNTRIES.length} country outline(s).`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
console.error(usage());
|
||||
process.exit(1);
|
||||
});
|
||||
486
tools/countries/ne_10m_admin_0_countries.README.html
Normal file
486
tools/countries/ne_10m_admin_0_countries.README.html
Normal file
@ -0,0 +1,486 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" lang="en-US">
|
||||
|
||||
<head profile="http://gmpg.org/xfn/11">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
|
||||
<title>Natural Earth » Blog Archive » Admin 0 – Countries - Free vector and raster map data at 1:10m, 1:50m, and 1:110m scales </title>
|
||||
|
||||
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon">
|
||||
<link rel="alternate" type="application/rss+xml" title="Natural Earth RSS Feed" href="https://www.naturalearthdata.com/feed/" />
|
||||
<link rel="pingback" href="http://www.naturalearthdata.com/xmlrpc.php" />
|
||||
<script type="text/javascript" src="http://www.naturalearthdata.com/wp-content/themes/NEV/includes/js/suckerfish.js"></script>
|
||||
<!--[if lt IE 7]>
|
||||
<script src="http://ie7-js.googlecode.com/svn/version/2.0(beta3)/IE7.js" type="text/javascript"></script>
|
||||
<script defer="defer" type="text/javascript" src="http://www.naturalearthdata.com/wp-content/themes/NEV/includes/js/pngfix.js"></script>
|
||||
<![endif]-->
|
||||
<link rel="stylesheet" href="http://www.naturalearthdata.com/wp-content/themes/NEV/style.css" type="text/css" media="screen" />
|
||||
|
||||
<link rel='dns-prefetch' href='//s.w.org' />
|
||||
<script type="text/javascript">
|
||||
window._wpemojiSettings = {"baseUrl":"https:\/\/s.w.org\/images\/core\/emoji\/13.0.0\/72x72\/","ext":".png","svgUrl":"https:\/\/s.w.org\/images\/core\/emoji\/13.0.0\/svg\/","svgExt":".svg","source":{"concatemoji":"http:\/\/www.naturalearthdata.com\/wp-includes\/js\/wp-emoji-release.min.js?ver=5.5.9"}};
|
||||
!function(e,a,t){var n,r,o,i=a.createElement("canvas"),p=i.getContext&&i.getContext("2d");function s(e,t){var a=String.fromCharCode;p.clearRect(0,0,i.width,i.height),p.fillText(a.apply(this,e),0,0);e=i.toDataURL();return p.clearRect(0,0,i.width,i.height),p.fillText(a.apply(this,t),0,0),e===i.toDataURL()}function c(e){var t=a.createElement("script");t.src=e,t.defer=t.type="text/javascript",a.getElementsByTagName("head")[0].appendChild(t)}for(o=Array("flag","emoji"),t.supports={everything:!0,everythingExceptFlag:!0},r=0;r<o.length;r++)t.supports[o[r]]=function(e){if(!p||!p.fillText)return!1;switch(p.textBaseline="top",p.font="600 32px Arial",e){case"flag":return s([127987,65039,8205,9895,65039],[127987,65039,8203,9895,65039])?!1:!s([55356,56826,55356,56819],[55356,56826,8203,55356,56819])&&!s([55356,57332,56128,56423,56128,56418,56128,56421,56128,56430,56128,56423,56128,56447],[55356,57332,8203,56128,56423,8203,56128,56418,8203,56128,56421,8203,56128,56430,8203,56128,56423,8203,56128,56447]);case"emoji":return!s([55357,56424,8205,55356,57212],[55357,56424,8203,55356,57212])}return!1}(o[r]),t.supports.everything=t.supports.everything&&t.supports[o[r]],"flag"!==o[r]&&(t.supports.everythingExceptFlag=t.supports.everythingExceptFlag&&t.supports[o[r]]);t.supports.everythingExceptFlag=t.supports.everythingExceptFlag&&!t.supports.flag,t.DOMReady=!1,t.readyCallback=function(){t.DOMReady=!0},t.supports.everything||(n=function(){t.readyCallback()},a.addEventListener?(a.addEventListener("DOMContentLoaded",n,!1),e.addEventListener("load",n,!1)):(e.attachEvent("onload",n),a.attachEvent("onreadystatechange",function(){"complete"===a.readyState&&t.readyCallback()})),(n=t.source||{}).concatemoji?c(n.concatemoji):n.wpemoji&&n.twemoji&&(c(n.twemoji),c(n.wpemoji)))}(window,document,window._wpemojiSettings);
|
||||
</script>
|
||||
<style type="text/css">
|
||||
img.wp-smiley,
|
||||
img.emoji {
|
||||
display: inline !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
height: 1em !important;
|
||||
width: 1em !important;
|
||||
margin: 0 .07em !important;
|
||||
vertical-align: -0.1em !important;
|
||||
background: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
</style>
|
||||
<link rel='stylesheet' id='wp-block-library-css' href='http://www.naturalearthdata.com/wp-includes/css/dist/block-library/style.min.css?ver=5.5.9' type='text/css' media='all' />
|
||||
<link rel='stylesheet' id='bbp-child-bbpress-css' href='http://www.naturalearthdata.com/wp-content/themes/NEV/css/bbpress.css?ver=2.6.6' type='text/css' media='screen' />
|
||||
<link rel="https://api.w.org/" href="https://www.naturalearthdata.com/wp-json/" /><link rel="alternate" type="application/json" href="https://www.naturalearthdata.com/wp-json/wp/v2/posts/74" /><link rel="EditURI" type="application/rsd+xml" title="RSD" href="https://www.naturalearthdata.com/xmlrpc.php?rsd" />
|
||||
<link rel="wlwmanifest" type="application/wlwmanifest+xml" href="http://www.naturalearthdata.com/wp-includes/wlwmanifest.xml" />
|
||||
<link rel='prev' title='Admin 0 – Countries point-of-views' href='https://www.naturalearthdata.com/blog/admin-0-countries-point-of-views/' />
|
||||
<link rel='next' title='Ocean' href='https://www.naturalearthdata.com/downloads/10m-physical-vectors/10m-ocean/' />
|
||||
<meta name="generator" content="WordPress 5.5.9" />
|
||||
<link rel="canonical" href="https://www.naturalearthdata.com/downloads/10m-cultural-vectors/10m-admin-0-countries/" />
|
||||
<link rel='shortlink' href='https://www.naturalearthdata.com/?p=74' />
|
||||
<link rel="alternate" type="application/json+oembed" href="https://www.naturalearthdata.com/wp-json/oembed/1.0/embed?url=https%3A%2F%2Fwww.naturalearthdata.com%2Fdownloads%2F10m-cultural-vectors%2F10m-admin-0-countries%2F" />
|
||||
<link rel="alternate" type="text/xml+oembed" href="https://www.naturalearthdata.com/wp-json/oembed/1.0/embed?url=https%3A%2F%2Fwww.naturalearthdata.com%2Fdownloads%2F10m-cultural-vectors%2F10m-admin-0-countries%2F&format=xml" />
|
||||
|
||||
<script type="text/javascript">
|
||||
/* <![CDATA[ */
|
||||
var ajaxurl = 'https://www.naturalearthdata.com/wp-admin/admin-ajax.php';
|
||||
|
||||
/* ]]> */
|
||||
</script>
|
||||
|
||||
|
||||
<!-- begin gallery scripts -->
|
||||
<link rel="stylesheet" href="http://www.naturalearthdata.com/wp-content/plugins/featured-content-gallery/css/jd.gallery.css.php" type="text/css" media="screen" charset="utf-8"/>
|
||||
<link rel="stylesheet" href="http://www.naturalearthdata.com/wp-content/plugins/featured-content-gallery/css/jd.gallery.css" type="text/css" media="screen" charset="utf-8"/>
|
||||
<script type="text/javascript" src="http://www.naturalearthdata.com/wp-content/plugins/featured-content-gallery/scripts/mootools.v1.11.js"></script>
|
||||
<script type="text/javascript" src="http://www.naturalearthdata.com/wp-content/plugins/featured-content-gallery/scripts/jd.gallery.js.php"></script>
|
||||
<script type="text/javascript" src="http://www.naturalearthdata.com/wp-content/plugins/featured-content-gallery/scripts/jd.gallery.transitions.js"></script>
|
||||
<!-- end gallery scripts -->
|
||||
<link href="http://www.naturalearthdata.com/wp-content/themes/NEV/css/default.css" rel="stylesheet" type="text/css" />
|
||||
<style type="text/css">.recentcomments a{display:inline !important;padding:0 !important;margin:0 !important;}</style><!--[if lte IE 7]>
|
||||
<link rel="stylesheet" type="text/css" href="http://www.naturalearthdata.com/wp-content/themes/NEV/ie.css" />
|
||||
<![endif]-->
|
||||
<script src="http://www.naturalearthdata.com/wp-content/themes/NEV/js/jquery-1.2.6.min.js" type="text/javascript" charset="utf-8"></script>
|
||||
<script>
|
||||
jQuery.noConflict();
|
||||
</script>
|
||||
<script type="text/javascript" charset="utf-8">
|
||||
$(function(){
|
||||
var tabContainers = $('div#maintabdiv > div');
|
||||
tabContainers.hide().filter('#comments').show();
|
||||
|
||||
$('div#maintabdiv ul#tabnav a').click(function () {
|
||||
tabContainers.hide();
|
||||
tabContainers.filter(this.hash).show();
|
||||
$('div#maintabdiv ul#tabnav a').removeClass('current');
|
||||
$(this).addClass('current');
|
||||
return false;
|
||||
}).filter('#comments').click();
|
||||
|
||||
|
||||
});
|
||||
</script>
|
||||
|
||||
<script type="text/javascript" language="javascript" src="http://www.naturalearthdata.com/dataTables/media/js/jquery.dataTables.js"></script>
|
||||
<script type="text/javascript" charset="utf-8">
|
||||
$(document).ready(function() {
|
||||
$('#ne_table').dataTable();
|
||||
} );
|
||||
</script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="page">
|
||||
<div id="header">
|
||||
<div id="headerimg">
|
||||
<h1><a href="https://www.naturalearthdata.com/"><img src="http://www.naturalearthdata.com/wp-content/themes/NEV/images/nev_logo.png" alt="Natural Earth title="Natural Earth" /></a></h1>
|
||||
<div class="description">Free vector and raster map data at 1:10m, 1:50m, and 1:110m scales</div>
|
||||
<div class="header_search"><form method="get" id="searchform" action="https://www.naturalearthdata.com/">
|
||||
<label class="hidden" for="s">Search for:</label>
|
||||
<div><input type="text" value="" name="s" id="s" />
|
||||
<input type="submit" id="searchsubmit" value="Search" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!--<div class="translate_panel" style="align:top; margin-left:650px; top:50px;">
|
||||
<div id="google_translate_element" style="float:left;"></div>
|
||||
<script>
|
||||
function googleTranslateElementInit() {
|
||||
new google.translate.TranslateElement({
|
||||
pageLanguage: 'en'
|
||||
}, 'google_translate_element');
|
||||
}
|
||||
</script>
|
||||
<script src="http://translate.google.com/translate_a/element.js?cb=googleTranslateElementInit"></script>
|
||||
</div>-->
|
||||
</div>
|
||||
</div>
|
||||
<div id="pagemenu" style="align:bottom;">
|
||||
<ul id="page-list" class="clearfix"><li class="page_item page-item-4"><a href="https://www.naturalearthdata.com/">Home</a></li>
|
||||
<li class="page_item page-item-10"><a href="https://www.naturalearthdata.com/features/">Features</a></li>
|
||||
<li class="page_item page-item-12 page_item_has_children"><a href="https://www.naturalearthdata.com/downloads/">Downloads</a></li>
|
||||
<li class="page_item page-item-6 current_page_parent"><a href="https://www.naturalearthdata.com/blog/">Blog</a></li>
|
||||
<li class="page_item page-item-5044"><a href="https://www.naturalearthdata.com/issues/">Issues</a></li>
|
||||
<li class="page_item page-item-366"><a href="https://www.naturalearthdata.com/corrections/">Corrections</a></li>
|
||||
<li class="page_item page-item-16 page_item_has_children"><a href="https://www.naturalearthdata.com/about/">About</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<hr /><div id="main">
|
||||
<div id="content" class="narrowcolumn">
|
||||
|
||||
|
||||
<div class="post" id="post-74">
|
||||
<h2>Admin 0 – Countries</h2>
|
||||
|
||||
<div class="entry">
|
||||
<div class="downloadPromoBlock">
|
||||
<div style="float: left; width: 170px;"><img loading="lazy" class="alignleft size-thumbnail wp-image-92" title="home_image_3" src="https://www.naturalearthdata.com/wp-content/uploads/2009/09/thumb_countries.png" alt="countries_thumb" width="150" height="97"></div>
|
||||
<div style="float: left; width: 410px;"><em>There are <b>258 countries</b> in the world. Greenland as separate from Denmark. Most users will want this file instead of sovereign states, though some users will want map units instead when needing to distinguish overseas regions of France.</em></div>
|
||||
<div style="float: left; width: 410px;">
|
||||
<p><em>Natural Earth shows <a href="https://www.naturalearthdata.com/about/disputed-boundaries-policy/"><b>de facto</b></a> boundaries by default according to who controls the territory, versus <i>de jure</i>. Optional point-of-view (POV) variants are available for several dozen countries in the next section.</em> </p>
|
||||
<div class="download-link-div">
|
||||
<a class="download-link" rel="nofollow" title="Downloaded 516715 times (Shapefile, geoDB, or TIFF format)" onclick="if (window.urchinTracker) urchinTracker ('https://www.naturalearthdata.com/http//www.naturalearthdata.com/download/10m/cultural/ne_10m_admin_0_countries.zip');" href="https://www.naturalearthdata.com/http//www.naturalearthdata.com/download/10m/cultural/ne_10m_admin_0_countries.zip">Download countries</a> <span class="download-link-span">(4.7 MB) version 5.1.1</span>
|
||||
</div>
|
||||
<div class="download-link-div">
|
||||
<a class="download-link" rel="nofollow" title="Downloaded 4769876 times (Shapefile, geoDB, or TIFF format)" onclick="if (window.urchinTracker) urchinTracker ('https://www.naturalearthdata.com/http//www.naturalearthdata.com/download/10m/cultural/ne_10m_admin_0_countries_lakes.zip');" href="https://www.naturalearthdata.com/http//www.naturalearthdata.com/download/10m/cultural/ne_10m_admin_0_countries_lakes.zip">Download without boundary lakes</a> <span class="download-link-span">(4.87 MB) version 5.1.1</span>
|
||||
</div>
|
||||
<p><span id="more-74"></span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="downloadMainBlock">
|
||||
<p><strong>About</strong></p>
|
||||
<p>Countries distinguish between metropolitan (homeland) and independent and semi-independent portions of sovereign states. If you want to see the dependent overseas regions broken out (like in ISO codes, see France for example), use <a href="https://www.naturalearthdata.com/downloads/10m-political-vectors/10m-admin-0-details/">map units</a> instead.</p>
|
||||
<p>Each country is coded with a world region that roughly follows the <a href="http://unstats.un.org/unsd/methods/m49/m49regin.htm">United Nations setup</a>.</p>
|
||||
<p>Countries are coded with standard ISO and FIPS codes. French <a href="http://en.wikipedia.org/wiki/INSEE">INSEE codes</a> are also included.</p>
|
||||
<p>Includes some thematic data from the United Nations (<a href="http://data.un.org/DataMartInfo.aspx">1</a>), U.S. Central Intelligence Agency, and elsewhere.</p>
|
||||
<p><img loading="lazy" class="alignnone size-full wp-image-1896" title="countries_banner" src="https://www.naturalearthdata.com/wp-content/uploads/2009/09/banner_countries.png" alt="countries_banner" width="580" height="150"></p>
|
||||
<p><strong>Disclaimer</strong></p>
|
||||
<p>Natural Earth Vector draws boundaries of countries according to defacto status. We show who actually controls the situation on the ground. Please feel free to mashup our <a href="https://www.naturalearthdata.com/downloads/10m-cultural-vectors/10m-admin-0-breakaway-disputed-areas/">disputed area themes</a> to match your particular political outlook.</p>
|
||||
<p><strong>Known Problems</strong></p>
|
||||
<p>None.</p>
|
||||
<p><strong>Version History</strong></p>
|
||||
<ul>
|
||||
<li>
|
||||
<a rel="nofollow" title="Download version 5.1.1 of ne_10m_admin_0_countries.zip" href="https://www.naturalearthdata.com/http//www.naturalearthdata.com/download/10m/cultural/ne_10m_admin_0_countries.zip">5.1.1</a>
|
||||
</li>
|
||||
<li>
|
||||
<a rel="nofollow" title="Download version 5.1.0 of ne_10m_admin_0_countries.zip" href="https://www.naturalearthdata.com/http//www.naturalearthdata.com/download/10m/cultural/ne_10m_admin_0_countries.zip?version=5.1.0">5.1.0</a>
|
||||
</li>
|
||||
<li>
|
||||
<a rel="nofollow" title="Download version 5.0.1 of ne_10m_admin_0_countries.zip" href="https://www.naturalearthdata.com/http//www.naturalearthdata.com/download/10m/cultural/ne_10m_admin_0_countries.zip?version=5.0.1">5.0.1</a>
|
||||
</li>
|
||||
<li>
|
||||
<a rel="nofollow" title="Download version 5.0.0 of ne_10m_admin_0_countries.zip" href="https://www.naturalearthdata.com/http//www.naturalearthdata.com/download/10m/cultural/ne_10m_admin_0_countries.zip?version=5.0.0">5.0.0</a>
|
||||
</li>
|
||||
<li>
|
||||
<a rel="nofollow" title="Download version 4.1.0 of ne_10m_admin_0_countries.zip" href="https://www.naturalearthdata.com/http//www.naturalearthdata.com/download/10m/cultural/ne_10m_admin_0_countries.zip?version=4.1.0">4.1.0</a>
|
||||
</li>
|
||||
<li>
|
||||
<a rel="nofollow" title="Download version 4.0.0 of ne_10m_admin_0_countries.zip" href="https://www.naturalearthdata.com/http//www.naturalearthdata.com/download/10m/cultural/ne_10m_admin_0_countries.zip?version=4.0.0">4.0.0</a>
|
||||
</li>
|
||||
<li>
|
||||
<a rel="nofollow" title="Download version 3.1.0 of ne_10m_admin_0_countries.zip" href="https://www.naturalearthdata.com/http//www.naturalearthdata.com/download/10m/cultural/ne_10m_admin_0_countries.zip?version=3.1.0">3.1.0</a>
|
||||
</li>
|
||||
<li>
|
||||
3.0.0
|
||||
</li>
|
||||
<li>
|
||||
2.0.0
|
||||
</li>
|
||||
<li>
|
||||
1.4.0
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p><a href="https://github.com/nvkelso/natural-earth-vector/blob/master/CHANGELOG">The master changelog is available on Github »</a></p>
|
||||
</div>
|
||||
|
||||
|
||||
<p class="postmetadata2">
|
||||
<small>
|
||||
This entry was posted
|
||||
on Friday, September 25th, 2009 at 2:13 am and is filed under <a href="https://www.naturalearthdata.com/download/downloads/10m-cultural-vectors/" rel="category tag">10m-cultural-vectors</a>, <a href="https://www.naturalearthdata.com/download/downloads/featured-on-homepage/" rel="category tag">Featured on Homepage</a>.
|
||||
You can follow any responses to this entry through the <a href="https://www.naturalearthdata.com/downloads/10m-cultural-vectors/10m-admin-0-countries/feed/">RSS 2.0</a> feed.
|
||||
|
||||
Both comments and pings are currently closed.
|
||||
|
||||
|
||||
</small>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- You can start editing here. -->
|
||||
|
||||
|
||||
<!-- If comments are closed. -->
|
||||
<p class="nocomments">Comments are closed.</p>
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
<div id="sidebar">
|
||||
<ul>
|
||||
<li id="rssfeeds">Subscribe: <a href="https://www.naturalearthdata.com/feed/">Entries</a> | <a href="https://www.naturalearthdata.com/comments/feed/">Comments</a></li>
|
||||
<li id="search-3" class="widget widget_search"><h2 class="widgettitle">Search</h2>
|
||||
<form method="get" id="searchform" action="https://www.naturalearthdata.com/">
|
||||
<label class="hidden" for="s">Search for:</label>
|
||||
<div><input type="text" value="" name="s" id="s" />
|
||||
<input type="submit" id="searchsubmit" value="Search" />
|
||||
</div>
|
||||
</form>
|
||||
</li>
|
||||
<li id="linkcat-2" class="widget widget_links"><h2 class="widgettitle">Links</h2>
|
||||
|
||||
<ul class='xoxo blogroll'>
|
||||
<li><a href="http://www.nacis.org">NACIS</a></li>
|
||||
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li id="tag_cloud-3" class="widget widget_tag_cloud"><h2 class="widgettitle">Tags</h2>
|
||||
<div class="tagcloud"><a href="https://www.naturalearthdata.com/tag/10m/" class="tag-cloud-link tag-link-65 tag-link-position-1" style="font-size: 16.4pt;" aria-label="10m (2 items)">10m</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/50m/" class="tag-cloud-link tag-link-64 tag-link-position-2" style="font-size: 8pt;" aria-label="50m (1 item)">50m</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/90/" class="tag-cloud-link tag-link-61 tag-link-position-3" style="font-size: 8pt;" aria-label="90 (1 item)">90</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/180/" class="tag-cloud-link tag-link-60 tag-link-position-4" style="font-size: 8pt;" aria-label="180 (1 item)">180</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/admin-0/" class="tag-cloud-link tag-link-40 tag-link-position-5" style="font-size: 8pt;" aria-label="admin-0 (1 item)">admin-0</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/bjorn/" class="tag-cloud-link tag-link-46 tag-link-position-6" style="font-size: 8pt;" aria-label="bjorn (1 item)">bjorn</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/bounding-box/" class="tag-cloud-link tag-link-68 tag-link-position-7" style="font-size: 8pt;" aria-label="bounding box (1 item)">bounding box</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/browser/" class="tag-cloud-link tag-link-47 tag-link-position-8" style="font-size: 8pt;" aria-label="browser (1 item)">browser</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/change-log/" class="tag-cloud-link tag-link-74 tag-link-position-9" style="font-size: 8pt;" aria-label="change log (1 item)">change log</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/corrections/" class="tag-cloud-link tag-link-34 tag-link-position-10" style="font-size: 8pt;" aria-label="corrections (1 item)">corrections</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/countries/" class="tag-cloud-link tag-link-41 tag-link-position-11" style="font-size: 8pt;" aria-label="countries (1 item)">countries</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/downloads/" class="tag-cloud-link tag-link-432 tag-link-position-12" style="font-size: 8pt;" aria-label="Downloads (1 item)">Downloads</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/error/" class="tag-cloud-link tag-link-67 tag-link-position-13" style="font-size: 8pt;" aria-label="error (1 item)">error</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/extent/" class="tag-cloud-link tag-link-69 tag-link-position-14" style="font-size: 8pt;" aria-label="extent (1 item)">extent</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/ext-js/" class="tag-cloud-link tag-link-56 tag-link-position-15" style="font-size: 8pt;" aria-label="ext js (1 item)">ext js</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/forums/" class="tag-cloud-link tag-link-35 tag-link-position-16" style="font-size: 8pt;" aria-label="forums (1 item)">forums</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/geoext/" class="tag-cloud-link tag-link-57 tag-link-position-17" style="font-size: 8pt;" aria-label="geoext (1 item)">geoext</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/hans/" class="tag-cloud-link tag-link-63 tag-link-position-18" style="font-size: 8pt;" aria-label="hans (1 item)">hans</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/imagery/" class="tag-cloud-link tag-link-59 tag-link-position-19" style="font-size: 8pt;" aria-label="imagery (1 item)">imagery</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/import/" class="tag-cloud-link tag-link-66 tag-link-position-20" style="font-size: 8pt;" aria-label="import (1 item)">import</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/mapnik/" class="tag-cloud-link tag-link-53 tag-link-position-21" style="font-size: 8pt;" aria-label="mapnik (1 item)">mapnik</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/maptiler/" class="tag-cloud-link tag-link-51 tag-link-position-22" style="font-size: 8pt;" aria-label="maptiler (1 item)">maptiler</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/map-tiles/" class="tag-cloud-link tag-link-49 tag-link-position-23" style="font-size: 8pt;" aria-label="map tiles (1 item)">map tiles</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/marine-boundary/" class="tag-cloud-link tag-link-76 tag-link-position-24" style="font-size: 8pt;" aria-label="marine boundary (1 item)">marine boundary</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/national-parks/" class="tag-cloud-link tag-link-75 tag-link-position-25" style="font-size: 8pt;" aria-label="national parks (1 item)">national parks</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/new-data/" class="tag-cloud-link tag-link-36 tag-link-position-26" style="font-size: 8pt;" aria-label="new data (1 item)">new data</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/nsd/" class="tag-cloud-link tag-link-72 tag-link-position-27" style="font-size: 8pt;" aria-label="nsd (1 item)">nsd</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/openlayers/" class="tag-cloud-link tag-link-55 tag-link-position-28" style="font-size: 8pt;" aria-label="openlayers (1 item)">openlayers</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/physical-labels/" class="tag-cloud-link tag-link-38 tag-link-position-29" style="font-size: 8pt;" aria-label="physical labels (1 item)">physical labels</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/pngng/" class="tag-cloud-link tag-link-52 tag-link-position-30" style="font-size: 8pt;" aria-label="pngng (1 item)">pngng</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/populated-places/" class="tag-cloud-link tag-link-39 tag-link-position-31" style="font-size: 8pt;" aria-label="populated places (1 item)">populated places</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/raster/" class="tag-cloud-link tag-link-58 tag-link-position-32" style="font-size: 16.4pt;" aria-label="raster (2 items)">raster</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/terrestrial-hypsography/" class="tag-cloud-link tag-link-45 tag-link-position-33" style="font-size: 8pt;" aria-label="terrestrial hypsography (1 item)">terrestrial hypsography</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/tfw/" class="tag-cloud-link tag-link-62 tag-link-position-34" style="font-size: 8pt;" aria-label="tfw (1 item)">tfw</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/thematic-mapping/" class="tag-cloud-link tag-link-48 tag-link-position-35" style="font-size: 8pt;" aria-label="thematic mapping (1 item)">thematic mapping</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/themese/" class="tag-cloud-link tag-link-37 tag-link-position-36" style="font-size: 8pt;" aria-label="themese (1 item)">themese</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/tif/" class="tag-cloud-link tag-link-71 tag-link-position-37" style="font-size: 8pt;" aria-label="tif (1 item)">tif</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/tilecache/" class="tag-cloud-link tag-link-54 tag-link-position-38" style="font-size: 8pt;" aria-label="tilecache (1 item)">tilecache</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/tiles/" class="tag-cloud-link tag-link-50 tag-link-position-39" style="font-size: 8pt;" aria-label="tiles (1 item)">tiles</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/time-zones/" class="tag-cloud-link tag-link-44 tag-link-position-40" style="font-size: 8pt;" aria-label="time zones (1 item)">time zones</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/towns/" class="tag-cloud-link tag-link-42 tag-link-position-41" style="font-size: 8pt;" aria-label="towns (1 item)">towns</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/transportation/" class="tag-cloud-link tag-link-43 tag-link-position-42" style="font-size: 8pt;" aria-label="transportation (1 item)">transportation</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/update/" class="tag-cloud-link tag-link-33 tag-link-position-43" style="font-size: 22pt;" aria-label="update (3 items)">update</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/visitors/" class="tag-cloud-link tag-link-73 tag-link-position-44" style="font-size: 8pt;" aria-label="visitors (1 item)">visitors</a>
|
||||
<a href="https://www.naturalearthdata.com/tag/world-file/" class="tag-cloud-link tag-link-70 tag-link-position-45" style="font-size: 8pt;" aria-label="world file (1 item)">world file</a></div>
|
||||
</li>
|
||||
<li id="recent-comments-3" class="widget widget_recent_comments"><h2 class="widgettitle">Recent Comments</h2>
|
||||
<ul id="recentcomments"><li class="recentcomments"><span class="comment-author-link"><a href='http://www.bbscode.top/index.php/2022/03/17/aligning-natural-earth-geojson-and-raster-to-render-in-d3/' rel='external nofollow ugc' class='url'>Aligning Natural Earth Geojson and Raster to render in D3 – BBSCODE</a></span> on <a href="https://www.naturalearthdata.com/downloads/50m-raster-data/50m-shaded-relief/comment-page-1/#comment-6144">1:50m Shaded Relief</a></li><li class="recentcomments"><span class="comment-author-link"><a href='https://wp.csusm.edu/adahn/2022/03/14/qgis-a-mapping-tool/' rel='external nofollow ugc' class='url'>QGIS a Mapping Tool – April Dahn</a></span> on <a href="https://www.naturalearthdata.com/downloads/50m-raster-data/50m-shaded-relief/comment-page-1/#comment-6143">1:50m Shaded Relief</a></li><li class="recentcomments"><span class="comment-author-link"><a href='https://wp.csusm.edu/rsheehan/2022/03/13/more-mapping-with-qgis/' rel='external nofollow ugc' class='url'>More Mapping with QGIS – History 502</a></span> on <a href="https://www.naturalearthdata.com/downloads/50m-raster-data/50m-shaded-relief/comment-page-1/#comment-6142">1:50m Shaded Relief</a></li><li class="recentcomments"><span class="comment-author-link"><a href='http://ivermectin6mg.quest' rel='external nofollow ugc' class='url'>buy ivermectin 12 mg tablets</a></span> on <a href="https://www.naturalearthdata.com/forums/topic/download-urls-double-slash/comment-page-1/#comment-6141">Download URLs – double slash</a></li><li class="recentcomments"><span class="comment-author-link"><a href='https://slacker.ro/2021/10/01/building-a-beautiful-and-clear-map-from-massive-complex-data/' rel='external nofollow ugc' class='url'>Building A Beautiful And Clear Map From Massive, Complex Data – Slacker News</a></span> on <a href="https://www.naturalearthdata.com/downloads/10m-raster-data/10m-shaded-relief/comment-page-1/#comment-6140">1:10m Shaded Relief</a></li></ul></li>
|
||||
<li id="bbp_topics_widget-2" class="widget widget_display_topics"><h2 class="widgettitle">Recent Forum Topics</h2>
|
||||
|
||||
<ul class="bbp-topics-widget newness">
|
||||
|
||||
|
||||
<li>
|
||||
<a class="bbp-forum-title" href="https://www.naturalearthdata.com/forums/topic/natural-earth-in-wagner-vii/">Natural Earth in Wagner VII</a>
|
||||
|
||||
|
||||
by <span class="topic-author"><a href="https://www.naturalearthdata.com/forums/users/frax/" title="View Hugo Ahlenius's profile" class="bbp-author-link"><span class="bbp-author-avatar"><img alt='' src='http://2.gravatar.com/avatar/2bd3b3347fd34f7dd734bf4b78fb353d?s=14&d=retro&r=g' srcset='http://2.gravatar.com/avatar/2bd3b3347fd34f7dd734bf4b78fb353d?s=28&d=retro&r=g 2x' class='avatar avatar-14 photo' height='14' width='14' loading='lazy'/></span><span class="bbp-author-name">Hugo Ahlenius</span></a></span>
|
||||
|
||||
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
<a class="bbp-forum-title" href="https://www.naturalearthdata.com/forums/topic/downloads-are-404ing/">Downloads are 404ing</a>
|
||||
|
||||
|
||||
by <span class="topic-author"><a href="https://www.naturalearthdata.com/forums/users/nathaniel/" title="View Nathaniel's profile" class="bbp-author-link"><span class="bbp-author-avatar"><img alt='' src='http://2.gravatar.com/avatar/e679fead4b7cb50b7b60e7c4f99bc348?s=14&d=retro&r=g' srcset='http://2.gravatar.com/avatar/e679fead4b7cb50b7b60e7c4f99bc348?s=28&d=retro&r=g 2x' class='avatar avatar-14 photo' height='14' width='14' loading='lazy'/></span><span class="bbp-author-name">Nathaniel</span></a></span>
|
||||
|
||||
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
<a class="bbp-forum-title" href="https://www.naturalearthdata.com/forums/topic/disputed-territories-type-field/">Disputed Territories: "type" field</a>
|
||||
|
||||
|
||||
by <span class="topic-author"><a href="https://www.naturalearthdata.com/forums/users/alykat/" title="View alykat's profile" class="bbp-author-link"><span class="bbp-author-avatar"><img alt='' src='http://2.gravatar.com/avatar/8c60e36c6b6542afb738eb950429d6ba?s=14&d=retro&r=g' srcset='http://2.gravatar.com/avatar/8c60e36c6b6542afb738eb950429d6ba?s=28&d=retro&r=g 2x' class='avatar avatar-14 photo' height='14' width='14' loading='lazy'/></span><span class="bbp-author-name">alykat</span></a></span>
|
||||
|
||||
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
<a class="bbp-forum-title" href="https://www.naturalearthdata.com/forums/topic/iso-code-confusion/">ISO code confusion</a>
|
||||
|
||||
|
||||
by <span class="topic-author"><a href="https://www.naturalearthdata.com/forums/users/nth/" title="View nth's profile" class="bbp-author-link"><span class="bbp-author-avatar"><img alt='' src='http://0.gravatar.com/avatar/f5060dc4d25dbced2933380279e0c1c5?s=14&d=retro&r=g' srcset='http://0.gravatar.com/avatar/f5060dc4d25dbced2933380279e0c1c5?s=28&d=retro&r=g 2x' class='avatar avatar-14 photo' height='14' width='14' loading='lazy'/></span><span class="bbp-author-name">nth</span></a></span>
|
||||
|
||||
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
<a class="bbp-forum-title" href="https://www.naturalearthdata.com/forums/topic/bad-adm1name-encoding-in-version-3-0-0-and-missing-diacritics-in-name/">Bad ADM1NAME, encoding in version 3.0.0 and missing diacritics in NAME</a>
|
||||
|
||||
|
||||
by <span class="topic-author"><a href="https://www.naturalearthdata.com/forums/users/pfunes/" title="View pfunes's profile" class="bbp-author-link"><span class="bbp-author-avatar"><img alt='' src='http://2.gravatar.com/avatar/eded29f5ea7062bfe350281260b0596b?s=14&d=retro&r=g' srcset='http://2.gravatar.com/avatar/eded29f5ea7062bfe350281260b0596b?s=28&d=retro&r=g 2x' class='avatar avatar-14 photo' height='14' width='14' loading='lazy'/></span><span class="bbp-author-name">pfunes</span></a></span>
|
||||
|
||||
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
<a class="bbp-forum-title" href="https://www.naturalearthdata.com/forums/topic/u-s-county-shape-file-2/">U.S. County Shape File</a>
|
||||
|
||||
|
||||
by <span class="topic-author"><a href="https://www.naturalearthdata.com/forums/users/gzingsheim/" title="View gzingsheim's profile" class="bbp-author-link"><span class="bbp-author-avatar"><img alt='' src='http://1.gravatar.com/avatar/d617957f9d5d7d062deeadbfa011d8b6?s=14&d=retro&r=g' srcset='http://1.gravatar.com/avatar/d617957f9d5d7d062deeadbfa011d8b6?s=28&d=retro&r=g 2x' class='avatar avatar-14 photo' height='14' width='14' loading='lazy'/></span><span class="bbp-author-name">gzingsheim</span></a></span>
|
||||
|
||||
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
<a class="bbp-forum-title" href="https://www.naturalearthdata.com/forums/topic/projection-proportion-compatibility/">Projection / Proportion / Compatibility?</a>
|
||||
|
||||
|
||||
by <span class="topic-author"><a href="https://www.naturalearthdata.com/forums/users/liquidized/" title="View Liquidized's profile" class="bbp-author-link"><span class="bbp-author-avatar"><img alt='' src='http://0.gravatar.com/avatar/06422086ab7aab2b767e3df7f7a67bdd?s=14&d=retro&r=g' srcset='http://0.gravatar.com/avatar/06422086ab7aab2b767e3df7f7a67bdd?s=28&d=retro&r=g 2x' class='avatar avatar-14 photo' height='14' width='14' loading='lazy'/></span><span class="bbp-author-name">Liquidized</span></a></span>
|
||||
|
||||
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
<a class="bbp-forum-title" href="https://www.naturalearthdata.com/forums/topic/download-urls-double-slash/">Download URLs – double slash</a>
|
||||
|
||||
|
||||
by <span class="topic-author"><a href="https://www.naturalearthdata.com/forums/users/vastur/" title="View vastur's profile" class="bbp-author-link"><span class="bbp-author-avatar"><img alt='' src='http://2.gravatar.com/avatar/e07b4667376982e080d64cf0250acc9b?s=14&d=retro&r=g' srcset='http://2.gravatar.com/avatar/e07b4667376982e080d64cf0250acc9b?s=28&d=retro&r=g 2x' class='avatar avatar-14 photo' height='14' width='14' loading='lazy'/></span><span class="bbp-author-name">vastur</span></a></span>
|
||||
|
||||
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
<a class="bbp-forum-title" href="https://www.naturalearthdata.com/forums/topic/map-soft-writer-me/">map soft – writer: me</a>
|
||||
|
||||
|
||||
by <span class="topic-author"><a href="https://www.naturalearthdata.com/forums/users/krzysztof/" title="View krzysztof's profile" class="bbp-author-link"><span class="bbp-author-avatar"><img alt='' src='http://2.gravatar.com/avatar/be9767706435aa6e10d50185f6def05e?s=14&d=retro&r=g' srcset='http://2.gravatar.com/avatar/be9767706435aa6e10d50185f6def05e?s=28&d=retro&r=g 2x' class='avatar avatar-14 photo' height='14' width='14' loading='lazy'/></span><span class="bbp-author-name">krzysztof</span></a></span>
|
||||
|
||||
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
<a class="bbp-forum-title" href="https://www.naturalearthdata.com/forums/topic/unicode-encoding-issue-ne_10m_lakes-dbf/">Unicode encoding issue – ne_10m_lakes.dbf</a>
|
||||
|
||||
|
||||
by <span class="topic-author"><a href="https://www.naturalearthdata.com/forums/users/filter-1/" title="View filter.1's profile" class="bbp-author-link"><span class="bbp-author-avatar"><img alt='' src='http://1.gravatar.com/avatar/daae13b2efd52a3865320ada9ed3ae18?s=14&d=retro&r=g' srcset='http://1.gravatar.com/avatar/daae13b2efd52a3865320ada9ed3ae18?s=28&d=retro&r=g 2x' class='avatar avatar-14 photo' height='14' width='14' loading='lazy'/></span><span class="bbp-author-name">filter.1</span></a></span>
|
||||
|
||||
|
||||
</li>
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
<div id="footer">
|
||||
<div id="footerarea">
|
||||
<div id="footerlogos">
|
||||
<p>Supported by:</p>
|
||||
<div class="footer-ad-box">
|
||||
<a href="http://www.nacis.org" target="_blank"><img src="http://www.naturalearthdata.com/wp-content/themes/NEV/images/nacis.png" alt="NACIS" /></a>
|
||||
</div>
|
||||
<div class="footer-ad-box">
|
||||
<a href="http://www.cartotalk.com" target="_blank"><img src="http://www.naturalearthdata.com/wp-content/themes/NEV/images/cartotalk_ad.png" alt="Cartotalk" /></a>
|
||||
</div>
|
||||
<div class="footer-ad-box">
|
||||
<img src="http://www.naturalearthdata.com/wp-content/themes/NEV/images/mapgiving.png" alt="Mapgiving" />
|
||||
</div>
|
||||
<div class="footer-ad-box">
|
||||
<a href="http://www.geography.wisc.edu/cartography/" target="_blank"><img src="http://www.naturalearthdata.com/wp-content/themes/NEV/images/wisconsin.png" alt="University of Wisconsin Madison - Cartography Dept." /></a>
|
||||
</div>
|
||||
<div class="footer-ad-box">
|
||||
<a href="http://www.shadedrelief.com" target="_blank"><img src="http://www.naturalearthdata.com/wp-content/themes/NEV/images/shaded_relief.png" alt="Shaded Relief" /></a>
|
||||
</div>
|
||||
<div class="footer-ad-box">
|
||||
<a href="http://www.xnrproductions.com " target="_blank"><img src="http://www.naturalearthdata.com/wp-content/themes/NEV/images/xnr.png" alt="XNR Productions" /></a>
|
||||
</div>
|
||||
|
||||
<p style="clear:both;"></p>
|
||||
|
||||
<div class="footer-ad-box">
|
||||
<a href="http://www.freac.fsu.edu" target="_blank"><img src="http://www.naturalearthdata.com/wp-content/themes/NEV/images/fsu.png" alt="Florida State University - FREAC" /></a>
|
||||
</div>
|
||||
<div class="footer-ad-box">
|
||||
<a href="http://www.springercartographics.com" target="_blank"><img src="http://www.naturalearthdata.com/wp-content/themes/NEV/images/scllc.png" alt="Springer Cartographics LLC" /></a>
|
||||
</div>
|
||||
<div class="footer-ad-box">
|
||||
<a href="http://www.washingtonpost.com" target="_blank"><img src="http://www.naturalearthdata.com/wp-content/themes/NEV/images/wpost.png" alt="Washington Post" /></a>
|
||||
</div>
|
||||
<div class="footer-ad-box">
|
||||
<a href="http://www.redgeographics.com" target="_blank"><img src="http://www.naturalearthdata.com/wp-content/themes/NEV/images/redgeo.png" alt="Red Geographics" /></a>
|
||||
</div>
|
||||
<div class="footer-ad-box">
|
||||
<a href="http://kelsocartography.com/blog " target="_blank"><img src="http://www.naturalearthdata.com/wp-content/themes/NEV/images/kelso.png" alt="Kelso Cartography" /></a>
|
||||
</div>
|
||||
|
||||
<p style="clear:both;"></p>
|
||||
<div class="footer-ad-box">
|
||||
<a href="http://www.avenza.com" target="_blank"><img src="http://www.naturalearthdata.com/wp-content/themes/NEV/images/avenza.png" alt="Avenza Systems Inc." /></a>
|
||||
</div>
|
||||
<div class="footer-ad-box">
|
||||
<a href="http://www.stamen.com" target="_blank"><img src="http://www.naturalearthdata.com/wp-content/themes/NEV/images/stamen_ne_logo.png" alt="Stamen Design" /></a>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p style="clear:both;"></p>
|
||||
<span id="footerleft">
|
||||
© 2009 - 2022. Natural Earth. All rights reserved.
|
||||
</span>
|
||||
<span id="footerright">
|
||||
<!-- Please help promote WordPress and simpleX. Do not remove -->
|
||||
<div>Powered by <a href="http://wordpress.org/">WordPress</a></div>
|
||||
<div><a href="http://www.naturalearthdata.com/wp-admin">Staff Login »</a></div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<script type='text/javascript' src='http://www.naturalearthdata.com/wp-includes/js/wp-embed.min.js?ver=5.5.9' id='wp-embed-js'></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
<!-- Dynamic page generated in 0.242 seconds. -->
|
||||
<!-- Cached page generated by WP-Super-Cache on 2022-05-09 00:23:33 -->
|
||||
|
||||
<!-- Compression = gzip -->
|
||||
<!-- super cache -->
|
||||
1
tools/countries/ne_10m_admin_0_countries.VERSION.txt
Normal file
1
tools/countries/ne_10m_admin_0_countries.VERSION.txt
Normal file
@ -0,0 +1 @@
|
||||
5.1.1
|
||||
1
tools/countries/ne_10m_admin_0_countries.cpg
Normal file
1
tools/countries/ne_10m_admin_0_countries.cpg
Normal file
@ -0,0 +1 @@
|
||||
UTF-8
|
||||
BIN
tools/countries/ne_10m_admin_0_countries.dbf
Normal file
BIN
tools/countries/ne_10m_admin_0_countries.dbf
Normal file
Binary file not shown.
1
tools/countries/ne_10m_admin_0_countries.prj
Normal file
1
tools/countries/ne_10m_admin_0_countries.prj
Normal file
@ -0,0 +1 @@
|
||||
GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137.0,298.257223563]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]]
|
||||
BIN
tools/countries/ne_10m_admin_0_countries.shp
Normal file
BIN
tools/countries/ne_10m_admin_0_countries.shp
Normal file
Binary file not shown.
BIN
tools/countries/ne_10m_admin_0_countries.shx
Normal file
BIN
tools/countries/ne_10m_admin_0_countries.shx
Normal file
Binary file not shown.
BIN
tools/ne_10m_admin_0_countries.zip
Normal file
BIN
tools/ne_10m_admin_0_countries.zip
Normal file
Binary file not shown.
93
tools/package-lock.json
generated
Normal file
93
tools/package-lock.json
generated
Normal file
@ -0,0 +1,93 @@
|
||||
{
|
||||
"name": "tools",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "tools",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"shapefile": "0.6.6"
|
||||
}
|
||||
},
|
||||
"node_modules/array-source": {
|
||||
"version": "0.0.4",
|
||||
"resolved": "https://registry.npmjs.org/array-source/-/array-source-0.0.4.tgz",
|
||||
"integrity": "sha512-frNdc+zBn80vipY+GdcJkLEbMWj3xmzArYApmUGxoiV8uAu/ygcs9icPdsGdA26h0MkHUMW6EN2piIvVx+M5Mw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-source": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/file-source/-/file-source-0.6.1.tgz",
|
||||
"integrity": "sha512-1R1KneL7eTXmXfKxC10V/9NeGOdbsAXJ+lQ//fvvcHUgtaZcZDWNJNblxAoVOyV1cj45pOtUrR3vZTBwqcW8XA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"stream-source": "0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/path-source": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/path-source/-/path-source-0.1.3.tgz",
|
||||
"integrity": "sha512-dWRHm5mIw5kw0cs3QZLNmpUWty48f5+5v9nWD2dw3Y0Hf+s01Ag8iJEWV0Sm0kocE8kK27DrIowha03e1YR+Qw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"array-source": "0.0",
|
||||
"file-source": "0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/shapefile": {
|
||||
"version": "0.6.6",
|
||||
"resolved": "https://registry.npmjs.org/shapefile/-/shapefile-0.6.6.tgz",
|
||||
"integrity": "sha512-rLGSWeK2ufzCVx05wYd+xrWnOOdSV7xNUW5/XFgx3Bc02hBkpMlrd2F1dDII7/jhWzv0MSyBFh5uJIy9hLdfuw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"array-source": "0.0",
|
||||
"commander": "2",
|
||||
"path-source": "0.1",
|
||||
"slice-source": "0.4",
|
||||
"stream-source": "0.3",
|
||||
"text-encoding": "^0.6.4"
|
||||
},
|
||||
"bin": {
|
||||
"dbf2json": "bin/dbf2json",
|
||||
"shp2json": "bin/shp2json"
|
||||
}
|
||||
},
|
||||
"node_modules/slice-source": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/slice-source/-/slice-source-0.4.1.tgz",
|
||||
"integrity": "sha512-YiuPbxpCj4hD9Qs06hGAz/OZhQ0eDuALN0lRWJez0eD/RevzKqGdUx1IOMUnXgpr+sXZLq3g8ERwbAH0bCb8vg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/stream-source": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/stream-source/-/stream-source-0.3.5.tgz",
|
||||
"integrity": "sha512-ZuEDP9sgjiAwUVoDModftG0JtYiLUV8K4ljYD1VyUMRWtbVf92474o4kuuul43iZ8t/hRuiDAx1dIJSvirrK/g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/text-encoding": {
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.6.4.tgz",
|
||||
"integrity": "sha512-hJnc6Qg3dWoOMkqP53F0dzRIgtmsAge09kxUIqGrEUS4qr5rWLckGYaQAVr+opBrIMRErGgy6f5aPnyPpyGRfg==",
|
||||
"deprecated": "no longer maintained",
|
||||
"dev": true,
|
||||
"license": "Unlicense"
|
||||
}
|
||||
}
|
||||
}
|
||||
15
tools/package.json
Normal file
15
tools/package.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "tools",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"license": "ISC",
|
||||
"author": "",
|
||||
"type": "module",
|
||||
"main": "convert-geojson-outlines.js",
|
||||
"scripts": {
|
||||
"convert": "node convert-geojson-outlines.js countries ../frontend/data/outlines --pretty"
|
||||
},
|
||||
"devDependencies": {
|
||||
"shapefile": "0.6.6"
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user