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.
This commit is contained in:
@@ -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
|
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
|
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;
|
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
|
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.
|
ids on first use without a lock, and two threads racing there hand out the same id.
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
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
|
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
|
arterials, then land cover, then side streets — and placed greedily, dropping anything that would overlap a
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ public sealed record BuildingDto
|
|||||||
|
|
||||||
public required float Height { get; init; }
|
public required float Height { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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".
|
||||||
|
/// </summary>
|
||||||
|
public byte Levels { get; init; }
|
||||||
|
|
||||||
public required float[] Outline { get; init; }
|
public required float[] Outline { get; init; }
|
||||||
|
|
||||||
public float[][]? Holes { get; init; }
|
public float[][]? Holes { get; init; }
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ public sealed class ChunkExporter
|
|||||||
Id = source.Id,
|
Id = source.Id,
|
||||||
Kind = building.Kind,
|
Kind = building.Kind,
|
||||||
Height = building.HeightMeters,
|
Height = building.HeightMeters,
|
||||||
|
Levels = building.Levels,
|
||||||
Outline = Flatten(shapes.Get(outline.ShapeId)),
|
Outline = Flatten(shapes.Get(outline.ShapeId)),
|
||||||
Holes = FlattenHoles(shapes, holes),
|
Holes = FlattenHoles(shapes, holes),
|
||||||
Name = name.Value,
|
Name = name.Value,
|
||||||
|
|||||||
@@ -138,6 +138,23 @@
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<aside id="building-panel" class="building-panel" hidden>
|
||||||
|
<header class="building-panel__header">
|
||||||
|
<h2 id="building-name" class="building-panel__name"></h2>
|
||||||
|
<button
|
||||||
|
id="building-close"
|
||||||
|
type="button"
|
||||||
|
class="icon-button"
|
||||||
|
data-i18n-title="building.close"
|
||||||
|
data-i18n-aria="building.close"
|
||||||
|
title="Close"
|
||||||
|
aria-label="Close"
|
||||||
|
>×</button>
|
||||||
|
</header>
|
||||||
|
<dl id="building-facts" class="building-facts"></dl>
|
||||||
|
<a id="building-source" class="building-panel__source" target="_blank" rel="noopener noreferrer"></a>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<div id="hud" class="hud"></div>
|
<div id="hud" class="hud"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,8 @@ export interface BuildingFeature {
|
|||||||
id: number;
|
id: number;
|
||||||
kind: BuildingKind;
|
kind: BuildingKind;
|
||||||
height: number;
|
height: number;
|
||||||
|
/** Storeys, when OSM said. Absent on worlds generated before this was exported. */
|
||||||
|
levels?: number;
|
||||||
outline: FlatPoints;
|
outline: FlatPoints;
|
||||||
holes?: FlatPoints[];
|
holes?: FlatPoints[];
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|||||||
@@ -115,6 +115,36 @@ export const en: Messages = {
|
|||||||
weatherOff: 'Show weather effects',
|
weatherOff: 'Show weather effects',
|
||||||
weatherAria: '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: {
|
hud: {
|
||||||
metersPerPixel: '{n} m/px',
|
metersPerPixel: '{n} m/px',
|
||||||
chunks: '{loaded}/{total} chunks',
|
chunks: '{loaded}/{total} chunks',
|
||||||
|
|||||||
@@ -115,6 +115,36 @@ export const ru: Messages = {
|
|||||||
weatherOff: 'Показать погоду',
|
weatherOff: 'Показать погоду',
|
||||||
weatherAria: 'Погодные эффекты',
|
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: {
|
hud: {
|
||||||
metersPerPixel: '{n} м/пикс',
|
metersPerPixel: '{n} м/пикс',
|
||||||
chunks: '{loaded}/{total} чанков',
|
chunks: '{loaded}/{total} чанков',
|
||||||
|
|||||||
@@ -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`.
|
* ICU-lite plural forms. `t(key, { n })` picks a category via `Intl.PluralRules`.
|
||||||
@@ -108,6 +108,21 @@ export interface Messages {
|
|||||||
weatherOff: string;
|
weatherOff: string;
|
||||||
weatherAria: string;
|
weatherAria: string;
|
||||||
};
|
};
|
||||||
|
building: {
|
||||||
|
title: string;
|
||||||
|
unnamed: string;
|
||||||
|
close: string;
|
||||||
|
kind: Record<BuildingKind, string>;
|
||||||
|
type: string;
|
||||||
|
height: string;
|
||||||
|
floors: string;
|
||||||
|
footprint: string;
|
||||||
|
metres: string;
|
||||||
|
squareMetres: string;
|
||||||
|
floorCount: Plural;
|
||||||
|
source: string;
|
||||||
|
unknown: string;
|
||||||
|
};
|
||||||
hud: {
|
hud: {
|
||||||
metersPerPixel: string;
|
metersPerPixel: string;
|
||||||
chunks: string;
|
chunks: string;
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
} from './ui/gameTime';
|
} from './ui/gameTime';
|
||||||
import { climateFromLatitude, climateExample, climateLabel, describeClimate, findClimate } from './ui/climate';
|
import { climateFromLatitude, climateExample, climateLabel, describeClimate, findClimate } from './ui/climate';
|
||||||
import { conditionIcon, describeWeather, formatTemperature, formatWeather } from './ui/weather';
|
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 { catalog, initI18n, subscribe, t } from './i18n';
|
||||||
import { applyDomTranslations, mountLocaleSwitch, paintLocaleSwitch } from './i18n/dom';
|
import { applyDomTranslations, mountLocaleSwitch, paintLocaleSwitch } from './i18n/dom';
|
||||||
import { translateStage } from './i18n/stage';
|
import { translateStage } from './i18n/stage';
|
||||||
@@ -46,6 +48,11 @@ const elements = {
|
|||||||
continueName: required<HTMLElement>('continue-name'),
|
continueName: required<HTMLElement>('continue-name'),
|
||||||
status: required<HTMLElement>('status'),
|
status: required<HTMLElement>('status'),
|
||||||
hud: required<HTMLDivElement>('hud'),
|
hud: required<HTMLDivElement>('hud'),
|
||||||
|
buildingPanel: required<HTMLElement>('building-panel'),
|
||||||
|
buildingName: required<HTMLElement>('building-name'),
|
||||||
|
buildingFacts: required<HTMLElement>('building-facts'),
|
||||||
|
buildingSource: required<HTMLAnchorElement>('building-source'),
|
||||||
|
buildingClose: required<HTMLButtonElement>('building-close'),
|
||||||
worldTitle: required<HTMLElement>('world-title'),
|
worldTitle: required<HTMLElement>('world-title'),
|
||||||
back: required<HTMLButtonElement>('back-button'),
|
back: required<HTMLButtonElement>('back-button'),
|
||||||
localeSwitch: required<HTMLDivElement>('locale-switch'),
|
localeSwitch: required<HTMLDivElement>('locale-switch'),
|
||||||
@@ -80,6 +87,7 @@ let gameSnapshotAt = 0;
|
|||||||
let gamePollTimer: number | null = null;
|
let gamePollTimer: number | null = null;
|
||||||
let gamePaintTimer: number | null = null;
|
let gamePaintTimer: number | null = null;
|
||||||
let clockUpdating = false;
|
let clockUpdating = false;
|
||||||
|
let pickedBuilding: PickedBuilding | null = null;
|
||||||
let lastMapStatus: MapStatus | null = null;
|
let lastMapStatus: MapStatus | null = null;
|
||||||
|
|
||||||
function required<T extends HTMLElement>(id: string): T {
|
function required<T extends HTMLElement>(id: string): T {
|
||||||
@@ -96,6 +104,7 @@ function setStatus(message: string, tone: 'info' | 'error' | 'busy' = 'info'): v
|
|||||||
function showMenu(): void {
|
function showMenu(): void {
|
||||||
elements.menu.hidden = false;
|
elements.menu.hidden = false;
|
||||||
elements.game.hidden = true;
|
elements.game.hidden = true;
|
||||||
|
selectBuilding(null);
|
||||||
stopGameClockLoop();
|
stopGameClockLoop();
|
||||||
startMenuClockLoop();
|
startMenuClockLoop();
|
||||||
}
|
}
|
||||||
@@ -514,6 +523,7 @@ async function openWorld(id: string): Promise<void> {
|
|||||||
localStorage.setItem(LAST_WORLD_KEY, id);
|
localStorage.setItem(LAST_WORLD_KEY, id);
|
||||||
|
|
||||||
view.showWorld(map);
|
view.showWorld(map);
|
||||||
|
selectBuilding(null);
|
||||||
elements.worldTitle.textContent = map.name;
|
elements.worldTitle.textContent = map.name;
|
||||||
elements.hud.textContent = '';
|
elements.hud.textContent = '';
|
||||||
setStatus('');
|
setStatus('');
|
||||||
@@ -682,6 +692,39 @@ function applyWeatherEffects(enabled: boolean): void {
|
|||||||
elements.weatherToggle.title = enabled ? t('game.weatherOn') : t('game.weatherOff');
|
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 {
|
function toggleTheme(): void {
|
||||||
const current = (document.documentElement.dataset.theme as ThemeName | undefined) ?? readStoredTheme();
|
const current = (document.documentElement.dataset.theme as ThemeName | undefined) ?? readStoredTheme();
|
||||||
applyTheme(current === 'day' ? 'night' : 'day');
|
applyTheme(current === 'day' ? 'night' : 'day');
|
||||||
@@ -712,6 +755,7 @@ function onLocaleChanged(): void {
|
|||||||
|
|
||||||
applyTheme((document.documentElement.dataset.theme as ThemeName | undefined) ?? readStoredTheme());
|
applyTheme((document.documentElement.dataset.theme as ThemeName | undefined) ?? readStoredTheme());
|
||||||
applyWeatherEffects(readWeatherEffects());
|
applyWeatherEffects(readWeatherEffects());
|
||||||
|
paintBuildingPanel();
|
||||||
if (gameClock) applyClockToControls(gameClock);
|
if (gameClock) applyClockToControls(gameClock);
|
||||||
applyWeatherToControls(gameWeather ?? undefined);
|
applyWeatherToControls(gameWeather ?? undefined);
|
||||||
if (lastMapStatus) renderHud(lastMapStatus);
|
if (lastMapStatus) renderHud(lastMapStatus);
|
||||||
@@ -737,6 +781,11 @@ async function start(): Promise<void> {
|
|||||||
elements.back.addEventListener('click', () => {
|
elements.back.addEventListener('click', () => {
|
||||||
void returnToMenu();
|
void returnToMenu();
|
||||||
});
|
});
|
||||||
|
view.onBuildingPicked = selectBuilding;
|
||||||
|
elements.buildingClose.addEventListener('click', closeBuildingPanel);
|
||||||
|
window.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape') closeBuildingPanel();
|
||||||
|
});
|
||||||
elements.weatherToggle.addEventListener('click', () => {
|
elements.weatherToggle.addEventListener('click', () => {
|
||||||
applyWeatherEffects(!view.weatherEffectsEnabled);
|
applyWeatherEffects(!view.weatherEffectsEnabled);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
boundingArea,
|
boundingArea,
|
||||||
dashPolyline,
|
dashPolyline,
|
||||||
|
pointInPolygon,
|
||||||
|
pointInShape,
|
||||||
pointsAlong,
|
pointsAlong,
|
||||||
|
polygonArea,
|
||||||
polygonCentroid,
|
polygonCentroid,
|
||||||
polylineAnchor,
|
polylineAnchor,
|
||||||
polylineLength,
|
polylineLength,
|
||||||
@@ -214,3 +217,67 @@ describe('translate and boundingArea', () => {
|
|||||||
expect(boundingArea(line(0, 0, 1, 1))).toBe(0);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -246,6 +246,52 @@ export function translate(points: FlatPoints, dx: number, dy: number): FlatPoint
|
|||||||
return moved;
|
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. */
|
/** 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 {
|
export function boundingArea(points: FlatPoints): number {
|
||||||
if (points.length < 6) return 0;
|
if (points.length < 6) return 0;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Application, Container, Graphics } from 'pixi.js';
|
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 { Camera, type Viewport } from './camera';
|
||||||
import { ChunkManager } from './chunkManager';
|
import { ChunkManager } from './chunkManager';
|
||||||
import { profileForZoom, type RenderProfile } from './chunkRenderer';
|
import { profileForZoom, type RenderProfile } from './chunkRenderer';
|
||||||
import { LabelLayer } from './labelLayer';
|
import { LabelLayer } from './labelLayer';
|
||||||
import { LAYER_ORDER, type MapLayers } from './layers';
|
import { LAYER_ORDER, type MapLayers } from './layers';
|
||||||
|
import { isClick, pickBuilding, type PickedBuilding } from './picking';
|
||||||
import { precipitationSpec, skyState } from './sky';
|
import { precipitationSpec, skyState } from './sky';
|
||||||
import { THEMES, type Theme, type ThemeName } from './theme';
|
import { THEMES, type Theme, type ThemeName } from './theme';
|
||||||
import { WeatherLayer } from './weatherLayer';
|
import { WeatherLayer } from './weatherLayer';
|
||||||
@@ -44,6 +45,7 @@ export class MapView {
|
|||||||
private readonly app = new Application();
|
private readonly app = new Application();
|
||||||
private readonly root = new Container();
|
private readonly root = new Container();
|
||||||
private readonly background = new Graphics();
|
private readonly background = new Graphics();
|
||||||
|
private readonly selection = new Graphics();
|
||||||
private readonly border = new Graphics();
|
private readonly border = new Graphics();
|
||||||
private readonly camera = new Camera();
|
private readonly camera = new Camera();
|
||||||
private readonly layers: MapLayers = createLayers();
|
private readonly layers: MapLayers = createLayers();
|
||||||
@@ -53,6 +55,8 @@ export class MapView {
|
|||||||
private readonly labels = new LabelLayer(this.theme);
|
private readonly labels = new LabelLayer(this.theme);
|
||||||
private readonly weather = new WeatherLayer();
|
private readonly weather = new WeatherLayer();
|
||||||
|
|
||||||
|
private selected: PickedBuilding | null = null;
|
||||||
|
private pressedAt: { x: number; y: number } | null = null;
|
||||||
private weatherState: Weather | null = null;
|
private weatherState: Weather | null = null;
|
||||||
private weatherEnabled = true;
|
private weatherEnabled = true;
|
||||||
private gameTime: Date | null = null;
|
private gameTime: Date | null = null;
|
||||||
@@ -68,6 +72,9 @@ export class MapView {
|
|||||||
|
|
||||||
onStatusChange: ((status: MapStatus) => void) | null = null;
|
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<void> {
|
async init(host: HTMLElement): Promise<void> {
|
||||||
this.host = host;
|
this.host = host;
|
||||||
|
|
||||||
@@ -84,6 +91,7 @@ export class MapView {
|
|||||||
|
|
||||||
this.root.addChild(this.background);
|
this.root.addChild(this.background);
|
||||||
for (const id of LAYER_ORDER) this.root.addChild(this.layers[id]);
|
for (const id of LAYER_ORDER) this.root.addChild(this.layers[id]);
|
||||||
|
this.root.addChild(this.selection);
|
||||||
this.root.addChild(this.border);
|
this.root.addChild(this.border);
|
||||||
|
|
||||||
this.app.stage.addChild(this.root);
|
this.app.stage.addChild(this.root);
|
||||||
@@ -112,6 +120,7 @@ export class MapView {
|
|||||||
this.labels.clear();
|
this.labels.clear();
|
||||||
this.weather.clear();
|
this.weather.clear();
|
||||||
this.weatherState = null;
|
this.weatherState = null;
|
||||||
|
this.clearSelection();
|
||||||
this.worldSizeMeters = map.sizeMeters;
|
this.worldSizeMeters = map.sizeMeters;
|
||||||
this.latitude = map.latitude;
|
this.latitude = map.latitude;
|
||||||
|
|
||||||
@@ -141,10 +150,39 @@ export class MapView {
|
|||||||
this.app.renderer.background.color = this.theme.outside;
|
this.app.renderer.background.color = this.theme.outside;
|
||||||
|
|
||||||
this.paintBackground();
|
this.paintBackground();
|
||||||
|
this.drawSelection();
|
||||||
this.cameraDirty = true;
|
this.cameraDirty = true;
|
||||||
this.lastChunkUpdate = 0;
|
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. */
|
/** The world's current weather. One reading covers the whole map. */
|
||||||
setWeather(weather: Weather | null): void {
|
setWeather(weather: Weather | null): void {
|
||||||
this.weatherState = weather;
|
this.weatherState = weather;
|
||||||
@@ -178,6 +216,7 @@ export class MapView {
|
|||||||
this.border.clear();
|
this.border.clear();
|
||||||
this.weatherState = null;
|
this.weatherState = null;
|
||||||
this.gameTime = null;
|
this.gameTime = null;
|
||||||
|
this.clearSelection();
|
||||||
this.worldSizeMeters = 0;
|
this.worldSizeMeters = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +258,7 @@ export class MapView {
|
|||||||
|
|
||||||
this.profile = profileForZoom(this.camera.zoom, this.theme);
|
this.profile = profileForZoom(this.camera.zoom, this.theme);
|
||||||
this.drawBorder();
|
this.drawBorder();
|
||||||
|
this.drawSelection();
|
||||||
this.cameraDirty = false;
|
this.cameraDirty = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +298,23 @@ export class MapView {
|
|||||||
this.weather.advance(deltaMs);
|
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 {
|
private drawBorder(): void {
|
||||||
const half = this.worldSizeMeters / 2;
|
const half = this.worldSizeMeters / 2;
|
||||||
this.border
|
this.border
|
||||||
@@ -284,6 +341,9 @@ export class MapView {
|
|||||||
canvas.setPointerCapture(event.pointerId);
|
canvas.setPointerCapture(event.pointerId);
|
||||||
this.activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
this.activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||||
this.pinchDistance = this.currentPinchDistance();
|
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) => {
|
canvas.addEventListener('pointermove', (event) => {
|
||||||
@@ -293,6 +353,7 @@ export class MapView {
|
|||||||
this.activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
this.activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||||
|
|
||||||
if (this.activePointers.size >= 2) {
|
if (this.activePointers.size >= 2) {
|
||||||
|
this.pressedAt = null;
|
||||||
this.handlePinch();
|
this.handlePinch();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -306,8 +367,21 @@ export class MapView {
|
|||||||
this.pinchDistance = this.currentPinchDistance();
|
this.pinchDistance = this.currentPinchDistance();
|
||||||
};
|
};
|
||||||
|
|
||||||
canvas.addEventListener('pointerup', release);
|
canvas.addEventListener('pointerup', (event) => {
|
||||||
canvas.addEventListener('pointercancel', release);
|
// 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('pointerleave', release);
|
||||||
|
|
||||||
canvas.addEventListener(
|
canvas.addEventListener(
|
||||||
|
|||||||
@@ -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<BuildingFeature> & { 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<MapChunk>,
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -42,6 +42,10 @@ export interface Theme {
|
|||||||
|
|
||||||
label: number;
|
label: number;
|
||||||
labelHalo: number;
|
labelHalo: number;
|
||||||
|
|
||||||
|
/** Outline and wash over the building the player has picked. */
|
||||||
|
selection: number;
|
||||||
|
selectionFill: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DAY: Theme = {
|
const DAY: Theme = {
|
||||||
@@ -159,6 +163,9 @@ const DAY: Theme = {
|
|||||||
|
|
||||||
label: 0x3a3833,
|
label: 0x3a3833,
|
||||||
labelHalo: 0xfbfaf7,
|
labelHalo: 0xfbfaf7,
|
||||||
|
|
||||||
|
selection: 0x1f6f4a,
|
||||||
|
selectionFill: 0x3d9c6d,
|
||||||
};
|
};
|
||||||
|
|
||||||
const NIGHT: Theme = {
|
const NIGHT: Theme = {
|
||||||
@@ -277,6 +284,9 @@ const NIGHT: Theme = {
|
|||||||
|
|
||||||
label: 0xd7dde3,
|
label: 0xd7dde3,
|
||||||
labelHalo: 0x0d1116,
|
labelHalo: 0x0d1116,
|
||||||
|
|
||||||
|
selection: 0x6fd6a0,
|
||||||
|
selectionFill: 0x4d8f66,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const THEMES: Record<ThemeName, Theme> = { day: DAY, night: NIGHT };
|
export const THEMES: Record<ThemeName, Theme> = { day: DAY, night: NIGHT };
|
||||||
|
|||||||
@@ -202,6 +202,71 @@ body {
|
|||||||
white-space: nowrap;
|
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. */
|
/* Off reads as muted rather than hidden, so the control does not vanish when it is doing nothing. */
|
||||||
.icon-button[aria-pressed='false'] {
|
.icon-button[aria-pressed='false'] {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
|
|||||||
@@ -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> = {}): 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user