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