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:
@@ -14,45 +14,64 @@
|
|||||||
<h1>The Living World</h1>
|
<h1>The Living World</h1>
|
||||||
<p class="menu__subtitle">Generate a world from OpenStreetMap</p>
|
<p class="menu__subtitle">Generate a world from OpenStreetMap</p>
|
||||||
</div>
|
</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>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="worlds">
|
<button id="continue-button" type="button" class="continue" hidden>
|
||||||
<div class="worlds__header">
|
<span class="continue__kicker">Continue</span>
|
||||||
<h2>Worlds</h2>
|
<span id="continue-name" class="continue__name"></span>
|
||||||
<span id="slot-count" class="slot-count"></span>
|
<span class="continue__arrow" aria-hidden="true">→</span>
|
||||||
</div>
|
</button>
|
||||||
<ul id="world-list" class="world-list"></ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<form id="generate-form" class="form">
|
<div class="menu__body">
|
||||||
<h2 class="form__title">Create world</h2>
|
<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>
|
||||||
|
|
||||||
<label class="field">
|
<form id="generate-form" class="form">
|
||||||
<span>Name</span>
|
<h2 class="form__title">New world</h2>
|
||||||
<input id="field-name" type="text" placeholder="Robert Lee" autocomplete="off" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="field-row">
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Latitude</span>
|
<span>Name</span>
|
||||||
<input id="field-lat" type="number" step="any" value="31.8966010" required />
|
<input id="field-name" type="text" placeholder="Optional — defaults to the coordinates" autocomplete="off" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Longitude</span>
|
<span class="field__label">
|
||||||
<input id="field-lon" type="number" step="any" value="-100.4858591" required />
|
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>
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Size <output id="field-size-value">10 km</output></span>
|
<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" />
|
<input id="field-size" type="range" min="1" max="20" step="1" value="10" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button id="generate-button" type="submit" class="button">Generate world</button>
|
<p id="form-hint" class="form__hint" hidden></p>
|
||||||
</form>
|
<button id="generate-button" type="submit" class="button">Generate world</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<footer id="status" class="status"></footer>
|
<footer id="status" class="status"></footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { api, waitForWorld } from './api/client';
|
|||||||
import type { WorldSummary } from './api/types';
|
import type { WorldSummary } from './api/types';
|
||||||
import { MapView, type MapStatus } from './map/mapView';
|
import { MapView, type MapStatus } from './map/mapView';
|
||||||
import { THEMES, type ThemeName } from './map/theme';
|
import { THEMES, type ThemeName } from './map/theme';
|
||||||
|
import { formatCoordinates, parseCoordinates } from './ui/coordinates';
|
||||||
|
|
||||||
const LAST_WORLD_KEY = 'the-living-world:last-world';
|
const LAST_WORLD_KEY = 'the-living-world:last-world';
|
||||||
const THEME_KEY = 'the-living-world:theme';
|
const THEME_KEY = 'the-living-world:theme';
|
||||||
@@ -13,13 +14,17 @@ const elements = {
|
|||||||
stage: required<HTMLDivElement>('stage'),
|
stage: required<HTMLDivElement>('stage'),
|
||||||
form: required<HTMLFormElement>('generate-form'),
|
form: required<HTMLFormElement>('generate-form'),
|
||||||
name: required<HTMLInputElement>('field-name'),
|
name: required<HTMLInputElement>('field-name'),
|
||||||
latitude: required<HTMLInputElement>('field-lat'),
|
coords: required<HTMLInputElement>('field-coords'),
|
||||||
longitude: required<HTMLInputElement>('field-lon'),
|
|
||||||
size: required<HTMLInputElement>('field-size'),
|
size: required<HTMLInputElement>('field-size'),
|
||||||
sizeValue: required<HTMLOutputElement>('field-size-value'),
|
sizeValue: required<HTMLOutputElement>('field-size-value'),
|
||||||
generate: required<HTMLButtonElement>('generate-button'),
|
generate: required<HTMLButtonElement>('generate-button'),
|
||||||
|
formHint: required<HTMLElement>('form-hint'),
|
||||||
|
useLocation: required<HTMLButtonElement>('use-location'),
|
||||||
worldList: required<HTMLUListElement>('world-list'),
|
worldList: required<HTMLUListElement>('world-list'),
|
||||||
|
worldsEmpty: required<HTMLElement>('worlds-empty'),
|
||||||
slotCount: required<HTMLElement>('slot-count'),
|
slotCount: required<HTMLElement>('slot-count'),
|
||||||
|
continue: required<HTMLButtonElement>('continue-button'),
|
||||||
|
continueName: required<HTMLElement>('continue-name'),
|
||||||
status: required<HTMLElement>('status'),
|
status: required<HTMLElement>('status'),
|
||||||
hud: required<HTMLDivElement>('hud'),
|
hud: required<HTMLDivElement>('hud'),
|
||||||
worldTitle: required<HTMLElement>('world-title'),
|
worldTitle: required<HTMLElement>('world-title'),
|
||||||
@@ -31,8 +36,10 @@ const elements = {
|
|||||||
const view = new MapView();
|
const view = new MapView();
|
||||||
let activeWorldId: string | null = null;
|
let activeWorldId: string | null = null;
|
||||||
let maxConcurrentWorlds = 8;
|
let maxConcurrentWorlds = 8;
|
||||||
|
let worldCount = 0;
|
||||||
let mapReady = false;
|
let mapReady = false;
|
||||||
let generating = false;
|
let generating = false;
|
||||||
|
let continueWorldId: string | null = null;
|
||||||
|
|
||||||
function required<T extends HTMLElement>(id: string): T {
|
function required<T extends HTMLElement>(id: string): T {
|
||||||
const element = document.getElementById(id);
|
const element = document.getElementById(id);
|
||||||
@@ -66,41 +73,104 @@ function renderHud(status: MapStatus): void {
|
|||||||
elements.hud.textContent = `${scale} · ${position} · ${chunks}${status.loading ? ' · loading…' : ''}`;
|
elements.hud.textContent = `${scale} · ${position} · ${chunks}${status.loading ? ' · loading…' : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateGenerateEnabled(worldCount: number): void {
|
function updateGenerateEnabled(): void {
|
||||||
elements.generate.disabled = generating || worldCount >= maxConcurrentWorlds;
|
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[]> {
|
async function refreshWorldList(): Promise<WorldSummary[]> {
|
||||||
const list = await api.listWorlds();
|
const list = await api.listWorlds();
|
||||||
maxConcurrentWorlds = list.maxConcurrentWorlds;
|
maxConcurrentWorlds = list.maxConcurrentWorlds;
|
||||||
elements.slotCount.textContent = `${list.worlds.length} / ${maxConcurrentWorlds}`;
|
worldCount = list.worlds.length;
|
||||||
elements.worldList.replaceChildren(...list.worlds.map(renderWorldItem));
|
elements.slotCount.textContent = `${worldCount} / ${maxConcurrentWorlds}`;
|
||||||
updateGenerateEnabled(list.worlds.length);
|
|
||||||
|
const worlds = sortWorlds(list.worlds);
|
||||||
|
elements.worldsEmpty.hidden = worlds.length > 0;
|
||||||
|
elements.worldList.replaceChildren(...worlds.map(renderWorldItem));
|
||||||
|
updateContinue(worlds);
|
||||||
|
updateGenerateEnabled();
|
||||||
return list.worlds;
|
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 {
|
function renderWorldItem(world: WorldSummary): HTMLLIElement {
|
||||||
const item = document.createElement('li');
|
const item = document.createElement('li');
|
||||||
item.className = 'world';
|
item.className = 'world';
|
||||||
item.dataset.status = world.status;
|
item.dataset.status = world.status;
|
||||||
if (world.id === activeWorldId || world.id === localStorage.getItem(LAST_WORLD_KEY)) {
|
if (world.id === lastWorldId() && world.status === 'ready') {
|
||||||
item.dataset.active = 'true';
|
item.dataset.last = 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
const open = document.createElement('button');
|
const open = document.createElement('button');
|
||||||
open.type = 'button';
|
open.type = 'button';
|
||||||
open.className = 'world__open';
|
open.className = 'world__open';
|
||||||
open.disabled = world.status !== 'ready';
|
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');
|
const title = document.createElement('span');
|
||||||
title.className = 'world__name';
|
title.className = 'world__name';
|
||||||
title.textContent = 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');
|
const detail = document.createElement('span');
|
||||||
detail.className = 'world__detail';
|
detail.className = 'world__detail';
|
||||||
detail.textContent = describeWorld(world);
|
detail.textContent = describeWorld(world);
|
||||||
|
|
||||||
open.append(title, detail);
|
open.append(nameRow, detail);
|
||||||
open.addEventListener('click', () => {
|
open.addEventListener('click', () => {
|
||||||
void openWorld(world.id);
|
void openWorld(world.id);
|
||||||
});
|
});
|
||||||
@@ -108,7 +178,8 @@ function renderWorldItem(world: WorldSummary): HTMLLIElement {
|
|||||||
const remove = document.createElement('button');
|
const remove = document.createElement('button');
|
||||||
remove.type = 'button';
|
remove.type = 'button';
|
||||||
remove.className = 'world__delete';
|
remove.className = 'world__delete';
|
||||||
remove.title = 'Delete world';
|
remove.title = `Delete ${world.name}`;
|
||||||
|
remove.setAttribute('aria-label', `Delete ${world.name}`);
|
||||||
remove.textContent = '×';
|
remove.textContent = '×';
|
||||||
remove.addEventListener('click', () => {
|
remove.addEventListener('click', () => {
|
||||||
void deleteWorld(world);
|
void deleteWorld(world);
|
||||||
@@ -120,7 +191,7 @@ function renderWorldItem(world: WorldSummary): HTMLLIElement {
|
|||||||
|
|
||||||
function describeWorld(world: WorldSummary): string {
|
function describeWorld(world: WorldSummary): string {
|
||||||
if (world.status === 'failed') return world.error ?? 'Generation failed';
|
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`;
|
const size = `${(world.sizeMeters / 1000).toFixed(0)} km`;
|
||||||
if (!world.stats) return size;
|
if (!world.stats) return size;
|
||||||
@@ -186,7 +257,7 @@ async function deleteWorld(world: WorldSummary): Promise<void> {
|
|||||||
elements.hud.textContent = '';
|
elements.hud.textContent = '';
|
||||||
elements.worldTitle.textContent = '';
|
elements.worldTitle.textContent = '';
|
||||||
showMenu();
|
showMenu();
|
||||||
} else if (localStorage.getItem(LAST_WORLD_KEY) === world.id) {
|
} else if (lastWorldId() === world.id) {
|
||||||
localStorage.removeItem(LAST_WORLD_KEY);
|
localStorage.removeItem(LAST_WORLD_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,19 +271,23 @@ async function deleteWorld(world: WorldSummary): Promise<void> {
|
|||||||
async function generate(event: SubmitEvent): Promise<void> {
|
async function generate(event: SubmitEvent): Promise<void> {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const latitude = Number(elements.latitude.value);
|
const location = parseCoordinates(elements.coords.value);
|
||||||
const longitude = Number(elements.longitude.value);
|
if (!location) {
|
||||||
const sizeKm = Number(elements.size.value);
|
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;
|
generating = true;
|
||||||
updateGenerateEnabled(Number.POSITIVE_INFINITY);
|
updateGenerateEnabled();
|
||||||
setStatus('Requesting world…', 'busy');
|
setStatus('Requesting world…', 'busy');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const created = await api.createWorld({
|
const created = await api.createWorld({
|
||||||
name: elements.name.value.trim() || undefined,
|
name: elements.name.value.trim() || undefined,
|
||||||
latitude,
|
latitude: location.latitude,
|
||||||
longitude,
|
longitude: location.longitude,
|
||||||
sizeKm,
|
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 {
|
function message(error: unknown): string {
|
||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
@@ -245,9 +339,12 @@ function message(error: unknown): string {
|
|||||||
function applyTheme(name: ThemeName): void {
|
function applyTheme(name: ThemeName): void {
|
||||||
if (mapReady) view.setTheme(name);
|
if (mapReady) view.setTheme(name);
|
||||||
document.documentElement.dataset.theme = name;
|
document.documentElement.dataset.theme = name;
|
||||||
const label = THEMES[name].dark ? '☀' : '☾';
|
const next = name === 'day' ? 'Night' : 'Day';
|
||||||
elements.menuThemeToggle.textContent = label;
|
elements.menuThemeToggle.textContent = next;
|
||||||
elements.gameThemeToggle.textContent = label;
|
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);
|
localStorage.setItem(THEME_KEY, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +367,10 @@ async function start(): Promise<void> {
|
|||||||
elements.form.addEventListener('submit', (event) => {
|
elements.form.addEventListener('submit', (event) => {
|
||||||
void generate(event);
|
void generate(event);
|
||||||
});
|
});
|
||||||
|
elements.useLocation.addEventListener('click', useMyLocation);
|
||||||
|
elements.continue.addEventListener('click', () => {
|
||||||
|
if (continueWorldId) void openWorld(continueWorldId);
|
||||||
|
});
|
||||||
elements.back.addEventListener('click', () => {
|
elements.back.addEventListener('click', () => {
|
||||||
void returnToMenu();
|
void returnToMenu();
|
||||||
});
|
});
|
||||||
@@ -281,7 +382,8 @@ async function start(): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const worlds = await refreshWorldList();
|
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.');
|
else setStatus('Choose a world or create a new one.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(`Could not reach the API: ${message(error)}`, 'error');
|
setStatus(`Could not reach the API: ${message(error)}`, 'error');
|
||||||
|
|||||||
@@ -53,11 +53,11 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.menu {
|
.menu {
|
||||||
width: min(420px, 100%);
|
width: min(760px, 100%);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 22px;
|
gap: 18px;
|
||||||
padding: 22px;
|
padding: 22px;
|
||||||
background: var(--panel-bg);
|
background: var(--panel-bg);
|
||||||
border: 1px solid var(--panel-border);
|
border: 1px solid var(--panel-border);
|
||||||
@@ -85,6 +85,13 @@ body {
|
|||||||
color: var(--text-muted);
|
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 {
|
.screen--game {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -154,6 +161,70 @@ body {
|
|||||||
background: var(--surface-hover);
|
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 {
|
.form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -170,6 +241,16 @@ body {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form__hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form__hint[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -178,10 +259,11 @@ body {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-row {
|
.field__label {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 1fr 1fr;
|
align-items: baseline;
|
||||||
gap: 10px;
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field input[type='text'],
|
.field input[type='text'],
|
||||||
@@ -210,6 +292,22 @@ body {
|
|||||||
font-variant-numeric: tabular-nums;
|
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 {
|
.button {
|
||||||
padding: 9px 12px;
|
padding: 9px 12px;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
@@ -235,6 +333,7 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.worlds__header {
|
.worlds__header {
|
||||||
@@ -244,6 +343,21 @@ body {
|
|||||||
gap: 10px;
|
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 {
|
.slot-count {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
@@ -257,18 +371,10 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
max-height: 240px;
|
max-height: min(52vh, 420px);
|
||||||
overflow-y: auto;
|
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 {
|
.world {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
@@ -279,11 +385,20 @@ body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.world[data-active='true'] {
|
.world[data-last='true'] {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: inset 2px 0 0 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 {
|
.world[data-status='failed'] .world__detail {
|
||||||
color: var(--error);
|
color: var(--error);
|
||||||
}
|
}
|
||||||
@@ -303,18 +418,41 @@ body {
|
|||||||
|
|
||||||
.world__open:disabled {
|
.world__open:disabled {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
opacity: 0.75;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
.world__open:hover:not(:disabled) {
|
.world__open:hover:not(:disabled) {
|
||||||
background: var(--surface-hover);
|
background: var(--surface-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.world__name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.world__name {
|
.world__name {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
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 {
|
.world__detail {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -346,12 +484,15 @@ body {
|
|||||||
color: var(--error);
|
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: '';
|
content: '';
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
animation: pulse 1.1s ease-in-out infinite;
|
animation: pulse 1.1s ease-in-out infinite;
|
||||||
@@ -382,17 +523,22 @@ body {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 720px) {
|
||||||
.screen--menu {
|
.screen--menu {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu {
|
.menu {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
gap: 18px;
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu__body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.world-list {
|
.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(',', '.'));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user