Refactor world generation form and UI components for improved usability; update theme toggle button and enhance world list display with better status indicators and hints for empty states.

This commit is contained in:
Leonid Pershin
2026-08-16 19:06:05 +03:00
parent 28c5d64a2a
commit b75844d11a
5 changed files with 426 additions and 71 deletions
+31 -12
View File
@@ -14,45 +14,64 @@
<h1>The Living World</h1>
<p class="menu__subtitle">Generate a world from OpenStreetMap</p>
</div>
<button id="menu-theme-toggle" type="button" class="icon-button" title="Switch theme" aria-label="Switch theme">
<button id="menu-theme-toggle" type="button" class="theme-button" title="Switch theme">
Night
</button>
</header>
<button id="continue-button" type="button" class="continue" hidden>
<span class="continue__kicker">Continue</span>
<span id="continue-name" class="continue__name"></span>
<span class="continue__arrow" aria-hidden="true"></span>
</button>
<div class="menu__body">
<section class="worlds">
<div class="worlds__header">
<h2>Worlds</h2>
<span id="slot-count" class="slot-count"></span>
</div>
<p id="worlds-empty" class="worlds__empty" hidden>
Worlds you generate will show up here. A small town takes a few seconds.
</p>
<ul id="world-list" class="world-list"></ul>
</section>
<form id="generate-form" class="form">
<h2 class="form__title">Create world</h2>
<h2 class="form__title">New world</h2>
<label class="field">
<span>Name</span>
<input id="field-name" type="text" placeholder="Robert Lee" autocomplete="off" />
<input id="field-name" type="text" placeholder="Optional — defaults to the coordinates" autocomplete="off" />
</label>
<div class="field-row">
<label class="field">
<span>Latitude</span>
<input id="field-lat" type="number" step="any" value="31.8966010" required />
<span class="field__label">
Location
<button id="use-location" type="button" class="link-button">Use my location</button>
</span>
<input
id="field-coords"
type="text"
inputmode="decimal"
spellcheck="false"
autocapitalize="off"
autocomplete="off"
value="31.8966010, -100.4858591"
placeholder="Latitude, longitude"
required
/>
</label>
<label class="field">
<span>Longitude</span>
<input id="field-lon" type="number" step="any" value="-100.4858591" required />
</label>
</div>
<label class="field">
<span>Size <output id="field-size-value">10 km</output></span>
<input id="field-size" type="range" min="1" max="20" step="1" value="10" />
</label>
<p id="form-hint" class="form__hint" hidden></p>
<button id="generate-button" type="submit" class="button">Generate world</button>
</form>
</div>
<footer id="status" class="status"></footer>
</div>
+125 -23
View File
@@ -3,6 +3,7 @@ import { api, waitForWorld } from './api/client';
import type { WorldSummary } from './api/types';
import { MapView, type MapStatus } from './map/mapView';
import { THEMES, type ThemeName } from './map/theme';
import { formatCoordinates, parseCoordinates } from './ui/coordinates';
const LAST_WORLD_KEY = 'the-living-world:last-world';
const THEME_KEY = 'the-living-world:theme';
@@ -13,13 +14,17 @@ const elements = {
stage: required<HTMLDivElement>('stage'),
form: required<HTMLFormElement>('generate-form'),
name: required<HTMLInputElement>('field-name'),
latitude: required<HTMLInputElement>('field-lat'),
longitude: required<HTMLInputElement>('field-lon'),
coords: required<HTMLInputElement>('field-coords'),
size: required<HTMLInputElement>('field-size'),
sizeValue: required<HTMLOutputElement>('field-size-value'),
generate: required<HTMLButtonElement>('generate-button'),
formHint: required<HTMLElement>('form-hint'),
useLocation: required<HTMLButtonElement>('use-location'),
worldList: required<HTMLUListElement>('world-list'),
worldsEmpty: required<HTMLElement>('worlds-empty'),
slotCount: required<HTMLElement>('slot-count'),
continue: required<HTMLButtonElement>('continue-button'),
continueName: required<HTMLElement>('continue-name'),
status: required<HTMLElement>('status'),
hud: required<HTMLDivElement>('hud'),
worldTitle: required<HTMLElement>('world-title'),
@@ -31,8 +36,10 @@ const elements = {
const view = new MapView();
let activeWorldId: string | null = null;
let maxConcurrentWorlds = 8;
let worldCount = 0;
let mapReady = false;
let generating = false;
let continueWorldId: string | null = null;
function required<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id);
@@ -66,41 +73,104 @@ function renderHud(status: MapStatus): void {
elements.hud.textContent = `${scale} · ${position} · ${chunks}${status.loading ? ' · loading…' : ''}`;
}
function updateGenerateEnabled(worldCount: number): void {
elements.generate.disabled = generating || worldCount >= maxConcurrentWorlds;
function updateGenerateEnabled(): void {
const full = worldCount >= maxConcurrentWorlds;
elements.generate.disabled = generating || full;
if (full) {
elements.formHint.hidden = false;
elements.formHint.textContent = `All ${maxConcurrentWorlds} slots are in use. Delete a world to create another.`;
} else {
elements.formHint.hidden = true;
elements.formHint.textContent = '';
}
}
function lastWorldId(): string | null {
return localStorage.getItem(LAST_WORLD_KEY);
}
function sortWorlds(worlds: WorldSummary[]): WorldSummary[] {
const lastId = lastWorldId();
const rank = (world: WorldSummary): number => {
if (world.status === 'pending' || world.status === 'generating') return 0;
if (world.id === lastId) return 1;
if (world.status === 'failed') return 3;
return 2;
};
return [...worlds].sort((a, b) => {
const byRank = rank(a) - rank(b);
if (byRank !== 0) return byRank;
return b.createdAt.localeCompare(a.createdAt);
});
}
function updateContinue(worlds: WorldSummary[]): void {
const last = worlds.find((world) => world.id === lastWorldId() && world.status === 'ready');
continueWorldId = last?.id ?? null;
elements.continue.hidden = last === undefined;
elements.continueName.textContent = last?.name ?? '';
}
async function refreshWorldList(): Promise<WorldSummary[]> {
const list = await api.listWorlds();
maxConcurrentWorlds = list.maxConcurrentWorlds;
elements.slotCount.textContent = `${list.worlds.length} / ${maxConcurrentWorlds}`;
elements.worldList.replaceChildren(...list.worlds.map(renderWorldItem));
updateGenerateEnabled(list.worlds.length);
worldCount = list.worlds.length;
elements.slotCount.textContent = `${worldCount} / ${maxConcurrentWorlds}`;
const worlds = sortWorlds(list.worlds);
elements.worldsEmpty.hidden = worlds.length > 0;
elements.worldList.replaceChildren(...worlds.map(renderWorldItem));
updateContinue(worlds);
updateGenerateEnabled();
return list.worlds;
}
function worldBadge(world: WorldSummary): string | null {
if (world.status === 'pending' || world.status === 'generating') return world.stage ?? 'Generating';
if (world.status === 'failed') return 'Failed';
if (world.id === lastWorldId()) return 'Last played';
return null;
}
function renderWorldItem(world: WorldSummary): HTMLLIElement {
const item = document.createElement('li');
item.className = 'world';
item.dataset.status = world.status;
if (world.id === activeWorldId || world.id === localStorage.getItem(LAST_WORLD_KEY)) {
item.dataset.active = 'true';
if (world.id === lastWorldId() && world.status === 'ready') {
item.dataset.last = 'true';
}
const open = document.createElement('button');
open.type = 'button';
open.className = 'world__open';
open.disabled = world.status !== 'ready';
open.title = world.status === 'ready'
? `Open ${world.name}`
: (world.error ?? world.stage ?? world.status);
const nameRow = document.createElement('span');
nameRow.className = 'world__name-row';
const title = document.createElement('span');
title.className = 'world__name';
title.textContent = world.name;
nameRow.append(title);
const badgeText = worldBadge(world);
if (badgeText) {
const badge = document.createElement('span');
badge.className = 'world__badge';
badge.textContent = badgeText;
nameRow.append(badge);
}
const detail = document.createElement('span');
detail.className = 'world__detail';
detail.textContent = describeWorld(world);
open.append(title, detail);
open.append(nameRow, detail);
open.addEventListener('click', () => {
void openWorld(world.id);
});
@@ -108,7 +178,8 @@ function renderWorldItem(world: WorldSummary): HTMLLIElement {
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'world__delete';
remove.title = 'Delete world';
remove.title = `Delete ${world.name}`;
remove.setAttribute('aria-label', `Delete ${world.name}`);
remove.textContent = '×';
remove.addEventListener('click', () => {
void deleteWorld(world);
@@ -120,7 +191,7 @@ function renderWorldItem(world: WorldSummary): HTMLLIElement {
function describeWorld(world: WorldSummary): string {
if (world.status === 'failed') return world.error ?? 'Generation failed';
if (world.status !== 'ready') return world.stage ?? world.status;
if (world.status !== 'ready') return formatCoordinates(world.latitude, world.longitude, 4);
const size = `${(world.sizeMeters / 1000).toFixed(0)} km`;
if (!world.stats) return size;
@@ -186,7 +257,7 @@ async function deleteWorld(world: WorldSummary): Promise<void> {
elements.hud.textContent = '';
elements.worldTitle.textContent = '';
showMenu();
} else if (localStorage.getItem(LAST_WORLD_KEY) === world.id) {
} else if (lastWorldId() === world.id) {
localStorage.removeItem(LAST_WORLD_KEY);
}
@@ -200,19 +271,23 @@ async function deleteWorld(world: WorldSummary): Promise<void> {
async function generate(event: SubmitEvent): Promise<void> {
event.preventDefault();
const latitude = Number(elements.latitude.value);
const longitude = Number(elements.longitude.value);
const sizeKm = Number(elements.size.value);
const location = parseCoordinates(elements.coords.value);
if (!location) {
setStatus('Enter a location as latitude, longitude — for example 31.90, -100.49.', 'error');
elements.coords.focus();
return;
}
const sizeKm = Number(elements.size.value);
generating = true;
updateGenerateEnabled(Number.POSITIVE_INFINITY);
updateGenerateEnabled();
setStatus('Requesting world…', 'busy');
try {
const created = await api.createWorld({
name: elements.name.value.trim() || undefined,
latitude,
longitude,
latitude: location.latitude,
longitude: location.longitude,
sizeKm,
});
@@ -237,6 +312,25 @@ async function generate(event: SubmitEvent): Promise<void> {
}
}
function useMyLocation(): void {
if (!navigator.geolocation) {
setStatus('Geolocation is not available in this browser.', 'error');
return;
}
setStatus('Finding your location…', 'busy');
navigator.geolocation.getCurrentPosition(
(position) => {
elements.coords.value = formatCoordinates(position.coords.latitude, position.coords.longitude);
setStatus('Location filled in — adjust the size and generate.');
},
(error) => {
setStatus(`Could not get location: ${error.message}`, 'error');
},
{ enableHighAccuracy: false, maximumAge: 60_000, timeout: 10_000 },
);
}
function message(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -245,9 +339,12 @@ function message(error: unknown): string {
function applyTheme(name: ThemeName): void {
if (mapReady) view.setTheme(name);
document.documentElement.dataset.theme = name;
const label = THEMES[name].dark ? '' : '';
elements.menuThemeToggle.textContent = label;
elements.gameThemeToggle.textContent = label;
const next = name === 'day' ? 'Night' : 'Day';
elements.menuThemeToggle.textContent = next;
elements.menuThemeToggle.title = `Switch to ${next.toLowerCase()} theme`;
elements.gameThemeToggle.textContent = THEMES[name].dark ? '☀' : '☾';
elements.gameThemeToggle.title = `Switch to ${next.toLowerCase()} theme`;
elements.gameThemeToggle.setAttribute('aria-label', `Switch to ${next.toLowerCase()} theme`);
localStorage.setItem(THEME_KEY, name);
}
@@ -270,6 +367,10 @@ async function start(): Promise<void> {
elements.form.addEventListener('submit', (event) => {
void generate(event);
});
elements.useLocation.addEventListener('click', useMyLocation);
elements.continue.addEventListener('click', () => {
if (continueWorldId) void openWorld(continueWorldId);
});
elements.back.addEventListener('click', () => {
void returnToMenu();
});
@@ -281,7 +382,8 @@ async function start(): Promise<void> {
try {
const worlds = await refreshWorldList();
if (worlds.length === 0) setStatus('No worlds yet — create one to get started.');
if (worlds.length === 0) setStatus('Pick a place and generate a world to get started.');
else if (continueWorldId) setStatus('Continue where you left off, or open another world.');
else setStatus('Choose a world or create a new one.');
} catch (error) {
setStatus(`Could not reach the API: ${message(error)}`, 'error');
+167 -21
View File
@@ -53,11 +53,11 @@ body {
}
.menu {
width: min(420px, 100%);
width: min(760px, 100%);
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 22px;
gap: 18px;
padding: 22px;
background: var(--panel-bg);
border: 1px solid var(--panel-border);
@@ -85,6 +85,13 @@ body {
color: var(--text-muted);
}
.menu__body {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
gap: 28px;
align-items: start;
}
.screen--game {
position: fixed;
inset: 0;
@@ -154,6 +161,70 @@ body {
background: var(--surface-hover);
}
.theme-button {
flex: none;
padding: 6px 10px;
font: inherit;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
background: var(--surface);
border: 1px solid var(--panel-border);
border-radius: 8px;
cursor: pointer;
}
.theme-button:hover {
color: var(--text);
background: var(--surface-hover);
}
.continue {
display: flex;
align-items: baseline;
gap: 8px;
width: 100%;
padding: 12px 14px;
font: inherit;
text-align: left;
color: #fff;
background: var(--accent);
border: none;
border-radius: 10px;
cursor: pointer;
}
.continue[hidden] {
display: none !important;
}
.continue:hover {
background: var(--accent-hover);
}
.continue__kicker {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
opacity: 0.85;
}
.continue__name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 15px;
font-weight: 600;
}
.continue__arrow {
font-size: 16px;
opacity: 0.85;
}
.form {
display: flex;
flex-direction: column;
@@ -170,6 +241,16 @@ body {
color: var(--text-muted);
}
.form__hint {
margin: 0;
font-size: 12px;
color: var(--text-muted);
}
.form__hint[hidden] {
display: none !important;
}
.field {
display: flex;
flex-direction: column;
@@ -178,10 +259,11 @@ body {
color: var(--text-muted);
}
.field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
.field__label {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.field input[type='text'],
@@ -210,6 +292,22 @@ body {
font-variant-numeric: tabular-nums;
}
.link-button {
padding: 0;
font: inherit;
font-size: 11px;
font-weight: 600;
color: var(--accent);
background: none;
border: none;
cursor: pointer;
}
.link-button:hover {
color: var(--accent-hover);
text-decoration: underline;
}
.button {
padding: 9px 12px;
font: inherit;
@@ -235,6 +333,7 @@ body {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.worlds__header {
@@ -244,6 +343,21 @@ body {
gap: 10px;
}
.worlds__empty {
margin: 0;
padding: 14px 12px;
font-size: 13px;
line-height: 1.45;
color: var(--text-muted);
background: var(--surface);
border: 1px dashed var(--panel-border);
border-radius: 8px;
}
.worlds__empty[hidden] {
display: none !important;
}
.slot-count {
font-size: 12px;
font-variant-numeric: tabular-nums;
@@ -257,18 +371,10 @@ body {
margin: 0;
padding: 0;
list-style: none;
max-height: 240px;
max-height: min(52vh, 420px);
overflow-y: auto;
}
.world-list:empty::after {
content: 'No worlds yet — create one below.';
display: block;
padding: 10px 0;
font-size: 12px;
color: var(--text-muted);
}
.world {
display: flex;
align-items: stretch;
@@ -279,11 +385,20 @@ body {
overflow: hidden;
}
.world[data-active='true'] {
.world[data-last='true'] {
border-color: var(--accent);
box-shadow: inset 2px 0 0 var(--accent);
}
.world[data-status='pending'],
.world[data-status='generating'] {
border-color: var(--accent);
}
.world[data-status='failed'] {
border-color: var(--error);
}
.world[data-status='failed'] .world__detail {
color: var(--error);
}
@@ -303,18 +418,41 @@ body {
.world__open:disabled {
cursor: default;
opacity: 0.75;
opacity: 0.85;
}
.world__open:hover:not(:disabled) {
background: var(--surface-hover);
}
.world__name-row {
display: flex;
align-items: baseline;
gap: 8px;
}
.world__name {
font-size: 13px;
font-weight: 600;
}
.world__badge {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--accent);
}
.world[data-status='pending'] .world__badge,
.world[data-status='generating'] .world__badge {
color: var(--accent);
}
.world[data-status='failed'] .world__badge {
color: var(--error);
}
.world__detail {
font-size: 11px;
color: var(--text-muted);
@@ -346,12 +484,15 @@ body {
color: var(--error);
}
.status[data-tone='busy']::after {
.status[data-tone='busy']::after,
.world[data-status='pending'] .world__badge::after,
.world[data-status='generating'] .world__badge::after {
content: '';
display: inline-block;
width: 6px;
height: 6px;
margin-left: 6px;
vertical-align: middle;
border-radius: 50%;
background: var(--accent);
animation: pulse 1.1s ease-in-out infinite;
@@ -382,17 +523,22 @@ body {
pointer-events: none;
}
@media (max-width: 640px) {
@media (max-width: 720px) {
.screen--menu {
padding: 12px;
}
.menu {
padding: 16px;
gap: 18px;
gap: 16px;
}
.menu__body {
grid-template-columns: 1fr;
gap: 20px;
}
.world-list {
max-height: 180px;
max-height: min(40vh, 280px);
}
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { formatCoordinates, parseCoordinates } from './coordinates';
describe('parseCoordinates', () => {
it('reads a comma-separated pair', () => {
expect(parseCoordinates('31.8966010, -100.4858591')).toEqual({
latitude: 31.896601,
longitude: -100.4858591,
});
});
it('reads a space-separated pair', () => {
expect(parseCoordinates('51.5074 -0.1278')).toEqual({
latitude: 51.5074,
longitude: -0.1278,
});
});
it('reads a semicolon-separated pair', () => {
expect(parseCoordinates('35.6762; 139.6503')).toEqual({
latitude: 35.6762,
longitude: 139.6503,
});
});
it('accepts a decimal comma inside each component', () => {
expect(parseCoordinates('31,8966010, -100,4858591')).toEqual({
latitude: 31.896601,
longitude: -100.4858591,
});
});
it('rejects a missing pair, out-of-range values, and leftover junk', () => {
expect(parseCoordinates('')).toBeNull();
expect(parseCoordinates('31.89')).toBeNull();
expect(parseCoordinates('91, 0')).toBeNull();
expect(parseCoordinates('0, 181')).toBeNull();
expect(parseCoordinates('hello')).toBeNull();
});
});
describe('formatCoordinates', () => {
it('writes a pasteable pair with a fixed number of digits', () => {
expect(formatCoordinates(31.896601, -100.4858591, 4)).toBe('31.8966, -100.4859');
});
});
@@ -0,0 +1,42 @@
export interface LatLon {
latitude: number;
longitude: number;
}
const NUMBER = /-?\d+(?:[.,]\d+)?/g;
/**
* Reads a pasted "lat, lon" pair. Accepts comma, semicolon or whitespace as the
* separator, and a decimal comma when each component is written `31,90`.
*/
export function parseCoordinates(raw: string): LatLon | null {
const tokens = raw.trim().match(NUMBER);
if (!tokens) return null;
let latitude: number;
let longitude: number;
if (tokens.length === 2) {
latitude = toNumber(tokens[0]!);
longitude = toNumber(tokens[1]!);
} else if (tokens.length === 4 && !raw.includes('.')) {
// "31,8966, -100,4859" — comma used both as decimal and as separator.
latitude = toNumber(`${tokens[0]}.${tokens[1]}`);
longitude = toNumber(`${tokens[2]}.${tokens[3]}`);
} else {
return null;
}
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) return null;
return { latitude, longitude };
}
export function formatCoordinates(latitude: number, longitude: number, digits = 7): string {
return `${latitude.toFixed(digits)}, ${longitude.toFixed(digits)}`;
}
function toNumber(token: string): number {
return Number(token.replace(',', '.'));
}