From adda6fa8569a7f31a2c203f01ea20b674be729d0 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Mon, 17 Aug 2026 11:12:18 +0300 Subject: [PATCH] Implement building selection panel and enhance building interaction; add functionality to display building details upon selection, including name, type, height, and source link. Update UI components for building interaction and integrate new styles for the building panel. Refactor related code for improved building data handling and selection logic. --- AGENTS.md | 3 +- README.md | 15 ++++ .../Contracts/MapContracts.cs | 6 ++ .../Export/ChunkExporter.cs | 1 + src/TheLivingWorld.Web/index.html | 17 ++++ src/TheLivingWorld.Web/src/api/types.ts | 2 + src/TheLivingWorld.Web/src/i18n/locales/en.ts | 30 +++++++ src/TheLivingWorld.Web/src/i18n/locales/ru.ts | 30 +++++++ src/TheLivingWorld.Web/src/i18n/types.ts | 17 +++- src/TheLivingWorld.Web/src/main.ts | 49 +++++++++++ .../src/map/geometry.test.ts | 67 ++++++++++++++ src/TheLivingWorld.Web/src/map/geometry.ts | 46 ++++++++++ src/TheLivingWorld.Web/src/map/mapView.ts | 80 ++++++++++++++++- .../src/map/picking.test.ts | 88 +++++++++++++++++++ src/TheLivingWorld.Web/src/map/picking.ts | 52 +++++++++++ src/TheLivingWorld.Web/src/map/theme.ts | 10 +++ src/TheLivingWorld.Web/src/styles.css | 65 ++++++++++++++ .../src/ui/building.test.ts | 80 +++++++++++++++++ src/TheLivingWorld.Web/src/ui/building.ts | 65 ++++++++++++++ 19 files changed, 718 insertions(+), 5 deletions(-) create mode 100644 src/TheLivingWorld.Web/src/map/picking.test.ts create mode 100644 src/TheLivingWorld.Web/src/map/picking.ts create mode 100644 src/TheLivingWorld.Web/src/ui/building.test.ts create mode 100644 src/TheLivingWorld.Web/src/ui/building.ts diff --git a/AGENTS.md b/AGENTS.md index fcc9760..0b9e414 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,8 @@ Ready worlds run a live game clock on the server (5 game minutes per real second ECS entities on top of a deterministic seasonal/diurnal baseline. UI: main menu (list + create, with a climate picker) → map screen with pause, speed, clock and weather, and a renderer that washes the map for time of day, cloud, fog and lying snow and drops rain or snow through it. One weather reading covers a whole world; -the overlay has an on/off button in the game bar. +the overlay has an on/off button in the game bar. Clicking a building selects it and opens an +info panel. New ECS components must be added to the probe entity in `SimulationComponents` — Arch assigns component type ids on first use without a lock, and two threads racing there hand out the same id. diff --git a/README.md b/README.md index e0ea03c..3d4767b 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,21 @@ The maths lives in `sky.ts`, which imports no PixiJS and is unit-tested; `weathe paint the result. A dark theme pulls the night wash back rather than switching it off, because the map is already drawn dark and dusk still has to feel like dusk. +### Picking a building + +Clicking a footprint selects it and opens a panel with its name, type, height, floors, footprint area and a +link to the original OpenStreetMap element. The hit test runs against the chunk data the client already has +cached, so it costs no request: `picking.ts` ray-casts the point against every loaded footprint and takes the +**smallest** match, because where footprints overlap the big one is nearly always the container and the small +one is what the player can see the edges of. A courtyard punched out of a block counts as outside it. + +Panning always nudges the pointer, so a press only counts as a click if it moved less than a few pixels — +otherwise dragging the map would reselect on every release. A second finger cancels the click outright, since +that is a pinch. Clicking bare ground, pressing Escape or the panel's close button clears the selection. + +The highlight is redrawn whenever the camera moves: the container is scaled, so a stroke of fixed world width +would thin to nothing as you zoom out. + Place names are drawn in screen space so text keeps a constant size at every zoom, and the work is split in two. `labelPlacement.ts` decides *which* names to show: candidates are ranked — water bodies first, then arterials, then land cover, then side streets — and placed greedily, dropping anything that would overlap a diff --git a/src/TheLivingWorld.Core/Contracts/MapContracts.cs b/src/TheLivingWorld.Core/Contracts/MapContracts.cs index 2d15a8c..44572bb 100644 --- a/src/TheLivingWorld.Core/Contracts/MapContracts.cs +++ b/src/TheLivingWorld.Core/Contracts/MapContracts.cs @@ -33,6 +33,12 @@ public sealed record BuildingDto public required float Height { get; init; } + /// + /// Storeys from the OSM tag, or zero when it said nothing. Worlds generated before this field existed + /// have chunks without it, so the client treats it as optional rather than as "no floors". + /// + public byte Levels { get; init; } + public required float[] Outline { get; init; } public float[][]? Holes { get; init; } diff --git a/src/TheLivingWorld.Core/Export/ChunkExporter.cs b/src/TheLivingWorld.Core/Export/ChunkExporter.cs index 012c60b..3bbb68e 100644 --- a/src/TheLivingWorld.Core/Export/ChunkExporter.cs +++ b/src/TheLivingWorld.Core/Export/ChunkExporter.cs @@ -59,6 +59,7 @@ public sealed class ChunkExporter Id = source.Id, Kind = building.Kind, Height = building.HeightMeters, + Levels = building.Levels, Outline = Flatten(shapes.Get(outline.ShapeId)), Holes = FlattenHoles(shapes, holes), Name = name.Value, diff --git a/src/TheLivingWorld.Web/index.html b/src/TheLivingWorld.Web/index.html index 94e09d4..2867f40 100644 --- a/src/TheLivingWorld.Web/index.html +++ b/src/TheLivingWorld.Web/index.html @@ -138,6 +138,23 @@ + +
diff --git a/src/TheLivingWorld.Web/src/api/types.ts b/src/TheLivingWorld.Web/src/api/types.ts index 623a980..367ff20 100644 --- a/src/TheLivingWorld.Web/src/api/types.ts +++ b/src/TheLivingWorld.Web/src/api/types.ts @@ -154,6 +154,8 @@ export interface BuildingFeature { id: number; kind: BuildingKind; height: number; + /** Storeys, when OSM said. Absent on worlds generated before this was exported. */ + levels?: number; outline: FlatPoints; holes?: FlatPoints[]; name?: string; diff --git a/src/TheLivingWorld.Web/src/i18n/locales/en.ts b/src/TheLivingWorld.Web/src/i18n/locales/en.ts index 6a19823..d2a4787 100644 --- a/src/TheLivingWorld.Web/src/i18n/locales/en.ts +++ b/src/TheLivingWorld.Web/src/i18n/locales/en.ts @@ -115,6 +115,36 @@ export const en: Messages = { weatherOff: 'Show weather effects', weatherAria: 'Weather effects', }, + building: { + title: 'Building', + unnamed: 'Unnamed building', + close: 'Close', + kind: { + unknown: 'Building', + house: 'House', + residential: 'Residential', + apartments: 'Apartments', + commercial: 'Commercial', + retail: 'Retail', + industrial: 'Industrial', + civic: 'Civic', + school: 'School', + church: 'Church', + garage: 'Garage', + shed: 'Shed', + farm: 'Farm', + ruins: 'Ruins', + }, + type: 'Type', + height: 'Height', + floors: 'Floors', + footprint: 'Footprint', + metres: '{n} m', + squareMetres: '{n} m²', + floorCount: { one: '{n} floor', other: '{n} floors' }, + source: 'View on OpenStreetMap', + unknown: 'unknown', + }, hud: { metersPerPixel: '{n} m/px', chunks: '{loaded}/{total} chunks', diff --git a/src/TheLivingWorld.Web/src/i18n/locales/ru.ts b/src/TheLivingWorld.Web/src/i18n/locales/ru.ts index 6b17e65..c62db59 100644 --- a/src/TheLivingWorld.Web/src/i18n/locales/ru.ts +++ b/src/TheLivingWorld.Web/src/i18n/locales/ru.ts @@ -115,6 +115,36 @@ export const ru: Messages = { weatherOff: 'Показать погоду', weatherAria: 'Погодные эффекты', }, + building: { + title: 'Здание', + unnamed: 'Здание без названия', + close: 'Закрыть', + kind: { + unknown: 'Здание', + house: 'Дом', + residential: 'Жилое', + apartments: 'Многоквартирный дом', + commercial: 'Офисное', + retail: 'Торговое', + industrial: 'Промышленное', + civic: 'Общественное', + school: 'Школа', + church: 'Церковь', + garage: 'Гараж', + shed: 'Сарай', + farm: 'Ферма', + ruins: 'Руины', + }, + type: 'Тип', + height: 'Высота', + floors: 'Этажей', + footprint: 'Площадь', + metres: '{n} м', + squareMetres: '{n} м²', + floorCount: { one: '{n} этаж', few: '{n} этажа', many: '{n} этажей', other: '{n} этажей' }, + source: 'Открыть в OpenStreetMap', + unknown: 'неизвестно', + }, hud: { metersPerPixel: '{n} м/пикс', chunks: '{loaded}/{total} чанков', diff --git a/src/TheLivingWorld.Web/src/i18n/types.ts b/src/TheLivingWorld.Web/src/i18n/types.ts index 0d49181..6729325 100644 --- a/src/TheLivingWorld.Web/src/i18n/types.ts +++ b/src/TheLivingWorld.Web/src/i18n/types.ts @@ -1,4 +1,4 @@ -import type { ClimateKind, WeatherCondition, WorldStatus } from '../api/types'; +import type { BuildingKind, ClimateKind, WeatherCondition, WorldStatus } from '../api/types'; /** * ICU-lite plural forms. `t(key, { n })` picks a category via `Intl.PluralRules`. @@ -108,6 +108,21 @@ export interface Messages { weatherOff: string; weatherAria: string; }; + building: { + title: string; + unnamed: string; + close: string; + kind: Record; + type: string; + height: string; + floors: string; + footprint: string; + metres: string; + squareMetres: string; + floorCount: Plural; + source: string; + unknown: string; + }; hud: { metersPerPixel: string; chunks: string; diff --git a/src/TheLivingWorld.Web/src/main.ts b/src/TheLivingWorld.Web/src/main.ts index 6415216..2467e14 100644 --- a/src/TheLivingWorld.Web/src/main.ts +++ b/src/TheLivingWorld.Web/src/main.ts @@ -13,6 +13,8 @@ import { } from './ui/gameTime'; import { climateFromLatitude, climateExample, climateLabel, describeClimate, findClimate } from './ui/climate'; import { conditionIcon, describeWeather, formatTemperature, formatWeather } from './ui/weather'; +import { buildingFacts, buildingSourceUrl, buildingTitle } from './ui/building'; +import type { PickedBuilding } from './map/picking'; import { catalog, initI18n, subscribe, t } from './i18n'; import { applyDomTranslations, mountLocaleSwitch, paintLocaleSwitch } from './i18n/dom'; import { translateStage } from './i18n/stage'; @@ -46,6 +48,11 @@ const elements = { continueName: required('continue-name'), status: required('status'), hud: required('hud'), + buildingPanel: required('building-panel'), + buildingName: required('building-name'), + buildingFacts: required('building-facts'), + buildingSource: required('building-source'), + buildingClose: required('building-close'), worldTitle: required('world-title'), back: required('back-button'), localeSwitch: required('locale-switch'), @@ -80,6 +87,7 @@ let gameSnapshotAt = 0; let gamePollTimer: number | null = null; let gamePaintTimer: number | null = null; let clockUpdating = false; +let pickedBuilding: PickedBuilding | null = null; let lastMapStatus: MapStatus | null = null; function required(id: string): T { @@ -96,6 +104,7 @@ function setStatus(message: string, tone: 'info' | 'error' | 'busy' = 'info'): v function showMenu(): void { elements.menu.hidden = false; elements.game.hidden = true; + selectBuilding(null); stopGameClockLoop(); startMenuClockLoop(); } @@ -514,6 +523,7 @@ async function openWorld(id: string): Promise { localStorage.setItem(LAST_WORLD_KEY, id); view.showWorld(map); + selectBuilding(null); elements.worldTitle.textContent = map.name; elements.hud.textContent = ''; setStatus(''); @@ -682,6 +692,39 @@ function applyWeatherEffects(enabled: boolean): void { elements.weatherToggle.title = enabled ? t('game.weatherOn') : t('game.weatherOff'); } +/** Draws the info panel for whatever the player clicked, or hides it when they clicked bare ground. */ +function paintBuildingPanel(): void { + if (!pickedBuilding) { + elements.buildingPanel.hidden = true; + return; + } + + const { building, areaSquareMetres } = pickedBuilding; + elements.buildingPanel.hidden = false; + elements.buildingName.textContent = buildingTitle(building); + + elements.buildingFacts.replaceChildren(...buildingFacts(building, areaSquareMetres).flatMap((fact) => { + const label = document.createElement('dt'); + label.textContent = fact.label; + const value = document.createElement('dd'); + value.textContent = fact.value; + return [label, value]; + })); + + elements.buildingSource.href = buildingSourceUrl(building); + elements.buildingSource.textContent = t('building.source'); +} + +function selectBuilding(picked: PickedBuilding | null): void { + pickedBuilding = picked; + paintBuildingPanel(); +} + +function closeBuildingPanel(): void { + view.clearSelection(); + selectBuilding(null); +} + function toggleTheme(): void { const current = (document.documentElement.dataset.theme as ThemeName | undefined) ?? readStoredTheme(); applyTheme(current === 'day' ? 'night' : 'day'); @@ -712,6 +755,7 @@ function onLocaleChanged(): void { applyTheme((document.documentElement.dataset.theme as ThemeName | undefined) ?? readStoredTheme()); applyWeatherEffects(readWeatherEffects()); + paintBuildingPanel(); if (gameClock) applyClockToControls(gameClock); applyWeatherToControls(gameWeather ?? undefined); if (lastMapStatus) renderHud(lastMapStatus); @@ -737,6 +781,11 @@ async function start(): Promise { elements.back.addEventListener('click', () => { void returnToMenu(); }); + view.onBuildingPicked = selectBuilding; + elements.buildingClose.addEventListener('click', closeBuildingPanel); + window.addEventListener('keydown', (event) => { + if (event.key === 'Escape') closeBuildingPanel(); + }); elements.weatherToggle.addEventListener('click', () => { applyWeatherEffects(!view.weatherEffectsEnabled); }); diff --git a/src/TheLivingWorld.Web/src/map/geometry.test.ts b/src/TheLivingWorld.Web/src/map/geometry.test.ts index f98e27d..8020419 100644 --- a/src/TheLivingWorld.Web/src/map/geometry.test.ts +++ b/src/TheLivingWorld.Web/src/map/geometry.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'; import { boundingArea, dashPolyline, + pointInPolygon, + pointInShape, pointsAlong, + polygonArea, polygonCentroid, polylineAnchor, polylineLength, @@ -214,3 +217,67 @@ describe('translate and boundingArea', () => { expect(boundingArea(line(0, 0, 1, 1))).toBe(0); }); }); + +describe('polygonArea', () => { + it('measures a square and a triangle', () => { + expect(polygonArea([0, 0, 10, 0, 10, 10, 0, 10])).toBeCloseTo(100, 6); + expect(polygonArea([0, 0, 10, 0, 0, 10])).toBeCloseTo(50, 6); + }); + + it('ignores winding, unlike the signed shoelace it is built on', () => { + const clockwise = [0, 0, 0, 10, 10, 10, 10, 0]; + const anticlockwise = [0, 0, 10, 0, 10, 10, 0, 10]; + expect(polygonArea(clockwise)).toBeCloseTo(polygonArea(anticlockwise), 6); + }); + + it('is zero for anything that is not a ring', () => { + expect(polygonArea([])).toBe(0); + expect(polygonArea([0, 0, 1, 1])).toBe(0); + }); + + it('differs from the bounding box for anything but a rectangle', () => { + const triangle = [0, 0, 10, 0, 0, 10]; + expect(polygonArea(triangle)).toBeLessThan(boundingArea(triangle)); + }); +}); + +describe('pointInPolygon', () => { + const square = [0, 0, 10, 0, 10, 10, 0, 10]; + + it('separates inside from outside', () => { + expect(pointInPolygon(square, 5, 5)).toBe(true); + expect(pointInPolygon(square, 15, 5)).toBe(false); + expect(pointInPolygon(square, -1, 5)).toBe(false); + expect(pointInPolygon(square, 5, 20)).toBe(false); + }); + + it('handles a concave ring, where a bounding box would not', () => { + // An L: the notch in the top right is outside even though the box covers it. + const ell = [0, 0, 10, 0, 10, 4, 4, 4, 4, 10, 0, 10]; + expect(pointInPolygon(ell, 2, 8)).toBe(true); + expect(pointInPolygon(ell, 8, 2)).toBe(true); + expect(pointInPolygon(ell, 8, 8)).toBe(false); + }); + + it('claims a shared edge for exactly one of two touching rings', () => { + const left = [0, 0, 10, 0, 10, 10, 0, 10]; + const right = [10, 0, 20, 0, 20, 10, 10, 10]; + const onTheSeam = [pointInPolygon(left, 10, 5), pointInPolygon(right, 10, 5)]; + expect(onTheSeam.filter(Boolean)).toHaveLength(1); + }); + + it('is false for a degenerate ring', () => { + expect(pointInPolygon([0, 0, 1, 1], 0, 0)).toBe(false); + }); +}); + +describe('pointInShape', () => { + const outline = [0, 0, 40, 0, 40, 40, 0, 40]; + const courtyard = [[10, 10, 30, 10, 30, 30, 10, 30]]; + + it('excludes the hole from the shape', () => { + expect(pointInShape(outline, courtyard, 5, 5)).toBe(true); + expect(pointInShape(outline, courtyard, 20, 20)).toBe(false); + expect(pointInShape(outline, undefined, 20, 20)).toBe(true); + }); +}); diff --git a/src/TheLivingWorld.Web/src/map/geometry.ts b/src/TheLivingWorld.Web/src/map/geometry.ts index 39f3bd2..e3a6ca8 100644 --- a/src/TheLivingWorld.Web/src/map/geometry.ts +++ b/src/TheLivingWorld.Web/src/map/geometry.ts @@ -246,6 +246,52 @@ export function translate(points: FlatPoints, dx: number, dy: number): FlatPoint return moved; } +/** + * True area of a closed ring, by the shoelace formula. Unsigned, so winding does not matter — callers here + * care how big a footprint is, never which way round it was drawn. + */ +export function polygonArea(points: FlatPoints): number { + if (points.length < 6) return 0; + + let twice = 0; + for (let i = 0, j = points.length - 2; i < points.length; j = i, i += 2) { + twice += (points[j]! * points[i + 1]!) - (points[i]! * points[j + 1]!); + } + + return Math.abs(twice) / 2; +} + +/** + * Whether a point falls inside a closed ring, by ray casting. The boundary itself counts as outside on one + * side and inside on the other, which is what keeps two buildings sharing a wall from both claiming a click. + */ +export function pointInPolygon(points: FlatPoints, x: number, y: number): boolean { + if (points.length < 6) return false; + + let inside = false; + for (let i = 0, j = points.length - 2; i < points.length; j = i, i += 2) { + const xi = points[i]!; + const yi = points[i + 1]!; + const xj = points[j]!; + const yj = points[j + 1]!; + + // Does the edge straddle the ray, and if so, is the crossing to the right of the point? + if ((yi > y) !== (yj > y) && x < (((xj - xi) * (y - yi)) / (yj - yi)) + xi) { + inside = !inside; + } + } + + return inside; +} + +/** Inside the outline and not inside any hole — a courtyard is not part of the building around it. */ +export function pointInShape(outline: FlatPoints, holes: FlatPoints[] | undefined, x: number, y: number): boolean { + if (!pointInPolygon(outline, x, y)) return false; + if (!holes) return true; + + return !holes.some((hole) => pointInPolygon(hole, x, y)); +} + /** Bounding-box area of a flat point array — a cheap stand-in for true area when filtering by size. */ export function boundingArea(points: FlatPoints): number { if (points.length < 6) return 0; diff --git a/src/TheLivingWorld.Web/src/map/mapView.ts b/src/TheLivingWorld.Web/src/map/mapView.ts index 9f82381..cd614d7 100644 --- a/src/TheLivingWorld.Web/src/map/mapView.ts +++ b/src/TheLivingWorld.Web/src/map/mapView.ts @@ -1,10 +1,11 @@ import { Application, Container, Graphics } from 'pixi.js'; -import type { Weather, WorldMap } from '../api/types'; +import type { BuildingFeature, Weather, WorldMap } from '../api/types'; import { Camera, type Viewport } from './camera'; import { ChunkManager } from './chunkManager'; import { profileForZoom, type RenderProfile } from './chunkRenderer'; import { LabelLayer } from './labelLayer'; import { LAYER_ORDER, type MapLayers } from './layers'; +import { isClick, pickBuilding, type PickedBuilding } from './picking'; import { precipitationSpec, skyState } from './sky'; import { THEMES, type Theme, type ThemeName } from './theme'; import { WeatherLayer } from './weatherLayer'; @@ -44,6 +45,7 @@ export class MapView { private readonly app = new Application(); private readonly root = new Container(); private readonly background = new Graphics(); + private readonly selection = new Graphics(); private readonly border = new Graphics(); private readonly camera = new Camera(); private readonly layers: MapLayers = createLayers(); @@ -53,6 +55,8 @@ export class MapView { private readonly labels = new LabelLayer(this.theme); private readonly weather = new WeatherLayer(); + private selected: PickedBuilding | null = null; + private pressedAt: { x: number; y: number } | null = null; private weatherState: Weather | null = null; private weatherEnabled = true; private gameTime: Date | null = null; @@ -68,6 +72,9 @@ export class MapView { onStatusChange: ((status: MapStatus) => void) | null = null; + /** Fires with the building the player clicked, or null when they clicked bare ground. */ + onBuildingPicked: ((picked: PickedBuilding | null) => void) | null = null; + async init(host: HTMLElement): Promise { this.host = host; @@ -84,6 +91,7 @@ export class MapView { this.root.addChild(this.background); for (const id of LAYER_ORDER) this.root.addChild(this.layers[id]); + this.root.addChild(this.selection); this.root.addChild(this.border); this.app.stage.addChild(this.root); @@ -112,6 +120,7 @@ export class MapView { this.labels.clear(); this.weather.clear(); this.weatherState = null; + this.clearSelection(); this.worldSizeMeters = map.sizeMeters; this.latitude = map.latitude; @@ -141,10 +150,39 @@ export class MapView { this.app.renderer.background.color = this.theme.outside; this.paintBackground(); + this.drawSelection(); this.cameraDirty = true; this.lastChunkUpdate = 0; } + /** Drops the current selection and rubs out its highlight. */ + clearSelection(): void { + this.selected = null; + this.selection.clear(); + } + + get selectedBuilding(): BuildingFeature | null { + return this.selected?.building ?? null; + } + + /** + * Hit-tests a screen position and selects whatever building is under it, firing + * {@link onBuildingPicked} either way — clicking bare ground is how the panel gets closed. + */ + pickAt(screenX: number, screenY: number): void { + if (this.worldSizeMeters === 0) return; + + const viewport = this.viewport; + const world = this.camera.screenToWorld(screenX, screenY, viewport); + + // Only the chunks on screen can be under the cursor, and they are the ones whose data is loaded. + const picked = pickBuilding(this.chunks.visibleChunks(this.camera.visibleRect(viewport)), world.x, world.y); + + this.selected = picked; + this.drawSelection(); + this.onBuildingPicked?.(picked); + } + /** The world's current weather. One reading covers the whole map. */ setWeather(weather: Weather | null): void { this.weatherState = weather; @@ -178,6 +216,7 @@ export class MapView { this.border.clear(); this.weatherState = null; this.gameTime = null; + this.clearSelection(); this.worldSizeMeters = 0; } @@ -219,6 +258,7 @@ export class MapView { this.profile = profileForZoom(this.camera.zoom, this.theme); this.drawBorder(); + this.drawSelection(); this.cameraDirty = false; } @@ -258,6 +298,23 @@ export class MapView { this.weather.advance(deltaMs); } + /** + * Redrawn whenever the camera moves, because the stroke has to stay a constant width on screen — the + * container is scaled, so a fixed world width would thin out to nothing as you zoom away. + */ + private drawSelection(): void { + this.selection.clear(); + if (!this.selected) return; + + const { outline, holes } = this.selected.building; + this.selection.poly(outline); + for (const hole of holes ?? []) this.selection.poly(hole); + + this.selection + .fill({ color: this.theme.selectionFill, alpha: 0.28 }) + .stroke({ width: 2 / this.camera.zoom, color: this.theme.selection, alignment: 0.5 }); + } + private drawBorder(): void { const half = this.worldSizeMeters / 2; this.border @@ -284,6 +341,9 @@ export class MapView { canvas.setPointerCapture(event.pointerId); this.activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY }); this.pinchDistance = this.currentPinchDistance(); + + // A second finger means a pinch, never a tap. + this.pressedAt = this.activePointers.size === 1 ? { x: event.clientX, y: event.clientY } : null; }); canvas.addEventListener('pointermove', (event) => { @@ -293,6 +353,7 @@ export class MapView { this.activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY }); if (this.activePointers.size >= 2) { + this.pressedAt = null; this.handlePinch(); return; } @@ -306,8 +367,21 @@ export class MapView { this.pinchDistance = this.currentPinchDistance(); }; - canvas.addEventListener('pointerup', release); - canvas.addEventListener('pointercancel', release); + canvas.addEventListener('pointerup', (event) => { + // Panning always nudges the pointer, so a press that barely moved is what counts as a click. + const pressed = this.pressedAt; + this.pressedAt = null; + release(event); + + if (pressed && isClick(pressed, { x: event.clientX, y: event.clientY })) { + const rect = canvas.getBoundingClientRect(); + this.pickAt(event.clientX - rect.left, event.clientY - rect.top); + } + }); + canvas.addEventListener('pointercancel', (event) => { + this.pressedAt = null; + release(event); + }); canvas.addEventListener('pointerleave', release); canvas.addEventListener( diff --git a/src/TheLivingWorld.Web/src/map/picking.test.ts b/src/TheLivingWorld.Web/src/map/picking.test.ts new file mode 100644 index 0000000..6cca78f --- /dev/null +++ b/src/TheLivingWorld.Web/src/map/picking.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import type { BuildingFeature, MapChunk } from '../api/types'; +import { isClick, pickBuilding } from './picking'; + +function building(overrides: Partial & { outline: number[] }): BuildingFeature { + return { id: 1, kind: 'house', height: 6, ...overrides }; +} + +/** A square of `size` metres with its south-west corner at (x, y). */ +function square(x: number, y: number, size: number): number[] { + return [x, y, x + size, y, x + size, y + size, x, y + size]; +} + +function chunk(buildings: BuildingFeature[]): MapChunk { + return { x: 0, y: 0, bounds: [0, 0, 100, 100], buildings, roads: [], areas: [], water: [] }; +} + +describe('pickBuilding', () => { + it('finds the building under the point', () => { + const chunks = [chunk([building({ id: 7, outline: square(10, 10, 20) })])]; + + expect(pickBuilding(chunks, 20, 20)?.building.id).toBe(7); + expect(pickBuilding(chunks, 5, 5)).toBeNull(); + }); + + it('reports the true footprint area, not the bounding box', () => { + // A right triangle over a 20 m square: half the box. + const chunks = [chunk([building({ outline: [0, 0, 20, 0, 0, 20] })])]; + + expect(pickBuilding(chunks, 2, 2)?.areaSquareMetres).toBeCloseTo(200, 6); + }); + + it('ignores a click that lands in a courtyard', () => { + const chunks = [chunk([building({ + id: 9, + outline: square(0, 0, 40), + holes: [square(10, 10, 20)], + })])]; + + // Inside the block itself, but not in the hole punched through it. + expect(pickBuilding(chunks, 5, 5)?.building.id).toBe(9); + expect(pickBuilding(chunks, 20, 20)).toBeNull(); + }); + + it('prefers the smaller of two overlapping footprints', () => { + const chunks = [chunk([ + building({ id: 1, outline: square(0, 0, 60) }), + building({ id: 2, outline: square(10, 10, 10) }), + ])]; + + // Inside both: the player is pointing at the small one they can see the edges of. + expect(pickBuilding(chunks, 15, 15)?.building.id).toBe(2); + // Inside only the big one. + expect(pickBuilding(chunks, 50, 50)?.building.id).toBe(1); + }); + + it('searches across every loaded chunk', () => { + const chunks = [ + chunk([building({ id: 1, outline: square(0, 0, 10) })]), + chunk([building({ id: 2, outline: square(50, 50, 10) })]), + ]; + + expect(pickBuilding(chunks, 55, 55)?.building.id).toBe(2); + }); + + it('copes with an empty map and with degenerate outlines', () => { + expect(pickBuilding([], 0, 0)).toBeNull(); + expect(pickBuilding([chunk([])], 0, 0)).toBeNull(); + expect(pickBuilding([chunk([building({ outline: [0, 0, 1, 1] })])], 0, 0)).toBeNull(); + }); +}); + +describe('isClick', () => { + it('accepts a still pointer and the wobble of a tap', () => { + expect(isClick({ x: 100, y: 100 }, { x: 100, y: 100 })).toBe(true); + expect(isClick({ x: 100, y: 100 }, { x: 103, y: 101 })).toBe(true); + }); + + it('rejects a drag', () => { + expect(isClick({ x: 100, y: 100 }, { x: 140, y: 100 })).toBe(false); + expect(isClick({ x: 100, y: 100 }, { x: 100, y: 160 })).toBe(false); + }); + + it('honours a custom tolerance', () => { + expect(isClick({ x: 0, y: 0 }, { x: 9, y: 0 }, 10)).toBe(true); + expect(isClick({ x: 0, y: 0 }, { x: 11, y: 0 }, 10)).toBe(false); + }); +}); diff --git a/src/TheLivingWorld.Web/src/map/picking.ts b/src/TheLivingWorld.Web/src/map/picking.ts new file mode 100644 index 0000000..0f6d1e6 --- /dev/null +++ b/src/TheLivingWorld.Web/src/map/picking.ts @@ -0,0 +1,52 @@ +import type { BuildingFeature, MapChunk } from '../api/types'; +import { pointInShape, polygonArea } from './geometry'; + +/** A building the player picked, with the chunk it came from so the highlight can be redrawn from source. */ +export interface PickedBuilding { + building: BuildingFeature; + /** Footprint in square metres, computed once at pick time for the info panel. */ + areaSquareMetres: number; +} + +/** + * Finds the building under a point of the map. + * + * Where footprints overlap — a chapel inside a school's grounds, an extension drawn over its parent — the + * smallest match wins. The big one is almost always the container, and the player is pointing at the thing + * they can see the edges of. + * + * Buildings straddling a chunk boundary appear in every chunk they touch, so the same id can be found twice; + * that is harmless here because both copies carry the same footprint. + */ +export function pickBuilding( + chunks: Iterable, + x: number, + y: number, +): PickedBuilding | null { + let best: PickedBuilding | null = null; + + for (const chunk of chunks) { + for (const building of chunk.buildings) { + if (!pointInShape(building.outline, building.holes, x, y)) continue; + + const areaSquareMetres = polygonArea(building.outline); + if (best === null || areaSquareMetres < best.areaSquareMetres) { + best = { building, areaSquareMetres }; + } + } + } + + return best; +} + +/** + * Whether two pointer positions are close enough to count as a click rather than a drag. Panning the map + * always moves the pointer a little, so a bare equality check would make the map unselectable on a trackpad. + */ +export function isClick( + from: { x: number; y: number }, + to: { x: number; y: number }, + tolerancePx = 5, +): boolean { + return Math.hypot(to.x - from.x, to.y - from.y) <= tolerancePx; +} diff --git a/src/TheLivingWorld.Web/src/map/theme.ts b/src/TheLivingWorld.Web/src/map/theme.ts index 2dcee61..4a06960 100644 --- a/src/TheLivingWorld.Web/src/map/theme.ts +++ b/src/TheLivingWorld.Web/src/map/theme.ts @@ -42,6 +42,10 @@ export interface Theme { label: number; labelHalo: number; + + /** Outline and wash over the building the player has picked. */ + selection: number; + selectionFill: number; } const DAY: Theme = { @@ -159,6 +163,9 @@ const DAY: Theme = { label: 0x3a3833, labelHalo: 0xfbfaf7, + + selection: 0x1f6f4a, + selectionFill: 0x3d9c6d, }; const NIGHT: Theme = { @@ -277,6 +284,9 @@ const NIGHT: Theme = { label: 0xd7dde3, labelHalo: 0x0d1116, + + selection: 0x6fd6a0, + selectionFill: 0x4d8f66, }; export const THEMES: Record = { day: DAY, night: NIGHT }; diff --git a/src/TheLivingWorld.Web/src/styles.css b/src/TheLivingWorld.Web/src/styles.css index efba718..c835da7 100644 --- a/src/TheLivingWorld.Web/src/styles.css +++ b/src/TheLivingWorld.Web/src/styles.css @@ -202,6 +202,71 @@ body { white-space: nowrap; } +.building-panel { + position: absolute; + top: 62px; + right: 16px; + z-index: 2; + width: min(280px, calc(100vw - 32px)); + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 14px; + background: var(--panel-bg); + border: 1px solid var(--panel-border); + border-radius: 10px; + backdrop-filter: blur(6px); +} + +.building-panel[hidden] { + display: none !important; +} + +.building-panel__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.building-panel__name { + margin: 0; + font-size: 14px; + font-weight: 600; + line-height: 1.3; + color: var(--text); + overflow-wrap: anywhere; +} + +.building-facts { + display: grid; + grid-template-columns: auto 1fr; + gap: 4px 12px; + margin: 0; + font-size: 12px; +} + +.building-facts dt { + color: var(--text-muted); +} + +.building-facts dd { + margin: 0; + color: var(--text); + font-variant-numeric: tabular-nums; + text-align: right; +} + +.building-panel__source { + font-size: 11px; + color: var(--accent); + text-decoration: none; +} + +.building-panel__source:hover { + text-decoration: underline; +} + /* Off reads as muted rather than hidden, so the control does not vanish when it is doing nothing. */ .icon-button[aria-pressed='false'] { opacity: 0.4; diff --git a/src/TheLivingWorld.Web/src/ui/building.test.ts b/src/TheLivingWorld.Web/src/ui/building.test.ts new file mode 100644 index 0000000..1c8cb28 --- /dev/null +++ b/src/TheLivingWorld.Web/src/ui/building.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import type { BuildingFeature } from '../api/types'; +import { setLocale } from '../i18n'; +import { buildingFacts, buildingSourceUrl, buildingTitle } from './building'; + +afterEach(() => { + setLocale('en'); +}); + +function building(overrides: Partial = {}): BuildingFeature { + return { id: 42, kind: 'house', height: 6, outline: [0, 0, 10, 0, 10, 10, 0, 10], ...overrides }; +} + +describe('buildingTitle', () => { + it('prefers the name OSM gave it', () => { + expect(buildingTitle(building({ name: 'Town Hall' }))).toBe('Town Hall'); + }); + + it('falls back to the kind, and to a generic label when even that is unknown', () => { + expect(buildingTitle(building({ kind: 'church' }))).toBe('Church'); + expect(buildingTitle(building({ kind: 'unknown' }))).toBe('Unnamed building'); + }); + + it('ignores a name that is only whitespace', () => { + expect(buildingTitle(building({ kind: 'school', name: ' ' }))).toBe('School'); + }); + + it('translates', () => { + setLocale('ru'); + expect(buildingTitle(building({ kind: 'apartments' }))).toBe('Многоквартирный дом'); + expect(buildingTitle(building({ kind: 'unknown' }))).toBe('Здание без названия'); + }); +}); + +describe('buildingFacts', () => { + it('always names the type', () => { + const facts = buildingFacts(building({ kind: 'retail', height: 0 }), 0); + expect(facts).toEqual([{ label: 'Type', value: 'Retail' }]); + }); + + it('leaves out what OpenStreetMap never said', () => { + // A missing height is not a height of zero, so the row simply does not appear. + const labels = buildingFacts(building({ height: 0, levels: 0 }), 0).map((fact) => fact.label); + expect(labels).toEqual(['Type']); + }); + + it('reports height, floors and footprint when they are known', () => { + const facts = buildingFacts(building({ height: 12.34, levels: 4 }), 250); + expect(facts).toEqual([ + { label: 'Type', value: 'House' }, + { label: 'Height', value: '12.3 m' }, + { label: 'Floors', value: '4 floors' }, + { label: 'Footprint', value: '250 m²' }, + ]); + }); + + it('gives a small footprint a decimal and a large one none', () => { + const small = buildingFacts(building(), 42.47).at(-1); + const large = buildingFacts(building(), 12_345.6).at(-1); + + expect(small?.value).toBe('42.5 m²'); + expect(large?.value).toBe('12346 m²'); + }); + + it('picks the Russian plural for the floor count', () => { + setLocale('ru'); + const floors = (n: number) => + buildingFacts(building({ levels: n }), 0).find((fact) => fact.label === 'Этажей')?.value; + + expect(floors(1)).toBe('1 этаж'); + expect(floors(3)).toBe('3 этажа'); + expect(floors(9)).toBe('9 этажей'); + }); +}); + +describe('buildingSourceUrl', () => { + it('points at the OSM element the footprint came from', () => { + expect(buildingSourceUrl(building({ id: 123456 }))).toBe('https://www.openstreetmap.org/way/123456'); + }); +}); diff --git a/src/TheLivingWorld.Web/src/ui/building.ts b/src/TheLivingWorld.Web/src/ui/building.ts new file mode 100644 index 0000000..315977c --- /dev/null +++ b/src/TheLivingWorld.Web/src/ui/building.ts @@ -0,0 +1,65 @@ +import type { BuildingFeature } from '../api/types'; +import { t } from '../i18n'; + +/** One row of the info panel. */ +export interface BuildingFact { + label: string; + value: string; +} + +/** The heading: the building's own name if OSM gave it one, otherwise what kind of thing it is. */ +export function buildingTitle(building: BuildingFeature): string { + const name = building.name?.trim(); + if (name) return name; + + return building.kind === 'unknown' + ? t('building.unnamed') + : t(`building.kind.${building.kind}`); +} + +/** + * The facts worth a row. Anything OpenStreetMap did not say is left out rather than shown as a blank or a + * zero — a building with no height tag is not a building of no height. + */ +export function buildingFacts(building: BuildingFeature, areaSquareMetres: number): BuildingFact[] { + const facts: BuildingFact[] = [ + { label: t('building.type'), value: t(`building.kind.${building.kind}`) }, + ]; + + if (building.height > 0) { + facts.push({ label: t('building.height'), value: t('building.metres', { n: round(building.height, 1) }) }); + } + + // Absent on worlds generated before levels were exported, and zero when the tag was missing. + if (building.levels && building.levels > 0) { + facts.push({ label: t('building.floors'), value: t('building.floorCount', { n: building.levels }) }); + } + + if (areaSquareMetres > 0) { + facts.push({ + label: t('building.footprint'), + value: t('building.squareMetres', { n: formatArea(areaSquareMetres) }), + }); + } + + return facts; +} + +/** Where this footprint came from, so a curious player can go and read the original tags. */ +export function buildingSourceUrl(building: BuildingFeature): string { + // Ids come straight from OSM. Relations are exported with the id they were assembled from, and OSM's + // browse URL for a way works for the overwhelming majority of footprints. + return `https://www.openstreetmap.org/way/${building.id}`; +} + +/** Whole square metres below a hectare, then one decimal of hectares — a house is not measured in ha. */ +function formatArea(squareMetres: number): string { + return squareMetres >= 10_000 + ? String(round(squareMetres, 0)) + : String(round(squareMetres, squareMetres < 100 ? 1 : 0)); +} + +function round(value: number, digits: number): number { + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +}