2024-02-29 17:24:33 -06:00
<!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" >
2024-03-07 00:28:54 -06:00
< div v-scope @ vue:mounted = "mounted" class = "container vh-100" >
2024-02-29 17:24:33 -06:00
< h1 > Name All the Cities< / h1 >
< div class = "mb-3" >
2024-03-07 00:28:54 -06:00
< label class = "form-label" > State:< / label >
2024-03-07 00:49:53 -06:00
< div class = "btn-toolbar" >
< div class = "input-group me-3 flex-grow-1" >
< select class = "form-select" v-model = "state_name" :disabled = "launched" >
< option value = "" selected > Select a state…< / option >
< option v-for = "state in states" > {{ state }}< / option >
< / select >
2024-03-07 01:27:10 -06:00
< button class = "btn btn-primary" type = "button" @ click = "launch" :disabled = "launched || !state_name" > Play< / button >
2024-03-07 00:49:53 -06:00
< / div >
< div class = "btn-group" >
< button class = "btn btn-secondary" type = "button" @ click = "save" :disabled = "!launched" > Save< / button >
2024-03-07 01:26:53 -06:00
< label for = "restore-input" class = "btn btn-success" :class = "{disabled: launched}" > Restore< / label >
2024-03-07 00:49:53 -06:00
< / div >
2024-03-07 01:26:53 -06:00
< input id = "restore-input" type = "file" @ input = "restore" style = "display: none;" > < / button >
2024-02-29 17:24:33 -06:00
< / div >
< / div >
2024-03-07 01:27:28 -06:00
< div class = "row" v-show = "launched" >
2024-03-01 00:48:54 -06:00
< 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 >
2024-03-07 00:28:54 -06:00
< h3 > {{ state_name }} cities< / h3 >
2024-03-01 00:48:54 -06:00
< div >
< span
v-for="(required_cities, name) in achievements"
2024-03-07 00:28:54 -06:00
:class="{ 'badge': true, 'me-1': true, 'text-bg-secondary': !required_cities(state_cities).every(c => c.guessed), 'text-bg-warning': required_cities(state_cities).every(c => c.guessed) }">
2024-03-01 00:48:54 -06:00
{{ name }}
2024-03-07 00:28:54 -06:00
({{ required_cities(state_cities).filter(c => c.guessed).length }}/{{ required_cities(state_cities).length }})
2024-03-01 00:48:54 -06:00
< / span >
< / div >
< table class = "table" >
< thead >
< tr >
< th > Rank< / th >
< th > Name< / th >
< th > Population< / th >
< / tr >
< / thead >
< tbody >
2024-03-07 00:28:54 -06:00
< template v-for = "(city, rank) in state_cities" >
2024-03-01 00:48:54 -06:00
< tr v-show = "city.guessed" >
< td > {{ rank + 1 }}< / td >
< td > {{ city.name }}< / td >
< td > {{ city.pop }}< / td >
< / tr >
< / template >
< / tbody >
< / table >
< / div >
2024-03-06 22:54:22 -06:00
< div class = "col-8" >
2024-03-07 00:28:54 -06:00
< canvas id = "canvas" class = "mx-3 my-auto" width = "800" height = "600" > < / canvas >
2024-03-05 23:29:06 -06:00
< div >
2024-03-06 22:54:39 -06:00
< input type = "checkbox" id = "show-unguessed-checkbox" v-model = "show_unguessed_cities" @ change = "draw" >
< label for = "show-unguessed-checkbox" > Show unguessed cities< / label >
2024-03-05 23:29:06 -06:00
< / div >
2024-03-01 00:48:54 -06:00
< / 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" >
2024-03-07 00:28:54 -06:00
import { createApp } from 'https://unpkg.com/petite-vue?module';
createApp({
launched: false,
shapes_request: null,
states: null,
state_name: null,
state_cities: [],
state_shape: null,
state_mercator_adjusted_bounds: null,
simplified_cities: null,
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),
},
2024-03-07 00:49:53 -06:00
async mounted() {
// Fire off this request ASAP
this.shapes_request = fetch("data/states.geojson");
2024-03-07 00:28:54 -06:00
2024-03-07 00:49:53 -06:00
// Check if there's a parameter in the URL already
this.state_name = (new URLSearchParams(window.location.search)).get('state');
if (this.state_name) {
this.launch();
} else {
const response = await fetch("data/states.json");
this.states = await response.json();
2024-03-07 00:28:54 -06:00
}
},
async launch() {
this.launched = true;
const response = await fetch(`data/${this.state_name}.json`);
this.state_cities = await response.json();
this.simplified_cities = this.state_cities.map(city => simplify(city.name));
const shapes_response = await this.shapes_request;
const shapes_data = await shapes_response.json();
const shape = shapes_data.features.find(f => f.properties.NAME.toLowerCase() == this.state_name.toLowerCase());
if (shape.geometry.type == "MultiPolygon") {
this.state_shape = shape.geometry.coordinates.map(a => a.flat());
} else { // Polygon
this.state_shape = shape.geometry.coordinates;
}
this.state_mercator_adjusted_bounds = find_bounds(this.state_shape.flat().map(mercator));
this.draw();
},
2024-03-07 00:49:53 -06:00
async save() {
const data = {
state: this.state_name,
cities: this.state_cities,
time: new Date,
};
// https://stackoverflow.com/a/30800715
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(data));
const downloadAnchorNode = document.createElement('a');
downloadAnchorNode.setAttribute("href", dataStr);
const safeStateName = this.state_name.replace(/[^A-Za-z]/, '-');
const datestring = `${data.time.getFullYear()}-${data.time.getMonth() + 1}-${data.time.getDate()}`;
downloadAnchorNode.setAttribute("download", `name-all-cities-${safeStateName}-${datestring}.json`);
document.body.appendChild(downloadAnchorNode); // required for firefox
downloadAnchorNode.click();
downloadAnchorNode.remove();
},
2024-03-07 01:26:53 -06:00
async restore(event) {
this.launched = true;
// Sure would be nice if the API were just designed this way!
const read_file = async (file) => new Promise((resolve) => {
const fileReader = new FileReader();
fileReader.onload = (e) => resolve(fileReader.result);
fileReader.readAsText(file);
});
const save_data = await read_file(event.target.files[0]);
const save = JSON.parse(save_data, (k, v) => k == 'time' ? new Date(v) : v);
this.state_cities = save.cities;
this.simplified_cities = this.state_cities.map(city => simplify(city.name));
this.state_name = save.state;
this.message = `Restored game from ${save.time.toLocaleString()} with ${save.cities.filter(c => c.guessed).length }/${ save.cities.length} cities guessed`;
// The rest of this function is duplicated from launch(), above
const shapes_response = await this.shapes_request;
const shapes_data = await shapes_response.json();
const shape = shapes_data.features.find(f => f.properties.NAME.toLowerCase() == this.state_name.toLowerCase());
if (shape.geometry.type == "MultiPolygon") {
this.state_shape = shape.geometry.coordinates.map(a => a.flat());
} else { // Polygon
this.state_shape = shape.geometry.coordinates;
}
this.state_mercator_adjusted_bounds = find_bounds(this.state_shape.flat().map(mercator));
this.draw();
2024-03-07 00:49:53 -06:00
},
guess() {
const rank = this.simplified_cities.indexOf(simplify(this.city_guess))
if (rank >= 0) {
const city = this.state_cities[rank];
2024-03-07 00:28:54 -06:00
2024-03-07 00:49:53 -06:00
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 = "";
}
2024-03-07 00:28:54 -06:00
}
},
draw() {
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
const [minx, maxx, miny, maxy] = this.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 this.state_shape) {
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 this.state_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();
}
}
2024-03-01 00:48:54 -06:00
}
2024-03-07 00:28:54 -06:00
}).mount();
2024-02-29 17:24:33 -06:00
2024-03-01 00:48:54 -06:00
// 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";
2024-02-29 17:24:33 -06:00
}
2024-03-01 00:48:54 -06:00
if (j === 3 & & k !== 13) {
return i + "rd";
}
return i + "th";
}
2024-02-29 17:24:33 -06:00
2024-03-01 00:48:54 -06:00
const simplify = s => s.toLowerCase().replace(/[^a-z]/g, '');
2024-02-29 17:24:33 -06:00
2024-03-05 23:27:09 -06:00
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];
}
2024-03-01 00:48:54 -06:00
// 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 >
2024-02-29 17:24:33 -06:00
< / body >
< / html >