Compare commits

...

20 Commits

Author SHA1 Message Date
f2eef5be40 GeoDraw v1.0 - Merge pull request 'niklas' (#6) from niklas into master
v1.0 - Stand master für Präsentation

Reviewed-on: #6
2026-06-02 00:01:03 +02:00
pengniklas
4780557c67 docs: finalize README 2026-06-01 16:43:23 +02:00
pengniklas
861f850401 update README with correct stack and features 2026-06-01 16:15:01 +02:00
pengniklas
a426bad841 unify nav/section copy, replace toggle links with pill switch 2026-06-01 15:54:42 +02:00
pengniklas
abf93a0848 fix: replace alert with inline error, extract sidebar inline style 2026-06-01 15:19:00 +02:00
pengniklas
67935ec234 Merge branch 'niklas' of https://gitea.fhgr.ch/jakobluca/fs2026-Frontend-Semesterarbeit into niklas 2026-06-01 15:09:46 +02:00
554be835dd Merge pull request 'Add PHP backend scaffolding' (#4) from luca into niklas
Reviewed-on: #4
2026-06-01 14:56:58 +02:00
c22a69f8b1 fix scaling when drawing 2026-06-01 13:55:03 +02:00
e54f99999b Merge pull request 'Revised README' (#3) from niklas into master
Reviewed-on: #3
Reviewed-by: Luca Jakob <luca.jakob@stud.fhgr.ch>
2026-06-01 09:17:56 +02:00
77c21ee07d add country outlines 2026-06-01 09:12:35 +02:00
de32543f5e remove data from git tracking 2026-06-01 08:44:52 +02:00
f9413da3de add php backend 2026-06-01 08:40:23 +02:00
4259762281 Update README.md 2026-05-31 23:16:07 +02:00
pengniklas
f50daf8424 Revised README 2026-05-31 23:12:09 +02:00
ba05fb6298 Merge pull request 'niklas' (#2) from niklas into master
Reviewed-on: #2
2026-05-31 18:24:01 +02:00
pengniklas
858ac34ec9 accessibility improvements and CSS optimizations 2026-05-22 16:57:23 +02:00
pengniklas
a1be788d36 add JSDoc types, fix timer CSS classes, safe storage writes 2026-05-22 16:42:25 +02:00
pengniklas
aa1865d734 merge index.css into main.css, unify form styles 2026-05-22 16:31:31 +02:00
pengniklas
6d854eb4a7 fix: remove dead main.js, fix lobby footer link, clean up game.js 2026-05-22 16:22:50 +02:00
5a94f26d0c Merge pull request for 'implemented frontend' from ivan branch into master 2026-05-22 16:08:06 +02:00
53 changed files with 24421 additions and 2402 deletions

3
.gitignore vendored
View File

@ -1 +1,2 @@
node_modules
node_modules
backend/data/

120
README.md
View File

@ -1,57 +1,99 @@
# FS 2026 Frontendentwicklung
# GeoDraw — FS 2026 Frontend-Entwicklung
## Game: Drawing countries
**[Link to our presentation](https://fhgraubuenden-my.sharepoint.com/:p:/g/personal/pengniklas_fhgr_ch/IQDz4D0YTsysR7oDcVnon2lnAYEj1K0Ie75lc4-guWgusns?e=gjZJ58)**
### Gameplay loop
---
This game can be played as one person or more.
## Project Description
1. You are given a blank canvas with an example country. In this case, **Switzerland**.
You are also given the location of 2-3 major cities within that country (**Bern**, **Zürich**)
GeoDraw is a competitive browser-based geography game. Players are shown the name of a country and must draw its border from memory on a blank canvas. City markers serve as spatial hints. After each round the drawing is scored by comparing the player's polygon against the real GeoJSON border using an Intersection over Union (IoU) algorithm. The goal is to achieve the highest total score across three rounds.
2. With those references, draw the outline of the country from memory. You have 60 seconds. (may be adjusted for balancing)
Players create or join a shared lobby. Scores are stored per lobby on the backend, so multiple players can compete and compare results on a shared leaderboard.
3. After the time is up, you will be given a score based on how accurate you were. The goal is to be the best in the configured amount of rounds.
---
## How to start
From the project root, run:
```bash
php serve.php
```
Then open `http://localhost:8000/` in your browser. PHP 8.0+ is required.
---
## How to play
1. **Create or join a lobby** — create a new lobby and share its name with friends, or enter an existing lobby name to join
2. **Enter your username** — your name will appear on the leaderboard
3. **Draw** — sketch the country border from memory within 60 seconds; city dots are your hints
4. **Submit** — submit your drawing to see the real border overlaid on yours
5. **Score** — your drawing is graded on accuracy (grades S / A / B / C / D)
6. **Results** — after 3 rounds, see your total score and round breakdown
7. **Leaderboard** — compare your score with other players in the same lobby
---
## Special Features
- **Freehand canvas drawing** using the Pointer Events API (mouse & touch support)
- **Real polygon scoring** via rasterized IoU (96×96 grid) against GeoJSON country outlines
- **Reference outline overlay** revealed on the canvas after each round submission
- **City hint markers** rendered directly on the canvas with labelled pins
- **Live countdown timer** with colour-coded urgency states (green → yellow → red)
- **Per-lobby leaderboard** stored on the PHP backend, top 20 entries per lobby
- **Scroll reveal animations** on the landing page via IntersectionObserver
- **Fully responsive** layout across desktop, tablet and mobile
- **WCAG Level A accessibility** — semantic HTML, `aria-label`, `aria-hidden`, `prefers-reduced-motion`
---
## Architecture
### Stack
```
frontend
├── HTML
├── Vanilla CSS
└── Vanilla JavaScript
└── Pixi.js (TBD)
backend
└── PHP (TBD)
└── Postgres (TBD)
frontend/
├── HTML5
├── Vanilla CSS3
└── Vanilla ES6+ (ES modules)
└── Canvas 2D API + Pointer Events (drawing & scoring)
backend/
└── PHP 8.5
└── JSON file storage (backend/data/lobbies.json)
frontend/data/
├── countries.json — country metadata and city coordinates
└── outlines/*.json — GeoJSON polygon outlines (10 countries)
tools/
└── convert-geojson-outlines.js — data pipeline: Natural Earth GeoJSON → game outlines
```
The backend is planned to be written in PHP, with a database using PostgreSQL. Language is pending.
### Technologies, Libraries & Frameworks
The drawing part will likely be handled by WebGL and a canvas.
| Layer | Technology | Reason |
|---|---|---|
| HTML | Vanilla HTML5 | Semantic markup, no abstraction needed |
| CSS | Vanilla CSS3 (Flexbox + Grid) | Full control; CSS fundamentals are a learning objective |
| JavaScript | Vanilla ES6+ (ES modules) | No build step required; core learning goal of the module |
| Drawing & Scoring | Canvas 2D API + Pointer Events | Native browser APIs; IoU scoring via 96×96 raster grid |
| Country data | GeoJSON outlines | Standard geospatial format, lightweight and precise |
| Backend | PHP 8.5 | Server-side REST API |
| Storage | JSON file (lobbies.json) | Dependency-free; no database server needed for this scope |
| Testing | Node.js built-in test runner | Unit tests for IoU scoring algorithm (scoring.test.mjs) |
| Linting / Formatting | Biome | Fast unified linter and formatter |
| Package Manager | npm | Dev-only dependency (Biome) |
### Coding environment
---
This project uses [Biomejs](https://biomejs.dev/) for linting and formatting.
[Npm](https://www.npmjs.com/) is used for library management.
## Group Members & Roles
### Coding aspects
- Evaluating arbitrary shapes and determining their surface area
- Comparing two arbitrary shapes to calculate a score
- Storing and receiving data in the database
- Handling user events (drawing, clicks, navigation)
## Setup
### Frontend
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.
### Backend
Setup for backend is WIP.
| Name | Role |
|---|---|
| **Ivan Szabo** | Frontend development (initial/base: pages, scripts, styles...) |
| **Luca Jakob** | Frontend review; Backend development |
| **Niklas Peng** | Frontend improvements (accessibility, code quality, CSS consolidation, JSDoc...); Backend review |

238
backend/index.php Normal file
View 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);

View File

@ -1,92 +0,0 @@
[
{
"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": "Country in Western Europe, 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 }
]
}
]

View File

@ -0,0 +1,102 @@
[
{
"name": "Switzerland",
"file": "switzerland.json",
"hint": "Alpine country in Central Europe",
"cities": [
{ "name": "Bern", "lon": 7.4474, "lat": 46.948 },
{ "name": "Zurich", "lon": 8.5417, "lat": 47.3769 },
{ "name": "Geneva", "lon": 6.1432, "lat": 46.2044 }
]
},
{
"name": "Norway",
"file": "norway.json",
"hint": "Scandinavian country with long coastline",
"cities": [
{ "name": "Oslo", "lon": 10.7522, "lat": 59.9139 },
{ "name": "Bergen", "lon": 5.3221, "lat": 60.39299 },
{ "name": "Tromso", "lon": 18.9553, "lat": 69.6492 }
]
},
{
"name": "Italy",
"file": "italy.json",
"hint": "Boot-shaped peninsula in Southern Europe",
"cities": [
{ "name": "Rome", "lon": 12.4964, "lat": 41.9028 },
{ "name": "Milan", "lon": 9.19, "lat": 45.4642 },
{ "name": "Naples", "lon": 14.2681, "lat": 40.8518 }
]
},
{
"name": "Japan",
"file": "japan.json",
"hint": "Island nation in East Asia",
"cities": [
{ "name": "Tokyo", "lon": 139.6503, "lat": 35.6762 },
{ "name": "Osaka", "lon": 135.5023, "lat": 34.6937 },
{ "name": "Sapporo", "lon": 141.3545, "lat": 43.0618 }
]
},
{
"name": "Brazil",
"file": "brazil.json",
"hint": "Largest country in South America",
"cities": [
{ "name": "Brasilia", "lon": -47.8825, "lat": -15.7942 },
{ "name": "Sao Paulo", "lon": -46.6333, "lat": -23.5505 },
{ "name": "Manaus", "lon": -60.0217, "lat": -3.119 }
]
},
{
"name": "Australia",
"file": "australia.json",
"hint": "Continent and country in the Southern Hemisphere",
"cities": [
{ "name": "Canberra", "lon": 149.13, "lat": -35.2809 },
{ "name": "Sydney", "lon": 151.2093, "lat": -33.8688 },
{ "name": "Perth", "lon": 115.8613, "lat": -31.9523 }
]
},
{
"name": "France",
"file": "france.json",
"hint": "Western Europe, roughly hexagonal shape",
"cities": [
{ "name": "Paris", "lon": 2.3522, "lat": 48.8566 },
{ "name": "Lyon", "lon": 4.8357, "lat": 45.764 },
{ "name": "Marseille", "lon": 5.3698, "lat": 43.2965 }
]
},
{
"name": "India",
"file": "india.json",
"hint": "Large peninsula in South Asia",
"cities": [
{ "name": "New Delhi", "lon": 77.209, "lat": 28.6139 },
{ "name": "Mumbai", "lon": 72.8777, "lat": 19.076 },
{ "name": "Chennai", "lon": 80.2707, "lat": 13.0827 }
]
},
{
"name": "Canada",
"file": "canada.json",
"hint": "Second largest country in the world",
"cities": [
{ "name": "Ottawa", "lon": -75.6972, "lat": 45.4215 },
{ "name": "Vancouver", "lon": -123.1207, "lat": 49.2827 },
{ "name": "Toronto", "lon": -79.3832, "lat": 43.6532 }
]
},
{
"name": "Germany",
"file": "germany.json",
"hint": "Central European country",
"cities": [
{ "name": "Berlin", "lon": 13.405, "lat": 52.52 },
{ "name": "Munich", "lon": 11.582, "lat": 48.1351 },
{ "name": "Hamburg", "lon": 9.9937, "lat": 53.5511 }
]
}
]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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
}
]
]
}
}

View File

@ -8,13 +8,16 @@
<link rel="stylesheet" href="./styles/game.css" />
</head>
<body>
<noscript>
<p class="noscript-msg">GeoDraw requires JavaScript. Please enable it in your browser settings.</p>
</noscript>
<div class="wrap">
<header class="header">
<div class="container header__inner">
<a href="./index.html" class="logo">
<div class="logo__mark">
<svg viewBox="0 0 24 24" fill="none">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="white" stroke-width="1.6"/>
<path d="M3 12 Q8 8 12 12 Q16 16 21 12" stroke="white" stroke-width="1.4" fill="none"/>
<path d="M12 3 Q14 8 12 12 Q10 16 12 21" stroke="white" stroke-width="1.4" fill="none"/>
@ -22,7 +25,7 @@
</div>
<span class="logo__text">GeoDraw</span>
</a>
<nav class="nav">
<nav class="nav" aria-label="Main navigation">
<a href="lobby.html" class="nav__link">← Back to lobby</a>
</nav>
</div>
@ -63,7 +66,7 @@
<div class="canvas-area">
<div class="canvas-wrap" id="canvas-wrap">
<canvas id="draw-canvas"></canvas>
<canvas id="draw-canvas" role="application" aria-label="Drawing canvas – sketch the country border here"></canvas>
<div id="score-feedback"></div>
</div>
@ -94,7 +97,7 @@
<div class="sidebar-card">
<p class="sidebar-card__label">How to play</p>
<p style="font-size:.82rem;color:var(--ink-soft);line-height:1.55;">
<p class="sidebar-hint">
Draw the country's outline from memory. City dots are hints. Press <strong>Submit</strong> when done or wait for the timer.
</p>
</div>
@ -105,13 +108,16 @@
<!-- ── Controls ── -->
<div class="game-controls">
<button id="btn-clear" class="btn btn--ghost btn--sm">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
</svg>
Clear
</button>
<button id="btn-submit" class="btn btn--primary">
Submit &amp; Next Round →
Submit
</button>
<button id="btn-next" class="btn btn--primary" disabled>
Next Round →
</button>
</div>
@ -139,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>

View File

