name-all-cities-by-populati.../index.html

241 lines
11 KiB
HTML

<!DOCTYPE html>
<html lang="en" class="h-100">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Name All the Cities</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
</head>
<body class="h-100 d-flex flex-column">
<div class="container vh-100">
<h1>Name All the Cities</h1>
<div class="mb-3">
<label for="state-select" class="form-label">State:</label>
<div class="input-group">
<select id="state-select" class="form-select" disabled>
<option select>Choose a state…</option>
</select>
<button class="btn btn-primary" type="button" id="go-button">Play</button>
</div>
</div>
<div id="game" v-scope class="row" style="visibility:hidden;">
<div class="col-4">
<div class="mb-3">
<label for="city-guess" class="form-label">City:</label>
<div class="input-group">
<input class="form-control" id="city-guess" v-model="city_guess" @keyup.enter="guess">
<button class="btn btn-primary" type="button" @click="guess">Guess</button>
</div>
</div>
<div v-if="message != ''" class="alert alert-secondary" role="alert">
{{ message }}
</div>
<h3>{{ state }} cities</h3>
<div>
<span
v-for="(required_cities, name) in achievements"
:class="{ 'badge': true, 'me-1': true, 'text-bg-secondary': !required_cities(cities).every(c => c.guessed), 'text-bg-warning': required_cities(cities).every(c => c.guessed) }">
{{ name }}
({{ required_cities(cities).filter(c => c.guessed).length }}/{{ required_cities(cities).length }})
</span>
</div>
<table class="table">
<thead>
<tr>
<th>Rank</th>
<th>Name</th>
<th>Population</th>
</tr>
</thead>
<tbody>
<template v-for="(city, rank) in cities">
<tr v-show="city.guessed">
<td>{{ rank + 1 }}</td>
<td>{{ city.name }}</td>
<td>{{ city.pop }}</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="col-8">
<canvas id="canvas" class="mx-3 my-auto" @vue:mounted="draw" width="800" height="600"></canvas>
<div>
<input type="checkbox" id="show-unguessed-checkbox" v-model="show_unguessed_cities" @change="draw">
<label for="show-unguessed-checkbox">Show unguessed cities</label>
</div>
</div>
</div>
</div>
<footer class="footer mt-auto py-3 text-center text-muted">
Created by <a href="https://chandlerswift.com">Chandler Swift</a> using 2020 US Census data |
<a href="https://git.chandlerswift.com/chandlerswift/name-all-cities-by-population-quiz">Source</a>
(<a href="https://git.chandlerswift.com/chandlerswift/name-all-cities-by-population-quiz/src/branch/main/LICENSE">GPL3</a>)
</footer>
<script type="module">
let state_shape_data;
let state_shape_data_request;
(async function init(){
const response = await fetch("data/states.json");
const states = await response.json();
const ss = document.getElementById('state-select');
ss.disabled = false;
for (let state of states) {
ss.options[ss.options.length] = new Option(state);
}
document.getElementById('go-button').addEventListener('click', async (e) => {
e.target.disabled = true;
ss.disabled = true;
await selectState(ss.value);
document.getElementById('game').style.visibility = 'visible';
});
// Check if there's a parameter in the URL already
const state = (new URLSearchParams(window.location.search)).get('state');
if (state) {
ss.value = state;
document.getElementById('go-button').click();
}
const shape_response = await fetch("data/states.geojson");
state_shape_data_request = shape_response.json().then(data => state_shape_data = data);
})();
import { createApp } from 'https://unpkg.com/petite-vue?module'
// https://stackoverflow.com/a/13627586
function ordinal_suffix_of(i) {
let j = i % 10,
k = i % 100;
if (j === 1 && k !== 11) {
return i + "st";
}
if (j === 2 && k !== 12) {
return i + "nd";
}
if (j === 3 && k !== 13) {
return i + "rd";
}
return i + "th";
}
const simplify = s => s.toLowerCase().replace(/[^a-z]/g, '');
async function selectState(stateName) {
const response = await fetch(`data/${stateName}.json`);
const cities = await response.json();
await state_shape_data_request; // We can't draw until this is in
const state_shape = state_shape_data.features.find(f => f.properties.NAME.toLowerCase() == stateName.toLowerCase());
let state_outlines;
if (state_shape.geometry.type == "MultiPolygon") {
state_outlines = state_shape.geometry.coordinates.map(a => a.flat());
} else { // Polygon
state_outlines = state_shape.geometry.coordinates;
}
console.log(state_outlines);
const state_mercator_adjusted_bounds = find_bounds(state_outlines.flat().map(mercator));
createApp({
eventListenerAdded: false,
state: stateName,
cities: cities,
simplified_cities: cities.map(city => simplify(city.name)),
city_guess: "",
message: "",
show_unguessed_cities: true,
achievements: {
"Top Five": cities => cities.slice(0, 5),
"Top Ten": cities => cities.slice(0, 10),
"Top Twenty": cities => cities.slice(0, 20),
"Top Fifty": cities => cities.slice(0, 50),
"Top Hundred": cities => cities.slice(0, 100),
"Every Single One": cities => cities,
"All above 100k": cities => cities.filter(city => city.pop >= 100000),
"All above 50k": cities => cities.filter(city => city.pop >= 50000),
"All above 25k": cities => cities.filter(city => city.pop >= 25000),
},
guess() {
const rank = this.simplified_cities.indexOf(simplify(this.city_guess))
if (rank >= 0) {
const city = this.cities[rank];
if (!city.guessed) {
city.guessed = true;
this.message = `${city.name} (population ${city.pop}) is the ${ordinal_suffix_of(rank + 1)} most populated city in ${this.state}.`;
this.city_guess = "";
this.draw();
} else {
this.message = `Already guessed ${city.name} (population ${city.pop}, rank ${rank}).`;
this.city_guess = "";
}
}
},
draw() {
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
const [minx, maxx, miny, maxy] = state_mercator_adjusted_bounds;
const height_scale = (canvas.height - 8) / (maxy - miny);
const width_scale = (canvas.width - 8) / (maxx - minx);
const scale = Math.min(height_scale, width_scale);
const y_offset = miny - (canvas.height / scale - (maxy - miny)) / 2;
const x_offset = minx - (canvas.width / scale - (maxx - minx)) / 2;
function transform(pt) {
pt = mercator(pt)
return [scale * (pt[0] - x_offset), canvas.height - (scale * (pt[1] - y_offset))];
}
for (let state_outline_segment of state_outlines) {
ctx.beginPath();
ctx.moveTo(...transform(state_outline_segment[0]))
for (let coord of state_outline_segment.slice(1)) {
ctx.lineTo(...transform(coord));
}
ctx.stroke();
}
for (let city of cities) {
if (city.guessed) {
ctx.beginPath();
const c = transform(city.location)
ctx.fillStyle = "black";
ctx.arc(c[0], c[1], 2, 0, 2*Math.PI, true);
ctx.fill();
} else if (this.show_unguessed_cities) {
ctx.beginPath();
const c = transform(city.location)
ctx.fillStyle = "lightgray";
ctx.arc(c[0], c[1], 2, 0, 2*Math.PI, true);
ctx.fill();
}
}
},
}).mount();
}
function mercator(pt) {
const radians_from_eq = Math.abs(Math.PI/180 * pt[1]);
// https://en.wikipedia.org/wiki/Mercator_projection#Derivation
const y_radians = Math.log(Math.tan(Math.PI/4 + radians_from_eq/2));
const y_degrees = 180/Math.PI * y_radians;
return [pt[0], y_degrees];
}
// returns minx, maxx, miny, maxy
function find_bounds(coords) {
return [
Math.min(...coords.map(c => c[0])),
Math.max(...coords.map(c => c[0])),
Math.min(...coords.map(c => c[1])),
Math.max(...coords.map(c => c[1]))
];
}
</script>
</body>
</html>