From b75844d11a0580d2e40471d3b7bc71d99cd125d6 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 16 Aug 2026 19:06:05 +0300 Subject: [PATCH] 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. --- src/TheLivingWorld.Web/index.html | 73 ++++--- src/TheLivingWorld.Web/src/main.ts | 148 +++++++++++--- src/TheLivingWorld.Web/src/styles.css | 188 ++++++++++++++++-- .../src/ui/coordinates.test.ts | 46 +++++ src/TheLivingWorld.Web/src/ui/coordinates.ts | 42 ++++ 5 files changed, 426 insertions(+), 71 deletions(-) create mode 100644 src/TheLivingWorld.Web/src/ui/coordinates.test.ts create mode 100644 src/TheLivingWorld.Web/src/ui/coordinates.ts diff --git a/src/TheLivingWorld.Web/index.html b/src/TheLivingWorld.Web/index.html index c243068..3b84e05 100644 --- a/src/TheLivingWorld.Web/index.html +++ b/src/TheLivingWorld.Web/index.html @@ -14,45 +14,64 @@

The Living World

- -
-
-

Worlds

- -
- -
+ -
-

Create world

+
diff --git a/src/TheLivingWorld.Web/src/main.ts b/src/TheLivingWorld.Web/src/main.ts index 12ce373..0c42686 100644 --- a/src/TheLivingWorld.Web/src/main.ts +++ b/src/TheLivingWorld.Web/src/main.ts @@ -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('stage'), form: required('generate-form'), name: required('field-name'), - latitude: required('field-lat'), - longitude: required('field-lon'), + coords: required('field-coords'), size: required('field-size'), sizeValue: required('field-size-value'), generate: required('generate-button'), + formHint: required('form-hint'), + useLocation: required('use-location'), worldList: required('world-list'), + worldsEmpty: required('worlds-empty'), slotCount: required('slot-count'), + continue: required('continue-button'), + continueName: required('continue-name'), status: required('status'), hud: required('hud'), worldTitle: required('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(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 { 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 { 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 { async function generate(event: SubmitEvent): Promise { 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 { } } +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 { 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 { 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'); diff --git a/src/TheLivingWorld.Web/src/styles.css b/src/TheLivingWorld.Web/src/styles.css index 3dda4b3..e6db070 100644 --- a/src/TheLivingWorld.Web/src/styles.css +++ b/src/TheLivingWorld.Web/src/styles.css @@ -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); } } diff --git a/src/TheLivingWorld.Web/src/ui/coordinates.test.ts b/src/TheLivingWorld.Web/src/ui/coordinates.test.ts new file mode 100644 index 0000000..c477409 --- /dev/null +++ b/src/TheLivingWorld.Web/src/ui/coordinates.test.ts @@ -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'); + }); +}); diff --git a/src/TheLivingWorld.Web/src/ui/coordinates.ts b/src/TheLivingWorld.Web/src/ui/coordinates.ts new file mode 100644 index 0000000..f8c9dca --- /dev/null +++ b/src/TheLivingWorld.Web/src/ui/coordinates.ts @@ -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(',', '.')); +}