@ -4,18 +4,19 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GeoDraw</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;500;700;800&family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles/main.css" />
<link rel="stylesheet" href="styles/index.css" />
</head>
<body>
<noscript>
<p class="noscript-msg">GeoDraw requires JavaScript. Please enable it in your browser settings.</p>
</noscript>
<div class="wrap">
<header class="header">
<div class="container header__inner">
<a href="#hero" class="logo">
<div class="logo__mark">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="white" stroke-width="1.6"/>
<path d="M3 12 Q8 8 12 12 Q16 16 21 12" stroke="white" stroke-width="1.4" fill="none"/>
<path d="M12 3 Q14 8 12 12 Q10 16 12 21" stroke="white" stroke-width="1.4" fill="none"/>
@ -24,10 +25,9 @@
<span class="logo__text">GeoDraw</span>
</a>
<nav class="nav">
<a href="#hero" class="nav__link">Play</a>
<nav class="nav" aria-label="Main navigation">
<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">Play</a>
</nav>
</div>
</header>
@ -53,9 +53,9 @@
<div class="hero__actions reveal reveal-delay-3">
<a href="#register" class="btn btn--primary">
Start Playing
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
<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>
</a>
<a href="#about" class="btn btn--ghost">How it works</a>
<a href="#about" class="btn btn--ghost">How to play</a>
</div>
</div>
@ -84,33 +84,57 @@
</div>
</section>
<section class="about" id="about">
<div class="container about__inner">
<div class="about__left">
<p class="section-label reveal">About the game</p>
<h2 class="section-title reveal reveal-delay-1">What it is &<br>How to play</h2>
<p class="section-label reveal">How to play</p>
<h2 class="section-title reveal reveal-delay-1">About<br>the Game</h2>
<p class="about__desc reveal reveal-delay-2">
GeoDraw is a competitive browser game built on memory and precision. You get a country name, sketch its outline, and the system calculates how close you were to the real border.
</p>
<div class="mini-globe-wrap reveal reveal-delay-3">
<div class="map-outline">
<svg viewBox="0 0 100 130" xmlns="http://www.w3.org/2000/svg">
<path d="M50 4 L78 14 L90 34 L82 56 L88 82 L62 110 L36 100 L16 76 L18 46 Z"
fill="rgba(26,127,196,.12)"
stroke="var(--sea)"
stroke-width="2.5"
stroke-linejoin="round"/>
<path d="M50 4 L78 14 L90 34 L82 56 L88 82 L62 110 L36 100 L16 76 L18 46 Z"
fill="none"
stroke="var(--leaf)"
stroke-width="1.5"
stroke-dasharray="5 4"
stroke-linejoin="round"
transform="translate(4,-2) scale(0.96) translate(-2,2)"/>
</svg>
</div>
<svg class="game-preview-svg" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<!-- Grid lines -->
<line x1="0" y1="66" x2="200" y2="66" stroke="rgba(11,31,42,.06)" stroke-width="1"/>
<line x1="0" y1="133" x2="200" y2="133" stroke="rgba(11,31,42,.06)" stroke-width="1"/>
<line x1="66" y1="0" x2="66" y2="200" stroke="rgba(11,31,42,.06)" stroke-width="1"/>
<line x1="133" y1="0" x2="133" y2="200" stroke="rgba(11,31,42,.06)" stroke-width="1"/>
<!-- Reference outline (filled) -->
<path d="M100 22 L148 40 L162 76 L150 112 L124 148 L80 142 L52 108 L54 66 Z"
fill="rgba(26,127,196,.10)"
stroke="rgba(26,127,196,.5)"
stroke-width="2"
stroke-linejoin="round"/>
<!-- Player's drawn path (slightly off, freehand feel) -->
<path d="M96 26 L152 44 L164 82 L146 118 L120 152 L76 144 L48 106 L52 62 Z"
fill="none"
stroke="#1a7fc4"
stroke-width="2.5"
stroke-linejoin="round"
stroke-linecap="round"/>
<!-- City dot: Bern -->
<circle cx="96" cy="100" r="4" fill="rgba(240,180,40,.9)"/>
<circle cx="96" cy="100" r="4" fill="none" stroke="white" stroke-width="1.5"/>
<!-- City dot: Zurich -->
<circle cx="118" cy="72" r="4" fill="rgba(240,180,40,.9)"/>
<circle cx="118" cy="72" r="4" fill="none" stroke="white" stroke-width="1.5"/>
<!-- Score badge -->
<rect x="118" y="22" width="62" height="26" rx="8" fill="white" filter="url(#shadow)"/>
<defs>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-color="rgba(11,31,42,.12)"/>
</filter>
</defs>
<circle cx="131" cy="35" r="4" fill="#41b869"/>
<text x="139" y="39" font-family="Syne, sans-serif" font-weight="700" font-size="11" fill="#0b1f2a">82%</text>
</svg>
</div>
</div>
@ -148,34 +172,53 @@
<div class="container register__inner">
<div class="register__intro reveal">
<p class="section-label">Join the game</p>
<h2 class="section-title">Create your<br>lobby</h2>
<p class="section-label">Play</p>
<h2 class="section-title">Create or join<br>a lobby</h2>
<p class="register__desc">
Create a private lobby to challenge friends or play solo.
Create your own lobby and share the name with friends, or enter an existing lobby name to join their game.
</p>
</div>
<div class="register__card reveal reveal-delay-1">
<p class="card-title">Create a game</p>
<form class="form">
<div class="field">
<label for="username">Lobby name</label>
<input id="username" type="text" placeholder="myLobby" />
<!-- Toggle switch -->
<div class="lobby-toggle" role="group" aria-label="Lobby action">
<button type="button" class="lobby-toggle__btn lobby-toggle__btn--active" id="toggle-create-btn">Create</button>
<button type="button" class="lobby-toggle__btn" id="toggle-join-btn">Join</button>
</div>
<!-- Create form (shown by default) -->
<div id="form-create">
<p class="card-title">Create a game</p>
<div class="form">
<div class="field">
<label for="lobby-create">Lobby name</label>
<input id="lobby-create" type="text" placeholder="myLobby" />
</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>
</button>
<span class="field-error" id="lobby-error" role="alert"></span>
</div>
</div>
<div class="field">
<label for="password">Password</label>
<input id="password" type="password" placeholder="1234" autocomplete="new-password" />
<!-- Join form (hidden by default) -->
<div id="form-join" hidden>
<p class="card-title">Join a game</p>
<div class="form">
<div class="field">
<label for="lobby-join">Lobby name</label>
<input id="lobby-join" type="text" placeholder="myLobby" />
</div>
<button type="button" class="btn btn--primary btn--full" id="join-btn">
Join 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>
</button>
<span class="field-error" id="join-error" role="alert"></span>
</div>
</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"><path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<p class="form-footer"><a href="#" style="color:var(--sea);font-weight:600;">Join a game instead</a></p>
</form>
</div>
</div>
@ -187,7 +230,6 @@
<div class="container footer__inner">
<div class="footer__left">
</div>
<div class="footer__center">
@ -203,7 +245,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>

View File

