Major overhaul
Now the whole shebang is inside of Vue, which fixes some init bugs and will pave the way for a few upcoming features like save/restore.
This commit is contained in:
parent
79d6c89f25
commit
d2a80397b2
258
index.html
258
index.html
|
@ -7,18 +7,18 @@
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
<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>
|
</head>
|
||||||
<body class="h-100 d-flex flex-column">
|
<body class="h-100 d-flex flex-column">
|
||||||
<div class="container vh-100">
|
<div v-scope @vue:mounted="mounted" class="container vh-100">
|
||||||
<h1>Name All the Cities</h1>
|
<h1>Name All the Cities</h1>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="state-select" class="form-label">State:</label>
|
<label class="form-label">State:</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<select id="state-select" class="form-select" disabled>
|
<select class="form-select" v-model="state_name" :disabled="launched">
|
||||||
<option select>Choose a state…</option>
|
<option v-for="state in states">{{ state }}</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="btn btn-primary" type="button" id="go-button">Play</button>
|
<button class="btn btn-primary" type="button" @click="launch" :disabled="launched">Play</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="game" v-scope class="row" style="visibility:hidden;">
|
<div class="row" v-show="state_cities != null">
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="city-guess" class="form-label">City:</label>
|
<label for="city-guess" class="form-label">City:</label>
|
||||||
|
@ -31,13 +31,13 @@
|
||||||
{{ message }}
|
{{ message }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>{{ state }} cities</h3>
|
<h3>{{ state_name }} cities</h3>
|
||||||
<div>
|
<div>
|
||||||
<span
|
<span
|
||||||
v-for="(required_cities, name) in achievements"
|
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) }">
|
: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) }">
|
||||||
{{ name }}
|
{{ name }}
|
||||||
({{ required_cities(cities).filter(c => c.guessed).length }}/{{ required_cities(cities).length }})
|
({{ required_cities(state_cities).filter(c => c.guessed).length }}/{{ required_cities(state_cities).length }})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
|
@ -49,7 +49,7 @@
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<template v-for="(city, rank) in cities">
|
<template v-for="(city, rank) in state_cities">
|
||||||
<tr v-show="city.guessed">
|
<tr v-show="city.guessed">
|
||||||
<td>{{ rank + 1 }}</td>
|
<td>{{ rank + 1 }}</td>
|
||||||
<td>{{ city.name }}</td>
|
<td>{{ city.name }}</td>
|
||||||
|
@ -60,7 +60,7 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<canvas id="canvas" class="mx-3 my-auto" @vue:mounted="draw" width="800" height="600"></canvas>
|
<canvas id="canvas" class="mx-3 my-auto" width="800" height="600"></canvas>
|
||||||
<div>
|
<div>
|
||||||
<input type="checkbox" id="show-unguessed-checkbox" v-model="show_unguessed_cities" @change="draw">
|
<input type="checkbox" id="show-unguessed-checkbox" v-model="show_unguessed_cities" @change="draw">
|
||||||
<label for="show-unguessed-checkbox">Show unguessed cities</label>
|
<label for="show-unguessed-checkbox">Show unguessed cities</label>
|
||||||
|
@ -75,37 +75,122 @@
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
let state_shape_data;
|
import { createApp } from 'https://unpkg.com/petite-vue?module';
|
||||||
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');
|
createApp({
|
||||||
ss.disabled = false;
|
launched: false,
|
||||||
for (let state of states) {
|
shapes_request: null,
|
||||||
ss.options[ss.options.length] = new Option(state);
|
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),
|
||||||
|
},
|
||||||
|
guess() {
|
||||||
|
const rank = this.simplified_cities.indexOf(simplify(this.city_guess))
|
||||||
|
if (rank >= 0) {
|
||||||
|
const city = this.state_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 = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
// Fire off this request ASAP
|
||||||
|
this.shapes_request = fetch("data/states.geojson");
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}).mount();
|
||||||
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
|
// https://stackoverflow.com/a/13627586
|
||||||
function ordinal_suffix_of(i) {
|
function ordinal_suffix_of(i) {
|
||||||
|
@ -125,99 +210,6 @@
|
||||||
|
|
||||||
const simplify = s => s.toLowerCase().replace(/[^a-z]/g, '');
|
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) {
|
function mercator(pt) {
|
||||||
const radians_from_eq = Math.abs(Math.PI/180 * pt[1]);
|
const radians_from_eq = Math.abs(Math.PI/180 * pt[1]);
|
||||||
// https://en.wikipedia.org/wiki/Mercator_projection#Derivation
|
// https://en.wikipedia.org/wiki/Mercator_projection#Derivation
|
||||||
|
|
Loading…
Reference in a new issue