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;
+}