@ -6,16 +6,19 @@
<title>GeoDraw — Leaderboard</title>
<link rel="stylesheet" href="./styles/main.css" />
<link rel="stylesheet" href="./styles/leader.css" />
</head>
<body>
<noscript>
<p class="noscript-msg">GeoDraw requires JavaScript. Please enable it in your browser settings.</p>
</noscript>
<div class="wrap">
<header class="header">
<div class="container header__inner">
<a href="index.html" class="logo">
<div class="logo__mark">
<svg viewBox="0 0 24 24" fill="none">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="white" stroke-width="1.6"/>
<path d="M3 12 Q8 8 12 12 Q16 16 21 12" stroke="white" stroke-width="1.4" fill="none"/>
<path d="M12 3 Q14 8 12 12 Q10 16 12 21" stroke="white" stroke-width="1.4" fill="none"/>
@ -23,7 +26,7 @@
</div>
<span class="logo__text">GeoDraw</span>
</a>
<nav class="nav">
<nav class="nav" aria-label="Main navigation">
<a href="index.html" class="nav__link">Home</a>
<a href="lobby.html" class="nav__cta">Play again →</a>
</nav>
@ -86,69 +89,6 @@
</div>
<script src="scripts/storage.js"></script>
<script>
document.addEventListener("DOMContentLoaded", () => {
const currentPlayer = Storage.getPlayerName();
const body = document.getElementById("lb-body");
function render() {
const board = Storage.getLeaderboard();
body.innerHTML = "";
if (!board.length) {
body.innerHTML = `
<div class="lb-empty">
<div class="lb-empty__icon">🏁</div>
<p class="lb-empty__text">No games played yet. Be the first on the board!</p>
<a href="lobby.html" class="btn btn--primary btn--sm">Play now →</a>
</div>`;
return;
}
const medals = ["🥇","🥈","🥉"];
const scoreClasses = ["gold","silver","bronze"];
board.forEach((entry, i) => {
const rank = i + 1;
const isYou = entry.name === currentPlayer;
const date = entry.date
? new Date(entry.date).toLocaleDateString("en-CH", { day:"2-digit", month:"short" })
: "—";
const rounds = (entry.scores || []).join(" · ") || "—";
const row = document.createElement("div");
row.className = `lb-row${rank <= 3 ? ` rank-${rank}` : ""}`;
row.style.animationDelay = `${i * 0.05}s`;
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-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>
`;
body.appendChild(row);
});
// Your latest score bar
const latest = board.find(e => e.name === currentPlayer);
if (latest) {
document.getElementById("your-bar").style.display = "flex";
document.getElementById("your-bar-name").textContent = latest.name;
document.getElementById("your-bar-score").textContent = latest.totalScore;
}
}
function escHtml(str) {
return String(str)
.replace(/&/g,"&amp;")
.replace(/</g,"&lt;")
.replace(/>/g,"&gt;");
}
render();
});
</script>
<script type="module" src="scripts/leaderboard.js"></script>
</body>
</html>

View File

@ -8,13 +8,16 @@
<link rel="stylesheet" href="./styles/lobby.css" />
</head>
<body>
<noscript>
<p class="noscript-msg">GeoDraw requires JavaScript. Please enable it in your browser settings.</p>
</noscript>
<div class="wrap">
<header class="header">
<div class="container header__inner">
<a href="index.html" class="logo">
<div class="logo__mark">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="white" stroke-width="1.6"/>
<path d="M3 12 Q8 8 12 12 Q16 16 21 12" stroke="white" stroke-width="1.4" fill="none"/>
<path d="M12 3 Q14 8 12 12 Q10 16 12 21" stroke="white" stroke-width="1.4" fill="none"/>
@ -22,7 +25,7 @@
</div>
<span class="logo__text">GeoDraw</span>
</a>
<nav class="nav">
<nav class="nav" aria-label="Main navigation">
<a href="index.html" class="nav__link">Home</a>
<a href="leaderboard.html" class="nav__link">Leaderboard</a>
</nav>
@ -80,7 +83,7 @@
<button id="btn-start" class="btn btn--primary btn--full">
Start Game
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<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>
</button>
@ -99,7 +102,7 @@
</div>
<div class="footer__center">
<a href="html" class="footer__brand">GeoDraw</a>
<a href="index.html" class="footer__brand">GeoDraw</a>
<p class="footer__sub">Browser geography game</p>
</div>
@ -111,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>

View File

@ -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"
}
}
}
}

View File

@ -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"

View File

@ -5,209 +5,19 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GeoDraw — Results</title>
<link rel="stylesheet" href="styles/main.css" />
<style>
.results-page {
min-height: calc(100vh - var(--hh));
display: grid;
place-items: center;
padding: 60px 0;
}
.results-inner {
width: 100%;
max-width: 680px;
display: grid;
gap: 24px;
}
.results-header {
text-align: center;
}
.results-emoji {
font-size: 3.5rem;
margin-bottom: 16px;
display: block;
animation: popIn .5s cubic-bezier(.34,1.56,.64,1) both;
}
@keyframes popIn {
from { transform: scale(0); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.results-title {
font-family: 'Syne', sans-serif;
font-weight: 800;
font-size: clamp(2rem, 5vw, 3.4rem);
letter-spacing: -.03em;
line-height: 1.05;
margin-bottom: 8px;
}
.results-player {
font-size: 1rem;
color: var(--ink-soft);
}
.results-player strong { color: var(--ink); }
/* Total score */
.score-total-card {
background: linear-gradient(135deg, var(--sea) 0%, #159fd4 50%, var(--leaf) 100%);
border-radius: var(--r-xl);
padding: 36px 40px;
text-align: center;
color: var(--white);
box-shadow: 0 16px 40px rgba(26,127,196,.3);
animation: slideUp .5s .1s cubic-bezier(.34,1.56,.64,1) both;
}
@keyframes slideUp {
from { transform: translateY(30px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.score-total-label {
font-size: .78rem;
font-weight: 700;
letter-spacing: .1em;
text-transform: uppercase;
opacity: .78;
margin-bottom: 8px;
}
.score-total-num {
font-family: 'Syne', sans-serif;
font-weight: 800;
font-size: clamp(4rem, 10vw, 6rem);
line-height: 1;
letter-spacing: -.04em;
}
.score-total-max {
font-size: 1.4rem;
opacity: .6;
font-weight: 400;
}
.score-grade {
display: inline-block;
margin-top: 12px;
padding: 4px 18px;
border-radius: 999px;
background: rgba(255,255,255,.22);
font-family: 'Syne', sans-serif;
font-weight: 800;
font-size: 1.1rem;
letter-spacing: .04em;
}
/* Round breakdown */
.rounds-card {
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-xl);
padding: 28px 32px;
box-shadow: var(--shadow-sm);
animation: slideUp .5s .2s cubic-bezier(.34,1.56,.64,1) both;
}
.rounds-card-title {
font-family: 'Syne', sans-serif;
font-weight: 700;
font-size: 1rem;
letter-spacing: -.01em;
margin-bottom: 20px;
color: var(--ink-soft);
}
.round-rows { display: grid; gap: 14px; }
.round-row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 16px;
}
.round-row__label {
font-size: .82rem;
font-weight: 600;
color: var(--ink-soft);
white-space: nowrap;
}
.round-row__bar-wrap {
height: 8px;
background: var(--cream);
border-radius: 999px;
overflow: hidden;
}
.round-row__bar {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--sea), var(--leaf));
transition: width .8s cubic-bezier(.34,1.56,.64,1);
}
.round-row__score {
font-family: 'Syne', sans-serif;
font-weight: 800;
font-size: .9rem;
color: var(--sea);
min-width: 36px;
text-align: right;
}
/* Country list */
.countries-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.country-tag {
padding: 5px 12px;
border-radius: 999px;
background: var(--sea-dim);
color: var(--sea);
font-size: .8rem;
font-weight: 600;
}
/* Actions */
.results-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
animation: slideUp .5s .3s cubic-bezier(.34,1.56,.64,1) both;
}
.results-actions .btn { flex: 1 1 180px; }
@media (max-width: 600px) {
.results-page { padding: 40px 0; }
.score-total-card { padding: 28px 24px; }
.rounds-card { padding: 22px 20px; }
.results-actions .btn { flex-basis: 100%; }
}
@media (max-width: 360px) {
.score-total-num { font-size: 3.4rem; }
}
</style>
<link rel="stylesheet" href="styles/results.css" />
</head>
<body>
<noscript>
<p class="noscript-msg">GeoDraw requires JavaScript. Please enable it in your browser settings.</p>
</noscript>
<div class="wrap">
<header class="header">
<div class="container header__inner">
<a href="index.html" class="logo">
<div class="logo__mark">
<svg viewBox="0 0 24 24" fill="none">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="white" stroke-width="1.6"/>
<path d="M3 12 Q8 8 12 12 Q16 16 21 12" stroke="white" stroke-width="1.4" fill="none"/>
<path d="M12 3 Q14 8 12 12 Q10 16 12 21" stroke="white" stroke-width="1.4" fill="none"/>
@ -215,7 +25,7 @@
</div>
<span class="logo__text">GeoDraw</span>
</a>
<nav class="nav">
<nav class="nav" aria-label="Main navigation">
<a href="index.html" class="nav__link">Home</a>
<a href="leaderboard.html" class="nav__link">Leaderboard</a>
<a href="lobby.html" class="nav__cta">Play again →</a>
@ -286,71 +96,6 @@
</div>
<script src="scripts/storage.js"></script>
<script src="scripts/scoring.js"></script>
<script>
document.addEventListener("DOMContentLoaded", () => {
const state = Storage.getGameState();
const name = Storage.getPlayerName();
document.getElementById("results-player").textContent = name;
if (!state) {
document.getElementById("results-title").textContent = "No game found.";
document.getElementById("total-score").textContent = "—";
return;
}
const { scores, totalScore, countries } = state;
// Emoji + title based on score
const avg = totalScore / 3;
let emoji = "🌍", title = "Not bad!";
if (avg >= 85) { emoji = "🔥"; title = "Incredible!"; }
else if (avg >= 70) { emoji = "🎉"; title = "Well done!"; }
else if (avg >= 50) { emoji = "👏"; title = "Good effort!"; }
else { emoji = "😅"; title = "Keep practising!"; }
document.getElementById("results-emoji").textContent = emoji;
document.getElementById("results-title").textContent = title;
document.getElementById("total-score").textContent = totalScore;
// Grade
const grade = Scoring.getGrade(avg);
const gradeEl = document.getElementById("total-grade");
gradeEl.textContent = `Grade ${grade.label}`;
// Round rows
const rowsContainer = document.getElementById("round-rows");
(scores || []).forEach((s, i) => {
const row = document.createElement("div");
row.className = "round-row";
row.innerHTML = `
<span class="round-row__label">Round ${i + 1}</span>
<div class="round-row__bar-wrap">
<div class="round-row__bar" style="width:0%" data-target="${s}"></div>
</div>
<span class="round-row__score">${s}%</span>
`;
rowsContainer.appendChild(row);
});
// Animate bars
requestAnimationFrame(() => {
document.querySelectorAll(".round-row__bar").forEach(bar => {
bar.style.width = bar.dataset.target + "%";
});
});
// Country tags
const tagsContainer = document.getElementById("countries-row");
(countries || []).forEach(c => {
const tag = document.createElement("span");
tag.className = "country-tag";
tag.textContent = c;
tagsContainer.appendChild(tag);
});
});
</script>
<script type="module" src="scripts/results.js"></script>
</body>
</html>

57
frontend/scripts/api.js Normal file
View 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 });
}

View File

@ -1,116 +1,227 @@
// countries.js — country data helpers (inline, no fetch needed)
// countries.js - country data and helpers
const Countries = (() => {
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 }
]
}
];
/**
* @typedef {{ name: string, lon: number, lat: number }} CitySource
* @typedef {{ name: string, x: number, y: number }} City
* @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
*/
let _data = [];
const MAX_MERCATOR_LAT = 85.05112878;
async function loadCountries() {
if (_data.length) return _data;
_data = COUNTRIES_DATA;
return _data;
}
/** @type {Country[]} */
let data = [];
function getRandomCountries(count = 3) {
const shuffled = [..._data].sort(() => Math.random() - 0.5);
return shuffled.slice(0, count);
}
/**
* Load country data and outlines into memory. Safe to call multiple times.
* @returns {Promise<Country[]>}
*/
export async function loadCountries() {
if (data.length) return data;
function getCities(countryName) {
const c = _data.find(c => c.name === countryName);
return c ? c.cities : [];
}
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}.`);
}
return { loadCountries, getRandomCountries, getCities };
})();
const outlineData = await response.json();
const outline = outlineData.outline;
return {
name: country.name,
hint: country.hint,
cities: projectCities(country.cities, outline),
outline,
};
}),
);
return data;
}
/**
* Load and validate country metadata from managed JSON.
* @returns {Promise<CountrySource[]>}
*/
async function loadCountrySources() {
const response = await fetch(new URL("../data/countries.json", import.meta.url));
if (!response.ok) {
throw new Error("Could not load country metadata.");
}
const countries = await response.json();
if (!Array.isArray(countries)) {
throw new Error("Country metadata must be an array.");
}
return countries.map(validateCountrySource);
}
/**
* @param {unknown} value
* @param {number} index
* @returns {CountrySource}
*/
function validateCountrySource(value, index) {
if (!value || typeof value !== "object") {
throw new Error(`Country metadata entry ${index + 1} must be an object.`);
}
const country = /** @type {Record<string, unknown>} */ (value);
if (typeof country.name !== "string" || country.name.trim() === "") {
throw new Error(`Country metadata entry ${index + 1} is missing a name.`);
}
if (typeof country.file !== "string" || country.file.trim() === "") {
throw new Error(`Country metadata entry ${country.name} is missing a file.`);
}
if (!Array.isArray(country.cities)) {
throw new Error(`Country metadata entry ${country.name} is missing cities.`);
}
return {
name: country.name,
file: country.file,
hint: typeof country.hint === "string" ? country.hint : "",
cities: country.cities.map((city, cityIndex) =>
validateCitySource(city, country.name, cityIndex),
),
};
}
/**
* @param {unknown} value
* @param {string} countryName
* @param {number} index
* @returns {CitySource}
*/
function validateCitySource(value, countryName, index) {
if (!value || typeof value !== "object") {
throw new Error(`City ${index + 1} for ${countryName} must be an object.`);
}
const city = /** @type {Record<string, unknown>} */ (value);
if (typeof city.name !== "string" || city.name.trim() === "") {
throw new Error(`City ${index + 1} for ${countryName} is missing a name.`);
}
if (typeof city.lon !== "number" || !Number.isFinite(city.lon)) {
throw new Error(`${city.name} for ${countryName} is missing a numeric lon.`);
}
if (typeof city.lat !== "number" || !Number.isFinite(city.lat)) {
throw new Error(`${city.name} for ${countryName} is missing a numeric lat.`);
}
return {
name: city.name,
lon: city.lon,
lat: city.lat,
};
}
/**
* 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));
}

View File

@ -1,148 +1,286 @@
// drawing.js — canvas drawing module
const Drawing = (() => {
let canvas, ctx;
let isDrawing = false;
let points = []; // [{x, y}, ...]
let cities = []; // [{name, x, y}, ...] (% coords)
/** @type {HTMLCanvasElement} */
let canvas;
/** @type {CanvasRenderingContext2D} */
let ctx;
const STROKE_COLOR = "#1a7fc4";
const STROKE_WIDTH = 2.5;
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;
function init(canvasEl) {
canvas = canvasEl;
ctx = canvas.getContext("2d");
const STROKE_COLOR = "#1a7fc4";
const STROKE_WIDTH = 2.5;
// Pointer events (mouse + touch)
canvas.addEventListener("pointerdown", onDown);
canvas.addEventListener("pointermove", onMove);
canvas.addEventListener("pointerup", onUp);
canvas.addEventListener("pointerleave", onUp);
canvas.style.touchAction = "none";
/**
* Initialise the drawing module on a canvas element.
* @param {HTMLCanvasElement} canvasEl
*/
export function init(canvasEl) {
canvas = canvasEl;
ctx = canvas.getContext("2d");
_resize();
window.addEventListener("resize", _resize);
}
canvas.addEventListener("pointerdown", onDown);
canvas.addEventListener("pointermove", onMove);
canvas.addEventListener("pointerup", onUp);
canvas.addEventListener("pointerleave", onUp);
canvas.style.touchAction = "none";
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();
window.addEventListener("resize", resize);
}
function _pos(e) {
const r = canvas.getBoundingClientRect();
return {
x: (e.clientX - r.left),
y: (e.clientY - r.top),
};
}
/** 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();
}
function onDown(e) {
e.preventDefault();
isDrawing = true;
const p = _pos(e);
points.push(p);
ctx.beginPath();
ctx.moveTo(p.x, p.y);
}
/**
* 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 };
}
function onMove(e) {
if (!isDrawing) return;
e.preventDefault();
const p = _pos(e);
points.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 onDown(e) {
e.preventDefault();
isDrawing = true;
const p = pos(e);
currentStroke = [p];
strokes.push(currentStroke);
ctx.beginPath();
ctx.moveTo(p.x, p.y);
}
function onUp(e) {
if (!isDrawing) return;
isDrawing = false;
e.preventDefault();
}
/** @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();
}
function clear() {
points = [];
if (!ctx) return;
const w = canvas.getBoundingClientRect().width;
const h = canvas.getBoundingClientRect().height;
ctx.clearRect(0, 0, w, h);
_drawCities();
}
/** @param {PointerEvent} e */
function onUp(e) {
if (!isDrawing) return;
isDrawing = false;
currentStroke = [];
e.preventDefault();
}
function setCities(cityList) {
cities = cityList || [];
_drawCities();
}
/** 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();
}
function _drawCities() {
if (!ctx || !cities.length) return;
const w = canvas.getBoundingClientRect().width;
const h = canvas.getBoundingClientRect().height;
/**
* 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();
}
cities.forEach(city => {
const cx = (city.x / 100) * w;
const cy = (city.y / 100) * h;
/** Render all city markers with labels. */
function drawCities() {
if (!ctx || !cities.length) return;
const viewport = getCanvasViewport();
// 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();
cities.forEach((city) => {
const { x: cx, y: cy } = toCanvasPoint(city, viewport);
// Label
ctx.font = "bold 11px 'DM Sans', sans-serif";
ctx.fillStyle = "#0b1f2a";
ctx.textAlign = "center";
// 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 background
const textW = ctx.measureText(city.name).width + 10;
const textH = 16;
const tx = cx;
const ty = cy - 14;
// 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.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.fillText(city.name, tx, ty + 4);
});
}
ctx.fillStyle = "#0b1f2a";
ctx.textAlign = "center";
ctx.fillText(city.name, tx, ty + 4);
});
}
function _redraw() {
_drawCities();
if (!points.length) return;
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));
ctx.stroke();
}
/** 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";
stroke.forEach((p, i) => {
if (i === 0) {
ctx.moveTo(p.x, p.y);
} else {
ctx.lineTo(p.x, p.y);
}
});
ctx.stroke();
}
drawReferenceOutline();
}
function getPoints() {
return [...points];
}
/**
* Return a flattened copy of all drawn points.
* @returns {{ x: number, y: number }[]}
*/
export function getPoints() {
return strokes.flat();
}
function destroy() {
window.removeEventListener("resize", _resize);
}
/**
* 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 { init, clear, setCities, getPoints, destroy };
})();
/**
* Return drawn strokes in the same 0-100 space used by country outlines.
* @returns {{ x: number, y: number }[][]}
*/
export function getNormalizedRings() {
if (!canvas) return [];
const viewport = getCanvasViewport();
if (viewport.size === 0) return [];
return strokes
.map((stroke) => stroke.map((point) => toNormalizedPoint(point, viewport)))
.filter((stroke) => stroke.length > 1);
}
/**
* Show the reference outline over the player's drawing.
* @param {{ rings: { x: number, y: number }[][] } | null} outline
*/
export function showReferenceOutline(outline) {
referenceOutline = outline;
redraw();
}
/** Draw the current reference outline if one is set. */
function drawReferenceOutline() {
if (!ctx || !referenceOutline?.rings?.length) return;
const viewport = getCanvasViewport();
if (viewport.size === 0) return;
ctx.save();
ctx.strokeStyle = "rgba(224,92,92,0.95)";
ctx.lineWidth = 2;
ctx.lineJoin = "round";
ctx.lineCap = "round";
ctx.setLineDash([8, 5]);
for (const ring of referenceOutline.rings) {
if (!ring.length) continue;
ctx.beginPath();
ring.forEach((point, index) => {
const { x, y } = toCanvasPoint(point, viewport);
if (index === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
ctx.stroke();
}
ctx.restore();
}
/**
* Return the square drawing viewport used for normalized 0-100 country space.
* @returns {{ x: number, y: number, size: number }}
*/
function getCanvasViewport() {
const { width, height } = canvas.getBoundingClientRect();
const size = Math.min(width, height);
return {
x: (width - size) / 2,
y: (height - size) / 2,
size,
};
}
/**
* @param {{ x: number, y: number }} point
* @param {{ x: number, y: number, size: number }} viewport
* @returns {{ x: number, y: number }}
*/
function toCanvasPoint(point, viewport) {
return {
x: viewport.x + (point.x / 100) * viewport.size,
y: viewport.y + (point.y / 100) * viewport.size,
};
}
/**
* @param {{ x: number, y: number }} point
* @param {{ x: number, y: number, size: number }} viewport
* @returns {{ x: number, y: number }}
*/
function toNormalizedPoint(point, viewport) {
return {
x: clampPercent(((point.x - viewport.x) / viewport.size) * 100),
y: clampPercent(((point.y - viewport.y) / viewport.size) * 100),
};
}
/** @param {number} value */
function clampPercent(value) {
return Math.max(0, Math.min(100, Math.round(value * 100) / 100));
}
/** Remove event listeners and clean up. */
export function destroy() {
window.removeEventListener("resize", resize);
}

View File

@ -1,158 +1,237 @@
// game.js — round management, timer, submit
// game.js - round management, timer, submit
const TOTAL_ROUNDS = 3;
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
/** @type {import('./countries.js').Country[]} */
let roundCountries = [];
let currentRound = 0;
let scores = [];
let timerInterval = null;
let timeLeft = ROUND_DURATION;
let currentRound = 0;
/** @type {number[]} */
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 elBtnClear = document.getElementById("btn-clear");
const elBtnSubmit = document.getElementById("btn-submit");
const elCanvas = document.getElementById("draw-canvas");
let elCountryName;
let elCountryHint;
let elRoundNum;
let elTimerNum;
let elTimerBar;
let elTimerWrap;
let elBtnClear;
let elBtnSubmit;
let elBtnNext;
let roundSubmitted = false;
// ── Init
/** Initialise DOM refs, drawing, events, countries, and the first round. */
async function initGame() {
await Countries.loadCountries();
roundCountries = Countries.getRandomCountries(TOTAL_ROUNDS);
currentRound = 0;
scores = [];
startRound();
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];
const country = roundCountries[currentRound];
// Update UI
elRoundNum.textContent = currentRound + 1;
elCountryName.textContent = country.name;
elCountryHint.textContent = country.hint || "";
elRoundNum.textContent = currentRound + 1;
elCountryName.textContent = country.name;
elCountryHint.textContent = country.hint || "";
// Round pips
if (typeof window.updateRoundPips === "function") {
window.updateRoundPips(currentRound + 1);
}
updateRoundPips(currentRound + 1);
// Reset canvas placeholder
document.getElementById("canvas-wrap")?.classList.remove("has-drawing");
document.getElementById("canvas-wrap")?.classList.remove("has-drawing");
hideScoreFeedback();
// Cities on canvas
Drawing.clear();
Drawing.setCities(country.cities || []);
setCities(country.cities || []);
clear();
roundSubmitted = false;
// Timer
timeLeft = ROUND_DURATION;
updateTimerUI();
clearInterval(timerInterval);
timerInterval = setInterval(tickTimer, 1000);
timeLeft = ROUND_DURATION;
updateTimerUI();
clearInterval(timerInterval);
timerInterval = setInterval(tickTimer, 1000);
// Button state
elBtnSubmit.disabled = false;
elBtnSubmit.textContent = currentRound < TOTAL_ROUNDS - 1
? "Submit & Next Round →"
: "Submit & See Results →";
elBtnSubmit.disabled = false;
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. */
function tickTimer() {
timeLeft--;
updateTimerUI();
if (timeLeft <= 0) {
clearInterval(timerInterval);
submitRound(true); // auto-submit
}
timeLeft--;
updateTimerUI();
if (timeLeft <= 0) {
clearInterval(timerInterval);
submitRound(true);
}
}
/**
* Sync timer bar width and apply urgency CSS classes.
* Uses `.timer--warning` and `.timer--danger` instead of inline styles.
*/
function updateTimerUI() {
elTimerNum.textContent = timeLeft;
const pct = (timeLeft / ROUND_DURATION) * 100;
elTimerBar.style.width = pct + "%";
elTimerNum.textContent = timeLeft;
elTimerBar.style.width = `${(timeLeft / ROUND_DURATION) * 100}%`;
// Colour shift
if (timeLeft <= 10) {
elTimerBar.style.background = "#e05c5c";
elTimerNum.style.color = "#e05c5c";
} else if (timeLeft <= 20) {
elTimerBar.style.background = "#f0b429";
elTimerNum.style.color = "#f0b429";
} else {
elTimerBar.style.background = "";
elTimerNum.style.color = "";
}
elTimerWrap.classList.toggle("timer--danger", timeLeft <= 10);
elTimerWrap.classList.toggle(
"timer--warning",
timeLeft > 10 && timeLeft <= 20,
);
}
/**
* 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);
scores.push(score);
clearInterval(timerInterval);
roundSubmitted = true;
elBtnSubmit.disabled = true;
elBtnClear.disabled = true;
// Update sidebar score row
if (typeof window.updateScoreDisplay === "function") {
window.updateScoreDisplay(currentRound, score);
}
const country = roundCountries[currentRound];
const rings = getNormalizedRings();
const score = calculateScore(rings, country.outline);
scores.push(score);
// Flash score feedback
showScoreFeedback(score);
showReferenceOutline(country.outline);
updateScoreDisplay(currentRound, score);
showScoreFeedback(score);
const delay = auto ? 400 : 1200;
setTimeout(() => {
if (currentRound < TOTAL_ROUNDS - 1) {
currentRound++;
startRound();
} else {
finishGame();
}
}, delay);
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();
}
}
/**
* 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 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);
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)";
}
function finishGame() {
const totalScore = scores.reduce((a, b) => a + b, 0);
const state = {
currentRound: TOTAL_ROUNDS,
scores,
totalScore,
countries: roundCountries.map(c => c.name),
};
Storage.saveGameState(state);
// Save to leaderboard
Storage.saveLeaderboard({
name: Storage.getPlayerName(),
totalScore,
scores,
date: new Date().toISOString(),
});
location.href = "results.html";
/** Hide the score grade overlay. */
function hideScoreFeedback() {
const el = document.getElementById("score-feedback");
el.style.opacity = "0";
el.style.transform = "translateY(-10px)";
}
// ── Events
elBtnClear.addEventListener("click", () => Drawing.clear());
elBtnSubmit.addEventListener("click", () => submitRound(false));
/** Persist game state, update leaderboard, and navigate to results. */
async function finishGame() {
const totalScore = scores.reduce((sum, s) => sum + s, 0);
const state = {
currentRound: TOTAL_ROUNDS,
scores,
totalScore,
countries: roundCountries.map((c) => c.name),
};
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";
}
/** @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);

View File

@ -1,27 +1,95 @@
document.getElementById("reg-btn")?.addEventListener("click", () => {
const lobbyInput = document.getElementById("username");
const lobbyName = lobbyInput ? lobbyInput.value.trim() : "";
if (lobbyName) {
Storage.saveLobbyName(lobbyName);
// index.js - landing page logic
import { createLobby } from "./api.js";
import { saveLobbyName } from "./storage.js";
// ── DOM refs
const formCreate = document.getElementById("form-create");
const formJoin = document.getElementById("form-join");
const createInput = /** @type {HTMLInputElement|null} */ (document.getElementById("lobby-create"));
const joinInput = /** @type {HTMLInputElement|null} */ (document.getElementById("lobby-join"));
const createBtn = /** @type {HTMLButtonElement|null} */ (document.getElementById("reg-btn"));
const joinBtn = /** @type {HTMLButtonElement|null} */ (document.getElementById("join-btn"));
const createError = document.getElementById("lobby-error");
const joinError = document.getElementById("join-error");
const toggleCreateBtn = /** @type {HTMLButtonElement|null} */ (document.getElementById("toggle-create-btn"));
const toggleJoinBtn = /** @type {HTMLButtonElement|null} */ (document.getElementById("toggle-join-btn"));
// ── Toggle switch
function showCreate() {
formCreate?.removeAttribute("hidden");
formJoin?.setAttribute("hidden", "");
toggleCreateBtn?.classList.add("lobby-toggle__btn--active");
toggleJoinBtn?.classList.remove("lobby-toggle__btn--active");
createInput?.focus();
}
function showJoin() {
formJoin?.removeAttribute("hidden");
formCreate?.setAttribute("hidden", "");
toggleJoinBtn?.classList.add("lobby-toggle__btn--active");
toggleCreateBtn?.classList.remove("lobby-toggle__btn--active");
joinInput?.focus();
}
toggleCreateBtn?.addEventListener("click", showCreate);
toggleJoinBtn?.addEventListener("click", showJoin);
// ── Create lobby
createBtn?.addEventListener("click", async () => {
if (!createInput || !createError) return;
const lobbyName = createInput.value.trim();
createError.textContent = "";
if (!lobbyName) {
createInput.focus();
return;
}
try {
createBtn.disabled = true;
await createLobby(lobbyName);
saveLobbyName(lobbyName);
window.location.href = "lobby.html";
} else {
lobbyInput?.focus();
} catch (error) {
createError.textContent =
error instanceof Error ? error.message : "Could not create lobby.";
createBtn.disabled = false;
}
});
const reveals = document.querySelectorAll(".reveal");
const io = new IntersectionObserver(
// ── Join lobby — actual joinLobby() API call happens in lobby.js
joinBtn?.addEventListener("click", () => {
if (!joinInput || !joinError) return;
const lobbyName = joinInput.value.trim();
joinError.textContent = "";
if (!lobbyName) {
joinInput.focus();
return;
}
saveLobbyName(lobbyName);
window.location.href = "lobby.html";
});
// Allow Enter key on both inputs
createInput?.addEventListener("keydown", (e) => { if (e.key === "Enter") createBtn?.click(); });
joinInput?.addEventListener("keydown", (e) => { if (e.key === "Enter") joinBtn?.click(); });
// ── Scroll reveal
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("visible");
io.unobserve(entry.target);
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.12 },
);
reveals.forEach((el) => {
io.observe(el);
});
document.querySelectorAll(".reveal").forEach((el) => observer.observe(el));

View File

@ -0,0 +1,91 @@
// leaderboard.js - leaderboard page logic
import { getLeaderboard } from "./api.js";
import { getLobbyName, getPlayerName } from "./storage.js";
document.addEventListener("DOMContentLoaded", () => {
const currentPlayer = getPlayerName();
const body = document.getElementById("lb-body");
/**
* Escape HTML special characters to prevent XSS.
* @param {string} str - Raw user-provided string.
* @returns {string} HTML-safe string.
*/
function escHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
/** 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) {
body.innerHTML = `
<div class="lb-empty">
<div class="lb-empty__icon">🏁</div>
<p class="lb-empty__text">No games played yet. Be the first on the board!</p>
<a href="lobby.html" class="btn btn--primary btn--sm">Play now →</a>
</div>`;
return;
}
const medals = ["🥇", "🥈", "🥉"];
const scoreClasses = ["gold", "silver", "bronze"];
board.forEach((entry, index) => {
const rank = index + 1;
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",
month: "short",
})
: "—";
const rounds = (entry.scores || []).join(" · ") || "—";
const row = document.createElement("div");
row.className = `lb-row${rank <= 3 ? ` rank-${rank}` : ""}`;
row.style.animationDelay = `${index * 0.05}s`;
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(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>
`;
body.appendChild(row);
});
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.playerName || latest.name;
document.getElementById("your-bar-score").textContent = latest.totalScore;
}
}
render();
});

View File

@ -1,47 +1,72 @@
// lobby.js
// 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");
const btn = document.getElementById("btn-start");
const errMsg = document.getElementById("name-error");
const input = document.getElementById("username");
const btn = document.getElementById("btn-start");
const errMsg = document.getElementById("name-error");
// Show lobby name
const lobbyNameEl = document.getElementById("lobby-name-display");
if (lobbyNameEl) {
lobbyNameEl.textContent = Storage.getLobbyName();
}
// Display the current lobby name
const lobbyNameEl = document.getElementById("lobby-name-display");
if (lobbyNameEl) {
lobbyNameEl.textContent = getLobbyName();
}
btn.addEventListener("click", () => {
const name = input.value.trim();
if (!name) {
errMsg.textContent = "Please enter a username to continue.";
input.classList.add("input--error");
input.focus();
return;
}
if (name.length < 2) {
errMsg.textContent = "Username must be at least 2 characters.";
input.classList.add("input--error");
input.focus();
return;
}
Storage.savePlayerName(name);
Storage.clearGameState();
location.href = "game.html";
});
/**
* Validate the username input and navigate to the game.
* Shows an inline error message on invalid input.
*/
btn.addEventListener("click", async () => {
const name = input.value.trim();
const lobbyName = getLobbyName();
input.addEventListener("input", () => {
errMsg.textContent = "";
input.classList.remove("input--error");
});
if (!name) {
errMsg.textContent = "Please enter a username to continue.";
input.classList.add("input--error");
input.focus();
return;
}
if (name.length < 2) {
errMsg.textContent = "Username must be at least 2 characters.";
input.classList.add("input--error");
input.focus();
return;
}
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") btn.click();
});
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;
}
});
// Pre-fill if returning player
const existing = Storage.getPlayerName();
if (existing && existing !== "Anonymous") {
input.value = existing;
}
// Clear validation state on every keystroke
input.addEventListener("input", () => {
errMsg.textContent = "";
input.classList.remove("input--error");
});
// Allow form submission via Enter key
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") btn.click();
});
// Pre-fill username if the player has played before
const existing = getPlayerName();
if (existing && existing !== "Anonymous") {
input.value = existing;
}
});

View File

@ -1,36 +0,0 @@
// main.js — index.html
document.addEventListener("DOMContentLoaded", () => {
// Smooth scroll for nav links
document.querySelectorAll('a[href^="#"]').forEach(link => {
link.addEventListener("click", e => {
const id = link.getAttribute("href").slice(1);
const target = document.getElementById(id);
if (!target) return;
e.preventDefault();
const headerH = document.querySelector(".header")?.offsetHeight || 0;
const top = target.getBoundingClientRect().top + window.scrollY - headerH - 16;
window.scrollTo({ top, behavior: "smooth" });
});
});
// Register button stub
const regBtn = document.getElementById("reg-btn");
if (regBtn) {
regBtn.addEventListener("click", () => {
alert("Frontend skeleton only. Backend will be connected later.");
});
}
// Scroll reveal
const reveals = document.querySelectorAll(".reveal");
const io = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add("visible");
io.unobserve(entry.target);
}
});
}, { threshold: 0.12 });
reveals.forEach(el => io.observe(el));
});

View File

@ -0,0 +1,72 @@
// results.js - results page logic
import { getGrade } from "./scoring.js";
import { getGameState, getPlayerName } from "./storage.js";
document.addEventListener("DOMContentLoaded", () => {
const state = getGameState();
const name = getPlayerName();
document.getElementById("results-player").textContent = name;
if (!state) {
document.getElementById("results-title").textContent = "No game found.";
document.getElementById("total-score").textContent = "—";
return;
}
const { scores, totalScore, countries } = state;
const avg = totalScore / 3;
let emoji = "🌍";
let title = "Not bad!";
if (avg >= 85) {
emoji = "🔥";
title = "Incredible!";
} else if (avg >= 70) {
emoji = "🎉";
title = "Well done!";
} else if (avg >= 50) {
emoji = "👏";
title = "Good effort!";
} else {
emoji = "😅";
title = "Keep practising!";
}
document.getElementById("results-emoji").textContent = emoji;
document.getElementById("results-title").textContent = title;
document.getElementById("total-score").textContent = totalScore;
const grade = getGrade(avg);
const gradeEl = document.getElementById("total-grade");
gradeEl.textContent = `Grade ${grade.label}`;
const rowsContainer = document.getElementById("round-rows");
(scores || []).forEach((score, index) => {
const row = document.createElement("div");
row.className = "round-row";
row.innerHTML = `
<span class="round-row__label">Round ${index + 1}</span>
<div class="round-row__bar-wrap">
<div class="round-row__bar" style="width:0%" data-target="${score}"></div>
</div>
<span class="round-row__score">${score}%</span>
`;
rowsContainer.appendChild(row);
});
requestAnimationFrame(() => {
document.querySelectorAll(".round-row__bar").forEach((bar) => {
bar.style.width = `${bar.dataset.target}%`;
});
});
const tagsContainer = document.getElementById("countries-row");
(countries || []).forEach((country) => {
const tag = document.createElement("span");
tag.className = "country-tag";
tag.textContent = country;
tagsContainer.appendChild(tag);
});
});

View File

@ -1,41 +1,156 @@
// scoring.js — accuracy calculation
// scoring.js - accuracy calculation
const Scoring = (() => {
const GRID_SIZE = 96;
const MIN_DRAWN_POINTS = 10;
const MIN_FILLED_CELLS = 8;
/**
* Calculate score from drawn path points.
* MVP: fake score based on number of points drawn (effort-based).
* TODO: replace with real compareShapes() using polygon overlap.
*/
function calculateScore(drawnPoints) {
if (!drawnPoints || drawnPoints.length < 10) return 0;
/**
* @typedef {{ x: number, y: number }} Point
* @typedef {{ rings: Point[][] }} CountryOutline
*/
// Fake scoring: reward effort + randomness so it feels real
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
const score = Math.max(0, Math.min(100, base + jitter));
return score;
}
/**
* 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);
}
/**
* Stub for real shape comparison (future).
* drawnPoints: [{x,y}, ...]
* referencePolygon: [{x,y}, ...] (normalised 0–1 coords)
*/
function compareShapes(_drawnPoints, _referencePolygon) {
// TODO: implement IoU (Intersection over Union) or
// Hausdorff distance for polygon comparison.
return 0;
}
/**
* 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;
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" };
}
const drawnRings = normalizeDrawnRings(drawnShape);
const referenceRings = referenceOutline.rings.filter(isValidReferenceRing).map(closeRing);
if (!drawnRings.length || !referenceRings.length) return 0;
return { calculateScore, compareShapes, getGrade };
})();
let intersection = 0;
let union = 0;
let drawnCells = 0;
for (let row = 0; row < GRID_SIZE; row++) {
for (let col = 0; col < GRID_SIZE; col++) {
const point = cellCenter(col, row);
const inDrawn = isInsideAnyRing(point, drawnRings);
const inReference = isInsideAnyRing(point, referenceRings);
if (inDrawn) drawnCells++;
if (inDrawn && inReference) intersection++;
if (inDrawn || inReference) union++;
}
}
if (drawnCells < MIN_FILLED_CELLS || union === 0) return 0;
return Math.round((intersection / union) * 100);
}
/**
* @param {Point[] | Point[][]} drawnShape
* @returns {Point[][]}
*/
function normalizeDrawnRings(drawnShape) {
if (!Array.isArray(drawnShape) || !drawnShape.length) return [];
if (isPoint(drawnShape[0])) {
return isValidRing(drawnShape) ? [closeRing(drawnShape)] : [];
}
return drawnShape
.filter(isValidRing)
.map(closeRing);
}
/**
* 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;
}
return inside;
}
/** @param {Point[]} ring */
function isValidRing(ring) {
return (
Array.isArray(ring) &&
ring.length >= MIN_DRAWN_POINTS &&
ring.every((point) => Number.isFinite(point.x) && Number.isFinite(point.y))
);
}
/** @param {Point[]} ring */
function isValidReferenceRing(ring) {
return (
Array.isArray(ring) &&
ring.length >= 4 &&
ring.every((point) => Number.isFinite(point.x) && Number.isFinite(point.y))
);
}
/** @param {Point[]} ring */
function closeRing(ring) {
const first = ring[0];
const last = ring[ring.length - 1];
if (first.x === last.x && first.y === last.y) return ring;
return [...ring, { ...first }];
}

View 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");

View File

@ -1,75 +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",
};
function savePlayerName(name) {
localStorage.setItem(KEYS.PLAYER_NAME, name.trim());
}
/**
* 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}"`);
}
}
function getPlayerName() {
return localStorage.getItem(KEYS.PLAYER_NAME) || "Anonymous";
}
/** @param {string} name */
export function savePlayerName(name) {
setItem(KEYS.PLAYER_NAME, name.trim());
}
function saveLobbyName(name) {
localStorage.setItem(KEYS.LOBBY_NAME, name.trim());
}
/** @returns {string} */
export function getPlayerName() {
return localStorage.getItem(KEYS.PLAYER_NAME) || "Anonymous";
}
function getLobbyName() {
return localStorage.getItem(KEYS.LOBBY_NAME) || "My Lobby";
}
/** @param {string} name */
export function saveLobbyName(name) {
setItem(KEYS.LOBBY_NAME, name.trim());
}
function saveGameState(state) {
localStorage.setItem(KEYS.GAME_STATE, JSON.stringify(state));
}
/** @returns {string} */
export function getLobbyName() {
return localStorage.getItem(KEYS.LOBBY_NAME) || "My Lobby";
}
function getGameState() {
try {
return JSON.parse(localStorage.getItem(KEYS.GAME_STATE)) || null;
} catch {
return null;
}
}
/**
* @param {{ scores: number[], totalScore: number, countries: string[] }} state
*/
export function saveGameState(state) {
setItem(KEYS.GAME_STATE, JSON.stringify(state));
}
function clearGameState() {
localStorage.removeItem(KEYS.GAME_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;
}
}
function saveLeaderboard(entry) {
const board = getLeaderboard();
board.push(entry);
board.sort((a, b) => b.totalScore - a.totalScore);
const top20 = board.slice(0, 20);
localStorage.setItem(KEYS.LEADERBOARD, JSON.stringify(top20));
}
export function clearGameState() {
localStorage.removeItem(KEYS.GAME_STATE);
}
function getLeaderboard() {
try {
return JSON.parse(localStorage.getItem(KEYS.LEADERBOARD)) || [];
} catch {
return [];
}
}
/**
* 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)));
}
function clearLeaderboard() {
localStorage.removeItem(KEYS.LEADERBOARD);
}
/**
* @returns {{ name: string, totalScore: number, scores: number[], date: string }[]}
*/
export function getLeaderboard() {
try {
return JSON.parse(localStorage.getItem(KEYS.LEADERBOARD)) || [];
} catch {
return [];
}
}
return {
savePlayerName,
getPlayerName,
saveLobbyName,
getLobbyName,
saveGameState,
getGameState,
clearGameState,
saveLeaderboard,
getLeaderboard,
clearLeaderboard,
};
})();
export function clearLeaderboard() {
localStorage.removeItem(KEYS.LEADERBOARD);
}

View File

@ -1,282 +1,349 @@
/* game.css */
.game-layout {
min-height: calc(100vh - var(--hh));
display: grid;
grid-template-rows: auto 1fr auto;
padding: 20px 0 28px;
gap: 16px;
min-height: calc(100vh - var(--hh));
display: grid;
grid-template-rows: auto 1fr auto;
padding: 20px 0 28px;
gap: 16px;
}
/* ── Top bar */
.game-topbar {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
}
.game-round {
display: flex;
align-items: center;
gap: 10px;
display: flex;
align-items: center;
gap: 10px;
}
.round-pip {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--line);
transition: background .3s;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--line);
transition: background 0.3s;
}
.round-pip.active { background: var(--sea); }
.round-pip.done { background: var(--leaf); }
.round-pip.active {
background: var(--sea);
}
.round-pip.done {
background: var(--leaf);
}
.round-label {
font-family: 'Syne', sans-serif;
font-size: .82rem;
font-weight: 700;
color: var(--ink-muted);
letter-spacing: .06em;
text-transform: uppercase;
font-family: "Syne", sans-serif;
font-size: 0.82rem;
font-weight: 700;
color: var(--ink-muted);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.round-label span {
color: var(--ink);
color: var(--ink);
}
/* Country name */
.game-country {
text-align: center;
text-align: center;
}
.country-name {
font-family: 'Syne', sans-serif;
font-weight: 800;
font-size: clamp(1.5rem, 3vw, 2.4rem);
letter-spacing: -.03em;
line-height: 1.1;
font-family: "Syne", sans-serif;
font-weight: 800;
font-size: clamp(1.5rem, 3vw, 2.4rem);
letter-spacing: -0.03em;
line-height: 1.1;
}
.country-hint {
font-size: .82rem;
color: var(--ink-muted);
margin-top: 4px;
font-size: 0.82rem;
color: var(--ink-muted);
margin-top: 4px;
}
/* Timer */
.game-timer {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.timer-display {
display: flex;
align-items: baseline;
gap: 4px;
display: flex;
align-items: baseline;
gap: 4px;
}
.timer-num {
font-family: 'Syne', sans-serif;
font-weight: 800;
font-size: 2rem;
line-height: 1;
color: var(--ink);
transition: color .3s;
min-width: 2ch;
text-align: right;
font-family: "Syne", sans-serif;
font-weight: 800;
font-size: 2rem;
line-height: 1;
color: var(--ink);
transition: color 0.3s;
min-width: 2ch;
text-align: right;
}
.timer-unit {
font-size: .78rem;
font-weight: 600;
color: var(--ink-muted);
font-size: 0.78rem;
font-weight: 600;
color: var(--ink-muted);
}
.timer-track {
width: 120px;
height: 4px;
background: var(--line);
border-radius: 999px;
overflow: hidden;
width: 120px;
height: 4px;
background: var(--line);
border-radius: 999px;
overflow: hidden;
}
.timer-bar {
height: 100%;
width: 100%;
background: linear-gradient(90deg, var(--sea), var(--leaf));
border-radius: 999px;
transition: width 1s linear, background .4s ease;
height: 100%;
width: 100%;
background: linear-gradient(90deg, var(--sea), var(--leaf));
border-radius: 999px;
transition:
width 1s linear,
background 0.4s ease;
}
/* Timer urgency states — applied via JS classList */
.timer--warning .timer-bar,
.timer--warning .timer-num {
color: var(--gold);
}
.timer--warning .timer-bar {
background: var(--gold);
}
.timer--danger .timer-bar,
.timer--danger .timer-num {
color: var(--danger);
}
.timer--danger .timer-bar {
background: var(--danger);
}
/* ── Canvas area */
.canvas-area {
display: grid;
grid-template-columns: 1fr 220px;
gap: 16px;
min-height: 0;
display: grid;
grid-template-columns: 1fr 220px;
gap: 16px;
min-height: 0;
align-items: start;
}
.canvas-wrap {
position: relative;
background: var(--white);
border: 1.5px solid var(--line);
border-radius: var(--r-xl);
box-shadow: var(--shadow);
overflow: hidden;
cursor: crosshair;
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);
box-shadow: var(--shadow);
overflow: hidden;
cursor: crosshair;
}
.canvas-wrap::before {
content: 'Draw the border here';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: .9rem;
font-weight: 500;
color: var(--ink-muted);
pointer-events: none;
opacity: 1;
transition: opacity .3s;
content: "Draw the border here";
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9rem;
font-weight: 500;
color: var(--ink-muted);
pointer-events: none;
opacity: 1;
transition: opacity 0.3s;
}
.canvas-wrap.has-drawing::before { opacity: 0; }
.canvas-wrap.has-drawing::before {
opacity: 0;
}
#draw-canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
/* Score feedback overlay */
#score-feedback {
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%) translateY(-10px);
font-family: 'Syne', sans-serif;
font-size: 2rem;
font-weight: 800;
opacity: 0;
transition: opacity .4s ease, transform .4s ease;
pointer-events: none;
text-shadow: 0 2px 8px rgba(0,0,0,.12);
white-space: nowrap;
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%) translateY(-10px);
font-family: "Syne", sans-serif;
font-size: 2rem;
font-weight: 800;
opacity: 0;
transition:
opacity 0.4s ease,
transform 0.4s ease;
pointer-events: none;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
white-space: nowrap;
}
/* Sidebar */
.game-sidebar {
display: flex;
flex-direction: column;
gap: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.sidebar-card {
background: var(--glass);
backdrop-filter: blur(10px);
border: 1px solid rgba(255,255,255,.85);
border-radius: var(--r-lg);
padding: 18px 20px;
box-shadow: var(--shadow-sm);
background: var(--glass);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.85);
border-radius: var(--r-lg);
padding: 18px 20px;
box-shadow: var(--shadow-sm);
}
.sidebar-card__label {
font-size: .72rem;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--ink-muted);
margin-bottom: 10px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-muted);
margin-bottom: 10px;
}
/* Round scores */
.round-scores { display: grid; gap: 8px; }
.round-scores {
display: grid;
gap: 8px;
}
.rscore-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.rscore-label {
font-size: .82rem;
color: var(--ink-soft);
font-weight: 500;
font-size: 0.82rem;
color: var(--ink-soft);
font-weight: 500;
}
.rscore-val {
font-family: 'Syne', sans-serif;
font-weight: 800;
font-size: .9rem;
color: var(--ink-muted);
font-family: "Syne", sans-serif;
font-weight: 800;
font-size: 0.9rem;
color: var(--ink-muted);
}
.rscore-val.filled { color: var(--sea); }
.rscore-val.filled {
color: var(--sea);
}
/* Player name in sidebar */
.player-name-display {
font-family: 'Syne', sans-serif;
font-weight: 700;
font-size: 1rem;
color: var(--ink);
word-break: break-word;
font-family: "Syne", sans-serif;
font-weight: 700;
font-size: 1rem;
color: var(--ink);
word-break: break-word;
}
.sidebar-hint {
font-size: 0.82rem;
color: var(--ink-soft);
line-height: 1.55;
}
/* Controls */
.game-controls {
display: flex;
gap: 10px;
display: flex;
gap: 10px;
}
/* ─── Responsive ─── */
@media (max-width: 900px) {
.canvas-area {
grid-template-columns: 1fr;
grid-template-rows: 1fr auto;
}
.canvas-area {
grid-template-columns: 1fr;
grid-template-rows: 1fr auto;
}
.game-sidebar {
flex-direction: row;
flex-wrap: wrap;
}
.game-sidebar {
flex-direction: row;
flex-wrap: wrap;
}
.sidebar-card { flex: 1 1 140px; }
.sidebar-card {
flex: 1 1 140px;
}
.canvas-wrap { min-height: 340px; }
.canvas-wrap {
width: min(100%, 72vh);
}
.game-topbar {
grid-template-columns: auto 1fr auto;
}
.game-topbar {
grid-template-columns: auto 1fr auto;
}
}
@media (max-width: 600px) {
.game-layout { padding: 14px 0 20px; gap: 12px; }
.game-layout {
padding: 14px 0 20px;
gap: 12px;
}
.game-topbar {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
gap: 10px;
}
.game-topbar {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
gap: 10px;
}
.game-country { text-align: left; }
.game-timer { align-items: flex-start; }
.timer-track { width: 100%; }
.game-country {
text-align: left;
}
.game-timer {
align-items: flex-start;
}
.timer-track {
width: 100%;
}
.canvas-wrap { min-height: 280px; }
.canvas-wrap {
width: 100%;
}
.game-controls { flex-direction: column; }
.game-controls .btn { width: 100%; }
.game-controls {
flex-direction: column;
}
.game-controls .btn {
width: 100%;
}
}
@media (max-width: 360px) {
.country-name { font-size: 1.4rem; }
.timer-num { font-size: 1.6rem; }
.canvas-wrap { min-height: 240px; }
.country-name {
font-size: 1.4rem;
}
.timer-num {
font-size: 1.6rem;
}
.canvas-wrap {
min-height: 0;
}
}

View File

@ -1,206 +1,6 @@
:root {
--ink: #0b1f2a;
--ink-soft: #3d5563;
--ink-muted: #7a9aaa;
--sea: #1a7fc4;
--sea-light: #4faae0;
--sea-dim: rgba(26, 127, 196, 0.12);
--leaf: #41b869;
--leaf-dim: rgba(65, 184, 105, 0.13);
--cream: #f4f9f6;
--white: #ffffff;
--glass: rgba(255, 255, 255, 0.72);
--line: rgba(11, 31, 42, 0.09);
--shadow: 0 24px 60px rgba(11, 31, 42, 0.1);
--r-xl: 32px;
--r-lg: 22px;
--r-md: 14px;
--hh: 80px;
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
scroll-behavior: smooth;
}
body {
font-family: "DM Sans", sans-serif;
color: var(--ink);
background: var(--cream);
overflow-x: hidden;
position: relative;
}
body::before {
content: "";
position: fixed;
inset: 0;
background:
radial-gradient(
ellipse 60% 50% at 10% 0%,
rgba(65, 184, 105, 0.12) 0%,
transparent 60%
),
radial-gradient(
ellipse 50% 60% at 90% 100%,
rgba(26, 127, 196, 0.12) 0%,
transparent 60%
);
pointer-events: none;
z-index: 0;
}
body::after {
content: "";
position: fixed;
inset: 0;
background-image:
linear-gradient(var(--line) 1px, transparent 1px),
linear-gradient(90deg, var(--line) 1px, transparent 1px);
background-size: 60px 60px;
pointer-events: none;
z-index: 0;
}
a {
color: inherit;
text-decoration: none;
}
img,
svg {
display: block;
max-width: 100%;
}
button,
input {
font: inherit;
}
.wrap {
position: relative;
z-index: 1;
}
.container {
width: 100%;
max-width: 1280px;
margin: 0 auto;
padding: 0 28px;
}
/* ─── HEADER ─── */
.header {
position: sticky;
top: 0;
z-index: 100;
height: var(--hh);
display: flex;
align-items: center;
backdrop-filter: blur(16px) saturate(1.4);
-webkit-backdrop-filter: blur(16px) saturate(1.4);
background: rgba(244, 249, 246, 0.82);
border-bottom: 1px solid var(--line);
}
.header__inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.logo {
display: inline-flex;
align-items: center;
gap: 12px;
}
.logo__mark {
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(135deg, var(--sea) 0%, var(--leaf) 100%);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
overflow: hidden;
}
.logo__mark svg {
width: 22px;
height: 22px;
}
.logo__text {
font-family: "Syne", sans-serif;
font-weight: 800;
font-size: 1.25rem;
letter-spacing: -0.02em;
}
.nav {
display: flex;
align-items: center;
gap: 4px;
}
.nav__link {
padding: 9px 16px;
border-radius: 999px;
font-size: 0.92rem;
font-weight: 500;
color: var(--ink-soft);
transition:
background 0.18s,
color 0.18s;
}
.nav__link:hover {
background: var(--sea-dim);
color: var(--sea);
}
.nav__cta {
margin-left: 8px;
padding: 9px 20px;
border-radius: 999px;
font-size: 0.92rem;
font-weight: 600;
color: var(--white);
background: var(--ink);
transition:
opacity 0.18s,
transform 0.18s;
}
.nav__cta:hover {
opacity: 0.82;
transform: translateY(-1px);
}
/* ─── HERO ─── */
.hero {
min-height: calc(100vh - var(--hh));
display: grid;
place-items: center;
padding: 80px 0 60px;
}
.hero__inner {
display: grid;
grid-template-columns: 1fr minmax(320px, 500px);
gap: 64px;
align-items: center;
}
/* index.css — landing page specific styles */
/* ─── EYEBROW ─── */
.eyebrow {
display: inline-flex;
align-items: center;
@ -237,6 +37,21 @@ input {
}
}
/* ─── HERO ─── */
.hero {
min-height: calc(100vh - var(--hh));
display: grid;
place-items: center;
padding: 80px 0 60px;
}
.hero__inner {
display: grid;
grid-template-columns: 1fr minmax(320px, 500px);
gap: 64px;
align-items: center;
}
.hero__title {
font-family: "Syne", sans-serif;
font-weight: 800;
@ -269,73 +84,7 @@ input {
flex-wrap: wrap;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 16px 30px;
border-radius: var(--r-lg);
font-family: "DM Sans", sans-serif;
font-size: 1rem;
font-weight: 600;
border: none;
cursor: pointer;
transition:
transform 0.2s,
box-shadow 0.2s,
opacity 0.2s;
}
.btn:hover {
transform: translateY(-2px);
}
.btn--primary {
color: var(--white);
background: linear-gradient(
135deg,
var(--sea) 0%,
#159fd4 50%,
var(--leaf) 100%
);
background-size: 200% 200%;
box-shadow: 0 8px 24px rgba(26, 127, 196, 0.3);
animation: gradShift 4s ease infinite;
}
@keyframes gradShift {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.btn--primary:hover {
box-shadow: 0 12px 32px rgba(26, 127, 196, 0.4);
}
.btn--ghost {
color: var(--ink-soft);
background: transparent;
border: 1.5px solid var(--line);
}
.btn--ghost:hover {
border-color: var(--ink-muted);
color: var(--ink);
}
.btn--full {
width: 100%;
}
/* Globe card */
/* ─── GLOBE CARD ─── */
.globe-card {
background: var(--glass);
backdrop-filter: blur(12px);
@ -494,7 +243,6 @@ input {
height: 8px;
border-radius: 50%;
}
.gb__dot--green {
background: var(--leaf);
}
@ -514,28 +262,6 @@ input {
align-items: center;
}
.section-label {
display: inline-block;
padding: 6px 14px;
border-radius: 999px;
background: var(--sea-dim);
color: var(--sea);
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
margin-bottom: 20px;
}
.section-title {
font-family: "Syne", sans-serif;
font-weight: 800;
font-size: clamp(2.4rem, 4.5vw, 4rem);
line-height: 1.04;
letter-spacing: -0.03em;
margin-bottom: 20px;
}
.about__desc {
font-size: 1.05rem;
line-height: 1.75;
@ -543,7 +269,42 @@ input {
margin-bottom: 36px;
}
/* Steps */
.mini-globe-wrap {
background: var(--glass);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.8);
border-radius: var(--r-xl);
box-shadow: var(--shadow);
padding: 32px;
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
max-width: 340px;
position: relative;
overflow: hidden;
}
.mini-globe-wrap::before {
content: "";
position: absolute;
inset: 0;
border-radius: var(--r-xl);
background: radial-gradient(
circle at 60% 40%,
rgba(26, 127, 196, 0.1),
transparent 50%
);
}
.game-preview-svg {
width: 100%;
height: auto;
position: relative;
z-index: 1;
}
/* ─── STEPS ─── */
.steps {
display: grid;
gap: 16px;
@ -633,48 +394,6 @@ input {
margin-bottom: 30px;
}
.form {
display: grid;
gap: 20px;
}
.field {
display: grid;
gap: 8px;
}
.field label {
font-size: 0.86rem;
font-weight: 600;
color: var(--ink-soft);
letter-spacing: 0.02em;
}
.field input {
width: 100%;
height: 54px;
padding: 0 18px;
border: 1.5px solid var(--line);
border-radius: var(--r-md);
background: var(--cream);
color: var(--ink);
font-size: 0.98rem;
transition:
border-color 0.18s,
box-shadow 0.18s;
}
.field input::placeholder {
color: var(--ink-muted);
}
.field input:focus {
outline: none;
border-color: var(--sea-light);
box-shadow: 0 0 0 3px rgba(26, 127, 196, 0.12);
background: var(--white);
}
.form-footer {
margin-top: 8px;
font-size: 0.84rem;
@ -682,92 +401,34 @@ input {
text-align: center;
}
.mini-globe-wrap {
background: var(--glass);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.8);
border-radius: var(--r-xl);
box-shadow: var(--shadow);
padding: 32px;
/* ─── LOBBY TOGGLE SWITCH ─── */
.lobby-toggle {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
max-width: 340px;
position: relative;
overflow: hidden;
background: var(--cream);
border: 1.5px solid var(--line);
border-radius: 999px;
padding: 4px;
gap: 4px;
margin-bottom: 24px;
}
.mini-globe-wrap::before {
content: "";
position: absolute;
inset: 0;
border-radius: var(--r-xl);
background: radial-gradient(
circle at 60% 40%,
rgba(26, 127, 196, 0.1),
transparent 50%
);
}
/* ─── FOOTER ─── */
.footer {
border-top: 1px solid var(--line);
background: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(12px);
padding: 40px 0;
}
.footer__inner {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 24px;
}
.footer__brand {
.lobby-toggle__btn {
flex: 1;
padding: 9px 16px;
border-radius: 999px;
border: none;
background: transparent;
font-family: "Syne", sans-serif;
font-weight: 800;
font-size: 1.5rem;
letter-spacing: -0.02em;
}
.footer__sub {
font-size: 0.86rem;
color: var(--ink-muted);
margin-top: 4px;
}
.footer__center {
text-align: center;
}
.footer__label {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
font-size: 0.88rem;
color: var(--ink-muted);
margin-bottom: 10px;
cursor: pointer;
transition: background 0.18s, color 0.18s;
}
.footer__right {
justify-self: end;
display: flex;
gap: 22px;
flex-wrap: wrap;
justify-content: flex-end;
}
.footer__link {
font-size: 0.9rem;
color: var(--ink-muted);
transition: color 0.18s;
}
.footer__link:hover {
color: var(--ink);
.lobby-toggle__btn--active {
background: var(--ink);
color: var(--white);
}
/* ─── RESPONSIVE ─── */
@ -782,30 +443,12 @@ input {
.globe-card {
min-height: 360px;
}
.footer__inner {
grid-template-columns: 1fr;
}
.footer__center,
.footer__right {
justify-self: start;
text-align: left;
}
.socials {
justify-content: flex-start;
}
.footer__right {
justify-content: flex-start;
}
.mini-globe-wrap {
max-width: 260px;
}
}
@media (max-width: 680px) {
.container {
padding: 0 18px;
}
.hero {
padding: 50px 0 40px;
}
@ -813,9 +456,6 @@ input {
.register {
padding: 70px 0;
}
.nav__link {
display: none;
}
.register__card {
padding: 28px 22px;
}
@ -823,52 +463,15 @@ input {
padding: 24px;
min-height: 280px;
}
.header {
height: auto;
padding: 14px 0;
}
:root {
--hh: 64px;
}
}
@media (max-width: 420px) {
.hero__title {
font-size: 3rem;
}
.section-title {
font-size: 2.2rem;
}
.btn {
padding: 14px 22px;
font-size: 0.95rem;
}
}
@media (max-width: 360px) {
:root {
--r-xl: 22px;
--r-lg: 16px;
}
.container {
padding: 0 14px;
}
.logo__mark {
width: 36px;
height: 36px;
}
.logo__text {
font-size: 1.05rem;
}
.nav__cta {
padding: 8px 14px;
font-size: 0.84rem;
margin-left: 0;
}
.hero {
padding: 36px 0 32px;
min-height: auto;
@ -881,7 +484,6 @@ input {
font-size: 0.95rem;
margin-bottom: 28px;
}
.globe-card {
padding: 16px;
min-height: 240px;
@ -890,18 +492,13 @@ input {
padding: 7px 10px;
font-size: 0.72rem;
}
.about,
.register {
padding: 52px 0;
}
.section-title {
font-size: 2rem;
}
.about__desc {
font-size: 0.95rem;
}
.step {
padding: 16px 18px;
gap: 14px;
@ -912,7 +509,6 @@ input {
font-size: 0.88rem;
border-radius: 12px;
}
.register__card {
padding: 22px 16px;
border-radius: var(--r-lg);
@ -921,54 +517,8 @@ input {
font-size: 1.25rem;
margin-bottom: 22px;
}
.field input {
height: 48px;
font-size: 0.92rem;
}
.btn--full {
padding: 14px 18px;
font-size: 0.95rem;
}
.footer {
padding: 30px 0;
}
.footer__brand {
font-size: 1.25rem;
}
.social {
width: 36px;
height: 36px;
font-size: 0.8rem;
}
.socials {
gap: 8px;
}
.footer__right {
gap: 14px;
}
}
/* ─── SCROLL REVEAL ─── */
.reveal {
opacity: 0;
transform: translateY(28px);
transition:
opacity 0.6s ease,
transform 0.6s ease;
}
.reveal.visible {
opacity: 1;
transform: translateY(0);
}
.reveal-delay-1 {
transition-delay: 0.1s;
}
.reveal-delay-2 {
transition-delay: 0.2s;
}
.reveal-delay-3 {
transition-delay: 0.32s;
}

View File

@ -1,196 +1,247 @@
/* leader.css — leaderboard page styles */
.lb-page {
min-height: calc(100vh - var(--hh));
padding: 60px 0;
}
min-height: calc(100vh - var(--hh));
padding: 60px 0;
}
.lb-inner {
max-width: 760px;
margin: 0 auto;
}
.lb-inner {
max-width: 760px;
margin: 0 auto;
}
.lb-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
margin-bottom: 32px;
flex-wrap: wrap;
}
.lb-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
margin-bottom: 32px;
flex-wrap: wrap;
}
.lb-title {
font-family: 'Syne', sans-serif;
font-weight: 800;
font-size: clamp(2rem, 5vw, 3.2rem);
letter-spacing: -.03em;
line-height: 1;
}
.lb-title {
font-family: "Syne", sans-serif;
font-weight: 800;
font-size: clamp(2rem, 5vw, 3.2rem);
letter-spacing: -0.03em;
line-height: 1;
}
.lb-title em {
font-style: normal;
background: linear-gradient(135deg, var(--sea), var(--leaf));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.lb-title em {
font-style: normal;
background: linear-gradient(135deg, var(--sea), var(--leaf));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.lb-actions { display: flex; gap: 10px; flex-wrap: wrap; }
.lb-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
/* Table */
.lb-table {
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-xl);
overflow: hidden;
box-shadow: var(--shadow);
}
/* ─── TABLE ─── */
.lb-table {
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-xl);
overflow: hidden;
box-shadow: var(--shadow);
}
.lb-table-head {
display: grid;
grid-template-columns: 56px 1fr 90px 90px 90px;
gap: 0;
padding: 14px 24px;
background: var(--cream);
border-bottom: 1px solid var(--line);
}
.lb-table-head {
display: grid;
grid-template-columns: 56px 1fr 90px 90px 90px;
padding: 14px 24px;
background: var(--cream);
border-bottom: 1px solid var(--line);
}
.lb-th {
font-size: .72rem;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--ink-muted);
}
.lb-th {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-muted);
}
.lb-th.right { text-align: right; }
.lb-th.right {
text-align: right;
}
.lb-body { display: grid; }
.lb-body {
display: grid;
}
.lb-row {
display: grid;
grid-template-columns: 56px 1fr 90px 90px 90px;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid var(--line);
transition: background .15s;
animation: fadeRow .4s ease both;
}
.lb-row {
display: grid;
grid-template-columns: 56px 1fr 90px 90px 90px;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid var(--line);
transition: background 0.15s;
animation: fadeRow 0.4s ease both;
}
.lb-row:last-child { border-bottom: none; }
.lb-row:last-child {
border-bottom: none;
}
.lb-row:hover {
background: var(--cream);
}
.lb-row:hover { background: var(--cream); }
@keyframes fadeRow {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeRow {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* ─── TOP 3 HIGHLIGHT ─── */
.lb-row.rank-1 {
background: rgba(240, 180, 40, 0.07);
}
.lb-row.rank-2 {
background: rgba(180, 180, 195, 0.06);
}
.lb-row.rank-3 {
background: rgba(205, 130, 70, 0.05);
}
/* Top 3 highlight */
.lb-row.rank-1 { background: rgba(240,180,40,.07); }
.lb-row.rank-2 { background: rgba(180,180,195,.06); }
.lb-row.rank-3 { background: rgba(205,130,70,.05); }
.lb-rank {
font-family: "Syne", sans-serif;
font-weight: 800;
font-size: 1rem;
color: var(--ink-muted);
}
.lb-rank {
font-family: 'Syne', sans-serif;
font-weight: 800;
font-size: 1rem;
color: var(--ink-muted);
}
.lb-row.rank-1 .lb-rank {
color: var(--gold);
}
.lb-row.rank-2 .lb-rank {
color: #a0a0b0;
}
.lb-row.rank-3 .lb-rank {
color: #c07840;
}
.lb-row.rank-1 .lb-rank { color: var(--gold); }
.lb-row.rank-2 .lb-rank { color: #a0a0b0; }
.lb-row.rank-3 .lb-rank { color: #c07840; }
.lb-medal {
font-size: 1.1rem;
}
.lb-medal { font-size: 1.1rem; }
.lb-name {
font-family: "Syne", sans-serif;
font-weight: 700;
font-size: 0.96rem;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lb-name {
font-family: 'Syne', sans-serif;
font-weight: 700;
font-size: .96rem;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lb-name.is-you::after {
content: " (you)";
font-weight: 400;
font-family: "DM Sans", sans-serif;
color: var(--ink-muted);
font-size: 0.8rem;
}
.lb-name.is-you::after {
content: ' (you)';
font-weight: 400;
font-family: 'DM Sans', sans-serif;
color: var(--ink-muted);
font-size: .8rem;
}
.lb-rounds {
font-size: 0.82rem;
color: var(--ink-muted);
text-align: right;
}
.lb-rounds {
font-size: .82rem;
color: var(--ink-muted);
text-align: right;
}
.lb-date {
font-size: 0.78rem;
color: var(--ink-muted);
text-align: right;
}
.lb-date {
font-size: .78rem;
color: var(--ink-muted);
text-align: right;
}
.lb-score {
font-family: "Syne", sans-serif;
font-weight: 800;
font-size: 1.05rem;
color: var(--sea);
text-align: right;
}
.lb-score {
font-family: 'Syne', sans-serif;
font-weight: 800;
font-size: 1.05rem;
color: var(--sea);
text-align: right;
}
.lb-score.gold {
color: var(--gold);
}
.lb-score.silver {
color: #888898;
}
.lb-score.bronze {
color: #c07840;
}
.lb-score.gold { color: var(--gold); }
.lb-score.silver { color: #888898; }
.lb-score.bronze { color: #c07840; }
/* ─── EMPTY STATE ─── */
.lb-empty {
padding: 64px 24px;
text-align: center;
}
/* Empty state */
.lb-empty {
padding: 64px 24px;
text-align: center;
}
.lb-empty__icon {
font-size: 3rem;
margin-bottom: 12px;
}
.lb-empty__icon { font-size: 3rem; margin-bottom: 12px; }
.lb-empty__text {
font-size: 1rem;
color: var(--ink-soft);
margin-bottom: 20px;
}
.lb-empty__text {
font-size: 1rem;
color: var(--ink-soft);
margin-bottom: 20px;
}
/* ─── CURRENT PLAYER BAR ─── */
.your-score-bar {
margin-top: 20px;
padding: 16px 24px;
background: linear-gradient(135deg, var(--sea-dim), var(--leaf-dim));
border: 1px solid rgba(26, 127, 196, 0.18);
border-radius: var(--r-lg);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
/* Current player highlight bar */
.your-score-bar {
margin-top: 20px;
padding: 16px 24px;
background: linear-gradient(135deg, var(--sea-dim), var(--leaf-dim));
border: 1px solid rgba(26,127,196,.18);
border-radius: var(--r-lg);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.your-score-bar__text {
font-size: 0.9rem;
color: var(--ink-soft);
}
.your-score-bar__text strong {
color: var(--ink);
}
.your-score-bar__text {
font-size: .9rem;
color: var(--ink-soft);
}
/* ─── RESPONSIVE ─── */
@media (max-width: 640px) {
.lb-page {
padding: 40px 0;
}
.your-score-bar__text strong { color: var(--ink); }
.lb-table-head,
.lb-row {
grid-template-columns: 44px 1fr 72px;
}
@media (max-width: 640px) {
.lb-page { padding: 40px 0; }
.lb-table-head,
.lb-row {
grid-template-columns: 44px 1fr 72px;
}
.lb-th.hide-sm,
.lb-rounds,
.lb-date { display: none; }
}
.lb-th.hide-sm,
.lb-rounds,
.lb-date {
display: none;
}
}
@media (max-width: 360px) {
.lb-table-head,
.lb-row { padding: 12px 16px; }
}
@media (max-width: 360px) {
.lb-table-head,
.lb-row {
padding: 12px 16px;
}
}

View File

@ -1,202 +1,180 @@
/* lobby.css */
.lobby {
min-height: calc(100vh - var(--hh));
display: grid;
place-items: center;
padding: 60px 0;
min-height: calc(100vh - var(--hh));
display: grid;
place-items: center;
padding: 60px 0;
}
.lobby__inner {
display: grid;
grid-template-columns: 1fr minmax(300px, 480px);
gap: 64px;
align-items: center;
width: 100%;
display: grid;
grid-template-columns: 1fr minmax(300px, 480px);
gap: 64px;
align-items: center;
width: 100%;
}
/* ─── Lobby name badge ─── */
.lobby__name-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 18px;
border-radius: var(--r-lg);
background: var(--glass);
border: 1px solid rgba(255,255,255,.9);
box-shadow: var(--shadow-sm);
backdrop-filter: blur(10px);
margin-bottom: 20px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 18px;
border-radius: var(--r-lg);
background: var(--glass);
border: 1px solid rgba(255, 255, 255, 0.9);
box-shadow: var(--shadow-sm);
backdrop-filter: blur(10px);
margin-bottom: 20px;
}
.lobby__name-icon { font-size: 1.1rem; }
.lobby__name-icon {
font-size: 1.1rem;
}
#lobby-name-display {
font-family: 'Syne', sans-serif;
font-weight: 700;
font-size: 1rem;
color: var(--ink);
letter-spacing: -.01em;
font-family: "Syne", sans-serif;
font-weight: 700;
font-size: 1rem;
color: var(--ink);
letter-spacing: -0.01em;
}
/* Left side promo */
.lobby__promo {}
/* ─── EYEBROW ─── */
.eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px 6px 10px;
border-radius: 999px;
border: 1px solid var(--leaf-dim);
background: rgba(65,184,105,.08);
color: #289149;
font-size: .78rem;
font-weight: 600;
letter-spacing: .05em;
text-transform: uppercase;
margin-bottom: 22px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px 6px 10px;
border-radius: 999px;
border: 1px solid var(--leaf-dim);
background: rgba(65, 184, 105, 0.08);
color: #289149;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
margin-bottom: 22px;
}
.eyebrow__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--leaf);
animation: pulse 2s ease-in-out infinite;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--leaf);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.45); opacity: .7; }
0%,
100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.45);
opacity: 0.7;
}
}
.lobby__title {
font-family: 'Syne', sans-serif;
font-weight: 800;
font-size: clamp(2.8rem, 5.5vw, 5rem);
line-height: .94;
letter-spacing: -.04em;
margin-bottom: 20px;
font-family: "Syne", sans-serif;
font-weight: 800;
font-size: clamp(2.8rem, 5.5vw, 5rem);
line-height: 0.94;
letter-spacing: -0.04em;
margin-bottom: 20px;
}
.lobby__title em {
font-style: normal;
background: linear-gradient(135deg, var(--sea), var(--leaf));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-style: normal;
background: linear-gradient(135deg, var(--sea), var(--leaf));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.lobby__desc {
font-size: 1.02rem;
line-height: 1.72;
color: var(--ink-soft);
max-width: 420px;
margin-bottom: 32px;
font-size: 1.02rem;
line-height: 1.72;
color: var(--ink-soft);
max-width: 420px;
margin-bottom: 32px;
}
.lobby__badges {
display: flex;
flex-wrap: wrap;
gap: 10px;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 999px;
background: var(--glass);
border: 1px solid rgba(255,255,255,.9);
box-shadow: var(--shadow-sm);
font-size: .82rem;
font-weight: 600;
color: var(--ink-soft);
backdrop-filter: blur(8px);
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 999px;
background: var(--glass);
border: 1px solid rgba(255, 255, 255, 0.9);
box-shadow: var(--shadow-sm);
font-size: 0.82rem;
font-weight: 600;
color: var(--ink-soft);
backdrop-filter: blur(8px);
}
.badge__icon { font-size: 1rem; }
.badge__icon {
font-size: 1rem;
}
/* Right side card */
.lobby__card {
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-xl);
padding: 44px;
box-shadow: var(--shadow);
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-xl);
padding: 44px;
box-shadow: var(--shadow);
}
.lobby__card-title {
font-family: 'Syne', sans-serif;
font-weight: 800;
font-size: 1.5rem;
letter-spacing: -.02em;
margin-bottom: 8px;
font-family: "Syne", sans-serif;
font-weight: 800;
font-size: 1.5rem;
letter-spacing: -0.02em;
margin-bottom: 8px;
}
.lobby__card-sub {
font-size: .9rem;
color: var(--ink-muted);
margin-bottom: 30px;
font-size: 0.9rem;
color: var(--ink-muted);
margin-bottom: 30px;
}
.form { display: grid; gap: 20px; }
.field { display: grid; gap: 8px; }
.field label {
font-size: .84rem;
font-weight: 600;
color: var(--ink-soft);
letter-spacing: .02em;
}
.field input {
width: 100%;
height: 54px;
padding: 0 18px;
border: 1.5px solid var(--line);
border-radius: var(--r-md);
background: var(--cream);
color: var(--ink);
font-size: .98rem;
transition: border-color .18s, box-shadow .18s;
}
.field input::placeholder { color: var(--ink-muted); }
.field input:focus {
outline: none;
border-color: var(--sea-light);
box-shadow: 0 0 0 3px rgba(26,127,196,.12);
background: var(--white);
}
.field input.input--error {
border-color: var(--danger);
box-shadow: 0 0 0 3px rgba(224,92,92,.12);
}
.field-error {
font-size: .82rem;
color: var(--danger);
min-height: 1.2em;
transition: opacity .18s;
}
/* Form and field styles are defined in main.css */
/* ─── Responsive ─── */
@media (max-width: 900px) {
.lobby__inner { grid-template-columns: 1fr; gap: 40px; }
.lobby__inner {
grid-template-columns: 1fr;
gap: 40px;
}
}
@media (max-width: 680px) {
.lobby { padding: 44px 0; }
.lobby__card { padding: 28px 22px; }
.lobby {
padding: 44px 0;
}
.lobby__card {
padding: 28px 22px;
}
}
@media (max-width: 360px) {
.lobby__card { padding: 22px 16px; }
.lobby__title { font-size: 2.4rem; }
.lobby__card {
padding: 22px 16px;
}
.lobby__title {
font-size: 2.4rem;
}
}

View File

@ -1,78 +1,107 @@
/* main.css — shared design tokens & base */
@import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;500;700;800&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600&display=swap');
@import url("https://fonts.googleapis.com/css2?family=Syne:wght@400;500;700;800&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600&display=swap");
:root {
--ink: #0b1f2a;
--ink-soft: #3d5563;
--ink-muted: #7a9aaa;
--sea: #1a7fc4;
--sea-light: #4faae0;
--sea-dim: rgba(26,127,196,.12);
--leaf: #41b869;
--leaf-dim: rgba(65,184,105,.13);
--gold: #f0b429;
--danger: #e05c5c;
--cream: #f4f9f6;
--white: #ffffff;
--glass: rgba(255,255,255,.72);
--line: rgba(11,31,42,.09);
--shadow: 0 24px 60px rgba(11,31,42,.10);
--shadow-sm: 0 4px 20px rgba(11,31,42,.07);
--r-xl: 32px;
--r-lg: 22px;
--r-md: 14px;
--r-sm: 8px;
--hh: 72px;
--ink: #0b1f2a;
--ink-soft: #3d5563;
--ink-muted: #7a9aaa;
--sea: #1a7fc4;
--sea-light: #4faae0;
--sea-dim: rgba(26, 127, 196, 0.12);
--leaf: #41b869;
--leaf-dim: rgba(65, 184, 105, 0.13);
--gold: #f0b429;
--danger: #e05c5c;
--cream: #f4f9f6;
--white: #ffffff;
--glass: rgba(255, 255, 255, 0.72);
--line: rgba(11, 31, 42, 0.09);
--shadow: 0 24px 60px rgba(11, 31, 42, 0.1);
--shadow-sm: 0 4px 20px rgba(11, 31, 42, 0.07);
--r-xl: 32px;
--r-lg: 22px;
--r-md: 14px;
--r-sm: 8px;
--hh: 72px;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html { scroll-behavior: smooth; }
html {
scroll-behavior: smooth;
}
body {
font-family: 'DM Sans', sans-serif;
color: var(--ink);
background: var(--cream);
overflow-x: hidden;
position: relative;
min-width: 320px;
font-family: "DM Sans", sans-serif;
color: var(--ink);
background: var(--cream);
overflow-x: hidden;
position: relative;
min-width: 320px;
}
body::before {
content: '';
position: fixed;
inset: 0;
background:
radial-gradient(ellipse 60% 50% at 10% 0%, rgba(65,184,105,.10) 0%, transparent 60%),
radial-gradient(ellipse 50% 60% at 90% 100%, rgba(26,127,196,.10) 0%, transparent 60%);
pointer-events: none;
z-index: 0;
content: "";
position: fixed;
inset: 0;
background:
radial-gradient(
ellipse 60% 50% at 10% 0%,
rgba(65, 184, 105, 0.1) 0%,
transparent 60%
),
radial-gradient(
ellipse 50% 60% at 90% 100%,
rgba(26, 127, 196, 0.1) 0%,
transparent 60%
);
pointer-events: none;
z-index: 0;
}
body::after {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(var(--line) 1px, transparent 1px),
linear-gradient(90deg, var(--line) 1px, transparent 1px);
background-size: 60px 60px;
pointer-events: none;
z-index: 0;
content: "";
position: fixed;
inset: 0;
background-image:
linear-gradient(var(--line) 1px, transparent 1px),
linear-gradient(90deg, var(--line) 1px, transparent 1px);
background-size: 60px 60px;
pointer-events: none;
z-index: 0;
}
a { color: inherit; text-decoration: none; }
img, svg { display: block; max-width: 100%; }
button, input { font: inherit; }
a {
color: inherit;
text-decoration: none;
}
img,
svg {
display: block;
max-width: 100%;
}
button,
input {
font: inherit;
}
.wrap { position: relative; z-index: 1; }
.wrap {
position: relative;
z-index: 1;
}
.container {
width: 100%;
max-width: 1280px;
margin: 0 auto;
padding: 0 28px;
width: 100%;
max-width: 1280px;
margin: 0 auto;
padding: 0 28px;
}
.header {
@ -166,91 +195,174 @@ button, input { font: inherit; }
transform: translateY(-1px);
}
/* ─── BUTTONS ─── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 15px 28px;
border-radius: var(--r-lg);
font-family: 'DM Sans', sans-serif;
font-size: 1rem;
font-weight: 600;
border: none;
cursor: pointer;
transition: transform .2s, box-shadow .2s, opacity .2s;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 15px 28px;
border-radius: var(--r-lg);
font-family: "DM Sans", sans-serif;
font-size: 1rem;
font-weight: 600;
border: none;
cursor: pointer;
transition:
transform 0.2s,
box-shadow 0.2s,
opacity 0.2s;
line-height: 1;
}
.btn:hover:not(:disabled) { transform: translateY(-2px); }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn:hover:not(:disabled) {
transform: translateY(-2px);
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn--primary {
color: var(--white);
background: linear-gradient(135deg, var(--sea) 0%, #159fd4 50%, var(--leaf) 100%);
background-size: 200% 200%;
box-shadow: 0 8px 24px rgba(26,127,196,.28);
animation: gradShift 4s ease infinite;
color: var(--white);
background: linear-gradient(
135deg,
var(--sea) 0%,
#159fd4 50%,
var(--leaf) 100%
);
background-size: 200% 200%;
box-shadow: 0 8px 24px rgba(26, 127, 196, 0.28);
animation: gradShift 4s ease infinite;
}
.btn--primary:hover:not(:disabled) { box-shadow: 0 12px 32px rgba(26,127,196,.4); }
.btn--primary:hover:not(:disabled) {
box-shadow: 0 12px 32px rgba(26, 127, 196, 0.4);
}
@keyframes gradShift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.btn--ghost {
color: var(--ink-soft);
background: transparent;
border: 1.5px solid var(--line);
color: var(--ink-soft);
background: transparent;
border: 1.5px solid var(--line);
}
.btn--ghost:hover:not(:disabled) { border-color: var(--ink-muted); color: var(--ink); }
.btn--ghost:hover:not(:disabled) {
border-color: var(--ink-muted);
color: var(--ink);
}
.btn--danger {
color: var(--white);
background: var(--danger);
box-shadow: 0 6px 18px rgba(224,92,92,.25);
color: var(--white);
background: var(--danger);
box-shadow: 0 6px 18px rgba(224, 92, 92, 0.25);
}
.btn--full { width: 100%; }
.btn--full {
width: 100%;
}
.btn--sm { padding: 10px 20px; font-size: .88rem; border-radius: var(--r-md); }
.btn--sm {
padding: 10px 20px;
font-size: 0.88rem;
border-radius: var(--r-md);
}
/* ─── CARD ─── */
.card {
background: var(--glass);
backdrop-filter: blur(12px);
border: 1px solid rgba(255,255,255,.8);
border-radius: var(--r-xl);
box-shadow: var(--shadow);
background: var(--glass);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.8);
border-radius: var(--r-xl);
box-shadow: var(--shadow);
}
/* ─── SECTION LABELS ─── */
.section-label {
display: inline-block;
padding: 5px 13px;
border-radius: 999px;
background: var(--sea-dim);
color: var(--sea);
font-size: .78rem;
font-weight: 600;
letter-spacing: .06em;
text-transform: uppercase;
margin-bottom: 16px;
display: inline-block;
padding: 5px 13px;
border-radius: 999px;
background: var(--sea-dim);
color: var(--sea);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 16px;
}
.section-title {
font-family: 'Syne', sans-serif;
font-weight: 800;
font-size: clamp(2rem, 4vw, 3.6rem);
line-height: 1.04;
letter-spacing: -.03em;
margin-bottom: 16px;
font-family: "Syne", sans-serif;
font-weight: 800;
font-size: clamp(2rem, 4vw, 3.6rem);
line-height: 1.04;
letter-spacing: -0.03em;
margin-bottom: 16px;
}
/* ─── FORM ─── */
.form {
display: grid;
gap: 20px;
}
.field {
display: grid;
gap: 8px;
}
.field label {
font-size: 0.84rem;
font-weight: 600;
color: var(--ink-soft);
letter-spacing: 0.02em;
}
.field input {
width: 100%;
height: 54px;
padding: 0 18px;
border: 1.5px solid var(--line);
border-radius: var(--r-md);
background: var(--cream);
color: var(--ink);
font-size: 0.98rem;
transition:
border-color 0.18s,
box-shadow 0.18s;
}
.field input::placeholder {
color: var(--ink-muted);
}
.field input:focus {
outline: none;
border-color: var(--sea-light);
box-shadow: 0 0 0 3px rgba(26, 127, 196, 0.12);
background: var(--white);
}
.field input.input--error {
border-color: var(--danger);
box-shadow: 0 0 0 3px rgba(224, 92, 92, 0.12);
}
.field-error {
font-size: 0.82rem;
color: var(--danger);
min-height: 1.2em;
}
/* ─── FOOTER ─── */
@ -285,7 +397,6 @@ button, input { font: inherit; }
text-align: center;
}
.footer__label {
font-size: 0.7rem;
font-weight: 700;
@ -315,30 +426,88 @@ button, input { font: inherit; }
/* ─── SCROLL REVEAL ─── */
.reveal {
opacity: 0;
transform: translateY(24px);
transition: opacity .55s ease, transform .55s ease;
opacity: 0;
transform: translateY(24px);
transition:
opacity 0.55s ease,
transform 0.55s ease;
}
.reveal.visible { opacity: 1; transform: translateY(0); }
.reveal-delay-1 { transition-delay: .1s; }
.reveal-delay-2 { transition-delay: .2s; }
.reveal-delay-3 { transition-delay: .32s; }
.reveal.visible {
opacity: 1;
transform: translateY(0);
}
.reveal-delay-1 {
transition-delay: 0.1s;
}
.reveal-delay-2 {
transition-delay: 0.2s;
}
.reveal-delay-3 {
transition-delay: 0.32s;
}
/* ─── NOSCRIPT ─── */
.noscript-msg {
padding: 1.5rem 2rem;
background: var(--danger);
color: var(--white);
font-weight: 600;
text-align: center;
}
/* ─── REDUCED MOTION ─── */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* ─── RESPONSIVE BASE ─── */
@media (max-width: 680px) {
.container { padding: 0 18px; }
.nav__link { display: none; }
.footer__inner { grid-template-columns: 1fr; }
.footer__center, .footer__right { justify-self: start; text-align: left; }
.socials { justify-content: flex-start; }
.footer__right { justify-content: flex-start; }
.container {
padding: 0 18px;
}
.nav__link {
display: none;
}
.footer__inner {
grid-template-columns: 1fr;
}
.footer__center,
.footer__right {
justify-self: start;
text-align: left;
}
.socials {
justify-content: flex-start;
}
.footer__right {
justify-content: flex-start;
}
}
@media (max-width: 360px) {
:root { --r-xl: 22px; --r-lg: 16px; }
.container { padding: 0 14px; }
.logo__mark { width: 34px; height: 34px; }
.logo__text { font-size: 1.05rem; }
.nav__cta { padding: 7px 12px; font-size: .82rem; }
:root {
--r-xl: 22px;
--r-lg: 16px;
}
.container {
padding: 0 14px;
}
.logo__mark {
width: 34px;
height: 34px;
}
.logo__text {
font-size: 1.05rem;
}
.nav__cta {
padding: 7px 12px;
font-size: 0.82rem;
}
}

230
frontend/styles/results.css Normal file
View File

@ -0,0 +1,230 @@
/* results.css — results page styles */
.results-page {
min-height: calc(100vh - var(--hh));
display: grid;
place-items: center;
padding: 60px 0;
}
.results-inner {
width: 100%;
max-width: 680px;
display: grid;
gap: 24px;
}
/* ─── HEADER ─── */
.results-header {
text-align: center;
}
.results-emoji {
font-size: 3.5rem;
margin-bottom: 16px;
display: block;
animation: popIn 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@keyframes popIn {
from {
transform: scale(0);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.results-title {
font-family: "Syne", sans-serif;
font-weight: 800;
font-size: clamp(2rem, 5vw, 3.4rem);
letter-spacing: -0.03em;
line-height: 1.05;
margin-bottom: 8px;
}
.results-player {
font-size: 1rem;
color: var(--ink-soft);
}
.results-player strong {
color: var(--ink);
}
/* ─── TOTAL SCORE ─── */
.score-total-card {
background: linear-gradient(
135deg,
var(--sea) 0%,
#159fd4 50%,
var(--leaf) 100%
);
border-radius: var(--r-xl);
padding: 36px 40px;
text-align: center;
color: var(--white);
box-shadow: 0 16px 40px rgba(26, 127, 196, 0.3);
animation: slideUp 0.5s 0.1s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@keyframes slideUp {
from {
transform: translateY(30px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.score-total-label {
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
opacity: 0.78;
margin-bottom: 8px;
}
.score-total-num {
font-family: "Syne", sans-serif;
font-weight: 800;
font-size: clamp(4rem, 10vw, 6rem);
line-height: 1;
letter-spacing: -0.04em;
}
.score-total-max {
font-size: 1.4rem;
opacity: 0.6;
font-weight: 400;
}
.score-grade {
display: inline-block;
margin-top: 12px;
padding: 4px 18px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.22);
font-family: "Syne", sans-serif;
font-weight: 800;
font-size: 1.1rem;
letter-spacing: 0.04em;
}
/* ─── ROUND BREAKDOWN ─── */
.rounds-card {
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-xl);
padding: 28px 32px;
box-shadow: var(--shadow-sm);
animation: slideUp 0.5s 0.2s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
.rounds-card-title {
font-family: "Syne", sans-serif;
font-weight: 700;
font-size: 1rem;
letter-spacing: -0.01em;
margin-bottom: 20px;
color: var(--ink-soft);
}
.round-rows {
display: grid;
gap: 14px;
}
.round-row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 16px;
}
.round-row__label {
font-size: 0.82rem;
font-weight: 600;
color: var(--ink-soft);
white-space: nowrap;
}
.round-row__bar-wrap {
height: 8px;
background: var(--cream);
border-radius: 999px;
overflow: hidden;
}
.round-row__bar {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--sea), var(--leaf));
transition: width 0.8s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.round-row__score {
font-family: "Syne", sans-serif;
font-weight: 800;
font-size: 0.9rem;
color: var(--sea);
min-width: 36px;
text-align: right;
}
/* ─── COUNTRY TAGS ─── */
.countries-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.country-tag {
padding: 5px 12px;
border-radius: 999px;
background: var(--sea-dim);
color: var(--sea);
font-size: 0.8rem;
font-weight: 600;
}
/* ─── ACTIONS ─── */
.results-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
animation: slideUp 0.5s 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
.results-actions .btn {
flex: 1 1 180px;
}
/* ─── RESPONSIVE ─── */
@media (max-width: 600px) {
.results-page {
padding: 40px 0;
}
.score-total-card {
padding: 28px 24px;
}
.rounds-card {
padding: 22px 20px;
}
.results-actions .btn {
flex-basis: 100%;
}
}
@media (max-width: 360px) {
.score-total-num {
font-size: 3.4rem;
}
}

38
router.php Normal file
View 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
View 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),
);

View 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);
});

View 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 &raquo; Blog Archive &raquo; Admin 0 &#8211; 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 &#8211; 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&#038;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 &#8211; 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>&nbsp;</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&nbsp;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 &#8211; 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 &#8211; 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 &#8211; 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 &#8211; 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&#039;s profile" class="bbp-author-link"><span class="bbp-author-avatar"><img alt='' src='http://2.gravatar.com/avatar/2bd3b3347fd34f7dd734bf4b78fb353d?s=14&#038;d=retro&#038;r=g' srcset='http://2.gravatar.com/avatar/2bd3b3347fd34f7dd734bf4b78fb353d?s=28&#038;d=retro&#038;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&#039;s profile" class="bbp-author-link"><span class="bbp-author-avatar"><img alt='' src='http://2.gravatar.com/avatar/e679fead4b7cb50b7b60e7c4f99bc348?s=14&#038;d=retro&#038;r=g' srcset='http://2.gravatar.com/avatar/e679fead4b7cb50b7b60e7c4f99bc348?s=28&#038;d=retro&#038;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: &quot;type&quot; field</a>
by <span class="topic-author"><a href="https://www.naturalearthdata.com/forums/users/alykat/" title="View alykat&#039;s profile" class="bbp-author-link"><span class="bbp-author-avatar"><img alt='' src='http://2.gravatar.com/avatar/8c60e36c6b6542afb738eb950429d6ba?s=14&#038;d=retro&#038;r=g' srcset='http://2.gravatar.com/avatar/8c60e36c6b6542afb738eb950429d6ba?s=28&#038;d=retro&#038;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&#039;s profile" class="bbp-author-link"><span class="bbp-author-avatar"><img alt='' src='http://0.gravatar.com/avatar/f5060dc4d25dbced2933380279e0c1c5?s=14&#038;d=retro&#038;r=g' srcset='http://0.gravatar.com/avatar/f5060dc4d25dbced2933380279e0c1c5?s=28&#038;d=retro&#038;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&#039;s profile" class="bbp-author-link"><span class="bbp-author-avatar"><img alt='' src='http://2.gravatar.com/avatar/eded29f5ea7062bfe350281260b0596b?s=14&#038;d=retro&#038;r=g' srcset='http://2.gravatar.com/avatar/eded29f5ea7062bfe350281260b0596b?s=28&#038;d=retro&#038;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&#039;s profile" class="bbp-author-link"><span class="bbp-author-avatar"><img alt='' src='http://1.gravatar.com/avatar/d617957f9d5d7d062deeadbfa011d8b6?s=14&#038;d=retro&#038;r=g' srcset='http://1.gravatar.com/avatar/d617957f9d5d7d062deeadbfa011d8b6?s=28&#038;d=retro&#038;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&#039;s profile" class="bbp-author-link"><span class="bbp-author-avatar"><img alt='' src='http://0.gravatar.com/avatar/06422086ab7aab2b767e3df7f7a67bdd?s=14&#038;d=retro&#038;r=g' srcset='http://0.gravatar.com/avatar/06422086ab7aab2b767e3df7f7a67bdd?s=28&#038;d=retro&#038;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&#039;s profile" class="bbp-author-link"><span class="bbp-author-avatar"><img alt='' src='http://2.gravatar.com/avatar/e07b4667376982e080d64cf0250acc9b?s=14&#038;d=retro&#038;r=g' srcset='http://2.gravatar.com/avatar/e07b4667376982e080d64cf0250acc9b?s=28&#038;d=retro&#038;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 &#8211; writer: me</a>
by <span class="topic-author"><a href="https://www.naturalearthdata.com/forums/users/krzysztof/" title="View krzysztof&#039;s profile" class="bbp-author-link"><span class="bbp-author-avatar"><img alt='' src='http://2.gravatar.com/avatar/be9767706435aa6e10d50185f6def05e?s=14&#038;d=retro&#038;r=g' srcset='http://2.gravatar.com/avatar/be9767706435aa6e10d50185f6def05e?s=28&#038;d=retro&#038;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 &#8211; ne_10m_lakes.dbf</a>
by <span class="topic-author"><a href="https://www.naturalearthdata.com/forums/users/filter-1/" title="View filter.1&#039;s profile" class="bbp-author-link"><span class="bbp-author-avatar"><img alt='' src='http://1.gravatar.com/avatar/daae13b2efd52a3865320ada9ed3ae18?s=14&#038;d=retro&#038;r=g' srcset='http://1.gravatar.com/avatar/daae13b2efd52a3865320ada9ed3ae18?s=28&#038;d=retro&#038;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">
&copy; 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 &raquo;</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 -->

View File

@ -0,0 +1 @@
5.1.1

View File

@ -0,0 +1 @@
UTF-8

Binary file not shown.

View 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]]

Binary file not shown.

Binary file not shown.

Binary file not shown.

93
tools/package-lock.json generated Normal file
View 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
View 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"
}
}