Fun changes
This commit is contained in:
parent
cd778dd088
commit
349a6c1d53
60 changed files with 1260 additions and 2600 deletions
|
|
@ -23,9 +23,6 @@ jobs:
|
|||
- name: Download map assets (fonts, sprites, twemoji)
|
||||
run: uv run python -m pipeline.download.map_assets --output frontend/public/assets
|
||||
|
||||
- name: Download arcgis data for finder
|
||||
run: uv run python -m pipeline.download.arcgis --output property-data/arcgis_data.parquet
|
||||
|
||||
- name: Install Docker CLI
|
||||
run: |
|
||||
ARCH=$(uname -m)
|
||||
|
|
@ -88,14 +85,3 @@ jobs:
|
|||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ steps.tags.outputs.repo }}-screenshot:buildcache
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ steps.tags.outputs.repo }}-screenshot:buildcache,mode=max
|
||||
|
||||
- name: Build and push finder service
|
||||
uses: https://github.com/docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.finder
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ steps.tags.outputs.repo }}-finder:latest
|
||||
${{ env.REGISTRY }}/${{ steps.tags.outputs.repo }}-finder:sha-${{ steps.tags.outputs.sha_short }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ steps.tags.outputs.repo }}-finder:buildcache
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ steps.tags.outputs.repo }}-finder:buildcache,mode=max
|
||||
|
|
|
|||
15
.github/workflows/docker-publish.yml
vendored
15
.github/workflows/docker-publish.yml
vendored
|
|
@ -27,9 +27,6 @@ jobs:
|
|||
- name: Download map assets (fonts, sprites, twemoji)
|
||||
run: uv run python -m pipeline.download.map_assets --output frontend/public/assets
|
||||
|
||||
- name: Download arcgis data for finder
|
||||
run: uv run python -m pipeline.download.arcgis --output property-data/arcgis_data.parquet
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
|
|
@ -73,15 +70,3 @@ jobs:
|
|||
cache-from: type=gha,scope=screenshot
|
||||
cache-to: type=gha,mode=max,scope=screenshot
|
||||
|
||||
- name: Build and push finder service
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.finder
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-finder:latest
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-finder:sha-${{ github.sha }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=finder
|
||||
cache-to: type=gha,mode=max,scope=finder
|
||||
|
|
|
|||
26
CLAUDE.md
26
CLAUDE.md
|
|
@ -98,8 +98,8 @@ Rust + Axum. Loads parquet into memory at startup.
|
|||
|
||||
**API endpoints:**
|
||||
- `GET /api/features` — Feature metadata with histograms and 2nd/98th percentiles
|
||||
- `GET /api/hexagons?resolution=&bounds=&filters=&fields=` — H3 aggregates (min/max per feature per hex), AABB-filtered to bounds
|
||||
- `GET /api/postcodes?bounds=&filters=&fields=` — Postcode polygon aggregates, AABB-filtered to bounds
|
||||
- `GET /api/hexagons?resolution=&bounds=&filters=&fields=&enum_dist=` — H3 aggregates (min/max per feature per hex), AABB-filtered to bounds. Optional `enum_dist=FeatureName` adds `dist_FeatureName: [count_per_value...]` arrays for pie chart visualization.
|
||||
- `GET /api/postcodes?bounds=&filters=&fields=&enum_dist=` — Postcode polygon aggregates, AABB-filtered to bounds. Same `enum_dist` support as hexagons.
|
||||
- `GET /api/postcode/:postcode` — Single postcode lookup (centroid + polygon)
|
||||
- `GET /api/hexagon-properties?h3=&resolution=&filters=&limit=&offset=` — Paginated properties within a hexagon
|
||||
- `GET /api/postcode-properties?postcode=&filters=&limit=&offset=` — Paginated properties within a postcode
|
||||
|
|
@ -110,7 +110,8 @@ Serves `frontend/dist/` as static fallback in production **only** when `--dist`
|
|||
|
||||
**Data representation (unified model):**
|
||||
- All features (numeric and enum): row-major flat `Vec<f32>`, NaN = null
|
||||
- Enum features: stored as f32 indices (0.0, 1.0, 2.0...) with `enum_values: FxHashMap<usize, Vec<String>>` mapping feature index → string values
|
||||
- Enum features: stored as f32 indices (0.0, 1.0, 2.0...) with `enum_values: FxHashMap<usize, Vec<String>>` mapping feature index → string values. Raw u16 indices are used directly for distribution counting (no dequantization needed for enums).
|
||||
- Enum distribution: `Aggregator` optionally tracks per-value counts via `EnumDist` struct (configured by `EnumDistConfig`). Emitted as `dist_FeatureName: [count_val0, count_val1, ...]` in hex/postcode responses when `enum_dist` param is set.
|
||||
- String fields (address, postcode): interned/packed for memory efficiency
|
||||
- All CLI args are required (no hidden defaults). Optional services use `Option<String>`: `r5_url` (travel time disabled when None), `pocketbase_admin_email`/`password` (collection auto-creation skipped when None). Required config like `gemini_model` and `public_url` must be explicitly provided via env or CLI.
|
||||
|
||||
|
|
@ -161,6 +162,7 @@ React 18 + TypeScript. deck.gl `H3HexagonLayer` over MapLibre GL. TailwindCSS. N
|
|||
- `features.ts` — `groupFeaturesByCategory(features)` groups FeatureMeta[] by their `group` field.
|
||||
- `format.ts` — `formatNumber(value, decimals)` for number formatting. `calculateHistogramMean(histogram)` for weighted mean calculation.
|
||||
- `property-fields.ts` — `getNum(property, key)` for getting a single numeric property value. Takes exactly one key — no fallback names.
|
||||
- `PieHexExtension.ts` — deck.gl `LayerExtension` that turns polygon fills into hexagonal pie charts. Injects GLSL that computes angle from fragment position to centroid, picks slice color from ENUM_PALETTE. See "deck.gl LayerExtension patterns" below.
|
||||
|
||||
When adding new UI, prefer using these shared components over inline implementations to maintain consistency.
|
||||
|
||||
|
|
@ -171,6 +173,21 @@ When adding new UI, prefer using these shared components over inline implementat
|
|||
- Extract to `lib/`: Pure functions used across components (formatting, calculations, lookups)
|
||||
- Keep inline: One-off UI specific to a single component
|
||||
|
||||
**deck.gl LayerExtension patterns (CRITICAL — hard-won knowledge):**
|
||||
|
||||
Creating custom `LayerExtension`s that add per-instance attributes to CompositeLayer sublayers (H3HexagonLayer, PolygonLayer, GeoJsonLayer) requires following the exact canonical pattern. Getting any part wrong silently fails (attributes read as zero).
|
||||
|
||||
1. **`static defaultProps` with `type: 'accessor'`** — This is what tells `LayerExtension.getSubLayerProps()` to wrap accessors via `getSubLayerAccessor()`, which unwraps `__source.object` to reach the original data item through CompositeLayer sublayer chains. Without this, accessors receive `undefined` or binary data objects instead of the original data.
|
||||
2. **`stepMode: 'dynamic'`** instead of `addInstanced()` — Use `am.add({...})` with `stepMode: 'dynamic'`, not `am.addInstanced({...})`. Dynamic step mode handles per-instance counting automatically for variable-geometry layers like SolidPolygonLayer.
|
||||
3. **`isEnabled(layer)` must guard all hooks** — Check in `getShaders()` and `initializeState()`. For polygon fills, use `layer.id.endsWith('-fill')` to skip PathLayer (stroke) sublayers.
|
||||
4. **Change layer ID when extensions change** — deck.gl recycles layers with the same ID. If you conditionally add/remove extensions, use a different layer ID (e.g., `'h3-hexagons-pie'` vs `'h3-hexagons'`) to force full teardown/rebuild. Otherwise `initializeState` never re-runs and attributes are never populated.
|
||||
5. **Include `data` in updateTriggers for extension accessors** — When API data changes (e.g., new response with `dist_` fields), `colorTrigger` may not change. Include the `data` array reference in the extension accessor updateTriggers so the attribute manager re-runs the accessors on fresh data.
|
||||
6. **FragmentGeometry only has `uv`** — In deck.gl v9's fragment shader, `geometry.position` does NOT exist. The `VertexGeometry` struct has `position`, `worldPosition`, `normal`, etc., but `FragmentGeometry` only has `uv`. To get fragment position in the FS, capture `geometry.position.xy` in the VS into a custom varying.
|
||||
7. **Binary attribute overrides go in `data.attributes`** — In deck.gl v9, `props.instanceFoo` is rejected with "has been removed". Use `data.attributes.instanceFoo` instead. But for extensions using the accessor pattern above, this isn't needed.
|
||||
8. **`getSubLayerProps` only forwards whitelisted props** — Custom props (binary buffers, accessors) set on a CompositeLayer are NOT automatically forwarded to sublayers. The `defaultProps` + `getSubLayerProps()` mechanism in step 1 is the ONLY reliable way to get extension data through the chain.
|
||||
|
||||
See `PieHexExtension.ts` for a working example and `DataFilterExtension` / `FillStyleExtension` in `@deck.gl/extensions` for reference implementations.
|
||||
|
||||
**Component size guideline:** If a component exceeds ~300 lines, look for extraction opportunities. Large components are usually doing too much — split into hooks (for logic) and child components (for UI sections).
|
||||
|
||||
**Naming conventions:**
|
||||
|
|
@ -376,7 +393,7 @@ Follow these conventions in all Rust code:
|
|||
- **POI transform validation**: Fails if any OSM category is unmapped — guarantees exhaustive coverage
|
||||
- **Fuzzy join**: Groups by postcode, uses `thefuzz.token_sort_ratio` with numeric token compatibility, greedy assignment from highest score
|
||||
- **Filter parsing is strict**: `parse_filters()` returns `Result` — malformed entries, unknown feature names, and unparseable numbers all return 400 Bad Request. No silent skipping of invalid filters.
|
||||
- **Data loading is strict**: `extract_string_col` and `lookup_enum_value` take a single column name (no fallback names). H3 precomputation panics on invalid coordinates. All configured features (defined in `features.rs`) must exist in at least one data source — the server panics at startup if any are missing (no NaN placeholders). This means all pipeline steps must be complete before starting the server. Polars `diagonal: true` concat fills nulls for features that exist in some but not all sources (e.g. "Listing date" from listings only).
|
||||
- **Data loading is strict**: `extract_string_col` and `lookup_enum_value` take a single column name (no fallback names). H3 precomputation panics on invalid coordinates. All configured features (defined in `features.rs`) must exist in the data — the server panics at startup if any are missing (no NaN placeholders). This means all pipeline steps must be complete before starting the server.
|
||||
- **Travel time is strict**: `mode` param is required (400) when `destination` is set — no silent default to "car". R5 failures return 502 Bad Gateway, not silent omission. `r5_url` is `Option<String>` — returns 503 if travel time requested without R5 configured.
|
||||
- **Filter bounds format**: `south,west,north,east` (not standard bbox order)
|
||||
- **Server-side AABB filtering**: Both `/api/hexagons` and `/api/postcodes` filter results by bounding-box intersection with query bounds. Hexagons use `h3_cell_bounds()` (h3o returns degrees, not radians). Postcodes compute polygon AABB from vertices. See `bounds_intersect()` in `parsing/bounds.rs`.
|
||||
|
|
@ -384,6 +401,7 @@ Follow these conventions in all Rust code:
|
|||
- **GridIndex returns slightly more than requested**: The 0.01° grid cells mean properties up to ~1km outside the viewport may be returned. The AABB filter in the route handlers catches these extras.
|
||||
- **POI proximity**: Uses 0.05° grid (~5km cells) to reduce candidates before haversine distance check
|
||||
- **OG tag injection**: Uses `<meta name="x-og-placeholder" content="__PERFECT_POSTCODES_OG_TAGS__"/>` placeholder in HTML, replaced at runtime by middleware
|
||||
- **Enum distribution (pie charts)**: When `enum_dist=FeatureName` is set on `/api/hexagons` or `/api/postcodes`, each cell includes `dist_FeatureName: [count_for_val0, count_for_val1, ...]`. The `Aggregator` struct has optional `EnumDist` that counts raw u16 enum indices per cell. `parse_enum_dist()` in `parsing/fields.rs` validates the feature name and confirms it's an enum. On the frontend, `PieHexExtension` (LayerExtension) injects GLSL into SolidPolygonLayer's fragment shader: computes angle from fragment position to hex centroid (passed as `instancePieCenter` varying), picks slice color from ENUM_PALETTE. `useMapData` adds the `enum_dist` query param when `viewFeatureIsEnum` is true.
|
||||
- **Dev invite code**: The code `devdevdevdev` is recognized as a valid admin invite in dev mode only (`state.index_html.is_none()`, i.e., `--dist` not passed). Both `get_invite` and `post_redeem_invite` short-circuit for this code, returning a fake valid admin invite / no-op "licensed" response without hitting PocketBase. Preview at `http://localhost:3001/invite/devdevdevdev`.
|
||||
|
||||
## Rust Performance Patterns (server-rs)
|
||||
|
|
|
|||
|
|
@ -24,8 +24,7 @@ COPY --from=frontend /app/frontend/dist ./frontend/dist/
|
|||
# Data is provided via volume mounts at runtime — not baked into the image.
|
||||
# Mount points:
|
||||
# /app/data - properties.parquet, postcode.parquet, filtered_uk_pois.parquet, places.parquet, uk.pmtiles, postcode_boundaries/, travel-times/
|
||||
# /app/data-scraped - online_listings_buy.parquet, online_listings_rent.parquet
|
||||
VOLUME ["/app/data", "/app/data-scraped"]
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
RUN chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
|
@ -33,4 +32,4 @@ EXPOSE 8001
|
|||
HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \
|
||||
CMD curl -f http://localhost:8001/health || exit 1
|
||||
ENTRYPOINT ["./property-map-server"]
|
||||
CMD ["--properties", "/app/data/properties.parquet", "--postcode-features", "/app/data/postcode.parquet", "--listings-buy", "/app/data-scraped/online_listings_buy.parquet", "--listings-rent", "/app/data-scraped/online_listings_rent.parquet", "--pois", "/app/data/filtered_uk_pois.parquet", "--places", "/app/data/places.parquet", "--tiles", "/app/data/uk.pmtiles", "--postcodes", "/app/data/postcode_boundaries", "--travel-times", "/app/data/travel-times", "--dist", "/app/frontend/dist"]
|
||||
CMD ["--properties", "/app/data/properties.parquet", "--postcode-features", "/app/data/postcode.parquet", "--pois", "/app/data/filtered_uk_pois.parquet", "--places", "/app/data/places.parquet", "--tiles", "/app/data/uk.pmtiles", "--postcodes", "/app/data/postcode_boundaries", "--travel-times", "/app/data/travel-times", "--dist", "/app/frontend/dist"]
|
||||
|
|
|
|||
|
|
@ -47,10 +47,9 @@ GREENSPACE := $(DATA_DIR)/greenspace_water.parquet
|
|||
OS_GREENSPACE := $(DATA_DIR)/os_greenspace.parquet
|
||||
PBF := $(DATA_DIR)/england-latest.osm.pbf
|
||||
PLACES := $(DATA_DIR)/places.parquet
|
||||
LISTINGS_BUY := $(DATA_DIR)/online_listings_buy.parquet
|
||||
LISTINGS_RENT := $(DATA_DIR)/online_listings_rent.parquet
|
||||
LSOA_POP := $(DATA_DIR)/lsoa_population.parquet
|
||||
MEDIAN_AGE := $(DATA_DIR)/median_age.parquet
|
||||
ELECTION := $(DATA_DIR)/election_results.parquet
|
||||
ENGLAND_BOUNDARY := $(DATA_DIR)/england_boundary.geojson
|
||||
RM_OUTCODES := frontend/src/lib/rightmove-outcodes.json
|
||||
|
||||
|
|
@ -94,6 +93,7 @@ download-pbf: $(PBF)
|
|||
download-places: $(PLACES)
|
||||
download-lsoa-population: $(LSOA_POP)
|
||||
download-median-age: $(MEDIAN_AGE)
|
||||
download-election-results: $(ELECTION)
|
||||
download-england-boundary: $(ENGLAND_BOUNDARY)
|
||||
download-rightmove-outcodes: $(RM_OUTCODES)
|
||||
transform-pois: $(POIS_FILTERED)
|
||||
|
|
@ -193,6 +193,9 @@ $(LSOA_POP):
|
|||
$(MEDIAN_AGE):
|
||||
uv run python -m pipeline.download.median_age --output $@
|
||||
|
||||
$(ELECTION):
|
||||
uv run python -m pipeline.download.election_results --output $@
|
||||
|
||||
$(ENGLAND_BOUNDARY):
|
||||
uv run python -m pipeline.download.england_boundary --output $@
|
||||
|
||||
|
|
@ -242,7 +245,7 @@ $(PC_BOUNDARIES):
|
|||
# ── Final merge → postcode.parquet + properties.parquet ──────────────────────
|
||||
|
||||
$(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \
|
||||
$(ETHNICITY) $(CRIME) $(NOISE) $(SCHOOL_PROX) $(BROADBAND) $(RENTAL) $(LSOA_POP) $(MEDIAN_AGE)
|
||||
$(ETHNICITY) $(CRIME) $(NOISE) $(SCHOOL_PROX) $(BROADBAND) $(RENTAL) $(LSOA_POP) $(MEDIAN_AGE) $(ELECTION)
|
||||
uv run python -m pipeline.transform.merge \
|
||||
--epc-pp $(EPC_PP) \
|
||||
--arcgis $(ARCGIS) \
|
||||
|
|
@ -256,6 +259,7 @@ $(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \
|
|||
--rental-prices $(RENTAL) \
|
||||
--lsoa-population $(LSOA_POP) \
|
||||
--median-age $(MEDIAN_AGE) \
|
||||
--election-results $(ELECTION) \
|
||||
--output-postcodes $(POSTCODES_PQ) \
|
||||
--output-properties $(PROPERTIES_PQ)
|
||||
@touch $@
|
||||
|
|
|
|||
|
|
@ -93,4 +93,3 @@ Test on android
|
|||
check rendered index html,
|
||||
|
||||
|
||||
only support new finder.py parquet type
|
||||
|
|
@ -10,7 +10,7 @@ services:
|
|||
command: >
|
||||
bash -c "
|
||||
cargo install cargo-watch &&
|
||||
cargo watch -i logs/ -x 'run -- --properties /app/data/properties.parquet --postcode-features /app/data/postcode.parquet --listings-buy /app/data-scraped/online_listings_buy.parquet --listings-rent /app/data-scraped/online_listings_rent.parquet --pois /app/data/filtered_uk_pois.parquet --places /app/data/places.parquet --tiles /app/data/uk.pmtiles --postcodes /app/data/postcode_boundaries --travel-times /app/data/travel-times'
|
||||
cargo watch -i logs/ -x 'run -- --properties /app/data/properties.parquet --postcode-features /app/data/postcode.parquet --pois /app/data/filtered_uk_pois.parquet --places /app/data/places.parquet --tiles /app/data/uk.pmtiles --postcodes /app/data/postcode_boundaries --travel-times /app/data/travel-times'
|
||||
"
|
||||
ports:
|
||||
- "8001:8001"
|
||||
|
|
@ -30,7 +30,6 @@ services:
|
|||
- cargo-target:/app/server-rs/target
|
||||
- ./property-data:/app/data:ro
|
||||
- ./property-data/travel-times:/app/data/travel-times:ro
|
||||
- /volumes/narrowit/property-data/scraped:/app/data-scraped:ro
|
||||
environment:
|
||||
POCKETBASE_URL: http://pocketbase:8090
|
||||
POCKETBASE_ADMIN_EMAIL: *pb-email
|
||||
|
|
@ -108,75 +107,6 @@ services:
|
|||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
# gluetun:
|
||||
# image: qmcgaw/gluetun:v3.40.4
|
||||
# volumes:
|
||||
# - gluetun-cache-v2:/gluetun
|
||||
# - gluetun-auth:/gluetun/auth:ro
|
||||
# environment:
|
||||
# # See https://github.com/qdm12/gluetun-wiki/tree/main/setup#setup
|
||||
# VPN_SERVICE_PROVIDER: mullvad
|
||||
# VPN_TYPE: wireguard
|
||||
# WIREGUARD_PRIVATE_KEY: "8FFKmtTvDsZlShnKl/opDDwCwb9v2ox4+Kkl3wX+9Gw="
|
||||
# WIREGUARD_ADDRESSES: "10.66.109.86/32"
|
||||
# OWNED_ONLY: "yes"
|
||||
# UPDATER_PERIOD: 24h
|
||||
# SERVER_COUNTRIES: Serbia,Slovakia,Croatia,Austria,Denmark,Finland
|
||||
# TZ: $TIME_ZONE
|
||||
# restart: unless-stopped
|
||||
# ports:
|
||||
# - "1234:1234"
|
||||
# healthcheck:
|
||||
# test: "wget -q https://www.google.com || exit 1"
|
||||
# interval: 1m
|
||||
# timeout: 15s
|
||||
# retries: 2
|
||||
# cap_add:
|
||||
# - NET_ADMIN
|
||||
# devices:
|
||||
# - /dev/net/tun:/dev/net/tun
|
||||
|
||||
# flaresolverr:
|
||||
# image: ghcr.io/flaresolverr/flaresolverr:latest
|
||||
# environment:
|
||||
# LOG_LEVEL: info
|
||||
# TZ: Europe/London
|
||||
# ports:
|
||||
# - "8191:8191"
|
||||
# networks:
|
||||
# - dev-network
|
||||
# restart: unless-stopped
|
||||
# healthcheck:
|
||||
# test: ["CMD", "curl", "-f", "http://localhost:8191/health"]
|
||||
# interval: 30s
|
||||
# timeout: 5s
|
||||
# retries: 3
|
||||
# start_period: 30s
|
||||
|
||||
# finder:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile.finder
|
||||
# init: true
|
||||
# network_mode: service:gluetun
|
||||
# volumes:
|
||||
# - ./finder:/app
|
||||
# environment:
|
||||
# FLARESOLVERR_URL: http://flaresolverr:8191
|
||||
# RELOAD_URL: http://server:8001/api/reload
|
||||
# depends_on:
|
||||
# gluetun:
|
||||
# condition: service_healthy
|
||||
# flaresolverr:
|
||||
# condition: service_healthy
|
||||
# restart: unless-stopped
|
||||
# healthcheck:
|
||||
# test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:1234/health')"]
|
||||
# interval: 30s
|
||||
# timeout: 5s
|
||||
# retries: 3
|
||||
# start_period: 60s
|
||||
|
||||
|
||||
volumes:
|
||||
pb-data:
|
||||
|
|
@ -184,8 +114,6 @@ volumes:
|
|||
cargo-target:
|
||||
frontend-node-modules:
|
||||
screenshot-cache:
|
||||
gluetun-cache-v2:
|
||||
gluetun-auth:
|
||||
|
||||
networks:
|
||||
dev-network:
|
||||
|
|
|
|||
|
|
@ -288,7 +288,7 @@ export default function App() {
|
|||
<MapPage
|
||||
features={features}
|
||||
poiCategoryGroups={poiCategoryGroups}
|
||||
initialFilters={urlState.filters || { 'Listing status': ['Historical sale'] }}
|
||||
initialFilters={urlState.filters || {}}
|
||||
initialViewState={initialViewState}
|
||||
initialPOICategories={urlState.poiCategories || new Set()}
|
||||
initialTab={urlState.tab || 'area'}
|
||||
|
|
@ -393,7 +393,7 @@ export default function App() {
|
|||
<MapPage
|
||||
features={features}
|
||||
poiCategoryGroups={poiCategoryGroups}
|
||||
initialFilters={mapUrlState.filters || { 'Listing status': ['Historical sale'] }}
|
||||
initialFilters={mapUrlState.filters || {}}
|
||||
initialViewState={initialViewState}
|
||||
initialPOICategories={mapUrlState.poiCategories || new Set()}
|
||||
initialTab={mapUrlState.tab || 'area'}
|
||||
|
|
|
|||
|
|
@ -127,8 +127,6 @@ function NotesInput({ value, onSave }: { value: string; onSave: (notes: string)
|
|||
}
|
||||
|
||||
function formatPropertyPrice(data: SavedPropertyData): string | null {
|
||||
if (data.askingPrice) return `£${formatNumber(data.askingPrice)}`;
|
||||
if (data.askingRent) return `£${formatNumber(data.askingRent)}/mo`;
|
||||
if (data.estimatedPrice) return `~£${formatNumber(data.estimatedPrice)}`;
|
||||
if (data.price) return `£${formatNumber(data.price)}`;
|
||||
return null;
|
||||
|
|
@ -141,7 +139,6 @@ function formatPropertyDetails(
|
|||
const parts: string[] = [];
|
||||
if (data.propertySubType) parts.push(data.propertySubType);
|
||||
else if (data.propertyType) parts.push(data.propertyType);
|
||||
if (data.bedrooms) parts.push(`${data.bedrooms} ${t('savedPage.bed')}`);
|
||||
if (data.floorArea) parts.push(`${formatNumber(data.floorArea)}m²`);
|
||||
if (data.energyRating) parts.push(`${t('savedPage.epc')} ${data.energyRating}`);
|
||||
return parts.join(' · ');
|
||||
|
|
@ -449,16 +446,6 @@ function SavedPropertiesTab({
|
|||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{prop.data.listingUrl && (
|
||||
<a
|
||||
href={prop.data.listingUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 block text-center px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
>
|
||||
{t('savedPage.viewListing')} →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -201,9 +201,6 @@ export default function HomePage({
|
|||
<thead>
|
||||
<tr className="border-b border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800">
|
||||
<th className="px-2 md:px-5 py-3 md:py-4 text-xs md:text-sm font-bold text-navy-950 dark:text-warm-100" />
|
||||
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-bold text-navy-950 dark:text-warm-100 text-center">
|
||||
{t('home.listingPortals')}
|
||||
</th>
|
||||
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-bold text-navy-950 dark:text-warm-100 text-center">
|
||||
{t('home.checkMyPostcode')}
|
||||
</th>
|
||||
|
|
@ -220,28 +217,24 @@ export default function HomePage({
|
|||
{
|
||||
feature: t('home.compSearchWithout'),
|
||||
subtitle: t('home.compSearchWithoutSub'),
|
||||
listings: false,
|
||||
postcode: false,
|
||||
guides: false,
|
||||
},
|
||||
{
|
||||
feature: t('home.compAreaData'),
|
||||
subtitle: t('home.compAreaDataSub'),
|
||||
listings: false,
|
||||
postcode: true,
|
||||
guides: true,
|
||||
},
|
||||
{
|
||||
feature: t('home.compPropertyData'),
|
||||
subtitle: t('home.compPropertyDataSub'),
|
||||
listings: true,
|
||||
postcode: false,
|
||||
guides: false,
|
||||
},
|
||||
{
|
||||
feature: t('home.compFilters'),
|
||||
subtitle: t('home.compFiltersSub'),
|
||||
listings: false,
|
||||
postcode: false,
|
||||
guides: false,
|
||||
},
|
||||
|
|
@ -262,7 +255,7 @@ export default function HomePage({
|
|||
</div>
|
||||
)}
|
||||
</td>
|
||||
{[row.listings, row.postcode, row.guides].map((has, j) => (
|
||||
{[row.postcode, row.guides].map((has, j) => (
|
||||
<td
|
||||
key={j}
|
||||
className={`px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base md:text-lg ${has ? 'text-green-500' : 'text-red-500'}`}
|
||||
|
|
|
|||
|
|
@ -82,6 +82,11 @@ const DATA_SOURCE_DEFS: DataSourceDef[] = [
|
|||
url: 'https://www.ons.gov.uk/peoplepopulationandcommunity/housing/datasets/privaterentalmarketsummarystatisticsinengland',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'election-results',
|
||||
url: 'https://electionresults.parliament.uk/general-elections/6',
|
||||
license: 'Open Parliament Licence v3.0',
|
||||
},
|
||||
];
|
||||
|
||||
// Maps data source id → [nameKey, originKey, useKey] in en.ts learnPage section
|
||||
|
|
@ -120,6 +125,11 @@ const DS_KEYS: Record<string, [string, string, string]> = {
|
|||
'learnPage.dsCouncilTaxUse',
|
||||
],
|
||||
'ons-rental': ['learnPage.dsRentalName', 'learnPage.dsRentalOrigin', 'learnPage.dsRentalUse'],
|
||||
'election-results': [
|
||||
'learnPage.dsElectionName',
|
||||
'learnPage.dsElectionOrigin',
|
||||
'learnPage.dsElectionUse',
|
||||
],
|
||||
};
|
||||
|
||||
function FAQItemCard({ question, answer }: { question: string; answer: string }) {
|
||||
|
|
|
|||
|
|
@ -189,8 +189,7 @@ export default function AreaPane({
|
|||
/>
|
||||
{expanded && (
|
||||
<div className="px-3 py-2 space-y-3">
|
||||
{stackedCharts
|
||||
? stackedCharts.map((chart) => {
|
||||
{stackedCharts?.map((chart) => {
|
||||
const segments = chart.components
|
||||
.map((name) => ({
|
||||
name,
|
||||
|
|
@ -205,9 +204,22 @@ export default function AreaPane({
|
|||
? aggregateStats.mean
|
||||
: segments.reduce((sum, s) => sum + s.value, 0);
|
||||
|
||||
const featureMeta = chart.feature
|
||||
? globalFeatureByName.get(chart.feature)
|
||||
// Use rateFeature (e.g. per-1k) for display if available
|
||||
const rateStats = chart.rateFeature
|
||||
? numericByName.get(chart.rateFeature)
|
||||
: undefined;
|
||||
const displayValue = rateStats ? rateStats.mean : total;
|
||||
|
||||
// Use rateFeature for info popup and national average when available
|
||||
const infoFeatureName = chart.rateFeature ?? chart.feature;
|
||||
const featureMeta = infoFeatureName
|
||||
? globalFeatureByName.get(infoFeatureName)
|
||||
: undefined;
|
||||
|
||||
const globalMean =
|
||||
featureMeta?.histogram
|
||||
? calculateHistogramMean(featureMeta.histogram)
|
||||
: undefined;
|
||||
|
||||
if (total === 0) return null;
|
||||
|
||||
|
|
@ -228,17 +240,30 @@ export default function AreaPane({
|
|||
{ts(chart.label)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(total)}
|
||||
{chart.unit ? ` ${chart.unit}` : ''}
|
||||
</span>
|
||||
<div className="text-right shrink-0">
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(displayValue)}
|
||||
{chart.unit ? ` ${chart.unit}` : ''}
|
||||
</span>
|
||||
{globalMean != null && (
|
||||
<div className="text-[10px] text-warm-400 dark:text-warm-500 whitespace-nowrap">
|
||||
{t('areaPane.nationalAvg')}: {formatValue(globalMean)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StackedBarChart segments={segments} total={total} />
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: group.features
|
||||
.filter((f) => !stackedEnumFeatureNames.has(f.name))
|
||||
})}
|
||||
{(() => {
|
||||
const stackedFeatureNames = new Set<string>(
|
||||
stackedCharts?.flatMap((c) =>
|
||||
[c.feature, c.rateFeature, ...c.components].filter((s): s is string => Boolean(s))
|
||||
) ?? []
|
||||
);
|
||||
return group.features
|
||||
.filter((f) => !stackedFeatureNames.has(f.name) && !stackedEnumFeatureNames.has(f.name))
|
||||
.map((feature) => {
|
||||
const numericStats = numericByName.get(feature.name);
|
||||
const enumStats = enumByName.get(feature.name);
|
||||
|
|
@ -289,19 +314,25 @@ export default function AreaPane({
|
|||
}
|
||||
|
||||
if (enumStats) {
|
||||
const globalFeature = globalFeatureByName.get(feature.name);
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
|
||||
<EnumBarChart counts={enumStats.counts} />
|
||||
<EnumBarChart
|
||||
counts={enumStats.counts}
|
||||
globalCounts={globalFeature?.counts}
|
||||
featureName={feature.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
});
|
||||
})()}
|
||||
{stackedEnumCharts?.map((chart) => {
|
||||
const featureMeta = chart.feature
|
||||
? globalFeatureByName.get(chart.feature)
|
||||
|
|
|
|||
|
|
@ -1,23 +1,77 @@
|
|||
export default function EnumBarChart({ counts }: { counts: Record<string, number> }) {
|
||||
import { getEnumValueColor } from '../../lib/consts';
|
||||
|
||||
export default function EnumBarChart({
|
||||
counts,
|
||||
globalCounts,
|
||||
featureName,
|
||||
}: {
|
||||
counts: Record<string, number>;
|
||||
globalCounts?: Record<string, number>;
|
||||
featureName?: string;
|
||||
}) {
|
||||
const entries = Object.entries(counts).sort(([, countA], [, countB]) => countB - countA);
|
||||
const localTotal = entries.reduce((sum, [, c]) => sum + c, 0);
|
||||
|
||||
// When global counts are available, normalize both to percentages for comparison
|
||||
const globalTotal = globalCounts
|
||||
? Object.values(globalCounts).reduce((sum, c) => sum + c, 0)
|
||||
: 0;
|
||||
|
||||
const hasGlobal = globalCounts && globalTotal > 0;
|
||||
|
||||
// Compute max percentage across both datasets for consistent bar scaling
|
||||
const maxPct = entries.reduce((max, [label, count]) => {
|
||||
const localPct = localTotal > 0 ? count / localTotal : 0;
|
||||
const globalPct = hasGlobal ? (globalCounts[label] ?? 0) / globalTotal : 0;
|
||||
return Math.max(max, localPct, globalPct);
|
||||
}, 0);
|
||||
|
||||
// Fallback to raw count scaling when no global data
|
||||
const maxCount = Math.max(...entries.map(([, count]) => count), 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-1 mt-1">
|
||||
{entries.map(([label, count]) => (
|
||||
<div key={label} className="flex items-center gap-2 text-xs">
|
||||
<span className="w-16 truncate text-warm-500 dark:text-warm-400 text-right shrink-0">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex-1 h-3 bg-warm-100 dark:bg-navy-700 rounded overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-teal-500 dark:bg-teal-400 rounded"
|
||||
style={{ width: `${(count / maxCount) * 100}%` }}
|
||||
/>
|
||||
{entries.map(([label, count]) => {
|
||||
const localPct = localTotal > 0 ? count / localTotal : 0;
|
||||
const globalPct = hasGlobal ? (globalCounts[label] ?? 0) / globalTotal : 0;
|
||||
|
||||
const localWidth = hasGlobal
|
||||
? maxPct > 0
|
||||
? (localPct / maxPct) * 100
|
||||
: 0
|
||||
: (count / maxCount) * 100;
|
||||
const globalWidth = hasGlobal && maxPct > 0 ? (globalPct / maxPct) * 100 : 0;
|
||||
|
||||
const overrideColor = featureName ? getEnumValueColor(featureName, label) : null;
|
||||
const barStyle = overrideColor
|
||||
? `rgb(${overrideColor[0]},${overrideColor[1]},${overrideColor[2]})`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div key={label} className="flex items-center gap-2 text-xs">
|
||||
<span className="w-16 truncate text-warm-500 dark:text-warm-400 text-right shrink-0">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex-1 h-3 bg-warm-100 dark:bg-navy-700 rounded overflow-hidden relative">
|
||||
{hasGlobal && (
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-warm-300/60 dark:bg-warm-600/60 rounded"
|
||||
style={{ width: `${globalWidth}%` }}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={
|
||||
barStyle ? 'h-full rounded relative' : 'h-full bg-teal-500 dark:bg-teal-400 rounded relative'
|
||||
}
|
||||
style={{ width: `${localWidth}%`, ...(barStyle ? { backgroundColor: barStyle } : {}) }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 text-warm-500 dark:text-warm-400 text-right shrink-0">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
<span className="w-8 text-warm-500 dark:text-warm-400 text-right shrink-0">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ export default function ExternalSearchLinks({
|
|||
() => buildPropertySearchUrls({ location, filters, rightmoveLocationId }),
|
||||
[location, filters, rightmoveLocationId]
|
||||
);
|
||||
const radiusMiles = location.isPostcode ? 0.25 : (H3_RADIUS_MILES[location.resolution] ?? 1);
|
||||
const label = `${radiusMiles}mi radius`;
|
||||
const radiusMiles = location.isPostcode ? 0 : (H3_RADIUS_MILES[location.resolution] ?? 1);
|
||||
const label = radiusMiles === 0 ? t('externalSearch.exact') : `${radiusMiles}mi radius`;
|
||||
|
||||
if (!urls) return null;
|
||||
|
||||
|
|
@ -61,11 +61,6 @@ export default function ExternalSearchLinks({
|
|||
<a href={urls.zoopla} target="_blank" rel="noopener noreferrer" className={linkClass}>
|
||||
Zoopla
|
||||
</a>
|
||||
{urls.openrent && (
|
||||
<a href={urls.openrent} target="_blank" rel="noopener noreferrer" className={linkClass}>
|
||||
OpenRent
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -30,8 +30,6 @@ import {
|
|||
travelFieldKey,
|
||||
} from '../../hooks/useTravelTime';
|
||||
|
||||
type ListingType = 'historical' | 'buy' | 'rent';
|
||||
|
||||
function EditableLabel({
|
||||
value,
|
||||
formatted,
|
||||
|
|
@ -210,7 +208,6 @@ interface FiltersProps {
|
|||
isLoggedIn: boolean;
|
||||
onLoginRequired: () => void;
|
||||
isLicensed: boolean;
|
||||
isAdmin: boolean;
|
||||
onUpgradeClick?: () => void;
|
||||
onResetTutorial?: () => void;
|
||||
filterImpacts?: Record<string, number>;
|
||||
|
|
@ -252,7 +249,6 @@ export default memo(function Filters({
|
|||
isLoggedIn,
|
||||
onLoginRequired,
|
||||
isLicensed,
|
||||
isAdmin,
|
||||
onUpgradeClick,
|
||||
onResetTutorial,
|
||||
filterImpacts,
|
||||
|
|
@ -261,119 +257,14 @@ export default memo(function Filters({
|
|||
savingSearch,
|
||||
}: FiltersProps) {
|
||||
const { t } = useTranslation();
|
||||
const modeRestrictions = useMemo(() => {
|
||||
const map: Record<string, Set<ListingType>> = {};
|
||||
for (const f of features) {
|
||||
if (f.modes && f.modes.length > 0) {
|
||||
map[f.name] = new Set(f.modes as ListingType[]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [features]);
|
||||
|
||||
const linkedFeatures = useMemo(() => {
|
||||
const pairs: [string, string][] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const f of features) {
|
||||
if (f.linked && !seen.has(f.name)) {
|
||||
pairs.push([f.name, f.linked]);
|
||||
seen.add(f.linked);
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}, [features]);
|
||||
|
||||
const isAllowed = useCallback(
|
||||
(name: string, mode: ListingType) => {
|
||||
const allowed = modeRestrictions[name];
|
||||
return !allowed || allowed.has(mode);
|
||||
},
|
||||
[modeRestrictions]
|
||||
);
|
||||
|
||||
const activeListingType = useMemo((): ListingType => {
|
||||
const val = filters['Listing status'] as string[] | undefined;
|
||||
if (!val || val.length === 0) return 'historical';
|
||||
if (val.includes('For sale')) return 'buy';
|
||||
if (val.includes('For rent')) return 'rent';
|
||||
return 'historical';
|
||||
}, [filters]);
|
||||
|
||||
const availableFeatures = useMemo(
|
||||
() =>
|
||||
features.filter((f) => !enabledFeatures.has(f.name) && isAllowed(f.name, activeListingType)),
|
||||
[features, enabledFeatures, activeListingType, isAllowed]
|
||||
);
|
||||
const enabledFeatureList = useMemo(
|
||||
() => features.filter((f) => enabledFeatures.has(f.name) && f.name !== 'Listing status'),
|
||||
() => features.filter((f) => !enabledFeatures.has(f.name)),
|
||||
[features, enabledFeatures]
|
||||
);
|
||||
|
||||
const parkedFiltersRef = useRef<FeatureFilters>({});
|
||||
|
||||
const handleListingSelect = useCallback(
|
||||
(type: ListingType) => {
|
||||
// Track what will be active after swaps (to avoid conflicts with restoration)
|
||||
const activeAfterSwaps = new Set<string>();
|
||||
|
||||
for (const name of Object.keys(filters)) {
|
||||
if (name === 'Listing status') continue;
|
||||
if (isAllowed(name, type)) {
|
||||
activeAfterSwaps.add(name);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this feature has a linked counterpart in the new mode
|
||||
let swapped = false;
|
||||
for (const [a, b] of linkedFeatures) {
|
||||
const counterpart = name === a ? b : name === b ? a : null;
|
||||
if (counterpart && isAllowed(counterpart, type)) {
|
||||
onFilterChange(counterpart, filters[name] as [number, number]);
|
||||
onRemoveFilter(name);
|
||||
activeAfterSwaps.add(counterpart);
|
||||
swapped = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!swapped) {
|
||||
parkedFiltersRef.current[name] = filters[name];
|
||||
onRemoveFilter(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore parked filters that are now allowed in the new mode
|
||||
const restored: string[] = [];
|
||||
for (const [name, value] of Object.entries(parkedFiltersRef.current)) {
|
||||
if (isAllowed(name, type) && !activeAfterSwaps.has(name)) {
|
||||
onFilterChange(name, value);
|
||||
activeAfterSwaps.add(name);
|
||||
restored.push(name);
|
||||
} else if (!isAllowed(name, type)) {
|
||||
// Try restoring as linked counterpart
|
||||
for (const [a, b] of linkedFeatures) {
|
||||
const counterpart = name === a ? b : name === b ? a : null;
|
||||
if (counterpart && isAllowed(counterpart, type) && !activeAfterSwaps.has(counterpart)) {
|
||||
onFilterChange(counterpart, value);
|
||||
activeAfterSwaps.add(counterpart);
|
||||
restored.push(name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const name of restored) {
|
||||
delete parkedFiltersRef.current[name];
|
||||
}
|
||||
|
||||
const valueMap: Record<string, string> = {
|
||||
historical: 'Historical sale',
|
||||
buy: 'For sale',
|
||||
rent: 'For rent',
|
||||
};
|
||||
onFilterChange('Listing status', [valueMap[type]]);
|
||||
},
|
||||
[filters, onFilterChange, onRemoveFilter, isAllowed, linkedFeatures]
|
||||
const enabledFeatureList = useMemo(
|
||||
() => features.filter((f) => enabledFeatures.has(f.name)),
|
||||
[features, enabledFeatures]
|
||||
);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -548,31 +439,6 @@ export default memo(function Filters({
|
|||
onLoginRequired={onLoginRequired}
|
||||
/>
|
||||
<div className="px-3 pb-2 space-y-2">
|
||||
{isAdmin && (
|
||||
<div className="flex rounded-lg bg-warm-100 dark:bg-warm-800 p-0.5">
|
||||
{(['historical', 'buy', 'rent'] as const).map((type) => {
|
||||
const labels = {
|
||||
historical: t('filters.historical'),
|
||||
buy: t('filters.buy'),
|
||||
rent: t('filters.rent'),
|
||||
};
|
||||
const isActive = activeListingType === type;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => handleListingSelect(type)}
|
||||
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md cursor-pointer ${
|
||||
isActive
|
||||
? 'bg-white dark:bg-warm-700 text-teal-600 dark:text-teal-400 ring-2 ring-teal-400 shadow-sm'
|
||||
: 'text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
|
||||
}`}
|
||||
>
|
||||
{labels[type]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowPhilosophy(true)}
|
||||
className="w-full px-3 py-1.5 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:bg-warm-50 dark:hover:bg-warm-700 text-teal-600 dark:text-teal-400 font-medium text-sm flex items-center justify-center gap-2"
|
||||
|
|
|
|||
|
|
@ -39,8 +39,8 @@ export default memo(function HoverCard({
|
|||
|
||||
const results: { name: string; value: string }[] = [];
|
||||
|
||||
// Show stats for active filters (up to 4), excluding Listing status
|
||||
for (const name of activeFilterNames.filter((n) => n !== 'Listing status').slice(0, 4)) {
|
||||
// Show stats for active filters (up to 4)
|
||||
for (const name of activeFilterNames.slice(0, 4)) {
|
||||
const val = data[`avg_${name}`] ?? data[`min_${name}`];
|
||||
if (val == null || typeof val !== 'number') continue;
|
||||
const meta = featureMap.get(name);
|
||||
|
|
|
|||
|
|
@ -174,7 +174,22 @@ export default memo(function Map({
|
|||
}, [viewState, dimensions, onViewChange]);
|
||||
|
||||
const handleMove = useCallback((evt: { viewState: ViewState }) => {
|
||||
setInternalViewState(evt.viewState);
|
||||
setInternalViewState((prev) => {
|
||||
const next = evt.viewState;
|
||||
// Skip re-render when viewport values haven't changed (e.g. container resize
|
||||
// fires move events with identical lat/lng/zoom). Returning the same reference
|
||||
// tells React to bail out.
|
||||
if (
|
||||
prev.latitude === next.latitude &&
|
||||
prev.longitude === next.longitude &&
|
||||
prev.zoom === next.zoom &&
|
||||
prev.pitch === next.pitch &&
|
||||
prev.bearing === next.bearing
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => {
|
||||
|
|
@ -324,6 +339,7 @@ export default memo(function Map({
|
|||
enumValues={
|
||||
colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined
|
||||
}
|
||||
featureName={colorFeatureMeta.name}
|
||||
theme={theme}
|
||||
raw={colorFeatureMeta.raw}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -5,17 +5,23 @@ import {
|
|||
FEATURE_GRADIENT,
|
||||
DENSITY_GRADIENT,
|
||||
DENSITY_GRADIENT_DARK,
|
||||
ENUM_PALETTE,
|
||||
getEnumPaletteForFeature,
|
||||
} from '../../lib/consts';
|
||||
import { gradientToCss } from '../../lib/utils';
|
||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import { TickerValue } from '../ui/TickerValue';
|
||||
|
||||
function EnumSwatches({ values }: { values: string[] }) {
|
||||
function EnumSwatches({
|
||||
values,
|
||||
palette,
|
||||
}: {
|
||||
values: string[];
|
||||
palette: [number, number, number][];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{values.map((label, i) => {
|
||||
const color = ENUM_PALETTE[i % ENUM_PALETTE.length];
|
||||
const color = palette[i % palette.length];
|
||||
return (
|
||||
<div key={label} className="flex items-center gap-1.5">
|
||||
<div
|
||||
|
|
@ -30,11 +36,17 @@ function EnumSwatches({ values }: { values: string[] }) {
|
|||
);
|
||||
}
|
||||
|
||||
function InlineEnumSwatches({ values }: { values: string[] }) {
|
||||
function InlineEnumSwatches({
|
||||
values,
|
||||
palette,
|
||||
}: {
|
||||
values: string[];
|
||||
palette: [number, number, number][];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-1 min-w-[40%] flex-wrap">
|
||||
{values.map((label, i) => {
|
||||
const color = ENUM_PALETTE[i % ENUM_PALETTE.length];
|
||||
const color = palette[i % palette.length];
|
||||
return (
|
||||
<div key={label} className="flex items-center gap-1">
|
||||
<div
|
||||
|
|
@ -58,6 +70,7 @@ export default function MapLegend({
|
|||
onCancel,
|
||||
mode,
|
||||
enumValues,
|
||||
featureName,
|
||||
theme = 'light',
|
||||
inline = false,
|
||||
suffix,
|
||||
|
|
@ -70,6 +83,7 @@ export default function MapLegend({
|
|||
onCancel: () => void;
|
||||
mode: 'feature' | 'density';
|
||||
enumValues?: string[];
|
||||
featureName?: string;
|
||||
theme?: 'light' | 'dark';
|
||||
inline?: boolean;
|
||||
suffix?: string;
|
||||
|
|
@ -78,6 +92,7 @@ export default function MapLegend({
|
|||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isEnum = enumValues && enumValues.length > 0;
|
||||
const enumPalette = getEnumPaletteForFeature(featureName ?? null, enumValues);
|
||||
const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
|
||||
const gradientStyle =
|
||||
mode === 'density' ? gradientToCss(densityGradient) : gradientToCss(FEATURE_GRADIENT);
|
||||
|
|
@ -114,7 +129,7 @@ export default function MapLegend({
|
|||
</button>
|
||||
)}
|
||||
{isEnum ? (
|
||||
<InlineEnumSwatches values={enumValues} />
|
||||
<InlineEnumSwatches values={enumValues} palette={enumPalette} />
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-[40%] text-warm-500 dark:text-warm-400">
|
||||
{rangeMin}
|
||||
|
|
@ -144,7 +159,7 @@ export default function MapLegend({
|
|||
)}
|
||||
</div>
|
||||
{isEnum ? (
|
||||
<EnumSwatches values={enumValues} />
|
||||
<EnumSwatches values={enumValues} palette={enumPalette} />
|
||||
) : (
|
||||
<>
|
||||
<div className="h-3 rounded" style={{ background: gradientStyle }} />
|
||||
|
|
|
|||
|
|
@ -187,6 +187,16 @@ export default function MapPage({
|
|||
handleToggleBest,
|
||||
} = useTravelTime(initialTravelTime);
|
||||
|
||||
const mapFlyToRef = useRef<((lat: number, lng: number, zoom: number) => void) | null>(null);
|
||||
|
||||
const mapData = useMapData({
|
||||
filters,
|
||||
features,
|
||||
viewFeature,
|
||||
activeFeature,
|
||||
travelTimeEntries: entries,
|
||||
});
|
||||
|
||||
const handleAiFilterSubmit = useCallback(
|
||||
async (query: string) => {
|
||||
// Build context from current filters for conversational refinement
|
||||
|
|
@ -213,8 +223,33 @@ export default function MapPage({
|
|||
useBest: false,
|
||||
}));
|
||||
handleSetEntries(newEntries);
|
||||
|
||||
// Pan to the first travel time destination (mirroring handleTravelTimeSetDestination)
|
||||
const firstTT = result.travelTimeFilters[0];
|
||||
if (firstTT?.slug) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
apiUrl('travel-destinations', new URLSearchParams({ mode: firstTT.mode })),
|
||||
authHeaders({})
|
||||
);
|
||||
if (res.ok) {
|
||||
const data: { destinations: { slug: string; lat: number; lon: number }[] } =
|
||||
await res.json();
|
||||
const dest = data.destinations.find((d) => d.slug === firstTT.slug);
|
||||
if (dest) {
|
||||
mapFlyToRef.current?.(
|
||||
dest.lat,
|
||||
dest.lon,
|
||||
mapData.currentView?.zoom ?? INITIAL_VIEW_STATE.zoom
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — filters are already applied, just skip the pan
|
||||
}
|
||||
}
|
||||
},
|
||||
[fetchAiFilters, handleSetFilters, handleSetEntries, activeEntries, filters]
|
||||
[fetchAiFilters, handleSetFilters, handleSetEntries, activeEntries, filters, mapData.currentView?.zoom]
|
||||
);
|
||||
|
||||
const handleClearAll = useCallback(() => {
|
||||
|
|
@ -244,16 +279,6 @@ export default function MapPage({
|
|||
|
||||
const license = useLicense();
|
||||
|
||||
const mapFlyToRef = useRef<((lat: number, lng: number, zoom: number) => void) | null>(null);
|
||||
|
||||
const mapData = useMapData({
|
||||
filters,
|
||||
features,
|
||||
viewFeature,
|
||||
activeFeature,
|
||||
travelTimeEntries: entries,
|
||||
});
|
||||
|
||||
const filterCounts = useFilterCounts(filters, features, mapData.bounds, entries);
|
||||
|
||||
const handleTravelTimeSetDestination = useCallback(
|
||||
|
|
@ -461,12 +486,7 @@ export default function MapPage({
|
|||
if (mapData.licenseRequired) trackEvent('Upgrade Modal Shown');
|
||||
}, [mapData.licenseRequired]);
|
||||
|
||||
const densityLabel = useMemo(() => {
|
||||
const listingVal = filters['Listing status'] as string[] | undefined;
|
||||
if (listingVal?.includes('For sale')) return t('mapLegend.propertiesForSale');
|
||||
if (listingVal?.includes('For rent')) return t('mapLegend.propertiesForRent');
|
||||
return t('mapLegend.historicalMatches');
|
||||
}, [filters, t]);
|
||||
const densityLabel = t('mapLegend.historicalMatches');
|
||||
|
||||
const mobileLegendMeta = useMemo(
|
||||
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
|
||||
|
|
@ -652,7 +672,6 @@ export default function MapPage({
|
|||
isLoggedIn={!!user}
|
||||
onLoginRequired={onRegisterClick ?? (() => {})}
|
||||
isLicensed={user?.subscription === 'licensed'}
|
||||
isAdmin={user?.isAdmin === true}
|
||||
onUpgradeClick={() => onNavigateTo('pricing')}
|
||||
onResetTutorial={tutorial.resetTutorial}
|
||||
filterImpacts={filterCounts.impacts}
|
||||
|
|
@ -771,6 +790,7 @@ export default function MapPage({
|
|||
onCancel={handleCancelPin}
|
||||
mode="feature"
|
||||
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
|
||||
featureName={mobileLegendMeta.name}
|
||||
theme={theme}
|
||||
inline
|
||||
raw={mobileLegendMeta.raw}
|
||||
|
|
|
|||
|
|
@ -180,11 +180,6 @@ function PropertyCard({
|
|||
const rooms = getNum(property, 'Number of bedrooms & living rooms');
|
||||
const age = getNum(property, 'Construction year');
|
||||
const transactionDate = getNum(property, 'Date of last transaction');
|
||||
const askingPrice = getNum(property, 'Asking price');
|
||||
const askingRent = getNum(property, 'Asking rent (monthly)');
|
||||
const bedrooms = getNum(property, 'Bedrooms');
|
||||
const bathrooms = getNum(property, 'Bathrooms');
|
||||
const listingDate = getNum(property, 'Listing date');
|
||||
|
||||
return (
|
||||
<div className="p-4 border-b border-warm-100 dark:border-warm-800 hover:bg-warm-50 dark:hover:bg-warm-800">
|
||||
|
|
@ -193,7 +188,14 @@ function PropertyCard({
|
|||
<div className="font-semibold dark:text-warm-100">
|
||||
{property.address || t('propertyCard.unknownAddress')}
|
||||
</div>
|
||||
<div className="text-sm text-warm-600 dark:text-warm-400">{property.postcode}</div>
|
||||
<div className="text-sm text-warm-600 dark:text-warm-400 flex items-center gap-1.5">
|
||||
{property.postcode}
|
||||
{property.former_council_house === 'Yes' && (
|
||||
<span className="text-xs bg-teal-50 dark:bg-teal-900/30 text-teal-700 dark:text-teal-400 rounded-full px-1.5 py-0.5 font-medium leading-none">
|
||||
{t('propertyCard.exCouncilBadge')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onSave && (
|
||||
<button
|
||||
|
|
@ -216,51 +218,20 @@ function PropertyCard({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{askingPrice !== undefined && (
|
||||
{price !== undefined && (
|
||||
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
|
||||
{property.price_qualifier && (
|
||||
<span className="text-sm font-normal text-warm-500 dark:text-warm-400">
|
||||
{property.price_qualifier}{' '}
|
||||
£{formatNumber(price)}
|
||||
{transactionDate !== undefined && (
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
||||
{' '}
|
||||
({formatTransactionDate(transactionDate)})
|
||||
</span>
|
||||
)}
|
||||
£{formatNumber(askingPrice)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{askingRent !== undefined && (
|
||||
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
|
||||
£{formatNumber(askingRent)}
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
||||
{t('propertyCard.perMonth')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{price !== undefined && (
|
||||
<div
|
||||
className={`${askingPrice !== undefined || askingRent !== undefined ? '' : 'mt-2 '}text-lg font-bold text-teal-700 dark:text-teal-400`}
|
||||
>
|
||||
{askingPrice !== undefined || askingRent !== undefined ? (
|
||||
{pricePerSqm !== undefined && (
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
||||
{t('propertyCard.lastSold', { price: formatNumber(price) })}
|
||||
{transactionDate !== undefined && ` (${formatTransactionDate(transactionDate)})`}
|
||||
{' '}
|
||||
£{formatNumber(pricePerSqm)}/m²
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
£{formatNumber(price)}
|
||||
{transactionDate !== undefined && (
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
||||
{' '}
|
||||
({formatTransactionDate(transactionDate)})
|
||||
</span>
|
||||
)}
|
||||
{pricePerSqm !== undefined && (
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
||||
{' '}
|
||||
£{formatNumber(pricePerSqm)}/m²
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -299,18 +270,6 @@ function PropertyCard({
|
|||
{formatNumber(floorArea)}m²
|
||||
</div>
|
||||
)}
|
||||
{bedrooms !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.bedrooms')}</span>{' '}
|
||||
{formatNumber(bedrooms)}
|
||||
</div>
|
||||
)}
|
||||
{bathrooms !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.bathrooms')}</span>{' '}
|
||||
{formatNumber(bathrooms)}
|
||||
</div>
|
||||
)}
|
||||
{rooms !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.rooms')}</span>{' '}
|
||||
|
|
@ -323,14 +282,6 @@ function PropertyCard({
|
|||
{formatAge(age, property.is_construction_date_approximate)}
|
||||
</div>
|
||||
)}
|
||||
{property.former_council_house === 'Yes' && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">
|
||||
{t('propertyCard.formerCouncil')}
|
||||
</span>{' '}
|
||||
{ts(property.former_council_house)}
|
||||
</div>
|
||||
)}
|
||||
{property.current_energy_rating && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.epcRating')}</span>{' '}
|
||||
|
|
@ -345,32 +296,8 @@ function PropertyCard({
|
|||
{ts(property.potential_energy_rating)}
|
||||
</div>
|
||||
)}
|
||||
{listingDate !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.listed')}</span>{' '}
|
||||
{formatTransactionDate(listingDate)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{property.listing_features && property.listing_features.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">
|
||||
{t('propertyCard.keyFeatures')}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{property.listing_features.map((feature, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="text-xs bg-warm-100 dark:bg-warm-700 text-warm-700 dark:text-warm-300 rounded px-1.5 py-0.5"
|
||||
>
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{property.renovation_history && property.renovation_history.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">
|
||||
|
|
@ -390,18 +317,6 @@ function PropertyCard({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{property.listing_url && (
|
||||
<div className="mt-2">
|
||||
<a
|
||||
href={property.listing_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
>
|
||||
{t('propertyCard.viewExternalListing')} →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,15 +28,6 @@ export function FeatureLabel({
|
|||
const iconClass = `${mobileHide}w-3.5 h-3.5 text-teal-600 dark:text-teal-400 shrink-0`;
|
||||
const featureIcon = getFeatureIcon(feature.name, iconClass);
|
||||
const GroupIcon = !featureIcon && feature.group ? getGroupIcon(feature.group) : null;
|
||||
const modeLabels: Record<string, string> = {
|
||||
historical: t('filters.historical'),
|
||||
buy: t('filters.buy'),
|
||||
rent: t('filters.rent'),
|
||||
};
|
||||
const modeTag =
|
||||
feature.modes && feature.modes.length > 0
|
||||
? feature.modes.map((m) => modeLabels[m] || m).join(' \u00B7 ')
|
||||
: null;
|
||||
|
||||
const translatedName = ts(feature.name);
|
||||
const translatedDesc = description ? tsDesc(feature.name, description) : undefined;
|
||||
|
|
@ -48,11 +39,6 @@ export function FeatureLabel({
|
|||
>
|
||||
{translatedName}
|
||||
</span>
|
||||
{modeTag && (
|
||||
<span className="shrink-0 text-[10px] leading-none font-medium px-1.5 py-0.5 rounded-full bg-warm-100 dark:bg-warm-800 text-warm-500 dark:text-warm-400 border border-warm-200 dark:border-warm-700">
|
||||
{modeTag}
|
||||
</span>
|
||||
)}
|
||||
{feature.detail && onShowInfo && (
|
||||
<button
|
||||
onClick={() => onShowInfo(feature)}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ export function Slider({ className, ...props }: SliderProps) {
|
|||
className={`relative flex w-full touch-none select-none items-center ${className || ''}`}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-warm-200 dark:bg-navy-700">
|
||||
<SliderPrimitive.Track
|
||||
className="relative h-2 w-full grow overflow-hidden rounded-full bg-warm-200 dark:bg-navy-700"
|
||||
onPointerDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<SliderPrimitive.Range className="absolute h-full bg-teal-600" />
|
||||
</SliderPrimitive.Track>
|
||||
{props.value?.map((_, i) => (
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ function buildSummary(
|
|||
const parts: string[] = [];
|
||||
|
||||
for (const [name, value] of Object.entries(filters)) {
|
||||
if (name === 'Listing status') continue;
|
||||
if (Array.isArray(value) && value.length === 2 && typeof value[0] === 'number') {
|
||||
parts.push(ts(name));
|
||||
} else if (Array.isArray(value)) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useRef, useState, useMemo, useEffect } from 'react';
|
||||
import { H3HexagonLayer } from '@deck.gl/geo-layers';
|
||||
import { GeoJsonLayer, IconLayer, TextLayer, ScatterplotLayer, PolygonLayer } from '@deck.gl/layers';
|
||||
import { GeoJsonLayer, IconLayer, TextLayer, ScatterplotLayer } from '@deck.gl/layers';
|
||||
import { cellToBoundary } from 'h3-js';
|
||||
import Supercluster from 'supercluster';
|
||||
import type { PickingInfo } from '@deck.gl/core';
|
||||
|
|
@ -22,6 +22,7 @@ import {
|
|||
MINOR_POI_ZOOM_THRESHOLD,
|
||||
POI_CLUSTER_RADIUS,
|
||||
POI_CLUSTER_MAX_ZOOM,
|
||||
getEnumPaletteForFeature,
|
||||
} from '../lib/consts';
|
||||
import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils';
|
||||
import type { TravelTimeEntry } from './useTravelTime';
|
||||
|
|
@ -146,6 +147,12 @@ export function useDeckLayers({
|
|||
? colorFeatureMeta.values.length
|
||||
: 0;
|
||||
|
||||
// Per-feature color palette (uses overrides when defined)
|
||||
const enumPaletteRef = useRef(
|
||||
getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values)
|
||||
);
|
||||
enumPaletteRef.current = getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values);
|
||||
|
||||
const countRange = useMemo(() => {
|
||||
if (data.length === 0) return { min: 0, max: 1, total: 0 };
|
||||
let min = Infinity;
|
||||
|
|
@ -306,85 +313,42 @@ export function useDeckLayers({
|
|||
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${hoveredPostcode}|${theme}|${ttTrigger}`;
|
||||
|
||||
// --- Layers ---
|
||||
// For enum features, we bypass H3HexagonLayer and use PolygonLayer directly.
|
||||
// H3HexagonLayer has double CompositeLayer nesting (H3 → PolygonLayer → SolidPolygonLayer)
|
||||
// which prevents custom binary attributes from reaching the fill sublayer.
|
||||
// PolygonLayer has only one level of nesting, so _subLayerProps.fill works reliably.
|
||||
// PieHexExtension uses the canonical deck.gl v9 pattern: defaultProps with
|
||||
// type:'accessor' + stepMode:'dynamic'. LayerExtension.getSubLayerProps()
|
||||
// wraps accessors via getSubLayerAccessor() which unwraps __source.object,
|
||||
// letting accessor functions work through CompositeLayer sublayer chains.
|
||||
const hexLayer = useMemo(() => {
|
||||
const isEnum = enumCountRef.current > 0;
|
||||
const distKey = viewFeatureRef.current ? `dist_${viewFeatureRef.current}` : '';
|
||||
|
||||
if (isEnum) {
|
||||
const distKey = viewFeatureRef.current ? `dist_${viewFeatureRef.current}` : '';
|
||||
const n = data.length;
|
||||
|
||||
// Pre-compute hex boundaries and binary attribute buffers
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const polyData: any[] = new Array(n);
|
||||
const centers = new Float32Array(n * 2);
|
||||
const r0 = new Float32Array(n * 4);
|
||||
const r1 = new Float32Array(n * 4);
|
||||
const r2 = new Float32Array(n * 2);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const d = data[i];
|
||||
polyData[i] = { ...d, polygon: cellToBoundary(d.h3, true) };
|
||||
centers[i * 2] = d.lon as number;
|
||||
centers[i * 2 + 1] = d.lat as number;
|
||||
const r = distToRatios(d[distKey]);
|
||||
r0[i * 4] = r[0];
|
||||
r0[i * 4 + 1] = r[1];
|
||||
r0[i * 4 + 2] = r[2];
|
||||
r0[i * 4 + 3] = r[3];
|
||||
r1[i * 4] = r[4];
|
||||
r1[i * 4 + 1] = r[5];
|
||||
r1[i * 4 + 2] = r[6];
|
||||
r1[i * 4 + 3] = r[7];
|
||||
r2[i * 2] = r[8];
|
||||
r2[i * 2 + 1] = r[9];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return new (PolygonLayer as any)({
|
||||
id: 'h3-hexagons',
|
||||
data: polyData,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getPolygon: (d: any) => d.polygon,
|
||||
getFillColor: [200, 200, 200],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getLineColor: (d: any) => {
|
||||
if (d.h3 === hoveredHexagonIdRef.current)
|
||||
return [29, 228, 195, 200];
|
||||
return [0, 0, 0, 0];
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getLineWidth: (d: any) => {
|
||||
if (d.h3 === hoveredHexagonIdRef.current) return 2;
|
||||
return 0;
|
||||
},
|
||||
lineWidthUnits: 'pixels',
|
||||
updateTriggers: {
|
||||
getLineColor: [colorTrigger],
|
||||
getLineWidth: [colorTrigger],
|
||||
},
|
||||
extensions: [new PieHexExtension()],
|
||||
_subLayerProps: {
|
||||
fill: {
|
||||
instancePieCenter: { value: centers, size: 2 },
|
||||
instanceRatios0: { value: r0, size: 4 },
|
||||
instanceRatios1: { value: r1, size: 4 },
|
||||
instanceRatios2: { value: r2, size: 2 },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const pieProps: any = isEnum
|
||||
? {
|
||||
extensions: [new PieHexExtension(enumPaletteRef.current)],
|
||||
getCenter: (d: HexagonData) => [d.lon, d.lat],
|
||||
getRatios0: (d: HexagonData) => {
|
||||
const r = distToRatios(d[distKey]);
|
||||
return [r[0], r[1], r[2], r[3]];
|
||||
},
|
||||
},
|
||||
extruded: false,
|
||||
pickable: true,
|
||||
onClick: handleHexagonClick,
|
||||
onHover: handleHexagonHover,
|
||||
beforeId: 'landuse_park',
|
||||
});
|
||||
}
|
||||
getRatios1: (d: HexagonData) => {
|
||||
const r = distToRatios(d[distKey]);
|
||||
return [r[4], r[5], r[6], r[7]];
|
||||
},
|
||||
getRatios2: (d: HexagonData) => {
|
||||
const r = distToRatios(d[distKey]);
|
||||
return [r[8], r[9]];
|
||||
},
|
||||
updateTriggers: {
|
||||
getCenter: [colorTrigger, data],
|
||||
getRatios0: [colorTrigger, data],
|
||||
getRatios1: [colorTrigger, data],
|
||||
getRatios2: [colorTrigger, data],
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
// Non-enum: use H3HexagonLayer as normal
|
||||
return new H3HexagonLayer<HexagonData>({
|
||||
id: 'h3-hexagons',
|
||||
id: isEnum ? 'h3-hexagons-pie' : 'h3-hexagons',
|
||||
data,
|
||||
getHexagon: (d) => d.h3,
|
||||
getFillColor: (d) => {
|
||||
|
|
@ -434,7 +398,8 @@ export function useDeckLayers({
|
|||
densityGradientRef.current,
|
||||
dark,
|
||||
255,
|
||||
enumCountRef.current
|
||||
enumCountRef.current,
|
||||
enumPaletteRef.current
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -468,6 +433,7 @@ export function useDeckLayers({
|
|||
getFillColor: [colorTrigger],
|
||||
getLineColor: [colorTrigger],
|
||||
getLineWidth: [colorTrigger],
|
||||
...(pieProps.updateTriggers || {}),
|
||||
},
|
||||
extruded: false,
|
||||
pickable: true,
|
||||
|
|
@ -475,59 +441,12 @@ export function useDeckLayers({
|
|||
highPrecision: true,
|
||||
onClick: handleHexagonClick,
|
||||
onHover: handleHexagonHover,
|
||||
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
|
||||
beforeId: 'landuse_park',
|
||||
...pieProps,
|
||||
});
|
||||
}, [data, colorTrigger, handleHexagonClick, handleHexagonHover]);
|
||||
|
||||
const postcodeLayer = useMemo(() => {
|
||||
const isEnum = enumCountRef.current > 0;
|
||||
const distKey = viewFeatureRef.current ? `dist_${viewFeatureRef.current}` : '';
|
||||
|
||||
// Same binary buffer approach as hexagons, routed via _subLayerProps.
|
||||
// GeoJsonLayer → 'polygons-fill' (PolygonLayer) → 'fill' (SolidPolygonLayer)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let pieProps: any = {};
|
||||
if (isEnum) {
|
||||
const n = postcodeData.length;
|
||||
const centers = new Float32Array(n * 2);
|
||||
const r0 = new Float32Array(n * 4);
|
||||
const r1 = new Float32Array(n * 4);
|
||||
const r2 = new Float32Array(n * 2);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const centroid = postcodeData[i].properties.centroid as [number, number];
|
||||
centers[i * 2] = centroid[0];
|
||||
centers[i * 2 + 1] = centroid[1];
|
||||
const r = distToRatios(postcodeData[i].properties[distKey]);
|
||||
r0[i * 4] = r[0];
|
||||
r0[i * 4 + 1] = r[1];
|
||||
r0[i * 4 + 2] = r[2];
|
||||
r0[i * 4 + 3] = r[3];
|
||||
r1[i * 4] = r[4];
|
||||
r1[i * 4 + 1] = r[5];
|
||||
r1[i * 4 + 2] = r[6];
|
||||
r1[i * 4 + 3] = r[7];
|
||||
r2[i * 2] = r[8];
|
||||
r2[i * 2 + 1] = r[9];
|
||||
}
|
||||
const fillAttrs = {
|
||||
instancePieCenter: { value: centers, size: 2 },
|
||||
instanceRatios0: { value: r0, size: 4 },
|
||||
instanceRatios1: { value: r1, size: 4 },
|
||||
instanceRatios2: { value: r2, size: 2 },
|
||||
};
|
||||
pieProps = {
|
||||
extensions: [new PieHexExtension()],
|
||||
_subLayerProps: {
|
||||
'polygons-fill': {
|
||||
_subLayerProps: {
|
||||
fill: fillAttrs,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return new GeoJsonLayer<PostcodeProperties>({
|
||||
id: 'postcode-polygons',
|
||||
data: postcodeData as PostcodeFeature[],
|
||||
|
|
@ -581,7 +500,8 @@ export function useDeckLayers({
|
|||
densityGradientRef.current,
|
||||
dark,
|
||||
180,
|
||||
enumCountRef.current
|
||||
enumCountRef.current,
|
||||
enumPaletteRef.current
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -627,8 +547,8 @@ export function useDeckLayers({
|
|||
pickable: true,
|
||||
onClick: handlePostcodeClick,
|
||||
onHover: handlePostcodeHoverCallback,
|
||||
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
|
||||
beforeId: 'landuse_park',
|
||||
...pieProps,
|
||||
});
|
||||
}, [postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback]);
|
||||
|
||||
|
|
|
|||
|
|
@ -71,7 +71,14 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
}, [handleUndo]);
|
||||
|
||||
const handleFilterChange = useCallback((name: string, value: [number, number] | string[]) => {
|
||||
setFilters((prev) => ({ ...prev, [name]: value }));
|
||||
setFilters((prev) => {
|
||||
if (Array.isArray(value) && value.length === 0) {
|
||||
const next = { ...prev };
|
||||
delete next[name];
|
||||
return next;
|
||||
}
|
||||
return { ...prev, [name]: value };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRemoveFilter = useCallback((name: string) => {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export function usePaneResize(
|
|||
const targetRef = useRef<HTMLElement | null>(null);
|
||||
const containerOffsetRef = useRef(0);
|
||||
const containerSizeRef = useRef(0);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
const isVertical = side === 'top' || side === 'bottom';
|
||||
const styleProp = isVertical ? 'height' : 'width';
|
||||
|
|
@ -80,12 +81,21 @@ export function usePaneResize(
|
|||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!draggingRef.current) return;
|
||||
const newSize = computeSize(e);
|
||||
liveSizeRef.current = newSize;
|
||||
liveSizeRef.current = computeSize(e);
|
||||
if (targetRef.current) {
|
||||
targetRef.current.style[styleProp] = `${newSize}px`;
|
||||
// Batch DOM updates to once per animation frame — on mobile, pointermove
|
||||
// can fire multiple times per frame, and each direct style.height write
|
||||
// forces a synchronous reflow that desynchronises MapLibre and deck.gl.
|
||||
if (rafRef.current == null) {
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
if (targetRef.current) {
|
||||
targetRef.current.style[styleProp] = `${liveSizeRef.current}px`;
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setSize(newSize);
|
||||
setSize(liveSizeRef.current);
|
||||
}
|
||||
},
|
||||
[computeSize, styleProp]
|
||||
|
|
@ -93,8 +103,16 @@ export function usePaneResize(
|
|||
|
||||
const handlePointerUp = useCallback(() => {
|
||||
draggingRef.current = false;
|
||||
if (rafRef.current != null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
// Apply final size synchronously so the commit is immediate
|
||||
if (targetRef.current) {
|
||||
targetRef.current.style[styleProp] = `${liveSizeRef.current}px`;
|
||||
}
|
||||
setSize(liveSizeRef.current);
|
||||
}, []);
|
||||
}, [styleProp]);
|
||||
|
||||
return [
|
||||
size,
|
||||
|
|
|
|||
|
|
@ -12,11 +12,7 @@ export interface SavedPropertyData {
|
|||
energyRating?: string;
|
||||
price?: number;
|
||||
estimatedPrice?: number;
|
||||
askingPrice?: number;
|
||||
askingRent?: number;
|
||||
bedrooms?: number;
|
||||
floorArea?: number;
|
||||
listingUrl?: string;
|
||||
}
|
||||
|
||||
export interface SavedProperty {
|
||||
|
|
@ -84,11 +80,7 @@ export function useSavedProperties(userId: string | null) {
|
|||
energyRating: property.current_energy_rating,
|
||||
price: getNum(property, 'Last known price'),
|
||||
estimatedPrice: getNum(property, 'Estimated current price'),
|
||||
askingPrice: getNum(property, 'Asking price'),
|
||||
askingRent: getNum(property, 'Asking rent (monthly)'),
|
||||
bedrooms: getNum(property, 'Bedrooms'),
|
||||
floorArea: getNum(property, 'Total floor area (sqm)'),
|
||||
listingUrl: property.listing_url || undefined,
|
||||
};
|
||||
|
||||
await pb.collection('saved_properties').create({
|
||||
|
|
|
|||
|
|
@ -6,31 +6,23 @@ import i18n from 'i18next';
|
|||
* English descriptions are NOT here — the server is the single source of truth
|
||||
* for English. Fix a typo in features.rs and it propagates automatically.
|
||||
*
|
||||
* Non-English translations are keyed by the stable feature name, so they're
|
||||
* Non-English translations are keyed by the stable feature name, so they’re
|
||||
* independent of the English description text. If a translation is missing,
|
||||
* tsDesc() falls back to the server's English description.
|
||||
* tsDesc() falls back to the server’s English description.
|
||||
*/
|
||||
const descriptions: Record<string, Record<string, string>> = {
|
||||
fr: {
|
||||
'Listing status':
|
||||
'Indique si le bien provient de ventes historiques, est en vente ou en location',
|
||||
'Property type': 'Type de bien : individuel, jumelé, mitoyen, appartement ou autre',
|
||||
'Leasehold/Freehold': 'Indique si le bien est en bail ou en pleine propriété',
|
||||
'Last known price': 'Dernier prix de vente enregistré au Land Registry',
|
||||
'Estimated current price': 'Estimation du prix actuel ajusté à l’inflation',
|
||||
'Asking price': 'Prix demandé pour les biens actuellement en vente',
|
||||
'Price per sqm': 'Prix de vente divisé par la surface totale',
|
||||
'Est. price per sqm': 'Prix actuel estimé divisé par la surface totale',
|
||||
'Asking price per sqm': 'Prix demandé divisé par la surface totale',
|
||||
'Estimated monthly rent': 'Loyer mensuel privé médian pour le secteur',
|
||||
'Asking rent (monthly)': 'Loyer mensuel affiché pour les biens en location',
|
||||
'Total floor area (sqm)': 'Surface intérieure issue du diagnostic EPC',
|
||||
'Number of bedrooms & living rooms': 'Nombre de pièces habitables selon le diagnostic EPC',
|
||||
Bedrooms: 'Nombre de chambres selon l’annonce en ligne',
|
||||
Bathrooms: 'Nombre de salles de bain selon l’annonce en ligne',
|
||||
'Construction year': 'Année de construction estimée selon l’EPC',
|
||||
'Date of last transaction': 'Date de la dernière vente enregistrée au Land Registry',
|
||||
'Listing date': 'Date de première mise en ligne du bien',
|
||||
'Former council house': 'Indique si le bien a été répertorié comme logement social',
|
||||
'Current energy rating': 'Classement énergétique EPC actuel (A = meilleur, G = pire)',
|
||||
'Potential energy rating':
|
||||
|
|
@ -86,8 +78,20 @@ const descriptions: Record<string, Record<string, string>> = {
|
|||
'% Mixed':
|
||||
'Pourcentage de la population se déclarant Métisse ou de plusieurs groupes ethniques',
|
||||
'% Other': 'Pourcentage de la population se déclarant d’un autre groupe ethnique',
|
||||
'Winning party':
|
||||
'Parti vainqueur dans la circonscription lors des élections générales de 2024',
|
||||
'Voter turnout (%)':
|
||||
'Pourcentage d’électeurs inscrits ayant voté aux élections générales de 2024',
|
||||
'Majority (%)':
|
||||
'Marge de victoire en pourcentage des votes valides aux élections générales de 2024',
|
||||
'% Labour': 'Part des voix travaillistes aux élections générales de 2024',
|
||||
'% Conservative': 'Part des voix conservatrices aux élections générales de 2024',
|
||||
'% Liberal Democrat': 'Part des voix libérales-démocrates aux élections générales de 2024',
|
||||
'% Reform UK': 'Part des voix Reform UK aux élections générales de 2024',
|
||||
'% Green': 'Part des voix vertes aux élections générales de 2024',
|
||||
'% Other parties': 'Part des voix combinée de tous les autres partis et indépendants',
|
||||
'Distance to nearest park (km)': 'Distance au parc ou espace vert le plus proche',
|
||||
'Number of parks within 2km': 'Nombre de parcs et espaces verts à moins de 2 km',
|
||||
'Number of parks within 1km': 'Nombre de parcs et espaces verts à moins de 1 km',
|
||||
'Number of restaurants within 2km': 'Nombre de restaurants et cafés à moins de 2 km',
|
||||
'Number of grocery shops and supermarkets within 2km':
|
||||
'Nombre d’épiceries et supermarchés à moins de 2 km',
|
||||
|
|
@ -95,26 +99,18 @@ const descriptions: Record<string, Record<string, string>> = {
|
|||
'Max available download speed (Mbps)': 'Débit descendant maximal disponible au code postal',
|
||||
},
|
||||
de: {
|
||||
'Listing status':
|
||||
'Ob die Immobilie aus historischen Verkäufen stammt, aktuell zum Verkauf oder zur Miete steht',
|
||||
'Property type':
|
||||
'Immobilientyp: freistehend, Doppelhaushälfte, Reihenhaus, Wohnung oder sonstige',
|
||||
'Leasehold/Freehold': 'Ob die Immobilie Erbbaurecht oder Volleigentum ist',
|
||||
'Last known price': 'Letzter Verkaufspreis laut Land Registry',
|
||||
'Estimated current price': 'Inflationsbereinigter Schätzwert der Immobilie',
|
||||
'Asking price': 'Angebotspreis für aktuell zum Verkauf stehende Immobilien',
|
||||
'Price per sqm': 'Verkaufspreis geteilt durch die Gesamtfläche',
|
||||
'Est. price per sqm': 'Geschätzter aktueller Preis geteilt durch die Gesamtfläche',
|
||||
'Asking price per sqm': 'Angebotspreis geteilt durch die Gesamtfläche',
|
||||
'Estimated monthly rent': 'Mittlere monatliche Privatmiete in der Gegend',
|
||||
'Asking rent (monthly)': 'Angebotene Monatsmiete für Mietimmobilien',
|
||||
'Total floor area (sqm)': 'Wohnfläche laut EPC-Gutachten',
|
||||
'Number of bedrooms & living rooms': 'Anzahl bewohnbarer Räume laut EPC-Gutachten',
|
||||
Bedrooms: 'Anzahl Schlafzimmer laut Online-Inserat',
|
||||
Bathrooms: 'Anzahl Badezimmer laut Online-Inserat',
|
||||
'Construction year': 'Geschätztes Baujahr laut EPC',
|
||||
'Date of last transaction': 'Datum des letzten Verkaufs laut Land Registry',
|
||||
'Listing date': 'Datum der Erstveröffentlichung des Inserats',
|
||||
'Former council house': 'Ob die Immobilie jemals als Sozialbau erfasst wurde',
|
||||
'Current energy rating': 'Aktuelle EPC-Energieeffizienzklasse (A = beste, G = schlechteste)',
|
||||
'Potential energy rating': 'Potenzielle EPC-Klasse bei Umsetzung aller empfohlenen Maßnahmen',
|
||||
|
|
@ -172,8 +168,20 @@ const descriptions: Record<string, Record<string, string>> = {
|
|||
'% Mixed':
|
||||
'Anteil der Bevölkerung, der sich als gemischt oder mehreren ethnischen Gruppen zugehörig identifiziert',
|
||||
'% Other': 'Anteil der Bevölkerung, der sich einer anderen ethnischen Gruppe zuordnet',
|
||||
'Winning party':
|
||||
'Siegreiche Partei im Wahlkreis bei der Parlamentswahl 2024',
|
||||
'Voter turnout (%)':
|
||||
'Anteil der registrierten Wähler, die bei der Parlamentswahl 2024 gewählt haben',
|
||||
'Majority (%)':
|
||||
'Gewinnspanne als Prozentsatz der gültigen Stimmen bei der Parlamentswahl 2024',
|
||||
'% Labour': 'Labour-Stimmenanteil bei der Parlamentswahl 2024',
|
||||
'% Conservative': 'Stimmenanteil der Konservativen bei der Parlamentswahl 2024',
|
||||
'% Liberal Democrat': 'Stimmenanteil der Liberaldemokraten bei der Parlamentswahl 2024',
|
||||
'% Reform UK': 'Stimmenanteil von Reform UK bei der Parlamentswahl 2024',
|
||||
'% Green': 'Stimmenanteil der Grünen bei der Parlamentswahl 2024',
|
||||
'% Other parties': 'Kombinierter Stimmenanteil aller anderen Parteien und Unabhängigen',
|
||||
'Distance to nearest park (km)': 'Entfernung zum nächsten Park oder Grünfläche',
|
||||
'Number of parks within 2km': 'Anzahl Parks und Grünflächen im Umkreis von 2 km',
|
||||
'Number of parks within 1km': 'Anzahl Parks und Grünflächen im Umkreis von 1 km',
|
||||
'Number of restaurants within 2km': 'Anzahl Restaurants und Cafés im Umkreis von 2 km',
|
||||
'Number of grocery shops and supermarkets within 2km':
|
||||
'Anzahl Lebensmittelgeschäfte und Supermärkte im Umkreis von 2 km',
|
||||
|
|
@ -182,24 +190,17 @@ const descriptions: Record<string, Record<string, string>> = {
|
|||
'Maximal verfügbare Breitband-Downloadgeschwindigkeit an der Postleitzahl',
|
||||
},
|
||||
zh: {
|
||||
'Listing status': '该房产是历史销售、当前在售还是出租',
|
||||
'Property type': '房产类型:独立式、半独立式、联排、公寓或其他',
|
||||
'Leasehold/Freehold': '该房产是租赁产权还是永久产权',
|
||||
'Last known price': 'Land Registry记录的最近一次售价',
|
||||
'Estimated current price': '经通胀调整后的当前估计价值',
|
||||
'Asking price': '当前在售房产的挂牌价',
|
||||
'Price per sqm': '售价除以总建筑面积',
|
||||
'Est. price per sqm': '估计当前价格除以总建筑面积',
|
||||
'Asking price per sqm': '挂牌价除以总建筑面积',
|
||||
'Estimated monthly rent': '当地私人租赁的中位月租',
|
||||
'Asking rent (monthly)': '当前出租房产的挂牌月租',
|
||||
'Total floor area (sqm)': 'EPC评估的室内建筑面积',
|
||||
'Number of bedrooms & living rooms': 'EPC评估的宜居房间数',
|
||||
Bedrooms: '在线房源中的卧室数量',
|
||||
Bathrooms: '在线房源中的浴室数量',
|
||||
'Construction year': 'EPC评估的建造年份',
|
||||
'Date of last transaction': 'Land Registry记录的最近一次销售日期',
|
||||
'Listing date': '房产首次在线上市的日期',
|
||||
'Former council house': '该房产是否曾被记录为公共住房',
|
||||
'Current energy rating': '当前EPC能效评级(A = 最佳,G = 最差)',
|
||||
'Potential energy rating': '实施所有建议改进后的潜在EPC评级',
|
||||
|
|
@ -241,32 +242,34 @@ const descriptions: Record<string, Record<string, string>> = {
|
|||
'% East Asian': '东亚裔人口比例',
|
||||
'% Mixed': '混血或多族裔人口比例',
|
||||
'% Other': '其他族裔人口比例',
|
||||
'Winning party': '2024年大选中该选区获胜的政党',
|
||||
'Voter turnout (%)': '2024年大选中登记选民的投票率',
|
||||
'Majority (%)': '2024年大选中获胜者的得票优势占有效票的百分比',
|
||||
'% Labour': '2024年大选中工党得票率',
|
||||
'% Conservative': '2024年大选中保守党得票率',
|
||||
'% Liberal Democrat': '2024年大选中自由民主党得票率',
|
||||
'% Reform UK': '2024年大选中英国改革党得票率',
|
||||
'% Green': '2024年大选中绿党得票率',
|
||||
'% Other parties': '所有其他政党和独立候选人的综合得票率',
|
||||
'Distance to nearest park (km)': '到最近公园或绿地的距离',
|
||||
'Number of parks within 2km': '2公里内公园和绿地数量',
|
||||
'Number of parks within 1km': '1公里内公园和绿地数量',
|
||||
'Number of restaurants within 2km': '2公里内餐厅和咖啡馆数量',
|
||||
'Number of grocery shops and supermarkets within 2km': '2公里内食品店和超市数量',
|
||||
'Noise (dB)': '该邮编的道路噪音水平(分贝,Lden)',
|
||||
'Max available download speed (Mbps)': '该邮编可用的最大宽带下载速度',
|
||||
},
|
||||
hu: {
|
||||
'Listing status': 'Az ingatlan korábbi eladásból származik, jelenleg eladó vagy kiadó',
|
||||
'Property type': 'Ingatlantípus: különálló, ikerház, sorház, lakás vagy egyéb',
|
||||
'Leasehold/Freehold': 'Az ingatlan bérleti jogú vagy teljes tulajdonú',
|
||||
'Last known price': 'A Land Registry-ben rögzített utolsó eladási ár',
|
||||
'Estimated current price': 'Inflációval korrigált becsült jelenlegi érték',
|
||||
'Asking price': 'A jelenleg eladásra kínált ingatlanok irányára',
|
||||
'Price per sqm': 'Eladási ár osztva az összes alapterülettel',
|
||||
'Est. price per sqm': 'Becsült jelenlegi ár osztva az összes alapterülettel',
|
||||
'Asking price per sqm': 'Irányár osztva az összes alapterülettel',
|
||||
'Estimated monthly rent': 'A környék medián havi magánbérleti díja',
|
||||
'Asking rent (monthly)': 'A kiadó ingatlanok hirdetett havi bérleti díja',
|
||||
'Total floor area (sqm)': 'Az EPC felmérésből származó belső alapterület',
|
||||
'Number of bedrooms & living rooms': 'Lakószobák száma az EPC felmérés alapján',
|
||||
Bedrooms: 'Hálószobák száma az online hirdetés szerint',
|
||||
Bathrooms: 'Fürdőszobák száma az online hirdetés szerint',
|
||||
'Construction year': 'Becsült építési év az EPC alapján',
|
||||
'Date of last transaction': 'Az utolsó eladás dátuma a Land Registry szerint',
|
||||
'Listing date': 'Az ingatlan első online megjelenésének dátuma',
|
||||
'Former council house': 'Az ingatlan szerepelt-e valaha önkormányzati lakásként',
|
||||
'Current energy rating': 'Jelenlegi EPC energiabesorolás (A = legjobb, G = legrosszabb)',
|
||||
'Potential energy rating':
|
||||
|
|
@ -319,8 +322,20 @@ const descriptions: Record<string, Record<string, string>> = {
|
|||
'% East Asian': 'A kelet-ázsiaiként azonosított lakosság aránya',
|
||||
'% Mixed': 'A vegyes vagy több etnikai csoporthoz tartozóként azonosított lakosság aránya',
|
||||
'% Other': 'Az egyéb etnikai csoportba tartozóként azonosított lakosság aránya',
|
||||
'Winning party':
|
||||
'A 2024-es parlamenti választáson a választókerületben győztes párt',
|
||||
'Voter turnout (%)':
|
||||
'A regisztrált választók szavazási aránya a 2024-es parlamenti választáson',
|
||||
'Majority (%)':
|
||||
'Győzelmi előny az érvényes szavazatok százalékában a 2024-es parlamenti választáson',
|
||||
'% Labour': 'A Munkáspárt szavazataránya a 2024-es parlamenti választáson',
|
||||
'% Conservative': 'A Konzervatív Párt szavazataránya a 2024-es parlamenti választáson',
|
||||
'% Liberal Democrat': 'A Liberális Demokraták szavazataránya a 2024-es parlamenti választáson',
|
||||
'% Reform UK': 'A Reform UK szavazataránya a 2024-es parlamenti választáson',
|
||||
'% Green': 'A Zöld Párt szavazataránya a 2024-es parlamenti választáson',
|
||||
'% Other parties': 'Az összes többi párt és független jelölt összesített szavazataránya',
|
||||
'Distance to nearest park (km)': 'Távolság a legközelebbi parkig vagy zöldterületig',
|
||||
'Number of parks within 2km': 'Parkok és zöldterületek száma 2 km-en belül',
|
||||
'Number of parks within 1km': 'Parkok és zöldterületek száma 1 km-en belül',
|
||||
'Number of restaurants within 2km': 'Éttermek és kávézók száma 2 km-en belül',
|
||||
'Number of grocery shops and supermarkets within 2km':
|
||||
'Élelmiszerboltok és szupermarketek száma 2 km-en belül',
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@
|
|||
*/
|
||||
export const details: Record<string, Record<string, string>> = {
|
||||
fr: {
|
||||
'Listing status':
|
||||
"Indique la source de l'enregistrement de la propriété : « Vente historique » provenant des données HM Land Registry Price Paid, « À vendre » provenant des annonces d'achat en ligne actuelles, ou « À louer » provenant des annonces de location en ligne actuelles.",
|
||||
'Property type':
|
||||
'Provient des données HM Land Registry Price Paid et des certificats EPC. Individuelle, Semi-individuelle, Mitoyenne (inclut tous les sous-types de maisons en rangée), Appartements/Maisons duplex, ou Autre (bungalows, mobil-homes, etc.).',
|
||||
'Leasehold/Freehold':
|
||||
|
|
@ -15,32 +13,20 @@ export const details: Record<string, Record<string, string>> = {
|
|||
"Le dernier prix de vente enregistré pour ce bien provenant des données HM Land Registry Price Paid. Couvre les ventes résidentielles en Angleterre. Peut dater de plusieurs années si le bien n'a pas été vendu récemment.",
|
||||
'Estimated current price':
|
||||
"Basé sur le dernier prix de vente, ajusté en fonction des évolutions locales des prix au fil du temps à l'aide d'un indice de ventes répétées (suivi par secteur de code postal et type de bien). Si des améliorations postérieures à la vente sont détectées d'après les relevés EPC, une prime de rénovation est ajoutée. Les ventes récentes seront proches du prix d'origine ; les ventes plus anciennes font l'objet d'un ajustement plus important.",
|
||||
'Asking price':
|
||||
"Le prix demandé tel qu'annoncé sur les portails immobiliers en ligne. Disponible uniquement pour les annonces « À vendre ».",
|
||||
'Price per sqm':
|
||||
'Calculé en divisant le dernier prix de vente connu par la surface habitable totale indiquée dans le certificat EPC. Utile pour comparer la valeur entre des biens de tailles différentes. Disponible uniquement lorsque les données de prix et de surface existent toutes les deux.',
|
||||
'Est. price per sqm':
|
||||
"Calculé en divisant le prix actuel estimé et ajusté à l'inflation (y compris toute prime de rénovation) par la surface habitable totale indiquée dans le certificat EPC. Fournit une comparaison prix/superficie plus actualisée que le prix au sqm basé sur le prix de vente historique.",
|
||||
'Asking price per sqm':
|
||||
'Calculé en divisant le prix demandé affiché par la surface habitable totale. Disponible uniquement pour les biens actuellement mis en vente pour lesquels les données de surface existent.',
|
||||
'Estimated monthly rent':
|
||||
"Prix médian mensuel de location provenant des statistiques sommaires du marché locatif privé de l'ONS (octobre 2022 - septembre 2023), correspondant à l'autorité locale et au nombre de chambres. Basé sur les données de locations de l'Agence d'évaluation (Valuation Office Agency).",
|
||||
'Asking rent (monthly)':
|
||||
'Le prix de location annoncé sur les portails immobiliers en ligne, converti en montant mensuel si nécessaire (par exemple, annonces hebdomadaires ou annuelles). Disponible uniquement pour les annonces « À louer ».',
|
||||
'Total floor area (sqm)':
|
||||
"Surface habitable totale en mètres carrés telle que mesurée lors de l'évaluation du certificat de performance énergétique (EPC). Inclut toutes les pièces habitables mais exclut les garages, dépendances et espaces extérieurs.",
|
||||
'Number of bedrooms & living rooms':
|
||||
"Nombre total de pièces habitables (chambres et salons) tel qu'enregistré dans le certificat de performance énergétique (EPC). Les cuisines et salles de bain sont généralement exclues, sauf si elles sont suffisamment grandes pour être comptées comme pièces habitables.",
|
||||
Bedrooms:
|
||||
"Nombre de chambres tel qu'annoncé dans l'annonce immobilière en ligne. Renseigné uniquement pour les annonces en ligne (vente et location) ; nul pour les ventes historiques.",
|
||||
Bathrooms:
|
||||
"Nombre de salles de bain tel qu'annoncé dans l'annonce immobilière en ligne. Renseigné uniquement pour les annonces en ligne (vente et location) ; nul pour les ventes historiques.",
|
||||
'Construction year':
|
||||
"Dérivé de la tranche d'âge de construction indiquée dans l'EPC (par exemple « 1930-1949 ») en prenant le point médian. Moins précis pour les bâtiments anciens où la tranche d'âge s'étend sur plusieurs décennies.",
|
||||
'Date of last transaction':
|
||||
'La date de la vente enregistrée la plus récente pour ce bien, provenant des données HM Land Registry Price Paid. Stockée sous forme de date/heure dans les données ; convertie en année fractionnaire pour le filtrage et les graphiques.',
|
||||
'Listing date':
|
||||
"La date à laquelle l'annonce immobilière est apparue pour la première fois sur le portail immobilier en ligne. Stockée sous forme de date/heure ; convertie en année fractionnaire pour le filtrage. Renseignée uniquement pour les annonces en ligne.",
|
||||
'Former council house':
|
||||
"Dérivé du champ TENURE dans les données du certificat de performance énergétique (EPC). Si l'un des certificats EPC pour ce bien a enregistré le régime d'occupation comme location sociale, cela indique que le bien faisait partie du parc de logements du conseil municipal ou d'une association de logement au moment de cette inspection. Les biens qui ont été vendus ultérieurement (par exemple via le Right to Buy) conservent cet indicateur.",
|
||||
'Current energy rating':
|
||||
|
|
@ -123,10 +109,28 @@ export const details: Record<string, Record<string, string>> = {
|
|||
"Provient du Census 2021. Pourcentage de la population de l'autorité locale s'identifiant comme Mixte ou appartenant à plusieurs groupes ethniques (Blanc et Noir caribéen, Blanc et Noir africain, Blanc et Asiatique, ou tout autre fond mixte ou multiple).",
|
||||
'% Other':
|
||||
"Provient du Census 2021. Pourcentage de la population de l'autorité locale s'identifiant comme appartenant à un autre groupe ethnique (Arabe ou tout autre groupe ethnique non couvert par les catégories principales).",
|
||||
'Winning party':
|
||||
"Le parti politique qui a obtenu le plus de votes dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024. Basé sur les résultats au scrutin uninominal majoritaire publiés par le Parlement britannique. Les circonscriptions ont été redessinées pour 2024 selon la révision de la Commission des limites de 2023.",
|
||||
'Voter turnout (%)':
|
||||
"La proportion de l'électorat inscrit qui a voté de manière valide lors des élections générales britanniques de juillet 2024. Calculée comme le nombre de votes valides divisé par la taille de l'électorat. Une participation plus élevée est généralement corrélée avec des zones plus aisées et des scrutins plus serrés.",
|
||||
'Majority (%)':
|
||||
"La différence de voix entre le candidat vainqueur et le second, exprimée en pourcentage du total des votes valides. Une faible majorité indique un siège marginal (compétitif) ; une forte majorité indique un siège sûr. Provient des résultats des élections générales britanniques de juillet 2024 publiés par le Parlement britannique.",
|
||||
'% Labour':
|
||||
"Pourcentage des votes valides exprimés pour le Parti travailliste dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024. Comprend les votes de tous les candidats travaillistes.",
|
||||
'% Conservative':
|
||||
"Pourcentage des votes valides exprimés pour le Parti conservateur dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.",
|
||||
'% Liberal Democrat':
|
||||
"Pourcentage des votes valides exprimés pour les Libéraux-démocrates dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.",
|
||||
'% Reform UK':
|
||||
"Pourcentage des votes valides exprimés pour Reform UK dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.",
|
||||
'% Green':
|
||||
"Pourcentage des votes valides exprimés pour le Parti vert dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.",
|
||||
'% Other parties':
|
||||
"Pourcentage des votes valides exprimés pour des partis autres que Travailliste, Conservateur, Libéral-démocrate, Reform UK et Vert dans la circonscription couvrant ce code postal. Comprend les indépendants, le Président de la Chambre et les partis mineurs.",
|
||||
'Distance to nearest park (km)':
|
||||
"Distance à vol d'oiseau en kilomètres depuis le code postal jusqu'à l'entrée du parc la plus proche. Couvre les parcs publics, jardins, terrains de jeux et espaces de loisirs. Utilise les emplacements des points d'accès issus du jeu de données OS Open Greenspace, de sorte que les propriétés bordant un grand parc affichent correctement une courte distance.",
|
||||
'Number of parks within 2km':
|
||||
'Nombre de parcs publics, jardins, terrains de jeux et espaces de loisirs dont au moins une entrée se trouve dans un rayon de 2km du centroïde du code postal de la propri<72><69>té. Dérivé du jeu de données OS Open Greenspace (Ordnance Survey), utilisant les emplacements des entrées de parcs pour une correspondance de proximité précise.',
|
||||
'Number of parks within 1km':
|
||||
'Nombre de parcs publics, jardins, terrains de jeux et espaces de loisirs dont au moins une entrée se trouve dans un rayon de 1km du centroïde du code postal de la propri<72><69>té. Dérivé du jeu de données OS Open Greenspace (Ordnance Survey), utilisant les emplacements des entrées de parcs pour une correspondance de proximité précise.',
|
||||
'Number of restaurants within 2km':
|
||||
'Restaurants, cafés et établissements de restauration dans un rayon de 2km du code postal. Source : OpenStreetMap.',
|
||||
'Number of grocery shops and supermarkets within 2km':
|
||||
|
|
@ -137,8 +141,6 @@ export const details: Record<string, Record<string, string>> = {
|
|||
"Vitesse de téléchargement fixe maximale disponible auprès de n'importe quel fournisseur, provenant d'Ofcom Connected Nations 2025. Représente le maximum théorique, et non les vitesses réellement atteintes. 10 Mbps = basique, 30 = superrapide, 100+ = ultra-rapide, 1000 = gigabit.",
|
||||
},
|
||||
de: {
|
||||
'Listing status':
|
||||
'Gibt die Quelle des Immobilieneintrags an: „Historical sale" aus den HM Land Registry Price Paid-Daten, „For sale" aus aktuellen Online-Kaufangeboten oder „For rent" aus aktuellen Online-Mietangeboten.',
|
||||
'Property type':
|
||||
'Aus den HM Land Registry Price Paid-Daten und EPC-Zertifikaten. Freistehend, Doppelhaushälfte, Reihenhaus (umfasst alle Untertypen), Wohnungen/Maisonettes oder Sonstiges (Bungalows, Mobilheime usw.).',
|
||||
'Leasehold/Freehold':
|
||||
|
|
@ -147,32 +149,20 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'Der zuletzt erfasste Verkaufspreis für diese Immobilie aus den HM Land Registry Price Paid-Daten. Umfasst Wohnimmobilienverkäufe in England. Kann Jahre alt sein, wenn die Immobilie nicht kürzlich verkauft wurde.',
|
||||
'Estimated current price':
|
||||
'Basiert auf dem letzten Verkaufspreis, angepasst an lokale Preisveränderungen im Laufe der Zeit mithilfe eines Repeat-Sales-Index (erfasst pro Postleitzahlensektor und Immobilientyp). Wenn nach dem Verkauf durchgeführte Renovierungen aus EPC-Aufzeichnungen erkennbar sind, wird ein Renovierungsaufschlag hinzugefügt. Kürzliche Verkäufe liegen nahe am ursprünglichen Preis; ältere Verkäufe werden stärker angepasst.',
|
||||
'Asking price':
|
||||
'Der beworbene Angebotspreis aus Online-Immobilienportalen. Nur für „For sale"-Angebote verfügbar.',
|
||||
'Price per sqm':
|
||||
'Berechnet durch Division des zuletzt bekannten Verkaufspreises durch die Gesamtnutzfläche aus dem EPC-Zertifikat. Nützlich zum Vergleich des Wertes verschiedener Immobiliengrößen. Nur verfügbar, wenn sowohl Preis- als auch Flächendaten vorhanden sind.',
|
||||
'Est. price per sqm':
|
||||
'Berechnet durch Division des inflationsbereinigten geschätzten aktuellen Preises (einschließlich etwaiger Renovierungsaufschläge) durch die Gesamtnutzfläche aus dem EPC-Zertifikat. Bietet einen aktuelleren Preis-pro-Fläche-Vergleich als der historische Verkaufspreis pro sqm.',
|
||||
'Asking price per sqm':
|
||||
'Berechnet durch Division des angebotenen Kaufpreises durch die Gesamtnutzfläche. Nur für zum Verkauf angebotene Immobilien verfügbar, für die Flächendaten vorhanden sind.',
|
||||
'Estimated monthly rent':
|
||||
'Monatlicher Median-Mietpreis aus den ONS Private Rental Market Summary Statistics (Okt. 2022 – Sep. 2023), abgeglichen nach Gemeinde und Zimmeranzahl. Basiert auf Vermietungsdaten der Valuation Office Agency.',
|
||||
'Asking rent (monthly)':
|
||||
'Der beworbene Mietpreis aus Online-Immobilienportalen, bei Bedarf in einen monatlichen Betrag umgerechnet (z. B. bei wöchentlichen oder jährlichen Angeboten). Nur für „For rent"-Angebote verfügbar.',
|
||||
'Total floor area (sqm)':
|
||||
'Gesamte nutzbare Wohnfläche in Quadratmetern, gemessen während der Bewertung für das Energieausweis-Zertifikat. Umfasst alle Wohnräume, schließt jedoch Garagen, Nebengebäude und Außenbereiche aus.',
|
||||
'Number of bedrooms & living rooms':
|
||||
'Gesamtanzahl der Wohnräume (Schlaf- und Wohnzimmer), wie im Energieausweis-Zertifikat erfasst. Küchen und Badezimmer sind in der Regel ausgeschlossen, sofern sie nicht groß genug sind, um als Wohnräume zu gelten.',
|
||||
Bedrooms:
|
||||
'Anzahl der Schlafzimmer, wie im Online-Immobilienangebot angegeben. Nur für Online-Angebote (Kauf und Miete) verfügbar; bei historischen Verkäufen nicht angegeben.',
|
||||
Bathrooms:
|
||||
'Anzahl der Badezimmer, wie im Online-Immobilienangebot angegeben. Nur für Online-Angebote (Kauf und Miete) verfügbar; bei historischen Verkäufen nicht angegeben.',
|
||||
'Construction year':
|
||||
'Abgeleitet aus dem Baualtersband im EPC (z. B. „1930–1949") durch Verwendung des Mittelpunkts. Bei älteren Gebäuden, bei denen das Altersband mehrere Jahrzehnte umfasst, weniger präzise.',
|
||||
'Date of last transaction':
|
||||
'Das Datum des zuletzt erfassten Verkaufs dieser Immobilie aus den HM Land Registry Price Paid-Daten. In den Daten als Datum-/Uhrzeitangabe gespeichert; für Filterung und Diagramme in ein Dezimaljahr umgerechnet.',
|
||||
'Listing date':
|
||||
'Das Datum, an dem das Immobilienangebot erstmals auf dem Online-Immobilienportal erschien. Als Datum-/Uhrzeitangabe gespeichert; für die Filterung in ein Dezimaljahr umgerechnet. Nur für Online-Angebote verfügbar.',
|
||||
'Former council house':
|
||||
'Abgeleitet aus dem TENURE-Feld in den Energieausweis-Daten. Wenn für diese Immobilie ein EPC-Zertifikat das Nutzungsverhältnis als Sozialmiete erfasste, deutet dies darauf hin, dass die Immobilie zum Zeitpunkt dieser Inspektion Gemeinde- oder Wohnungsbaugesellschaftsbestand war. Immobilien, die später verkauft wurden (z. B. über Right to Buy), behalten dieses Merkmal.',
|
||||
'Current energy rating':
|
||||
|
|
@ -255,10 +245,28 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'Aus dem Census 2021. Prozentsatz der Bevölkerung der Gemeinde, die sich als gemischt oder mit mehreren ethnischen Zugehörigkeiten identifiziert (Weiß und Schwarzkaribisch, Weiß und Schwarzafrikanisch, Weiß und Asiatisch oder sonstiger gemischter Hintergrund).',
|
||||
'% Other':
|
||||
'Aus dem Census 2021. Prozentsatz der Bevölkerung der Gemeinde, die sich als einer anderen ethnischen Gruppe zugehörig identifiziert (Arabisch oder eine andere ethnische Gruppe, die nicht von den Hauptkategorien abgedeckt wird).',
|
||||
'Winning party':
|
||||
'Die politische Partei, die im Wahlkreis dieser Postleitzahl bei der britischen Parlamentswahl im Juli 2024 die meisten Stimmen erhalten hat. Basierend auf den Ergebnissen des Mehrheitswahlrechts, veröffentlicht vom britischen Parlament. Die Wahlkreise wurden für 2024 nach der Überprüfung der Boundary Commission 2023 neu eingeteilt.',
|
||||
'Voter turnout (%)':
|
||||
'Der Anteil der registrierten Wahlberechtigten, die bei der britischen Parlamentswahl im Juli 2024 eine gültige Stimme abgegeben haben. Berechnet als gültige Stimmen geteilt durch die Größe der Wählerschaft. Eine höhere Wahlbeteiligung korreliert im Allgemeinen mit wohlhabenderen Gebieten und knapperen Ergebnissen.',
|
||||
'Majority (%)':
|
||||
'Die Stimmendifferenz zwischen dem Gewinner und dem Zweitplatzierten, ausgedrückt als Prozentsatz der gesamten gültigen Stimmen. Eine kleine Mehrheit weist auf einen umkämpften Wahlkreis hin; eine große Mehrheit auf einen sicheren Sitz. Aus den Ergebnissen der britischen Parlamentswahl vom Juli 2024, veröffentlicht vom britischen Parlament.',
|
||||
'% Labour':
|
||||
'Prozentsatz der gültigen Stimmen für die Labour Party im Wahlkreis dieser Postleitzahl bei der britischen Parlamentswahl im Juli 2024.',
|
||||
'% Conservative':
|
||||
'Prozentsatz der gültigen Stimmen für die Conservative Party im Wahlkreis dieser Postleitzahl bei der britischen Parlamentswahl im Juli 2024.',
|
||||
'% Liberal Democrat':
|
||||
'Prozentsatz der gültigen Stimmen für die Liberal Democrats im Wahlkreis dieser Postleitzahl bei der britischen Parlamentswahl im Juli 2024.',
|
||||
'% Reform UK':
|
||||
'Prozentsatz der gültigen Stimmen für Reform UK im Wahlkreis dieser Postleitzahl bei der britischen Parlamentswahl im Juli 2024.',
|
||||
'% Green':
|
||||
'Prozentsatz der gültigen Stimmen für die Green Party im Wahlkreis dieser Postleitzahl bei der britischen Parlamentswahl im Juli 2024.',
|
||||
'% Other parties':
|
||||
'Prozentsatz der gültigen Stimmen für Parteien außer Labour, Conservative, Liberal Democrat, Reform UK und Green im Wahlkreis. Umfasst Unabhängige, den Speaker und kleinere Parteien.',
|
||||
'Distance to nearest park (km)':
|
||||
'Luftlinienentfernung in Kilometern vom Postleitzahlenzentrum zum nächsten Parkeingang. Umfasst öffentliche Parks, Gärten, Sportplätze und Spielbereiche. Verwendet Zugangspunktstandorte aus dem OS Open Greenspace-Datensatz, sodass Immobilien an der Grenze eines großen Parks korrekt eine kurze Entfernung anzeigen.',
|
||||
'Number of parks within 2km':
|
||||
'Anzahl öffentlicher Parks, Gärten, Sportplätze und Spielbereiche mit mindestens einem Eingang innerhalb eines 2-km-Radius um den Postleitzahlenmittelpunkt der Immobilie. Abgeleitet aus dem OS Open Greenspace-Datensatz (Ordnance Survey) unter Verwendung von Parkeingangsstandorten für genaues Abstandsmatching.',
|
||||
'Number of parks within 1km':
|
||||
'Anzahl öffentlicher Parks, Gärten, Sportplätze und Spielbereiche mit mindestens einem Eingang innerhalb eines 1-km-Radius um den Postleitzahlenmittelpunkt der Immobilie. Abgeleitet aus dem OS Open Greenspace-Datensatz (Ordnance Survey) unter Verwendung von Parkeingangsstandorten für genaues Abstandsmatching.',
|
||||
'Number of restaurants within 2km':
|
||||
'Restaurants, Cafés und Gastronomiebetriebe innerhalb von 2 km vom Postleitzahlenzentrum. Bezogen aus OpenStreetMap.',
|
||||
'Number of grocery shops and supermarkets within 2km':
|
||||
|
|
@ -269,8 +277,6 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'Maximale verfügbare Festnetz-Download-Geschwindigkeit von einem beliebigen Anbieter, aus Ofcom Connected Nations 2025. Gibt die theoretische Höchstgeschwindigkeit an, keine tatsächlich erreichten Geschwindigkeiten. 10 Mbps = Basis, 30 = Superfast, 100+ = Ultrafast, 1000 = Gigabit.',
|
||||
},
|
||||
zh: {
|
||||
'Listing status':
|
||||
'表示房产记录的来源:"Historical sale"来自英国土地注册局价格数据,"For sale"来自当前在线买卖房源,"For rent"来自当前在线租赁房源。',
|
||||
'Property type':
|
||||
'来自英国土地注册局价格数据和EPC证书。包括独立式、半独立式、联排式(含所有联排子类型)、公寓/复式公寓,或其他类型(平房、移动式住宅等)。',
|
||||
'Leasehold/Freehold':
|
||||
|
|
@ -279,29 +285,20 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'来自英国土地注册局价格数据中该房产最近一次记录的成交价格。涵盖英格兰地区的住宅销售。若该房产近期未出售,数据可能已有数年之久。',
|
||||
'Estimated current price':
|
||||
'基于最后一次成交价格,使用重复销售指数(按邮政编码区段和房产类型追踪)调整当地房价随时间的变化。若EPC记录显示售后有改造记录,则会增加装修溢价。近期销售与原价接近;较早的销售调整幅度更大。',
|
||||
'Asking price': '来自在线房产平台的挂牌要价。仅适用于"For sale"房源。',
|
||||
'Price per sqm':
|
||||
'用最后已知成交价除以EPC证书中的总建筑面积计算得出。便于比较不同面积房产的价值。仅在价格和面积数据均存在时才可用。',
|
||||
'Est. price per sqm':
|
||||
'用经通胀调整的估算当前价格(含装修溢价)除以EPC证书中的总建筑面积计算得出。与历史成交价格每平方米相比,提供更为最新的单位面积价格对比。',
|
||||
'Asking price per sqm':
|
||||
'用挂牌要价除以总建筑面积计算得出。仅适用于当前在售且存在面积数据的房产。',
|
||||
'Estimated monthly rent':
|
||||
'来自ONS私人租赁市场摘要统计(2022年10月至2023年9月)的月租金中位数,按地方政府和卧室数量匹配。基于估价署租赁数据。',
|
||||
'Asking rent (monthly)':
|
||||
'来自在线房产平台的挂牌租金,如有需要会换算为月租金(例如按周或按年计价的房源)。仅适用于"For rent"房源。',
|
||||
'Total floor area (sqm)':
|
||||
'在能源性能证书(EPC)评估期间测量的总可用建筑面积(平方米)。包括所有可居住房间,但不含车库、附属建筑和外部区域。',
|
||||
'Number of bedrooms & living rooms':
|
||||
'EPC中记录的可居住房间总数(卧室加客厅)。厨房和浴室通常不计入,除非面积足够大可算作可居住房间。',
|
||||
Bedrooms: '在线房产房源中所列的卧室数量。仅适用于在线房源(出售和出租);历史销售记录为空。',
|
||||
Bathrooms: '在线房产房源中所列的浴室数量。仅适用于在线房源(出售和出租);历史销售记录为空。',
|
||||
'Construction year':
|
||||
'根据EPC中的建造年代段(例如"1930-1949")取中间值推算。对于年代段跨越数十年的老建筑,精度较低。',
|
||||
'Date of last transaction':
|
||||
'来自英国土地注册局价格数据中该房产最近一次成交的记录日期。数据中以日期时间格式存储;在筛选和图表中转换为小数年份。',
|
||||
'Listing date':
|
||||
'该房产房源首次出现在在线房产平台上的日期。以日期时间格式存储;筛选时转换为小数年份。仅适用于在线房源。',
|
||||
'Former council house':
|
||||
'来自EPC数据中的TENURE字段。若该房产的任何一份EPC证书将产权记录为社会租赁,则表明该房产在该次评估时为政府或住房协会存量房。通过Right to Buy等方式出售后的房产仍保留此标记。',
|
||||
'Current energy rating':
|
||||
|
|
@ -382,10 +379,28 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'来自2021年Census。地方政府人口中认同为<E5908C><E4B8BA>血或多种族群体(白人与黑人加勒比裔、白人与黑人非洲裔、白人与亚洲裔,或其他混血或多种族背景)的百分比。',
|
||||
'% Other':
|
||||
'来自2021年Census。地方政府人口中认同为其他族裔群体(阿拉伯人或其他未被主要类别涵盖的族裔)的百分比。',
|
||||
'Winning party':
|
||||
'在2024年7月英国大选中,该邮编所属选区得票最多的政党。基于英国议会公布的简单多数制选举结果。选区根据2023年边界委员会审查进行了重新划分。',
|
||||
'Voter turnout (%)':
|
||||
'2024年7月英国大选中投出有效选票的登记选民比例。计算方式为有效票数除以选民总数。较高的投票率通常与较富裕地区和竞争更激烈的选举相关。',
|
||||
'Majority (%)':
|
||||
'获胜候选人与第二名之间的票数差距,以有效投票总数的百分比表示。小的多数票表示边缘选区(竞争激烈);大的多数票表示安全席位。数据来自英国议会公布的2024年7月大选结果。',
|
||||
'% Labour':
|
||||
'2024年7月英国大选中,该邮编所属选区投给工党的有效选票百分比。包括所有工党候选人的选票。',
|
||||
'% Conservative':
|
||||
'2024年7月英国大选中,该邮编所属选区投给保守党的有效选票百分比。',
|
||||
'% Liberal Democrat':
|
||||
'2024年7月英国大选中,该邮编所属选区投给自由民主党的有效选票百分比。',
|
||||
'% Reform UK':
|
||||
'2024年7月英国大选中,该邮编所属选区投给英国改革党的有效选票百分比。',
|
||||
'% Green':
|
||||
'2024年7月英国大选中,该邮编所属选区投给绿党的有效选票百分比。',
|
||||
'% Other parties':
|
||||
'该选区中投给工党、保守党、自由民主党、英国改革党和绿党以外政党的有效选票百分比。包括独立候选人、议长和小型政党。',
|
||||
'Distance to nearest park (km)':
|
||||
'从邮政编码到最近公园入口的直线距离(km)。涵盖公共公园、花园、运动<E8BF90><E58AA8><EFBFBD>和游乐场地。使用OS Open Greenspace数据集中的出入口位置,因此紧邻大型公园的房产可正确显示较短距离。',
|
||||
'Number of parks within 2km':
|
||||
'以房产邮政编码中心点为圆心,2km半径内至少有一个入口的公共公园、花园、运动场和游乐场地数量。来源于OS Open Greenspace数据集(英国地形测量局),使用公园入口位置进行精确近距离匹配。',
|
||||
'Number of parks within 1km':
|
||||
'以房产邮政编码中心点为圆心,1km半径内至少有一个入口的公共公园、花园、运动场和游乐场地数量。来源于OS Open Greenspace数据集(英国地形测量局),使用公园入口位置进行精确近距离匹配。',
|
||||
'Number of restaurants within 2km':
|
||||
'邮政编码2km范围内的餐厅、咖啡馆和餐饮场所数量。来源于OpenStreetMap。',
|
||||
'Number of grocery shops and supermarkets within 2km':
|
||||
|
|
@ -396,8 +411,6 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'来自Ofcom Connected Nations 2025的任意运营商可提供的最大固定宽带下载速度。代表理论最大值,而非实际达到的速度。10 Mbps为基础级,30为超快级,100+为极速级,1000为千兆级。',
|
||||
},
|
||||
hu: {
|
||||
'Listing status':
|
||||
"Az ingatlan bejegyzés forrását jelzi: 'Korábbi adásvétel' az HM Land Registry Price Paid adatokból, 'Eladó' az aktuális online vételi hirdetésekből, vagy 'Kiadó' az aktuális online bérleti hirdetésekből.",
|
||||
'Property type':
|
||||
'Az HM Land Registry Price Paid adatokból és EPC tanúsítványokból. Különálló, Ikerház, Sorház (minden sorház altípust tartalmaz), Lakás/Maisonette, vagy Egyéb (bungaló, mobilház stb.).',
|
||||
'Leasehold/Freehold':
|
||||
|
|
@ -406,32 +419,20 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'Az ingatlan utolsó rögzített adásvételi ára az HM Land Registry Price Paid adatokból. Az angliai lakóingatlan-értékesítésekre vonatkozik. Lehet, hogy évekkel ezelőtti adat, ha az ingatlan nem kelt el a közelmúltban.',
|
||||
'Estimated current price':
|
||||
'Az utolsó adásvételi áron alapul, amelyet az idő múlásával bekövetkezett helyi árváltozásokhoz igazítottak egy ismételt értékesítési index segítségével (irányítószám-szektor és ingatlan típusa szerint nyomon követve). Ha az EPC-adatokból az értékesítés utáni felújítás észlelhető, felújítási prémium kerül hozzáadásra. A közelmúltbeli adásvételek közel lesznek az eredeti árhoz; a régebbi adásvételeket jobban korrigálják.',
|
||||
'Asking price':
|
||||
"Az online ingatlanportálokon hirdetett kért ár. Csak az 'Eladó' hirdetéseknél érhető el.",
|
||||
'Price per sqm':
|
||||
'Az utolsó ismert adásvételi árat az EPC tanúsítványból származó teljes alapterülettel elosztva számítják ki. Hasznos a különböző méretű ingatlanok értékének összehasonlításához. Csak akkor elérhető, ha mind az ár, mind az alapterület adatai rendelkezésre állnak.',
|
||||
'Est. price per sqm':
|
||||
'Az inflációval korrigált becsült aktuális árat (beleértve az esetleges felújítási prémiumot) az EPC tanúsítványból származó teljes alapterülettel elosztva számítják ki. Naprakészebb ár/terület összehasonlítást nyújt, mint a korábbi adásvételi ár per sqm.',
|
||||
'Asking price per sqm':
|
||||
'A megadott kért árat az összes alapterülettel elosztva számítják ki. Csak azon ingatlanokra vonatkozik, amelyek jelenleg eladók, és amelyekről alapterület-adatok állnak rendelkezésre.',
|
||||
'Estimated monthly rent':
|
||||
'Az ONS Magánbérleti Piaci Összefoglaló Statisztikákból (2022. október – 2023. szeptember) származó medián havi bérleti díj, helyi hatóság és hálószobák száma szerint párosítva. A Valuation Office Agency bérbeadási adatain alapul.',
|
||||
'Asking rent (monthly)':
|
||||
"Az online ingatlanportálokon hirdetett bérleti díj, szükség esetén havi összegre átszámítva (pl. heti vagy éves hirdetések esetén). Csak a 'Kiadó' hirdetéseknél érhető el.",
|
||||
'Total floor area (sqm)':
|
||||
'Az Energy Performance Certificate felmérése során mért teljes hasznos alapterület négyzetméterben. Tartalmazza az összes lakható helyiséget, de kizárja a garázsokat, melléképületeket és külső területeket.',
|
||||
'Number of bedrooms & living rooms':
|
||||
'Az Energy Performance Certificate-ben rögzített összes lakható helyiség száma (hálószobák és nappali szobák összege). A konyhák és fürdőszobák jellemzően nem számítanak bele, kivéve, ha elég nagyok ahhoz, hogy lakható helyiségnek minősüljenek.',
|
||||
Bedrooms:
|
||||
'Az online ingatlanhirdetésben meghirdetett hálószobák száma. Csak online hirdetéseknél (eladó és kiadó ingatlanok) kerül feltüntetésre; korábbi adásvételek esetén üres.',
|
||||
Bathrooms:
|
||||
'Az online ingatlanhirdetésben meghirdetett fürdőszobák száma. Csak online hirdetéseknél (eladó és kiadó ingatlanok) kerül feltüntetésre; korábbi adásvételek esetén üres.',
|
||||
'Construction year':
|
||||
"Az EPC-ben szereplő építési korszak alapján (pl. '1930–1949') a középértékkel becsülve. Régebbi épületeknél kevésbé pontos, ahol a korcsoport több évtizedet ölel fel.",
|
||||
'Date of last transaction':
|
||||
'Az ingatlan legutóbbi rögzített adásvételének dátuma az HM Land Registry Price Paid adatokból. Az adatokban dátum/idő formátumban tárolódik; szűréshez és diagramokhoz törtéves formátumra konvertálva.',
|
||||
'Listing date':
|
||||
'Az a dátum, amikor az ingatlanhirdetés először jelent meg az online ingatlanportálon. Dátum/idő formátumban tárolva; szűréshez törtéves formátumra konvertálva. Csak online hirdetéseknél kerül feltüntetésre.',
|
||||
'Former council house':
|
||||
'Az Energy Performance Certificate adatok TENURE mezőjéből származtatva. Ha az ingatlan bármely EPC tanúsítványa szociális bérlakásként rögzítette a bérleti jogviszonyt, ez azt jelzi, hogy az ingatlan az adott ellenőrzés idején önkormányzati vagy lakásszövetkezeti állomány volt. Azok az ingatlanok, amelyeket később értékesítettek (pl. Right to Buy útján), megőrzik ezt a jelzést.',
|
||||
'Current energy rating':
|
||||
|
|
@ -514,10 +515,28 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'A 2021-es Census alapján. A helyi hatóság területén vegyes vagy többes etnikai csoportként (fehér és fekete karibi, fehér és fekete afrikai, fehér és ázsiai, vagy bármely más vegyes vagy többes háttér) azonosított népesség százaléka.',
|
||||
'% Other':
|
||||
'A 2021-es Census alapján. A helyi hatóság területén egyéb etnikai csoportként (arab vagy bármely más, a főkategóriák által nem lefedett etnikai csoport) azonosított népesség százaléka.',
|
||||
'Winning party':
|
||||
'Az a politikai párt, amely a legtöbb szavazatot kapta az adott irányítószámhoz tartozó választókerületben a 2024. júliusi brit parlamenti választáson. Az Egyesült Királyság Parlamentje által közzétett, egyéni választókerületi rendszer szerinti eredmények alapján. A választókerületeket a 2023-as Határbizottsági felülvizsgálat alapján alakították át 2024-re.',
|
||||
'Voter turnout (%)':
|
||||
'A regisztrált szavazók azon aránya, akik érvényes szavazatot adtak le a 2024. júliusi brit parlamenti választáson. Az érvényes szavazatok száma osztva a választói névjegyzékben szereplők számával. A magasabb részvétel általában a tehetősebb területekkel és a szorosabb versenyekkel korrelál.',
|
||||
'Majority (%)':
|
||||
'A győztes jelölt és a második helyezett közötti szavazatkülönbség, az összes érvényes szavazat százalékában kifejezve. Kis többség billegő körzetre utal (versenyképes); nagy többség biztos körzetre. A 2024. júliusi brit parlamenti választás eredményeiből, amelyeket az Egyesült Királyság Parlamentje tett közzé.',
|
||||
'% Labour':
|
||||
'Az érvényes szavazatok százaléka, amelyeket a Munkáspártra adtak le az adott irányítószámhoz tartozó választókerületben a 2024. júliusi brit parlamenti választáson.',
|
||||
'% Conservative':
|
||||
'Az érvényes szavazatok százaléka, amelyeket a Konzervatív Pártra adtak le az adott irányítószámhoz tartozó választókerületben a 2024. júliusi brit parlamenti választáson.',
|
||||
'% Liberal Democrat':
|
||||
'Az érvényes szavazatok százaléka, amelyeket a Liberális Demokratákra adtak le az adott irányítószámhoz tartozó választókerületben a 2024. júliusi brit parlamenti választáson.',
|
||||
'% Reform UK':
|
||||
'Az érvényes szavazatok százaléka, amelyeket a Reform UK-ra adtak le az adott irányítószámhoz tartozó választókerületben a 2024. júliusi brit parlamenti választáson.',
|
||||
'% Green':
|
||||
'Az érvényes szavazatok százaléka, amelyeket a Zöld Pártra adtak le az adott irányítószámhoz tartozó választókerületben a 2024. júliusi brit parlamenti választáson.',
|
||||
'% Other parties':
|
||||
'Az érvényes szavazatok százaléka, amelyeket a Munkáspárton, Konzervatívokon, Liberális Demokratákon, Reform UK-n és Zöldeken kívüli pártokra adtak le. Tartalmazza a függetleneket, a Házelnököt és a kisebb pártokat.',
|
||||
'Distance to nearest park (km)':
|
||||
'Légvonalbeli távolság kilométerben az irányítószámtól a legközelebbi park bejáratáig. Magában foglalja a közparkokat, kerteket, játszótereket és szabadidős területeket. Az OS Open Greenspace adatkészlet hozzáférési pont helyszíneit használja, így a nagy park szomszédságában lévő ingatlanok helyesen rövid távolságot mutatnak.',
|
||||
'Number of parks within 2km':
|
||||
'A közparkok, kertek, játszóterek és szabadidős területek száma, amelyeknek legalább egy bejárata van az ingatlan irányítószám centroidjától számított 2 km-es körzetben. Az OS Open Greenspace adatkészletből (Ordnance Survey) származik, park bejárati helyszíneket használva a pontos közelségi egyeztetéshez.',
|
||||
'Number of parks within 1km':
|
||||
'A közparkok, kertek, játszóterek és szabadidős területek száma, amelyeknek legalább egy bejárata van az ingatlan irányítószám centroidjától számított 1 km-es körzetben. Az OS Open Greenspace adatkészletből (Ordnance Survey) származik, park bejárati helyszíneket használva a pontos közelségi egyeztetéshez.',
|
||||
'Number of restaurants within 2km':
|
||||
'Az ingatlan irányítószámjától 2 km-en belüli éttermek, kávézók és vendéglátóhelyek. Forrás: OpenStreetMap.',
|
||||
'Number of grocery shops and supermarkets within 2km':
|
||||
|
|
|
|||
|
|
@ -127,9 +127,6 @@ const de: Translations = {
|
|||
filters: {
|
||||
activeFilters: 'Aktive Filter',
|
||||
addFilter: 'Filter hinzufügen',
|
||||
historical: 'Historisch',
|
||||
buy: 'Kaufen',
|
||||
rent: 'Mieten',
|
||||
findingPerfectPostcode: 'Die perfekte Postleitzahl finden',
|
||||
addFiltersHint:
|
||||
'Füge unten Filter hinzu, um die Karte auf Gebiete einzugrenzen, die deinen Kriterien entsprechen',
|
||||
|
|
@ -214,9 +211,9 @@ const de: Translations = {
|
|||
aiSearch: 'KI-Suche',
|
||||
describeHint: 'beschreibe, wonach du suchst',
|
||||
placeholder: 'z. B. ruhige Gegend, unter £400k, nahe guten Schulen...',
|
||||
example1: 'Sichere Gegend nahe guten Schulen',
|
||||
example2: '30 Min. Pendelweg zu Kings Cross, unter £500k',
|
||||
example3: 'Ruhiges Dorf, 3 Schlafzimmer, schnelles Breitband',
|
||||
example1: 'Haus 40 Min. von Bank in einer Gegend mit wenig Kriminalität',
|
||||
example2: 'Wohnungen in der Nähe guter Grundschulen, nicht weit von Manchester',
|
||||
example3: 'Beste Ex-Council-Häuser unter 200k',
|
||||
analysing: 'Anfrage wird analysiert...',
|
||||
searchingDestinations: 'Ziele werden gesucht...',
|
||||
generatingFilters: 'Filter werden generiert...',
|
||||
|
|
@ -229,8 +226,6 @@ const de: Translations = {
|
|||
mapLegend: {
|
||||
clearColourView: 'Farbansicht zurücksetzen',
|
||||
historicalMatches: 'Historische Immobilientreffer',
|
||||
propertiesForSale: 'Immobilien zum Verkauf',
|
||||
propertiesForRent: 'Immobilien zur Miete',
|
||||
numberOfProperties: 'Anzahl der Immobilien',
|
||||
previewing: 'Vorschau von \u201c{{name}}\u201d',
|
||||
},
|
||||
|
|
@ -240,24 +235,18 @@ const de: Translations = {
|
|||
unknownAddress: 'Unbekannte Adresse',
|
||||
unsaveProperty: 'Immobilie nicht mehr merken',
|
||||
saveProperty: 'Immobilie merken',
|
||||
lastSold: 'Letzter Verkauf: £{{price}}',
|
||||
estValue: 'Gesch. Wert:',
|
||||
type: 'Typ:',
|
||||
builtForm: 'Bauweise:',
|
||||
tenure: 'Besitzart:',
|
||||
floorArea: 'Wohnfläche:',
|
||||
bedrooms: 'Schlafzimmer:',
|
||||
bathrooms: 'Badezimmer:',
|
||||
rooms: 'Zimmer:',
|
||||
built: 'Baujahr:',
|
||||
formerCouncil: 'Ehem. Sozialbau:',
|
||||
exCouncilBadge: 'Ehem. Sozialbau',
|
||||
epcRating: 'EPC-Bewertung:',
|
||||
epcPotential: 'EPC-Potenzial:',
|
||||
listed: 'Inseriert:',
|
||||
keyFeatures: 'Hauptmerkmale',
|
||||
renovations: 'Renovierungen',
|
||||
viewExternalListing: 'Externes Inserat ansehen',
|
||||
perMonth: '/Monat',
|
||||
perSqm: '/m²',
|
||||
searchPlaceholder: 'Nach Adresse oder Postleitzahl suchen...',
|
||||
propertyData: 'Immobiliendaten',
|
||||
|
|
@ -278,6 +267,7 @@ const de: Translations = {
|
|||
viewOnGoogleMaps: 'Auf Google Maps ansehen',
|
||||
walk: 'Zu Fuß',
|
||||
cycle: 'Fahrrad',
|
||||
nationalAvg: 'Landesdurchschnitt',
|
||||
},
|
||||
|
||||
// ── Histogram Legend ───────────────────────────────
|
||||
|
|
@ -308,6 +298,7 @@ const de: Translations = {
|
|||
// ── External Search Links ──────────────────────────
|
||||
externalSearch: {
|
||||
searchOn: '{{radius}} suchen auf',
|
||||
exact: 'genau',
|
||||
outcodeNotRecognised: 'Postleitzahlenbereich nicht erkannt',
|
||||
},
|
||||
|
||||
|
|
@ -358,7 +349,6 @@ const de: Translations = {
|
|||
howStep4Desc:
|
||||
'Jedes Gebiet auf deiner Liste erfüllt deine tatsächlichen Kriterien — nicht nur, was diese Woche inseriert war.',
|
||||
othersVs: 'Andere vs',
|
||||
listingPortals: 'Immobilienportale',
|
||||
checkMyPostcode: '„Meine Postleitzahl prüfen“',
|
||||
areaGuides: 'Gebietsratgeber',
|
||||
compSearchWithout: 'Suchen, ohne zuerst ein Gebiet auszuwählen',
|
||||
|
|
@ -484,6 +474,10 @@ const de: Translations = {
|
|||
dsRentalOrigin: 'ONS / Valuation Office Agency',
|
||||
dsRentalUse:
|
||||
'Monatliche Medianmieten des privaten Mietmarkts nach Bezirk und Schlafzimmerkategorie (Okt. 2022 - Sept. 2023). Über Bezirkscode und geschätzte Schlafzimmeranzahl mit Immobilien verknüpft.',
|
||||
dsElectionName: 'Ergebnisse der Parlamentswahl 2024',
|
||||
dsElectionOrigin: 'Britisches Parlament',
|
||||
dsElectionUse:
|
||||
'Ergebnisse auf Kandidatenebene der britischen Parlamentswahl vom Juli 2024. Aggregiert auf Wahlkreisebene: siegreiche Partei, Wahlbeteiligung (%) und Mehrheit (%). Über den Wahlkreiscode (pcon) aus dem NSPL-Postleitzahlenverzeichnis mit Immobilien verknüpft.',
|
||||
// FAQ section titles
|
||||
faqFindingTitle: 'Ihr Gebiet finden',
|
||||
faqCommuteTitle: 'Pendelweg und Reisezeit',
|
||||
|
|
@ -604,7 +598,6 @@ const de: Translations = {
|
|||
noSavedPropertiesDesc:
|
||||
'Merke dir Immobilien während du erkundest und erstelle deine Auswahlliste, ohne den Überblick zu verlieren.',
|
||||
openPostcode: 'Postleitzahl öffnen',
|
||||
viewListing: 'Inserat ansehen',
|
||||
clickToRename: 'Klicken zum Umbenennen',
|
||||
notesPlaceholder: 'Notiere deine Gedanken...',
|
||||
deleteSearch: 'Suche löschen',
|
||||
|
|
@ -722,27 +715,21 @@ const de: Translations = {
|
|||
Deprivation: 'Benachteiligung',
|
||||
Crime: 'Kriminalität',
|
||||
Demographics: 'Demografie',
|
||||
Politics: 'Politik',
|
||||
Amenities: 'Infrastruktur',
|
||||
|
||||
// ─ Feature names (Properties) ─
|
||||
'Listing status': 'Inseratsstatus',
|
||||
'Property type': 'Immobilientyp',
|
||||
'Leasehold/Freehold': 'Erbbaurecht/Volleigentum',
|
||||
'Last known price': 'Letzter bekannter Preis',
|
||||
'Estimated current price': 'Geschätzter aktueller Preis',
|
||||
'Asking price': 'Angebotspreis',
|
||||
'Price per sqm': 'Preis pro m²',
|
||||
'Est. price per sqm': 'Gesch. Preis pro m²',
|
||||
'Asking price per sqm': 'Angebotspreis pro m²',
|
||||
'Estimated monthly rent': 'Geschätzte Monatsmiete',
|
||||
'Asking rent (monthly)': 'Angebotsmiete (monatlich)',
|
||||
'Total floor area (sqm)': 'Gesamtwohnfläche (m²)',
|
||||
'Number of bedrooms & living rooms': 'Anzahl Schlaf- & Wohnzimmer',
|
||||
Bedrooms: 'Schlafzimmer',
|
||||
Bathrooms: 'Badezimmer',
|
||||
'Construction year': 'Baujahr',
|
||||
'Date of last transaction': 'Datum der letzten Transaktion',
|
||||
'Listing date': 'Inseratsdatum',
|
||||
'Former council house': 'Ehemaliger Sozialbau',
|
||||
'Current energy rating': 'Aktuelle Energiebewertung',
|
||||
'Potential energy rating': 'Potenzielle Energiebewertung',
|
||||
|
|
@ -798,9 +785,20 @@ const de: Translations = {
|
|||
'% Mixed': '% Gemischt',
|
||||
'% Other': '% Sonstige',
|
||||
|
||||
// ─ Feature names (Politics) ─
|
||||
'Winning party': 'Siegreiche Partei',
|
||||
'Voter turnout (%)': 'Wahlbeteiligung (%)',
|
||||
'Majority (%)': 'Mehrheit (%)',
|
||||
'% Labour': '% Labour',
|
||||
'% Conservative': '% Conservative',
|
||||
'% Liberal Democrat': '% Liberal Democrat',
|
||||
'% Reform UK': '% Reform UK',
|
||||
'% Green': '% Grüne',
|
||||
'% Other parties': '% Sonstige Parteien',
|
||||
|
||||
// ─ Feature names (Amenities) ─
|
||||
'Distance to nearest park (km)': 'Entfernung zum nächsten Park (km)',
|
||||
'Number of parks within 2km': 'Anzahl Parks im Umkreis von 2 km',
|
||||
'Number of parks within 1km': 'Anzahl Parks im Umkreis von 1 km',
|
||||
'Number of restaurants within 2km': 'Anzahl Restaurants im Umkreis von 2 km',
|
||||
'Number of grocery shops and supermarkets within 2km':
|
||||
'Anzahl Lebensmittelgeschäfte und Supermärkte im Umkreis von 2 km',
|
||||
|
|
@ -808,9 +806,12 @@ const de: Translations = {
|
|||
'Max available download speed (Mbps)': 'Max. verfügbare Downloadgeschwindigkeit (Mbps)',
|
||||
|
||||
// ─ Enum values ─
|
||||
'Historical sale': 'Historischer Verkauf',
|
||||
'For sale': 'Zum Verkauf',
|
||||
'For rent': 'Zur Miete',
|
||||
Labour: 'Labour',
|
||||
Conservative: 'Conservative',
|
||||
'Liberal Democrat': 'Liberal Democrat',
|
||||
'Reform UK': 'Reform UK',
|
||||
Green: 'Grüne',
|
||||
'Other parties': 'Sonstige Parteien',
|
||||
Detached: 'Freistehend',
|
||||
'Semi-Detached': 'Doppelhaushälfte',
|
||||
Terraced: 'Reihenhaus',
|
||||
|
|
@ -844,7 +845,7 @@ const de: Translations = {
|
|||
'Bus stop': 'Bushaltestelle',
|
||||
'Bus station': 'Busbahnhof',
|
||||
'Taxi rank': 'Taxistand',
|
||||
'Metro or Tram stop': 'U-Bahn- oder Straßenbahnhaltestelle',
|
||||
'Tube station': 'U-Bahn-Station',
|
||||
Café: 'Café',
|
||||
Restaurant: 'Restaurant',
|
||||
Pub: 'Pub',
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ const en = {
|
|||
upgrade: {
|
||||
title: 'See all of England',
|
||||
description:
|
||||
"You're currently exploring the demo area. Get lifetime access to every postcode, every filter, every neighbourhood. One payment, forever.",
|
||||
"You’re currently exploring the demo area. Get lifetime access to every postcode, every filter, every neighbourhood. One payment, forever.",
|
||||
free: 'Free',
|
||||
once: '/once',
|
||||
freeForEarly: 'Free for early adopters. No credit card required.',
|
||||
|
|
@ -115,7 +115,7 @@ const en = {
|
|||
|
||||
// ── License Success ────────────────────────────────
|
||||
licenseSuccess: {
|
||||
title: "You're in.",
|
||||
title: "You’re in.",
|
||||
subtitle: 'Your lifetime access is now active.',
|
||||
description: 'Full access to every feature, every postcode, across all of England.',
|
||||
startExploring: 'Start exploring',
|
||||
|
|
@ -125,9 +125,6 @@ const en = {
|
|||
filters: {
|
||||
activeFilters: 'Active Filters',
|
||||
addFilter: 'Add Filter',
|
||||
historical: 'Historical',
|
||||
buy: 'Buy',
|
||||
rent: 'Rent',
|
||||
findingPerfectPostcode: 'Finding the Perfect Postcode',
|
||||
addFiltersHint: 'Add filters below to narrow the map to areas that match your criteria',
|
||||
upgradePrompt:
|
||||
|
|
@ -209,25 +206,23 @@ const en = {
|
|||
aiFilter: {
|
||||
describeIdealArea: 'Describe your ideal area with AI',
|
||||
aiSearch: 'AI Search',
|
||||
describeHint: "describe what you're looking for",
|
||||
describeHint: "describe what you’re looking for",
|
||||
placeholder: 'e.g. quiet area, under £400k, near good schools...',
|
||||
example1: 'Safe area near good schools',
|
||||
example2: '30 min commute to Kings Cross, under £500k',
|
||||
example3: 'Quiet village, 3 bed, fast broadband',
|
||||
example1: 'House 40 mins from Bank in a low crime area',
|
||||
example2: 'Flats around good primary schools not too far from Manchester',
|
||||
example3: 'Best ex-council houses under 200k',
|
||||
analysing: 'Analysing your query...',
|
||||
searchingDestinations: 'Searching for destinations...',
|
||||
generatingFilters: 'Generating filters...',
|
||||
refiningResults: 'Refining results...',
|
||||
weeklyLimitReached:
|
||||
"You've reached the weekly AI usage limit. It will reset automatically next week.",
|
||||
"You’ve reached the weekly AI usage limit. It will reset automatically next week.",
|
||||
},
|
||||
|
||||
// ── Map Legend ─────────────────────────────────────
|
||||
mapLegend: {
|
||||
clearColourView: 'Clear colour view',
|
||||
historicalMatches: 'Historical property matches',
|
||||
propertiesForSale: 'Properties for sale',
|
||||
propertiesForRent: 'Properties for rent',
|
||||
numberOfProperties: 'Number of properties',
|
||||
previewing: 'Previewing \u201c{{name}}\u201d',
|
||||
},
|
||||
|
|
@ -237,24 +232,18 @@ const en = {
|
|||
unknownAddress: 'Unknown Address',
|
||||
unsaveProperty: 'Unsave property',
|
||||
saveProperty: 'Save property',
|
||||
lastSold: 'Last sold: £{{price}}',
|
||||
estValue: 'Est. value:',
|
||||
type: 'Type:',
|
||||
builtForm: 'Built form:',
|
||||
tenure: 'Tenure:',
|
||||
floorArea: 'Floor area:',
|
||||
bedrooms: 'Bedrooms:',
|
||||
bathrooms: 'Bathrooms:',
|
||||
rooms: 'Rooms:',
|
||||
built: 'Built:',
|
||||
formerCouncil: 'Ex-council:',
|
||||
exCouncilBadge: 'Ex-council',
|
||||
epcRating: 'EPC rating:',
|
||||
epcPotential: 'EPC potential:',
|
||||
listed: 'Listed:',
|
||||
keyFeatures: 'Key features',
|
||||
renovations: 'Renovations',
|
||||
viewExternalListing: 'View external listing',
|
||||
perMonth: '/mo',
|
||||
perSqm: '/m²',
|
||||
searchPlaceholder: 'Search by address or postcode...',
|
||||
propertyData: 'Property Data',
|
||||
|
|
@ -275,6 +264,7 @@ const en = {
|
|||
viewOnGoogleMaps: 'View on Google Maps',
|
||||
walk: 'Walk',
|
||||
cycle: 'Cycle',
|
||||
nationalAvg: 'National avg',
|
||||
},
|
||||
|
||||
// ── Histogram Legend ───────────────────────────────
|
||||
|
|
@ -305,6 +295,7 @@ const en = {
|
|||
// ── External Search Links ──────────────────────────
|
||||
externalSearch: {
|
||||
searchOn: 'Search {{radius}} on',
|
||||
exact: 'exact',
|
||||
outcodeNotRecognised: 'Outcode not recognised',
|
||||
},
|
||||
|
||||
|
|
@ -340,7 +331,7 @@ const en = {
|
|||
statPostcodeInEngland: 'postcode in England',
|
||||
ourPhilosophy: 'Our philosophy',
|
||||
philosophyP1:
|
||||
"On Rightmove, you pick an area first, then hope it's good. You end up cross-referencing crime stats, school reports, and broadband checkers across a dozen tabs, one postcode at a time.",
|
||||
"On Rightmove, you pick an area first, then hope it’s good. You end up cross-referencing crime stats, school reports, and broadband checkers across a dozen tabs, one postcode at a time.",
|
||||
philosophyP2:
|
||||
'We flip that. Tell us what you need (budget, commute, schools, safety) and we show you every area in England that qualifies. No guesswork. No wasted viewings.',
|
||||
howToUseIt: 'How to use it',
|
||||
|
|
@ -354,7 +345,6 @@ const en = {
|
|||
howStep4Desc:
|
||||
'Every area on your list meets your actual criteria — not just what was listed that week.',
|
||||
othersVs: 'Others vs',
|
||||
listingPortals: 'Listing portals',
|
||||
checkMyPostcode: '“Check my postcode”',
|
||||
areaGuides: 'Area guides',
|
||||
compSearchWithout: 'Search without choosing an area first',
|
||||
|
|
@ -366,7 +356,7 @@ const en = {
|
|||
compFilters: '56 combinable filters in one place',
|
||||
compFiltersSub: '(all insights, one interactive map)',
|
||||
ctaTitle: 'Make your biggest investment your smartest move.',
|
||||
ctaDescription: "This deserves proper tools behind it, don't leave it to luck.",
|
||||
ctaDescription: "This deserves proper tools behind it, don’t leave it to luck.",
|
||||
},
|
||||
|
||||
// ── Pricing Page ───────────────────────────────────
|
||||
|
|
@ -374,7 +364,7 @@ const en = {
|
|||
title: 'Early access pricing',
|
||||
subtitle: 'Pay once, access forever. The earlier you join, the less you pay.',
|
||||
costContext:
|
||||
"Buying a home costs £10k+ in stamp duty, £1,500 in solicitor fees, £500 for a survey. Get the wrong area and you're stuck with a long commute, bad schools, or a road you didn't know about.",
|
||||
"Buying a home costs £10k+ in stamp duty, £1,500 in solicitor fees, £500 for a survey. Get the wrong area and you’re stuck with a long commute, bad schools, or a road you didn’t know about.",
|
||||
lessThanSurvey: 'Less than a home survey. Far more useful.',
|
||||
currentTier: 'Current tier',
|
||||
firstNUsers: 'First {{count}} users',
|
||||
|
|
@ -408,7 +398,7 @@ const en = {
|
|||
dataSourcesIntro:
|
||||
'This application combines {{count}} open datasets covering property prices, energy performance, transport, demographics, crime, environment, and more.',
|
||||
faqIntro:
|
||||
"Whether you're buying, renting, or just exploring, here's how Perfect Postcode helps you find the right area.",
|
||||
"Whether you’re buying, renting, or just exploring, here’s how Perfect Postcode helps you find the right area.",
|
||||
supportIntro: 'Have a question? Check our FAQ or reach out to us directly.',
|
||||
source: 'Source:',
|
||||
optOut: 'Opt out of public disclosure',
|
||||
|
|
@ -478,6 +468,10 @@ const en = {
|
|||
dsRentalOrigin: 'ONS / Valuation Office Agency',
|
||||
dsRentalUse:
|
||||
'Median monthly private rental prices by local authority and bedroom category (Oct 2022 - Sep 2023). Joined to properties via local authority district code and estimated bedroom count.',
|
||||
dsElectionName: '2024 General Election Results',
|
||||
dsElectionOrigin: 'UK Parliament',
|
||||
dsElectionUse:
|
||||
'Candidate-level results for the July 2024 UK General Election. Aggregated to constituency level: winning party, voter turnout (%), and majority (%). Joined to properties via the parliamentary constituency code (pcon) from the NSPL postcode lookup.',
|
||||
// FAQ section titles
|
||||
faqFindingTitle: 'Finding Your Area',
|
||||
faqCommuteTitle: 'Commute and Travel',
|
||||
|
|
@ -489,10 +483,10 @@ const en = {
|
|||
faqPricingTitle: 'Pricing and Access',
|
||||
faqTipsTitle: 'Tips and Tricks',
|
||||
// FAQ items — Finding Your Area
|
||||
faqFinding1Q: "I don't even know which areas to look at. Can this help?",
|
||||
faqFinding1Q: "I don’t even know which areas to look at. Can this help?",
|
||||
faqFinding1A:
|
||||
'That\'s exactly what it\'s for. Set your filters (budget, commute time, low crime, good schools) and the map lights up to show you every area that ticks every box. No more Googling "best areas to live near Manchester" at midnight.',
|
||||
faqFinding2Q: "I'm moving somewhere I've never been. How do I even start?",
|
||||
faqFinding2Q: "I’m moving somewhere I’ve never been. How do I even start?",
|
||||
faqFinding2A:
|
||||
'Set your filters for what matters and the map instantly highlights the areas that qualify. You go from "I don\'t know a single street" to a shortlist in minutes.',
|
||||
faqFinding3Q: 'How do I find areas that tick all my boxes at once?',
|
||||
|
|
@ -501,17 +495,17 @@ const en = {
|
|||
// FAQ items — Commute and Travel
|
||||
faqCommute1Q: 'Can I see how long my commute would actually be from different areas?',
|
||||
faqCommute1A:
|
||||
"Set your workplace as a destination and we'll colour every postcode by journey time, whether that's by car, bike, or public transport. Filter to your max commute and the rest disappears.",
|
||||
"Set your workplace as a destination and we’ll colour every postcode by journey time, whether that’s by car, bike, or public transport. Filter to your max commute and the rest disappears.",
|
||||
faqCommute2Q: 'How is that better than checking Google Maps?',
|
||||
faqCommute2A:
|
||||
'Google Maps shows you one journey at a time. We colour every postcode in England by commute time in one go, so you can compare hundreds of areas side by side instead of searching them one by one.',
|
||||
// FAQ items — Budget and Value
|
||||
faqBudget1Q: 'How do I find areas where I get the most space for my money?',
|
||||
faqBudget1A:
|
||||
"Filter by price per sqm and you'll instantly see which postcodes give you the most space per pound. Pair it with the energy rating filter to avoid properties with high heating costs.",
|
||||
faqBudget2Q: "How do I make sure a cheap area isn't cheap for a reason?",
|
||||
"Filter by price per sqm and you’ll instantly see which postcodes give you the most space per pound. Pair it with the energy rating filter to avoid properties with high heating costs.",
|
||||
faqBudget2Q: "How do I make sure a cheap area isn’t cheap for a reason?",
|
||||
faqBudget2A:
|
||||
"Layer deprivation scores, crime stats, school ratings, and broadband speeds alongside price. If a postcode is affordable and scores well on everything that matters, you've found genuine value, not just a low price with trade-offs you haven't spotted yet.",
|
||||
"Layer deprivation scores, crime stats, school ratings, and broadband speeds alongside price. If a postcode is affordable and scores well on everything that matters, you’ve found genuine value, not just a low price with trade-offs you haven’t spotted yet.",
|
||||
// FAQ items — Safety and Neighbourhood
|
||||
faqSafety1Q: 'How can I check if an area is safe before I move there?',
|
||||
faqSafety1A:
|
||||
|
|
@ -519,7 +513,7 @@ const en = {
|
|||
faqSafety2Q:
|
||||
'I keep finding flats that look great online, then the area turns out to be rough.',
|
||||
faqSafety2A:
|
||||
"That's exactly why this exists. Stack crime rates, noise levels, deprivation scores, nearby pubs and parks, and broadband speeds all on one map so you know what a neighbourhood is actually like before you book a viewing.",
|
||||
"That’s exactly why this exists. Stack crime rates, noise levels, deprivation scores, nearby pubs and parks, and broadband speeds all on one map so you know what a neighbourhood is actually like before you book a viewing.",
|
||||
// FAQ items — Families and Schools
|
||||
faqFamilies1Q: 'Can I find areas with good schools AND low crime in one search?',
|
||||
faqFamilies1A:
|
||||
|
|
@ -528,7 +522,7 @@ const en = {
|
|||
faqFamilies2A:
|
||||
'Toggle on the parks and green spaces POI layer to see them right on the map. You can also filter by how many are within walking distance of each postcode.',
|
||||
// FAQ items — Environment and Quality of Life
|
||||
faqEnv1Q: "Can I find energy-efficient homes that aren't on a noisy road?",
|
||||
faqEnv1Q: "Can I find energy-efficient homes that aren’t on a noisy road?",
|
||||
faqEnv1A:
|
||||
'Filter by EPC rating (A to C), then layer on road noise data to rule out anything above your threshold. Colour-code by either feature to spot quiet, efficient streets at a glance.',
|
||||
faqEnv2Q: 'Does it show flood or subsidence risk?',
|
||||
|
|
@ -541,19 +535,19 @@ const en = {
|
|||
faqWhy1Q: 'I already use Rightmove. What does this add?',
|
||||
faqWhy1A:
|
||||
'Rightmove shows you houses. We show you areas. Crime rates, school ratings, broadband speeds, noise levels, deprivation scores, and more, all filterable on one map. You can judge a neighbourhood before you even look at listings.',
|
||||
faqWhy2Q: "Can't I just research all this myself for free?",
|
||||
faqWhy2Q: "Can’t I just research all this myself for free?",
|
||||
faqWhy2A:
|
||||
'You could cross-reference police data, Ofsted reports, EPC registers, Land Registry records, and ONS statistics one postcode at a time. Or you could have it all filterable and colour-coded on one map in seconds.',
|
||||
faqWhy3Q: 'Where does the data actually come from?',
|
||||
faqWhy3A:
|
||||
"Every dataset comes from official UK government sources: Land Registry, the EPC register, ONS, Ofsted, Ofcom, data.police.uk, and Defra. We don't scrape estate agents or make anything up. You can verify any record against the original source.",
|
||||
"Every dataset comes from official UK government sources: Land Registry, the EPC register, ONS, Ofsted, Ofcom, data.police.uk, and Defra. We don’t scrape estate agents or make anything up. You can verify any record against the original source.",
|
||||
// FAQ items — Pricing and Access
|
||||
faqPricing1Q: 'Is it really worth paying for a property search tool?',
|
||||
faqPricing1A:
|
||||
"Buying a home is likely the biggest purchase you'll make. Spotting one red flag (a noisy road, poor broadband, rising crime) before committing could save you years of regret. This costs less than a tank of petrol.",
|
||||
"Buying a home is likely the biggest purchase you’ll make. Spotting one red flag (a noisy road, poor broadband, rising crime) before committing could save you years of regret. This costs less than a tank of petrol.",
|
||||
faqPricing2Q: 'Is this a subscription?',
|
||||
faqPricing2A:
|
||||
"No. One-time payment, yours forever. Use it intensively during your search, come back whenever you're curious about a new area, and it's still there if you ever move again.",
|
||||
"No. One-time payment, yours forever. Use it intensively during your search, come back whenever you’re curious about a new area, and it’s still there if you ever move again.",
|
||||
faqPricing3Q: 'What can I access on the free tier?',
|
||||
faqPricing3A:
|
||||
'Free users can explore all features within the demo area (inner London, roughly zones 1 to 2). To access data for the rest of England, you need lifetime access.',
|
||||
|
|
@ -567,7 +561,7 @@ const en = {
|
|||
faqTips2Q: 'Can I save a search and come back to it later?',
|
||||
faqTips2A:
|
||||
'Hit the save button and everything is captured: your filters, zoom level, and which data layer you’re colouring by. Pick up exactly where you left off or share the link with your partner.',
|
||||
faqTips3Q: "Can I export the data I'm looking at?",
|
||||
faqTips3Q: "Can I export the data I’m looking at?",
|
||||
faqTips3A:
|
||||
'Use the export button to download the currently filtered properties as a spreadsheet. The export respects all your active filters, so you get exactly the data you want.',
|
||||
},
|
||||
|
|
@ -593,7 +587,6 @@ const en = {
|
|||
noSavedPropertiesDesc:
|
||||
'Bookmark properties as you explore and build your shortlist without losing track.',
|
||||
openPostcode: 'Open postcode',
|
||||
viewListing: 'View listing',
|
||||
clickToRename: 'Click to rename',
|
||||
notesPlaceholder: 'Jot down your thoughts...',
|
||||
deleteSearch: 'Delete search',
|
||||
|
|
@ -627,7 +620,7 @@ const en = {
|
|||
|
||||
// ── Invite Page ────────────────────────────────────
|
||||
invitePage: {
|
||||
youreInvited: "You're invited!",
|
||||
youreInvited: "You’re invited!",
|
||||
specialOffer: 'Special offer!',
|
||||
invitedByFree: '{{name}} has invited you to get free lifetime access.',
|
||||
invitedByDiscount: '{{name}} has shared a 30% discount on lifetime access.',
|
||||
|
|
@ -709,27 +702,21 @@ const en = {
|
|||
Deprivation: 'Deprivation',
|
||||
Crime: 'Crime',
|
||||
Demographics: 'Demographics',
|
||||
Politics: 'Politics',
|
||||
Amenities: 'Amenities',
|
||||
|
||||
// ─ Feature names (Properties) ─
|
||||
'Listing status': 'Listing status',
|
||||
'Property type': 'Property type',
|
||||
'Leasehold/Freehold': 'Leasehold/Freehold',
|
||||
'Last known price': 'Last known price',
|
||||
'Estimated current price': 'Estimated current price',
|
||||
'Asking price': 'Asking price',
|
||||
'Price per sqm': 'Price per sqm',
|
||||
'Est. price per sqm': 'Est. price per sqm',
|
||||
'Asking price per sqm': 'Asking price per sqm',
|
||||
'Estimated monthly rent': 'Estimated monthly rent',
|
||||
'Asking rent (monthly)': 'Asking rent (monthly)',
|
||||
'Total floor area (sqm)': 'Total floor area (sqm)',
|
||||
'Number of bedrooms & living rooms': 'Number of bedrooms & living rooms',
|
||||
Bedrooms: 'Bedrooms',
|
||||
Bathrooms: 'Bathrooms',
|
||||
'Construction year': 'Construction year',
|
||||
'Date of last transaction': 'Date of last transaction',
|
||||
'Listing date': 'Listing date',
|
||||
'Former council house': 'Former council house',
|
||||
'Current energy rating': 'Current energy rating',
|
||||
'Potential energy rating': 'Potential energy rating',
|
||||
|
|
@ -783,9 +770,20 @@ const en = {
|
|||
'% Mixed': '% Mixed',
|
||||
'% Other': '% Other',
|
||||
|
||||
// ─ Feature names (Politics) ─
|
||||
'Winning party': 'Winning party',
|
||||
'Voter turnout (%)': 'Voter turnout (%)',
|
||||
'Majority (%)': 'Majority (%)',
|
||||
'% Labour': '% Labour',
|
||||
'% Conservative': '% Conservative',
|
||||
'% Liberal Democrat': '% Liberal Democrat',
|
||||
'% Reform UK': '% Reform UK',
|
||||
'% Green': '% Green',
|
||||
'% Other parties': '% Other parties',
|
||||
|
||||
// ─ Feature names (Amenities) ─
|
||||
'Distance to nearest park (km)': 'Distance to nearest park (km)',
|
||||
'Number of parks within 2km': 'Number of parks within 2km',
|
||||
'Number of parks within 1km': 'Number of parks within 1km',
|
||||
'Number of restaurants within 2km': 'Number of restaurants within 2km',
|
||||
'Number of grocery shops and supermarkets within 2km':
|
||||
'Number of grocery shops and supermarkets within 2km',
|
||||
|
|
@ -793,9 +791,12 @@ const en = {
|
|||
'Max available download speed (Mbps)': 'Max available download speed (Mbps)',
|
||||
|
||||
// ─ Enum values ─
|
||||
'Historical sale': 'Historical sale',
|
||||
'For sale': 'For sale',
|
||||
'For rent': 'For rent',
|
||||
Labour: 'Labour',
|
||||
Conservative: 'Conservative',
|
||||
'Liberal Democrat': 'Liberal Democrat',
|
||||
'Reform UK': 'Reform UK',
|
||||
Green: 'Green',
|
||||
'Other parties': 'Other parties',
|
||||
Detached: 'Detached',
|
||||
'Semi-Detached': 'Semi-Detached',
|
||||
Terraced: 'Terraced',
|
||||
|
|
@ -829,7 +830,7 @@ const en = {
|
|||
'Bus stop': 'Bus stop',
|
||||
'Bus station': 'Bus station',
|
||||
'Taxi rank': 'Taxi rank',
|
||||
'Metro or Tram stop': 'Metro or Tram stop',
|
||||
'Tube station': 'Tube station',
|
||||
Café: 'Café',
|
||||
Restaurant: 'Restaurant',
|
||||
Pub: 'Pub',
|
||||
|
|
@ -912,7 +913,7 @@ const en = {
|
|||
export default en;
|
||||
|
||||
/**
|
||||
* Recursively maps a translation object's leaf values to `string`,
|
||||
* Recursively maps a translation object’s leaf values to `string`,
|
||||
* preserving the nested key structure. Used to type-check non-English
|
||||
* locale files: they must have exactly the same keys as English,
|
||||
* but with their own string values.
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ const fr: Translations = {
|
|||
|
||||
// ── Upgrade Modal ──────────────────────────────────
|
||||
upgrade: {
|
||||
title: "Découvrez toute l'Angleterre",
|
||||
title: "Découvrez toute l’Angleterre",
|
||||
description:
|
||||
'Vous explorez actuellement la zone de démonstration. Obtenez un accès à vie à chaque code postal, chaque filtre, chaque quartier. Un seul paiement, pour toujours.',
|
||||
free: 'Gratuit',
|
||||
|
|
@ -97,9 +97,9 @@ const fr: Translations = {
|
|||
freeForEarly: 'Gratuit pour les premiers utilisateurs. Aucune carte bancaire requise.',
|
||||
oneTimePayment: 'Paiement unique. Accès à vie. Garantie satisfait ou remboursé sous 30 jours.',
|
||||
redirecting: 'Redirection...',
|
||||
claimFreeAccess: "Réclamer l'accès gratuit",
|
||||
claimFreeAccess: "Réclamer l’accès gratuit",
|
||||
upgradeFor: 'Passer à la version complète pour {{price}}',
|
||||
registerAndUpgrade: "S'inscrire et passer à la version complète",
|
||||
registerAndUpgrade: "S’inscrire et passer à la version complète",
|
||||
alreadyHaveAccount: 'Vous avez déjà un compte ? Connectez-vous',
|
||||
continueWithDemo: 'Continuer avec la démo',
|
||||
checkoutFailed: 'Échec du paiement',
|
||||
|
|
@ -118,10 +118,10 @@ const fr: Translations = {
|
|||
|
||||
// ── License Success ────────────────────────────────
|
||||
licenseSuccess: {
|
||||
title: "C'est fait.",
|
||||
title: "C’est fait.",
|
||||
subtitle: 'Votre accès à vie est maintenant actif.',
|
||||
description:
|
||||
"Accès complet à chaque fonctionnalité, chaque code postal, dans toute l'Angleterre.",
|
||||
"Accès complet à chaque fonctionnalité, chaque code postal, dans toute l’Angleterre.",
|
||||
startExploring: 'Commencer à explorer',
|
||||
},
|
||||
|
||||
|
|
@ -129,14 +129,11 @@ const fr: Translations = {
|
|||
filters: {
|
||||
activeFilters: 'Filtres actifs',
|
||||
addFilter: 'Ajouter un filtre',
|
||||
historical: 'Historique',
|
||||
buy: 'Acheter',
|
||||
rent: 'Louer',
|
||||
findingPerfectPostcode: 'Trouver le code postal idéal',
|
||||
addFiltersHint:
|
||||
'Ajoutez des filtres ci-dessous pour restreindre la carte aux zones correspondant à vos critères',
|
||||
upgradePrompt:
|
||||
"Voir la criminalité, les écoles, le bruit, le débit internet et plus de 50 filtres dans toute l'Angleterre.",
|
||||
"Voir la criminalité, les écoles, le bruit, le débit internet et plus de 50 filtres dans toute l’Angleterre.",
|
||||
oneTimeLifetime: 'Paiement unique, accès à vie.',
|
||||
upgradeToFullMap: 'Passer à la carte complète',
|
||||
chooseFilters:
|
||||
|
|
@ -171,7 +168,7 @@ const fr: Translations = {
|
|||
step5Desc: '(restaurants, parcs, débit internet)',
|
||||
step6Title: 'Énergie',
|
||||
step6Desc: '(classements DPE, isolation, coûts de chauffage)',
|
||||
tip: "Astuce : si rien ne correspond, assouplissez un critère à la fois pour voir quel compromis ouvre le plus d'options.",
|
||||
tip: "Astuce : si rien ne correspond, assouplissez un critère à la fois pour voir quel compromis ouvre le plus d’options.",
|
||||
},
|
||||
|
||||
// ── Travel Time ────────────────────────────────────
|
||||
|
|
@ -182,9 +179,9 @@ const fr: Translations = {
|
|||
bestCase: 'Meilleur cas',
|
||||
bestCaseTitle: 'Meilleur temps de trajet',
|
||||
bestCaseDesc:
|
||||
"Utilise le temps de trajet réaliste le plus rapide (si vous partez au bon moment et avez de bonnes correspondances). Par défaut, la <strong>médiane</strong> est utilisée, représentant un trajet typique quelle que soit l'heure de départ.",
|
||||
"Utilise le temps de trajet réaliste le plus rapide (si vous partez au bon moment et avez de bonnes correspondances). Par défaut, la <strong>médiane</strong> est utilisée, représentant un trajet typique quelle que soit l’heure de départ.",
|
||||
previewOnMap: 'Aperçu sur la carte',
|
||||
stopPreviewing: "Arrêter l'aperçu",
|
||||
stopPreviewing: "Arrêter l’aperçu",
|
||||
removeTravelTime: 'Supprimer le temps de trajet',
|
||||
addTravelTime: 'Ajouter le temps de trajet en {{mode}}',
|
||||
clearDestination: 'Effacer la destination',
|
||||
|
|
@ -218,9 +215,9 @@ const fr: Translations = {
|
|||
aiSearch: 'Recherche IA',
|
||||
describeHint: 'décrivez ce que vous recherchez',
|
||||
placeholder: 'ex. quartier calme, moins de £400k, près de bonnes écoles...',
|
||||
example1: 'Quartier sûr près de bonnes écoles',
|
||||
example2: '30 min de trajet jusqu’à Kings Cross, moins de £500k',
|
||||
example3: 'Village tranquille, 3 chambres, débit internet rapide',
|
||||
example1: 'Maison à 40 min de Bank dans un quartier peu criminel',
|
||||
example2: 'Appartements près de bonnes écoles primaires, pas trop loin de Manchester',
|
||||
example3: 'Meilleures maisons ex-council à moins de 200k',
|
||||
analysing: 'Analyse de votre requête...',
|
||||
searchingDestinations: 'Recherche de destinations...',
|
||||
generatingFilters: 'Génération des filtres...',
|
||||
|
|
@ -233,8 +230,6 @@ const fr: Translations = {
|
|||
mapLegend: {
|
||||
clearColourView: 'Effacer la vue en couleur',
|
||||
historicalMatches: 'Correspondances immobilières historiques',
|
||||
propertiesForSale: 'Propriétés à vendre',
|
||||
propertiesForRent: 'Propriétés à louer',
|
||||
numberOfProperties: 'Nombre de propriétés',
|
||||
previewing: 'Aperçu de \u201c{{name}}\u201d',
|
||||
},
|
||||
|
|
@ -244,24 +239,18 @@ const fr: Translations = {
|
|||
unknownAddress: 'Adresse inconnue',
|
||||
unsaveProperty: 'Retirer des favoris',
|
||||
saveProperty: 'Ajouter aux favoris',
|
||||
lastSold: 'Dernière vente : £{{price}}',
|
||||
estValue: 'Valeur estimée :',
|
||||
type: 'Type :',
|
||||
builtForm: 'Forme du bâti :',
|
||||
tenure: 'Régime foncier :',
|
||||
floorArea: 'Surface :',
|
||||
bedrooms: 'Chambres :',
|
||||
bathrooms: 'Salles de bain :',
|
||||
rooms: 'Pièces :',
|
||||
built: 'Construction :',
|
||||
formerCouncil: 'Ancien logement social :',
|
||||
exCouncilBadge: 'Ex-social',
|
||||
epcRating: 'Classement DPE :',
|
||||
epcPotential: 'Potentiel DPE :',
|
||||
listed: 'Mise en vente :',
|
||||
keyFeatures: 'Caractéristiques clés',
|
||||
renovations: 'Rénovations',
|
||||
viewExternalListing: 'Voir l’annonce externe',
|
||||
perMonth: '/mois',
|
||||
perSqm: '/m²',
|
||||
searchPlaceholder: 'Rechercher par adresse ou code postal...',
|
||||
propertyData: 'Données immobilières',
|
||||
|
|
@ -282,6 +271,7 @@ const fr: Translations = {
|
|||
viewOnGoogleMaps: 'Voir sur Google Maps',
|
||||
walk: 'Marche',
|
||||
cycle: 'Vélo',
|
||||
nationalAvg: 'Moyenne nationale',
|
||||
},
|
||||
|
||||
// ── Histogram Legend ───────────────────────────────
|
||||
|
|
@ -302,9 +292,9 @@ const fr: Translations = {
|
|||
// ── POI Pane ───────────────────────────────────────
|
||||
poiPane: {
|
||||
pois: 'POI',
|
||||
pointsOfInterest: "Points d'intérêt",
|
||||
pointsOfInterest: "Points d’intérêt",
|
||||
poiDescription:
|
||||
"Données issues d'OpenStreetMap. Couvre les arrêts de transport, commerces, restaurants, établissements de santé, loisirs et plus encore. Mise à jour régulière avec une couverture complète des catégories.",
|
||||
"Données issues d’OpenStreetMap. Couvre les arrêts de transport, commerces, restaurants, établissements de santé, loisirs et plus encore. Mise à jour régulière avec une couverture complète des catégories.",
|
||||
searchCategories: 'Rechercher des catégories...',
|
||||
dataSourceInfo: 'Informations sur la source',
|
||||
},
|
||||
|
|
@ -312,6 +302,7 @@ const fr: Translations = {
|
|||
// ── External Search Links ──────────────────────────
|
||||
externalSearch: {
|
||||
searchOn: 'Rechercher {{radius}} sur',
|
||||
exact: 'exact',
|
||||
outcodeNotRecognised: 'Code postal non reconnu',
|
||||
},
|
||||
|
||||
|
|
@ -322,7 +313,7 @@ const fr: Translations = {
|
|||
lookupFailed: 'Échec de la recherche',
|
||||
searchLabel: 'Rechercher des lieux ou codes postaux',
|
||||
locateMe: 'Aller à ma position',
|
||||
geolocationUnsupported: "La géolocalisation n'est pas prise en charge par votre navigateur",
|
||||
geolocationUnsupported: "La géolocalisation n’est pas prise en charge par votre navigateur",
|
||||
geolocationFailed: 'Impossible de déterminer votre position',
|
||||
},
|
||||
|
||||
|
|
@ -339,18 +330,18 @@ const fr: Translations = {
|
|||
heroSubtitle:
|
||||
'Vous cherchez un bien ? Faites de votre plus gros investissement votre meilleure décision.',
|
||||
heroDescription:
|
||||
"Tant d'options — choisir la bonne peut sembler décourageant. Notre carte interactive simplifie tout : sélectionnez vos critères et voyez instantanément les zones qui correspondent.",
|
||||
"Tant d’options — choisir la bonne peut sembler décourageant. Notre carte interactive simplifie tout : sélectionnez vos critères et voyez instantanément les zones qui correspondent.",
|
||||
exploreTheMap: 'Explorer la carte',
|
||||
seeTheDifference: 'Voir la différence',
|
||||
statProperties: 'propriétés',
|
||||
statFilters: 'filtres',
|
||||
statEvery: 'Chaque',
|
||||
statPostcodeInEngland: "code postal d'Angleterre",
|
||||
statPostcodeInEngland: "code postal d’Angleterre",
|
||||
ourPhilosophy: 'Notre philosophie',
|
||||
philosophyP1:
|
||||
"Sur Rightmove, vous choisissez d'abord une zone, puis vous espérez qu'elle convient. Vous finissez par croiser statistiques de criminalité, rapports scolaires et tests de débit sur une dizaine d'onglets, un code postal à la fois.",
|
||||
"Sur Rightmove, vous choisissez d’abord une zone, puis vous espérez qu’elle convient. Vous finissez par croiser statistiques de criminalité, rapports scolaires et tests de débit sur une dizaine d’onglets, un code postal à la fois.",
|
||||
philosophyP2:
|
||||
"Nous inversons la logique. Dites-nous ce qu'il vous faut (budget, trajet, écoles, sécurité) et nous vous montrons chaque zone d'Angleterre qui correspond. Plus de devinettes. Plus de visites inutiles.",
|
||||
"Nous inversons la logique. Dites-nous ce qu’il vous faut (budget, trajet, écoles, sécurité) et nous vous montrons chaque zone d’Angleterre qui correspond. Plus de devinettes. Plus de visites inutiles.",
|
||||
howToUseIt: 'Comment l’utiliser',
|
||||
howStep1Title: 'Définissez vos indispensables',
|
||||
howStep1Desc: 'Budget, trajet, écoles — la carte n’affiche que ce qui correspond.',
|
||||
|
|
@ -363,11 +354,10 @@ const fr: Translations = {
|
|||
howStep4Desc:
|
||||
'Chaque zone de votre liste répond à vos vrais critères — pas seulement à ce qui était en vente cette semaine-là.',
|
||||
othersVs: 'Les autres vs',
|
||||
listingPortals: "Portails d'annonces",
|
||||
checkMyPostcode: '« Vérifier mon code postal »',
|
||||
areaGuides: 'Guides de quartier',
|
||||
compSearchWithout: "Rechercher sans d'abord choisir une zone",
|
||||
compSearchWithoutSub: "(partir de ses besoins, pas d'un lieu)",
|
||||
compSearchWithout: "Rechercher sans d’abord choisir une zone",
|
||||
compSearchWithoutSub: "(partir de ses besoins, pas d’un lieu)",
|
||||
compAreaData: 'Données de la zone',
|
||||
compAreaDataSub: '(criminalité, écoles, bruit, débit internet)',
|
||||
compPropertyData: 'Données par propriété',
|
||||
|
|
@ -383,8 +373,8 @@ const fr: Translations = {
|
|||
title: 'Tarifs early access',
|
||||
subtitle: 'Payez une fois, accédez pour toujours. Plus vous rejoignez tôt, moins vous payez.',
|
||||
costContext:
|
||||
"L'achat d'un bien coûte plus de £10 000 en droits de mutation, £1 500 en frais de notaire, £500 pour une expertise. Choisissez le mauvais quartier et vous vous retrouvez avec un long trajet, de mauvaises écoles ou une route dont vous ignoriez l'existence.",
|
||||
lessThanSurvey: "Moins cher qu'une expertise immobilière. Bien plus utile.",
|
||||
"L’achat d’un bien coûte plus de £10 000 en droits de mutation, £1 500 en frais de notaire, £500 pour une expertise. Choisissez le mauvais quartier et vous vous retrouvez avec un long trajet, de mauvaises écoles ou une route dont vous ignoriez l’existence.",
|
||||
lessThanSurvey: "Moins cher qu’une expertise immobilière. Bien plus utile.",
|
||||
currentTier: 'Palier actuel',
|
||||
firstNUsers: '{{count}} premiers utilisateurs',
|
||||
everyoneAfter: 'Tous les suivants',
|
||||
|
|
@ -401,7 +391,7 @@ const fr: Translations = {
|
|||
soldOut: 'Épuisé',
|
||||
upcoming: 'À venir',
|
||||
failedToLoad: 'Échec du chargement des tarifs. Veuillez réessayer plus tard.',
|
||||
feat1: "56 couches de données à travers l'Angleterre",
|
||||
feat1: "56 couches de données à travers l’Angleterre",
|
||||
feat2: 'Chaque code postal noté et filtrable',
|
||||
feat3: 'Exploration de la carte et exportations illimitées',
|
||||
feat4: 'Plusieurs décennies de données historiques de prix',
|
||||
|
|
@ -488,6 +478,10 @@ const fr: Translations = {
|
|||
dsRentalOrigin: 'ONS / Valuation Office Agency',
|
||||
dsRentalUse:
|
||||
'Loyers mensuels médians du marché locatif privé par autorité locale et catégorie de chambres (oct. 2022 - sept. 2023). Reliés aux propriétés via le code d’autorité locale et le nombre estimé de chambres.',
|
||||
dsElectionName: 'Résultats des élections générales 2024',
|
||||
dsElectionOrigin: 'Parlement britannique',
|
||||
dsElectionUse:
|
||||
'Résultats par candidat des élections générales britanniques de juillet 2024. Agrégés au niveau de la circonscription : parti vainqueur, participation électorale (%) et majorité (%). Reliés aux propriétés via le code de circonscription parlementaire (pcon) du répertoire de codes postaux NSPL.',
|
||||
// FAQ section titles
|
||||
faqFindingTitle: 'Trouver votre quartier',
|
||||
faqCommuteTitle: 'Trajet et déplacements',
|
||||
|
|
@ -607,7 +601,6 @@ const fr: Translations = {
|
|||
noSavedPropertiesDesc:
|
||||
'Ajoutez des propriétés en favoris au fil de votre exploration et constituez votre sélection sans rien perdre de vue.',
|
||||
openPostcode: 'Ouvrir le code postal',
|
||||
viewListing: 'Voir l’annonce',
|
||||
clickToRename: 'Cliquez pour renommer',
|
||||
notesPlaceholder: 'Notez vos impressions...',
|
||||
deleteSearch: 'Supprimer la recherche',
|
||||
|
|
@ -622,16 +615,16 @@ const fr: Translations = {
|
|||
|
||||
// ── Invites Page ───────────────────────────────────
|
||||
invitesPage: {
|
||||
inviteLinksLicensed: "Les liens d'invitation sont disponibles pour les utilisateurs licenciés.",
|
||||
inviteLinksLicensed: "Les liens d’invitation sont disponibles pour les utilisateurs licenciés.",
|
||||
inviteAdminLabel: 'Inviter des amis (100% de réduction)',
|
||||
inviteReferralLabel: 'Inviter des amis (30% de réduction)',
|
||||
generateFreeInvite: "Générer un lien d'invitation gratuit",
|
||||
generateFreeInvite: "Générer un lien d’invitation gratuit",
|
||||
generateReferralLink: 'Générer un lien de parrainage',
|
||||
copyInviteLink: "Copier le lien d'invitation",
|
||||
copyInviteLink: "Copier le lien d’invitation",
|
||||
adminInvitesTitle: 'Invitations admin (100% de réduction)',
|
||||
referralInvitesTitle: 'Invitations de parrainage (30% de réduction)',
|
||||
yourInviteLinks: "Vos liens d'invitation",
|
||||
noInvitesYet: "Aucune invitation générée pour l'instant",
|
||||
yourInviteLinks: "Vos liens d’invitation",
|
||||
noInvitesYet: "Aucune invitation générée pour l’instant",
|
||||
link: 'Lien',
|
||||
status: 'Statut',
|
||||
created: 'Créé',
|
||||
|
|
@ -644,26 +637,26 @@ const fr: Translations = {
|
|||
youreInvited: 'Vous êtes invité !',
|
||||
specialOffer: 'Offre spéciale !',
|
||||
invitedByFree: '{{name}} vous invite à obtenir un accès à vie gratuit.',
|
||||
invitedByDiscount: "{{name}} vous fait bénéficier d'une réduction de 30% sur l'accès à vie.",
|
||||
invitedByDiscount: "{{name}} vous fait bénéficier d’une réduction de 30% sur l’accès à vie.",
|
||||
genericFreeInvite: 'Vous avez été invité à obtenir un accès à vie gratuit.',
|
||||
genericDiscount: "Un ami vous fait bénéficier d'une réduction de 30% sur l'accès à vie.",
|
||||
exploreEvery: "Explorez chaque quartier d'Angleterre",
|
||||
genericDiscount: "Un ami vous fait bénéficier d’une réduction de 30% sur l’accès à vie.",
|
||||
exploreEvery: "Explorez chaque quartier d’Angleterre",
|
||||
propertyInfo:
|
||||
'Prix immobiliers, classements énergétiques, statistiques de criminalité, notes des écoles et plus encore',
|
||||
invalidInvite: 'Invitation invalide',
|
||||
inviteAlreadyUsed: 'Invitation déjà utilisée',
|
||||
inviteAlreadyUsedDesc: "Ce lien d'invitation a déjà été utilisé.",
|
||||
invalidInviteLink: "Lien d'invitation invalide",
|
||||
invalidInviteLinkDesc: "Ce lien d'invitation est invalide ou a expiré.",
|
||||
inviteAlreadyUsedDesc: "Ce lien d’invitation a déjà été utilisé.",
|
||||
invalidInviteLink: "Lien d’invitation invalide",
|
||||
invalidInviteLinkDesc: "Ce lien d’invitation est invalide ou a expiré.",
|
||||
licenseActivated: 'Licence activée !',
|
||||
fullAccessGranted: 'Vous avez désormais un accès complet à Perfect Postcode.',
|
||||
activating: 'Activation...',
|
||||
activateLicense: 'Activer la licence',
|
||||
claimDiscount: 'Réclamer la réduction',
|
||||
registerToClaim: "S'inscrire pour réclamer",
|
||||
registerToClaim: "S’inscrire pour réclamer",
|
||||
youAlreadyHaveLicense: 'Vous avez déjà une licence',
|
||||
accountHasFullAccess: 'Votre compte dispose déjà d’un accès complet.',
|
||||
failedToValidate: "Échec de la validation du lien d'invitation",
|
||||
failedToValidate: "Échec de la validation du lien d’invitation",
|
||||
},
|
||||
|
||||
// ── Map Page ───────────────────────────────────────
|
||||
|
|
@ -725,27 +718,21 @@ const fr: Translations = {
|
|||
Deprivation: 'Précarité',
|
||||
Crime: 'Criminalité',
|
||||
Demographics: 'Démographie',
|
||||
Politics: 'Politique',
|
||||
Amenities: 'Commodités',
|
||||
|
||||
// ─ Feature names (Properties) ─
|
||||
'Listing status': 'Statut de l’annonce',
|
||||
'Property type': 'Type de bien',
|
||||
'Leasehold/Freehold': 'Bail/Pleine propriété',
|
||||
'Last known price': 'Dernier prix connu',
|
||||
'Estimated current price': 'Prix actuel estimé',
|
||||
'Asking price': 'Prix demandé',
|
||||
'Price per sqm': 'Prix au m²',
|
||||
'Est. price per sqm': 'Prix estimé au m²',
|
||||
'Asking price per sqm': 'Prix demandé au m²',
|
||||
'Estimated monthly rent': 'Loyer mensuel estimé',
|
||||
'Asking rent (monthly)': 'Loyer demandé (mensuel)',
|
||||
'Total floor area (sqm)': 'Surface totale (m²)',
|
||||
'Number of bedrooms & living rooms': 'Nombre de chambres et séjours',
|
||||
Bedrooms: 'Chambres',
|
||||
Bathrooms: 'Salles de bain',
|
||||
'Construction year': 'Année de construction',
|
||||
'Date of last transaction': 'Date de la dernière transaction',
|
||||
'Listing date': 'Date de mise en ligne',
|
||||
'Former council house': 'Ancien logement social',
|
||||
'Current energy rating': 'Classement énergétique actuel',
|
||||
'Potential energy rating': 'Classement énergétique potentiel',
|
||||
|
|
@ -799,9 +786,20 @@ const fr: Translations = {
|
|||
'% Mixed': '% Métis',
|
||||
'% Other': '% Autres',
|
||||
|
||||
// ─ Feature names (Politics) ─
|
||||
'Winning party': 'Parti vainqueur',
|
||||
'Voter turnout (%)': 'Participation électorale (%)',
|
||||
'Majority (%)': 'Majorité (%)',
|
||||
'% Labour': '% Travaillistes',
|
||||
'% Conservative': '% Conservateurs',
|
||||
'% Liberal Democrat': '% Libéraux-démocrates',
|
||||
'% Reform UK': '% Reform UK',
|
||||
'% Green': '% Verts',
|
||||
'% Other parties': '% Autres partis',
|
||||
|
||||
// ─ Feature names (Amenities) ─
|
||||
'Distance to nearest park (km)': 'Distance au parc le plus proche (km)',
|
||||
'Number of parks within 2km': 'Nombre de parcs à moins de 2 km',
|
||||
'Number of parks within 1km': 'Nombre de parcs à moins de 1 km',
|
||||
'Number of restaurants within 2km': 'Nombre de restaurants à moins de 2 km',
|
||||
'Number of grocery shops and supermarkets within 2km':
|
||||
'Nombre d’épiceries et supermarchés à moins de 2 km',
|
||||
|
|
@ -809,9 +807,12 @@ const fr: Translations = {
|
|||
'Max available download speed (Mbps)': 'Débit descendant max. disponible (Mbps)',
|
||||
|
||||
// ─ Enum values ─
|
||||
'Historical sale': 'Vente historique',
|
||||
'For sale': 'En vente',
|
||||
'For rent': 'En location',
|
||||
Labour: 'Travailliste',
|
||||
Conservative: 'Conservateur',
|
||||
'Liberal Democrat': 'Libéral-démocrate',
|
||||
'Reform UK': 'Reform UK',
|
||||
Green: 'Vert',
|
||||
'Other parties': 'Autres partis',
|
||||
Detached: 'Individuelle',
|
||||
'Semi-Detached': 'Jumelée',
|
||||
Terraced: 'Mitoyenne',
|
||||
|
|
@ -845,7 +846,7 @@ const fr: Translations = {
|
|||
'Bus stop': 'Arrêt de bus',
|
||||
'Bus station': 'Gare routière',
|
||||
'Taxi rank': 'Station de taxi',
|
||||
'Metro or Tram stop': 'Station de métro ou tramway',
|
||||
'Tube station': 'Station de métro',
|
||||
Café: 'Café',
|
||||
Restaurant: 'Restaurant',
|
||||
Pub: 'Pub',
|
||||
|
|
|
|||
|
|
@ -129,9 +129,6 @@ const hu: Translations = {
|
|||
filters: {
|
||||
activeFilters: 'Aktív szűrők',
|
||||
addFilter: 'Szűrő hozzáadása',
|
||||
historical: 'Történelmi',
|
||||
buy: 'Vétel',
|
||||
rent: 'Bérlés',
|
||||
findingPerfectPostcode: 'A tökéletes irányítószám megtalálása',
|
||||
addFiltersHint: 'Adj hozzá szűrőket a térkép szűkítéséhez a feltételeidnek megfelelően',
|
||||
upgradePrompt: 'Bűnözés, iskolák, zaj, szélessáv és 50+ további szűrő egész Angliában.',
|
||||
|
|
@ -214,9 +211,9 @@ const hu: Translations = {
|
|||
aiSearch: 'AI keresés',
|
||||
describeHint: 'Írd le, mit keresel',
|
||||
placeholder: 'pl. csendes terület, £400e alatt, jó iskolák közelében...',
|
||||
example1: 'Biztonságos terület jó iskolák közelében',
|
||||
example2: '30 perces ingazás Kings Cross-hoz, £500e alatt',
|
||||
example3: 'Csendes falu, 3 háló, gyors internet',
|
||||
example1: 'Ház 40 percre Banktól, alacsony bűnözésű területen',
|
||||
example2: 'Lakások jó általános iskolák közelében, nem messze Manchestertől',
|
||||
example3: 'Legjobb ex-council házak 200k alatt',
|
||||
analysing: 'Lekérdezés elemzése...',
|
||||
searchingDestinations: 'Úticélok keresése...',
|
||||
generatingFilters: 'Szűrők létrehozása...',
|
||||
|
|
@ -228,8 +225,6 @@ const hu: Translations = {
|
|||
mapLegend: {
|
||||
clearColourView: 'Színezés törlése',
|
||||
historicalMatches: 'Korábbi ingatlan találatok',
|
||||
propertiesForSale: 'Eladó ingatlanok',
|
||||
propertiesForRent: 'Kiadó ingatlanok',
|
||||
numberOfProperties: 'Ingatlanok száma',
|
||||
previewing: '\u201c{{name}}\u201d előnézete',
|
||||
},
|
||||
|
|
@ -239,24 +234,18 @@ const hu: Translations = {
|
|||
unknownAddress: 'Ismeretlen cím',
|
||||
unsaveProperty: 'Ingatlan mentésének visszavonása',
|
||||
saveProperty: 'Ingatlan mentése',
|
||||
lastSold: 'Utolsó eladás: £{{price}}',
|
||||
estValue: 'Becsült érték:',
|
||||
type: 'Típus:',
|
||||
builtForm: 'Épületforma:',
|
||||
tenure: 'Tulajdonforma:',
|
||||
floorArea: 'Alapterület:',
|
||||
bedrooms: 'Hálószobák:',
|
||||
bathrooms: 'Fürdőszobák:',
|
||||
rooms: 'Szobák:',
|
||||
built: 'Építve:',
|
||||
formerCouncil: 'Volt önk. lakás:',
|
||||
exCouncilBadge: 'Volt önk.',
|
||||
epcRating: 'EPC minősítés:',
|
||||
epcPotential: 'EPC potenciál:',
|
||||
listed: 'Hirdetve:',
|
||||
keyFeatures: 'Főbb jellemzők',
|
||||
renovations: 'Felújítások',
|
||||
viewExternalListing: 'Külső hirdetés megtekintése',
|
||||
perMonth: '/hó',
|
||||
perSqm: '/m²',
|
||||
searchPlaceholder: 'Keresés cím vagy irányítószám alapján...',
|
||||
propertyData: 'Ingatlanadatok',
|
||||
|
|
@ -277,6 +266,7 @@ const hu: Translations = {
|
|||
viewOnGoogleMaps: 'Megtekintés a Google Maps-en',
|
||||
walk: 'Gyalog',
|
||||
cycle: 'Kerékpár',
|
||||
nationalAvg: 'Országos átlag',
|
||||
},
|
||||
|
||||
// ── Histogram Legend ───────────────────────────────
|
||||
|
|
@ -307,6 +297,7 @@ const hu: Translations = {
|
|||
// ── External Search Links ──────────────────────────
|
||||
externalSearch: {
|
||||
searchOn: 'Keresés {{radius}} sugárban ezen:',
|
||||
exact: 'pontos',
|
||||
outcodeNotRecognised: 'Nem felismert körzeti kód',
|
||||
},
|
||||
|
||||
|
|
@ -357,7 +348,6 @@ const hu: Translations = {
|
|||
howStep4Desc:
|
||||
'A listádon minden terület megfelel a valós feltételeidnek — nem csak annak, amit azon a héten hirdettek.',
|
||||
othersVs: 'Mások vs.',
|
||||
listingPortals: 'Hirdetési portálok',
|
||||
checkMyPostcode: '“Irányítószám ellenőrzése”',
|
||||
areaGuides: 'Területi útmutatók',
|
||||
compSearchWithout: 'Keresés terület előzetes kiválasztása nélkül',
|
||||
|
|
@ -483,6 +473,10 @@ const hu: Translations = {
|
|||
dsRentalOrigin: 'ONS / Valuation Office Agency',
|
||||
dsRentalUse:
|
||||
'Medián havi magánbérleti díjak helyi önkormányzatonként és hálószoba-kategóriánként (2022. okt. – 2023. szept.). Az ingatlanokhoz a helyi önkormányzati kerületi kódon és becsült hálószobaszámon keresztül csatolva.',
|
||||
dsElectionName: '2024-es parlamenti választási eredmények',
|
||||
dsElectionOrigin: 'Egyesült Királyság Parlamentje',
|
||||
dsElectionUse:
|
||||
'Jelöltszintű eredmények a 2024. júliusi brit parlamenti választásról. Választókerületi szintre aggregálva: győztes párt, részvételi arány (%) és többség (%). Az ingatlanokhoz az NSPL irányítószám-keresőből származó parlamenti választókerületi kódon (pcon) keresztül csatolva.',
|
||||
// FAQ section titles
|
||||
faqFindingTitle: 'Területed megtalálása',
|
||||
faqCommuteTitle: 'Ingazás és utazás',
|
||||
|
|
@ -599,7 +593,6 @@ const hu: Translations = {
|
|||
noSavedPropertiesDesc:
|
||||
'Jelöld meg az ingatlanokat felfedezés közben, és építsd a rövid listádat elvesztés nélkül.',
|
||||
openPostcode: 'Irányítószám megnyitása',
|
||||
viewListing: 'Hirdetés megtekintése',
|
||||
clickToRename: 'Kattints az átnevezéshez',
|
||||
notesPlaceholder: 'Írd le a gondolataidat...',
|
||||
deleteSearch: 'Keresés törlése',
|
||||
|
|
@ -718,27 +711,21 @@ const hu: Translations = {
|
|||
Deprivation: 'Depriváció',
|
||||
Crime: 'Bűnözés',
|
||||
Demographics: 'Demográfia',
|
||||
Politics: 'Politika',
|
||||
Amenities: 'Szolgáltatások',
|
||||
|
||||
// ─ Feature names (Properties) ─
|
||||
'Listing status': 'Hirdetés állapota',
|
||||
'Property type': 'Ingatlantípus',
|
||||
'Leasehold/Freehold': 'Bérleti/Tulajdonjog',
|
||||
'Last known price': 'Utolsó ismert ár',
|
||||
'Estimated current price': 'Becsült jelenlegi ár',
|
||||
'Asking price': 'Hirdetési ár',
|
||||
'Price per sqm': 'Ár per nm',
|
||||
'Est. price per sqm': 'Becsült ár per nm',
|
||||
'Asking price per sqm': 'Hirdetési ár per nm',
|
||||
'Estimated monthly rent': 'Becsült havi bérleti díj',
|
||||
'Asking rent (monthly)': 'Kért bérleti díj (havi)',
|
||||
'Total floor area (sqm)': 'Teljes alapterület (nm)',
|
||||
'Number of bedrooms & living rooms': 'Háló- és nappalik száma',
|
||||
Bedrooms: 'Hálószobák',
|
||||
Bathrooms: 'Fürdőszobák',
|
||||
'Construction year': 'Építési év',
|
||||
'Date of last transaction': 'Utolsó tranzakció dátuma',
|
||||
'Listing date': 'Hirdetés dátuma',
|
||||
'Former council house': 'Volt önkormányzati lakás',
|
||||
'Current energy rating': 'Jelenlegi energetikai minősítés',
|
||||
'Potential energy rating': 'Potenciális energetikai minősítés',
|
||||
|
|
@ -792,9 +779,20 @@ const hu: Translations = {
|
|||
'% Mixed': '% vegyes',
|
||||
'% Other': '% egyéb',
|
||||
|
||||
// ─ Feature names (Politics) ─
|
||||
'Winning party': 'Győztes párt',
|
||||
'Voter turnout (%)': 'Választási részvétel (%)',
|
||||
'Majority (%)': 'Többség (%)',
|
||||
'% Labour': '% Munkáspárt',
|
||||
'% Conservative': '% Konzervatív',
|
||||
'% Liberal Democrat': '% Liberális Demokrata',
|
||||
'% Reform UK': '% Reform UK',
|
||||
'% Green': '% Zöld',
|
||||
'% Other parties': '% Egyéb pártok',
|
||||
|
||||
// ─ Feature names (Amenities) ─
|
||||
'Distance to nearest park (km)': 'Távolság a legközelebbi parktól (km)',
|
||||
'Number of parks within 2km': 'Parkok száma 2 km-en belül',
|
||||
'Number of parks within 1km': 'Parkok száma 1 km-en belül',
|
||||
'Number of restaurants within 2km': 'Éttermek száma 2 km-en belül',
|
||||
'Number of grocery shops and supermarkets within 2km':
|
||||
'Élelmiszerboltok és szupermarketek száma 2 km-en belül',
|
||||
|
|
@ -802,9 +800,12 @@ const hu: Translations = {
|
|||
'Max available download speed (Mbps)': 'Max elérhető letöltési sebesség (Mbps)',
|
||||
|
||||
// ─ Enum values ─
|
||||
'Historical sale': 'Történelmi eladás',
|
||||
'For sale': 'Eladó',
|
||||
'For rent': 'Kiadó',
|
||||
Labour: 'Munkáspárt',
|
||||
Conservative: 'Konzervatív',
|
||||
'Liberal Democrat': 'Liberális Demokrata',
|
||||
'Reform UK': 'Reform UK',
|
||||
Green: 'Zöld',
|
||||
'Other parties': 'Egyéb pártok',
|
||||
Detached: 'Különálló',
|
||||
'Semi-Detached': 'Ikerház',
|
||||
Terraced: 'Sorház',
|
||||
|
|
@ -838,7 +839,7 @@ const hu: Translations = {
|
|||
'Bus stop': 'Buszmegálló',
|
||||
'Bus station': 'Buszpályaudvar',
|
||||
'Taxi rank': 'Taxiállomás',
|
||||
'Metro or Tram stop': 'Metró- vagy villamosmegálló',
|
||||
'Tube station': 'Metróállomás',
|
||||
Café: 'Kávézó',
|
||||
Restaurant: 'Étterem',
|
||||
Pub: 'Kocsma',
|
||||
|
|
|
|||
|
|
@ -126,9 +126,6 @@ const zh: Translations = {
|
|||
filters: {
|
||||
activeFilters: '当前筛选条件',
|
||||
addFilter: '添加筛选条件',
|
||||
historical: '历史交易',
|
||||
buy: '买房',
|
||||
rent: '租房',
|
||||
findingPerfectPostcode: '寻找理想的邮编',
|
||||
addFiltersHint: '添加以下筛选条件,将地图缩小到符合您要求的区域',
|
||||
upgradePrompt: '查看犯罪率、学校、噪音、宽带等 50 多项筛选条件,覆盖整个英格兰。',
|
||||
|
|
@ -210,9 +207,9 @@ const zh: Translations = {
|
|||
aiSearch: 'AI 搜索',
|
||||
describeHint: '描述您要找的区域',
|
||||
placeholder: '例如:安静的区域,低于 £40万,靠近好学校...',
|
||||
example1: '安全的区域,靠近好学校',
|
||||
example2: '到国王十字站30分钟通勤,低于 £50万',
|
||||
example3: '安静的村庄,3间卧室,快速宽带',
|
||||
example1: '距Bank站40分钟的低犯罪率地区的房子',
|
||||
example2: '曼彻斯特附近好小学周围的公寓',
|
||||
example3: '20万以下最好的前政府住房',
|
||||
analysing: '正在分析您的需求...',
|
||||
searchingDestinations: '正在搜索目的地...',
|
||||
generatingFilters: '正在生成筛选条件...',
|
||||
|
|
@ -224,8 +221,6 @@ const zh: Translations = {
|
|||
mapLegend: {
|
||||
clearColourView: '清除颜色视图',
|
||||
historicalMatches: '历史房产匹配',
|
||||
propertiesForSale: '待售房产',
|
||||
propertiesForRent: '待租房产',
|
||||
numberOfProperties: '房产数量',
|
||||
previewing: '预览\u201c{{name}}\u201d',
|
||||
},
|
||||
|
|
@ -235,24 +230,18 @@ const zh: Translations = {
|
|||
unknownAddress: '地址未知',
|
||||
unsaveProperty: '取消收藏',
|
||||
saveProperty: '收藏房产',
|
||||
lastSold: '上次成交价:£{{price}}',
|
||||
estValue: '估计价值:',
|
||||
type: '类型:',
|
||||
builtForm: '建筑形式:',
|
||||
tenure: '产权:',
|
||||
floorArea: '建筑面积:',
|
||||
bedrooms: '卧室:',
|
||||
bathrooms: '浴室:',
|
||||
rooms: '房间:',
|
||||
built: '建造年份:',
|
||||
formerCouncil: '原公房:',
|
||||
exCouncilBadge: '原公房',
|
||||
epcRating: '能源评级:',
|
||||
epcPotential: '潜在能源评级:',
|
||||
listed: '上市日期:',
|
||||
keyFeatures: '主要特点',
|
||||
renovations: '翻新记录',
|
||||
viewExternalListing: '查看外部房源',
|
||||
perMonth: '/月',
|
||||
perSqm: '/m²',
|
||||
searchPlaceholder: '按地址或邮编搜索...',
|
||||
propertyData: '房产数据',
|
||||
|
|
@ -273,6 +262,7 @@ const zh: Translations = {
|
|||
viewOnGoogleMaps: '在 Google Maps 上查看',
|
||||
walk: '步行',
|
||||
cycle: '骑行',
|
||||
nationalAvg: '全国平均',
|
||||
},
|
||||
|
||||
// ── Histogram Legend ───────────────────────────────
|
||||
|
|
@ -303,6 +293,7 @@ const zh: Translations = {
|
|||
// ── External Search Links ──────────────────────────
|
||||
externalSearch: {
|
||||
searchOn: '在 {{radius}} 范围内搜索',
|
||||
exact: '精确',
|
||||
outcodeNotRecognised: '无法识别该邮编区域',
|
||||
},
|
||||
|
||||
|
|
@ -351,7 +342,6 @@ const zh: Translations = {
|
|||
howStep4Title: '自信地列出候选名单',
|
||||
howStep4Desc: '您名单上的每个区域都满足您的实际需求——而不只是当周恰好有房源。',
|
||||
othersVs: '其他平台 vs',
|
||||
listingPortals: '房源网站',
|
||||
checkMyPostcode: '"查查我的邮编"类网站',
|
||||
areaGuides: '区域指南',
|
||||
compSearchWithout: '无需先选区域即可搜索',
|
||||
|
|
@ -469,6 +459,10 @@ const zh: Translations = {
|
|||
dsRentalOrigin: 'ONS / Valuation Office Agency',
|
||||
dsRentalUse:
|
||||
'按地方政府辖区和卧室类别划分的月度私人租金中位数(2022 年 10 月至 2023 年 9 月)。通过地方政府区域代码和估算卧室数量关联到房产。',
|
||||
dsElectionName: '2024年大选结果',
|
||||
dsElectionOrigin: '英国议会',
|
||||
dsElectionUse:
|
||||
'2024年7月英国大选的候选人级别结果。聚合到选区级别:获胜政党、投票率(%)和多数票(%)。通过NSPL邮编查询中的议会选区代码(pcon)关联到房产。',
|
||||
// FAQ section titles
|
||||
faqFindingTitle: '寻找理想区域',
|
||||
faqCommuteTitle: '通勤与出行',
|
||||
|
|
@ -581,7 +575,6 @@ const zh: Translations = {
|
|||
noSavedProperties: '暂无保存的房产',
|
||||
noSavedPropertiesDesc: '在浏览过程中收藏房产,建立您的候选名单,不会遗漏任何一处。',
|
||||
openPostcode: '打开邮编',
|
||||
viewListing: '查看房源',
|
||||
clickToRename: '点击重命名',
|
||||
notesPlaceholder: '记下您的想法...',
|
||||
deleteSearch: '删除搜索',
|
||||
|
|
@ -693,27 +686,21 @@ const zh: Translations = {
|
|||
Deprivation: '贫困指数',
|
||||
Crime: '犯罪',
|
||||
Demographics: '人口统计',
|
||||
Politics: '政治',
|
||||
Amenities: '配套设施',
|
||||
|
||||
// ─ Feature names (Properties) ─
|
||||
'Listing status': '房源状态',
|
||||
'Property type': '房产类型',
|
||||
'Leasehold/Freehold': '租赁产权/永久产权',
|
||||
'Last known price': '上次成交价',
|
||||
'Estimated current price': '估计当前价格',
|
||||
'Asking price': '挂牌价',
|
||||
'Price per sqm': '每平方米价格',
|
||||
'Est. price per sqm': '估计每平方米价格',
|
||||
'Asking price per sqm': '挂牌价每平方米',
|
||||
'Estimated monthly rent': '估计月租',
|
||||
'Asking rent (monthly)': '月租',
|
||||
'Total floor area (sqm)': '总建筑面积(平方米)',
|
||||
'Number of bedrooms & living rooms': '卧室和客厅数量',
|
||||
Bedrooms: '卧室',
|
||||
Bathrooms: '浴室',
|
||||
'Construction year': '建造年份',
|
||||
'Date of last transaction': '上次交易日期',
|
||||
'Listing date': '上市日期',
|
||||
'Former council house': '原公共住房',
|
||||
'Current energy rating': '当前能源评级',
|
||||
'Potential energy rating': '潜在能源评级',
|
||||
|
|
@ -766,18 +753,32 @@ const zh: Translations = {
|
|||
'% Mixed': '% 混血',
|
||||
'% Other': '% 其他',
|
||||
|
||||
// ─ Feature names (Politics) ─
|
||||
'Winning party': '获胜政党',
|
||||
'Voter turnout (%)': '投票率(%)',
|
||||
'Majority (%)': '多数票(%)',
|
||||
'% Labour': '% 工党',
|
||||
'% Conservative': '% 保守党',
|
||||
'% Liberal Democrat': '% 自由民主党',
|
||||
'% Reform UK': '% 英国改革党',
|
||||
'% Green': '% 绿党',
|
||||
'% Other parties': '% 其他政党',
|
||||
|
||||
// ─ Feature names (Amenities) ─
|
||||
'Distance to nearest park (km)': '到最近公园的距离(公里)',
|
||||
'Number of parks within 2km': '2公里内公园数量',
|
||||
'Number of parks within 1km': '1公里内公园数量',
|
||||
'Number of restaurants within 2km': '2公里内餐厅数量',
|
||||
'Number of grocery shops and supermarkets within 2km': '2公里内食品店和超市数量',
|
||||
'Noise (dB)': '噪音(分贝)',
|
||||
'Max available download speed (Mbps)': '最大可用下载速度(Mbps)',
|
||||
|
||||
// ─ Enum values ─
|
||||
'Historical sale': '历史交易',
|
||||
'For sale': '在售',
|
||||
'For rent': '出租',
|
||||
Labour: '工党',
|
||||
Conservative: '保守党',
|
||||
'Liberal Democrat': '自由民主党',
|
||||
'Reform UK': '英国改革党',
|
||||
Green: '绿党',
|
||||
'Other parties': '其他政党',
|
||||
Detached: '独立式住宅',
|
||||
'Semi-Detached': '半独立式住宅',
|
||||
Terraced: '联排住宅',
|
||||
|
|
@ -811,7 +812,7 @@ const zh: Translations = {
|
|||
'Bus stop': '公交站',
|
||||
'Bus station': '公交枢纽',
|
||||
'Taxi rank': '出租车站',
|
||||
'Metro or Tram stop': '地铁或有轨电车站',
|
||||
'Tube station': '地铁站',
|
||||
Café: '咖啡馆',
|
||||
Restaurant: '餐厅',
|
||||
Pub: '酒吧',
|
||||
|
|
|
|||
|
|
@ -4,30 +4,43 @@ import { ENUM_PALETTE } from './consts';
|
|||
|
||||
/**
|
||||
* LayerExtension that turns polygon fills into pie charts.
|
||||
* Injects a fragment shader that computes angle from each fragment's position
|
||||
* to the polygon centroid, then picks a slice color from the enum palette.
|
||||
*
|
||||
* Works with H3HexagonLayer (hex fills) and GeoJsonLayer (postcode fills).
|
||||
* Only activates on SolidPolygonLayer sublayers (fill), not PathLayer (stroke).
|
||||
* Follows the canonical deck.gl v9 extension pattern:
|
||||
* - defaultProps with type:'accessor' enables getSubLayerProps() to wrap
|
||||
* accessors via getSubLayerAccessor(), which unwraps __source.object to
|
||||
* access the original data item through CompositeLayer sublayer chains.
|
||||
* - stepMode:'dynamic' handles per-instance counting automatically.
|
||||
* - isEnabled() restricts to SolidPolygonLayer (fill) sublayers only.
|
||||
*
|
||||
* Required layer props when this extension is active:
|
||||
* getCenter: (d) => [lon, lat] — polygon centroid in world coordinates
|
||||
* getRatios0: (d) => number[4] — pie ratios for slices 0-3
|
||||
* getRatios1: (d) => number[4] — pie ratios for slices 4-7
|
||||
* getRatios2: (d) => number[2] — pie ratios for slices 8-9
|
||||
* Accepts an optional custom palette in the constructor for per-feature color overrides.
|
||||
*/
|
||||
|
||||
// Build palette as GLSL vec3 constants (normalized 0-1)
|
||||
const PALETTE_GLSL = ENUM_PALETTE.map(
|
||||
(c) =>
|
||||
`vec3(${(c[0] / 255).toFixed(4)}, ${(c[1] / 255).toFixed(4)}, ${(c[2] / 255).toFixed(4)})`
|
||||
).join(',\n ');
|
||||
function paletteToGlsl(palette: [number, number, number][]): string {
|
||||
return palette
|
||||
.map(
|
||||
(c) =>
|
||||
`vec3(${(c[0] / 255).toFixed(4)}, ${(c[1] / 255).toFixed(4)}, ${(c[2] / 255).toFixed(4)})`
|
||||
)
|
||||
.join(',\n ');
|
||||
}
|
||||
|
||||
export class PieHexExtension extends LayerExtension {
|
||||
static extensionName = 'PieHexExtension';
|
||||
static defaultProps = {
|
||||
getCenter: { type: 'accessor', value: [0, 0] },
|
||||
getRatios0: { type: 'accessor', value: [1, 0, 0, 0] },
|
||||
getRatios1: { type: 'accessor', value: [0, 0, 0, 0] },
|
||||
getRatios2: { type: 'accessor', value: [0, 0] },
|
||||
};
|
||||
|
||||
private paletteGlsl: string;
|
||||
|
||||
constructor(palette?: [number, number, number][]) {
|
||||
super();
|
||||
this.paletteGlsl = paletteToGlsl(palette ?? ENUM_PALETTE);
|
||||
}
|
||||
|
||||
isEnabled(layer: any): boolean {
|
||||
// Only apply to fill sublayers (SolidPolygonLayer), not stroke (PathLayer)
|
||||
return layer.id.endsWith('-fill');
|
||||
}
|
||||
|
||||
|
|
@ -61,7 +74,7 @@ in vec4 vRatios0;
|
|||
in vec4 vRatios1;
|
||||
in vec2 vRatios2;
|
||||
const vec3 pieColors[10] = vec3[10](
|
||||
${PALETTE_GLSL}
|
||||
${this.paletteGlsl}
|
||||
);`,
|
||||
'fs:DECKGL_FILTER_COLOR': `\
|
||||
{
|
||||
|
|
@ -98,25 +111,25 @@ const vec3 pieColors[10] = vec3[10](
|
|||
if (!extension.isEnabled(this)) return;
|
||||
const am = this.getAttributeManager();
|
||||
if (!am) return;
|
||||
am.addInstanced({
|
||||
am.add({
|
||||
instancePieCenter: {
|
||||
size: 2,
|
||||
type: 'float32',
|
||||
stepMode: 'dynamic',
|
||||
accessor: 'getCenter',
|
||||
},
|
||||
instanceRatios0: {
|
||||
size: 4,
|
||||
type: 'float32',
|
||||
stepMode: 'dynamic',
|
||||
accessor: 'getRatios0',
|
||||
},
|
||||
instanceRatios1: {
|
||||
size: 4,
|
||||
type: 'float32',
|
||||
stepMode: 'dynamic',
|
||||
accessor: 'getRatios1',
|
||||
},
|
||||
instanceRatios2: {
|
||||
size: 2,
|
||||
type: 'float32',
|
||||
stepMode: 'dynamic',
|
||||
accessor: 'getRatios2',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -109,6 +109,8 @@ export const STACKED_GROUPS: Record<
|
|||
label: string;
|
||||
/** If set, use this feature's stats for the total and info popup. Otherwise sum components. */
|
||||
feature?: string;
|
||||
/** If set, display this feature's mean as the primary value (e.g. per-1k rate) instead of the absolute total. */
|
||||
rateFeature?: string;
|
||||
/** Suffix shown after the total value (e.g. "avg/yr") */
|
||||
unit?: string;
|
||||
/** Feature names that make up the segments */
|
||||
|
|
@ -119,7 +121,8 @@ export const STACKED_GROUPS: Record<
|
|||
{
|
||||
label: 'Serious crime',
|
||||
feature: 'Serious crime (avg/yr)',
|
||||
unit: 'avg/yr',
|
||||
rateFeature: 'Serious crime per 1k residents (avg/yr)',
|
||||
unit: 'per 1k/yr',
|
||||
components: [
|
||||
'Violence and sexual offences (avg/yr)',
|
||||
'Robbery (avg/yr)',
|
||||
|
|
@ -130,7 +133,8 @@ export const STACKED_GROUPS: Record<
|
|||
{
|
||||
label: 'Minor crime',
|
||||
feature: 'Minor crime (avg/yr)',
|
||||
unit: 'avg/yr',
|
||||
rateFeature: 'Minor crime per 1k residents (avg/yr)',
|
||||
unit: 'per 1k/yr',
|
||||
components: [
|
||||
'Anti-social behaviour (avg/yr)',
|
||||
'Criminal damage and arson (avg/yr)',
|
||||
|
|
@ -179,7 +183,7 @@ export const STACKED_ENUM_GROUPS: Record<
|
|||
feature: 'Property type',
|
||||
components: ['Property type'],
|
||||
valueOrder: ['Detached', 'Semi-Detached', 'Terraced', 'Flats/Maisonettes', 'Other'],
|
||||
valueColors: ['#8b5cf6', '#3b82f6', '#14b8a6', '#f59e0b', '#6b7280'],
|
||||
valueColors: ['#f97316', '#3b82f6', '#22c55e', '#ec4899', '#6b7280'],
|
||||
},
|
||||
{
|
||||
label: 'Leasehold/Freehold',
|
||||
|
|
@ -208,6 +212,60 @@ export const ENUM_PALETTE: [number, number, number][] = [
|
|||
[107, 114, 128], // gray-500
|
||||
];
|
||||
|
||||
/**
|
||||
* Per-feature color overrides for enum values on the map and dashboard.
|
||||
* Keys are feature names (as returned by the server), values map enum value → RGB.
|
||||
* Any value not listed falls back to ENUM_PALETTE by index.
|
||||
*/
|
||||
export const ENUM_COLOR_OVERRIDES: Record<string, Record<string, [number, number, number]>> = {
|
||||
'Winning party': {
|
||||
Labour: [220, 36, 31], // Labour red
|
||||
Conservative: [0, 135, 220], // Conservative blue
|
||||
'Liberal Democrat': [253, 187, 48], // Lib Dem gold
|
||||
'Reform UK': [18, 178, 196], // Reform teal
|
||||
Green: [106, 176, 35], // Green party green
|
||||
'Other parties': [148, 130, 160], // muted purple
|
||||
},
|
||||
'Property type': {
|
||||
Detached: [249, 115, 22], // orange
|
||||
'Semi-Detached': [59, 130, 246], // blue
|
||||
Terraced: [34, 197, 94], // green
|
||||
'Flats/Maisonettes': [236, 72, 153], // pink
|
||||
Other: [107, 114, 128], // gray
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a 10-color palette for a given feature, using overrides where defined.
|
||||
* Returns the default ENUM_PALETTE when no overrides exist.
|
||||
*/
|
||||
export function getEnumPaletteForFeature(
|
||||
featureName: string | null,
|
||||
values?: string[]
|
||||
): [number, number, number][] {
|
||||
if (!featureName || !values) return ENUM_PALETTE;
|
||||
const overrides = ENUM_COLOR_OVERRIDES[featureName];
|
||||
if (!overrides) return ENUM_PALETTE;
|
||||
|
||||
const palette: [number, number, number][] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (i < values.length && overrides[values[i]]) {
|
||||
palette.push(overrides[values[i]]);
|
||||
} else {
|
||||
palette.push(ENUM_PALETTE[i % ENUM_PALETTE.length]);
|
||||
}
|
||||
}
|
||||
return palette;
|
||||
}
|
||||
|
||||
/** Look up override color for a specific enum value, or null if none. */
|
||||
export function getEnumValueColor(
|
||||
featureName: string,
|
||||
valueName: string
|
||||
): [number, number, number] | null {
|
||||
return ENUM_COLOR_OVERRIDES[featureName]?.[valueName] ?? null;
|
||||
}
|
||||
|
||||
/** Colors for stacked bar segments */
|
||||
export const SEGMENT_COLORS = [
|
||||
'#ef4444', // red-500
|
||||
|
|
|
|||
|
|
@ -49,12 +49,6 @@ const RIGHTMOVE_PRICES = [
|
|||
3000000, 4000000, 5000000, 7500000, 10000000, 15000000, 20000000,
|
||||
];
|
||||
|
||||
// Rightmove allowed monthly rent values (pcm)
|
||||
const RIGHTMOVE_RENTS = [
|
||||
250, 300, 350, 400, 450, 500, 600, 700, 800, 900, 1000, 1250, 1500, 1750, 2000, 2500, 3000, 3500,
|
||||
4000, 5000, 7500, 10000, 15000, 25000,
|
||||
];
|
||||
|
||||
// OnTheMarket allowed buy prices
|
||||
const OTM_PRICES = [
|
||||
50000, 60000, 70000, 80000, 90000, 100000, 110000, 120000, 125000, 130000, 140000, 150000, 160000,
|
||||
|
|
@ -64,12 +58,6 @@ const OTM_PRICES = [
|
|||
10000000, 15000000,
|
||||
];
|
||||
|
||||
// OnTheMarket allowed monthly rent values (pcm)
|
||||
const OTM_RENTS = [
|
||||
100, 200, 250, 300, 350, 400, 450, 500, 550, 600, 650, 700, 750, 800, 850, 900, 950, 1000, 1100,
|
||||
1200, 1250, 1300, 1400, 1500, 1750, 2000, 2500, 3000, 3500, 4000, 5000, 7500, 10000, 25000,
|
||||
];
|
||||
|
||||
// Zoopla allowed buy prices
|
||||
const ZOOPLA_PRICES = [
|
||||
10000, 25000, 50000, 75000, 100000, 125000, 150000, 175000, 200000, 225000, 250000, 275000,
|
||||
|
|
@ -78,12 +66,6 @@ const ZOOPLA_PRICES = [
|
|||
5000000, 7500000, 10000000, 15000000,
|
||||
];
|
||||
|
||||
// Zoopla allowed monthly rent values (pcm)
|
||||
const ZOOPLA_RENTS = [
|
||||
100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1250, 1500, 1750, 2000, 2500, 3000, 3500, 4000,
|
||||
5000, 7500, 10000, 25000,
|
||||
];
|
||||
|
||||
function snapToAllowed(value: number, allowed: number[], direction: 'floor' | 'ceil'): number {
|
||||
if (direction === 'floor') {
|
||||
for (let i = allowed.length - 1; i >= 0; i--) {
|
||||
|
|
@ -115,26 +97,14 @@ export function buildPropertySearchUrls({
|
|||
rightmove: string | null;
|
||||
onthemarket: string;
|
||||
zoopla: string;
|
||||
openrent: string | null;
|
||||
} | null {
|
||||
const { postcode, resolution, isPostcode } = location;
|
||||
if (!postcode) return null;
|
||||
|
||||
const radiusMiles = isPostcode ? 0.25 : (H3_RADIUS_MILES[resolution] ?? 1);
|
||||
const radiusMiles = isPostcode ? 0 : (H3_RADIUS_MILES[resolution] ?? 1);
|
||||
|
||||
const listingStatus = filters['Listing status'];
|
||||
const isRent =
|
||||
Array.isArray(listingStatus) &&
|
||||
typeof listingStatus[0] === 'string' &&
|
||||
(listingStatus as string[]).includes('For rent');
|
||||
|
||||
// Check price filters in priority order: asking price (current listings) > estimated > last known
|
||||
// For rent mode, check asking rent first
|
||||
const priceFilter = isRent
|
||||
? filters['Asking rent (monthly)']
|
||||
: (filters['Asking price'] ??
|
||||
filters['Estimated current price'] ??
|
||||
filters['Last known price']);
|
||||
const priceFilter =
|
||||
filters['Estimated current price'] ?? filters['Last known price'];
|
||||
const minPrice =
|
||||
Array.isArray(priceFilter) && typeof priceFilter[0] === 'number' ? priceFilter[0] : undefined;
|
||||
const maxPrice =
|
||||
|
|
@ -146,26 +116,6 @@ export function buildPropertySearchUrls({
|
|||
? (propertyTypes as string[])
|
||||
: [];
|
||||
|
||||
const bedroomFilter = filters['Bedrooms'];
|
||||
const minBedrooms =
|
||||
Array.isArray(bedroomFilter) && typeof bedroomFilter[0] === 'number'
|
||||
? bedroomFilter[0]
|
||||
: undefined;
|
||||
const maxBedrooms =
|
||||
Array.isArray(bedroomFilter) && typeof bedroomFilter[1] === 'number'
|
||||
? bedroomFilter[1]
|
||||
: undefined;
|
||||
|
||||
const bathroomFilter = filters['Bathrooms'];
|
||||
const minBathrooms =
|
||||
Array.isArray(bathroomFilter) && typeof bathroomFilter[0] === 'number'
|
||||
? bathroomFilter[0]
|
||||
: undefined;
|
||||
const maxBathrooms =
|
||||
Array.isArray(bathroomFilter) && typeof bathroomFilter[1] === 'number'
|
||||
? bathroomFilter[1]
|
||||
: undefined;
|
||||
|
||||
const tenureFilter = filters['Leasehold/Freehold'];
|
||||
const selectedTenures =
|
||||
Array.isArray(tenureFilter) && typeof tenureFilter[0] === 'string'
|
||||
|
|
@ -175,20 +125,15 @@ export function buildPropertySearchUrls({
|
|||
// Rightmove — requires locationIdentifier from typeahead API
|
||||
let rightmove: string | null = null;
|
||||
if (rightmoveLocationId) {
|
||||
const rmPrices = isRent ? RIGHTMOVE_RENTS : RIGHTMOVE_PRICES;
|
||||
const rmParams = new URLSearchParams();
|
||||
rmParams.set('searchLocation', postcode);
|
||||
rmParams.set('useLocationIdentifier', 'true');
|
||||
rmParams.set('locationIdentifier', rightmoveLocationId);
|
||||
rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII)));
|
||||
if (minPrice !== undefined)
|
||||
rmParams.set('minPrice', String(snapToAllowed(minPrice, rmPrices, 'floor')));
|
||||
rmParams.set('minPrice', String(snapToAllowed(minPrice, RIGHTMOVE_PRICES, 'floor')));
|
||||
if (maxPrice !== undefined)
|
||||
rmParams.set('maxPrice', String(snapToAllowed(maxPrice, rmPrices, 'ceil')));
|
||||
if (minBedrooms !== undefined) rmParams.set('minBedrooms', String(Math.floor(minBedrooms)));
|
||||
if (maxBedrooms !== undefined) rmParams.set('maxBedrooms', String(Math.ceil(maxBedrooms)));
|
||||
if (minBathrooms !== undefined) rmParams.set('minBathrooms', String(Math.floor(minBathrooms)));
|
||||
if (maxBathrooms !== undefined) rmParams.set('maxBathrooms', String(Math.ceil(maxBathrooms)));
|
||||
rmParams.set('maxPrice', String(snapToAllowed(maxPrice, RIGHTMOVE_PRICES, 'ceil')));
|
||||
if (selectedTypes.length > 0) {
|
||||
const rmTypes = [
|
||||
...new Set(
|
||||
|
|
@ -200,24 +145,22 @@ export function buildPropertySearchUrls({
|
|||
];
|
||||
if (rmTypes.length > 0) rmParams.set('propertyTypes', rmTypes.join(','));
|
||||
}
|
||||
if (!isRent && selectedTenures.length > 0) {
|
||||
if (selectedTenures.length > 0) {
|
||||
const rmTenures = selectedTenures.map((t) => (t === 'Freehold' ? 'FREEHOLD' : 'LEASEHOLD'));
|
||||
rmParams.set('tenureTypes', rmTenures.join(','));
|
||||
}
|
||||
if (!isRent) rmParams.set('_includeSSTC', 'on');
|
||||
const rmPath = isRent ? 'property-to-rent' : 'property-for-sale';
|
||||
rightmove = `https://www.rightmove.co.uk/${rmPath}/find.html?${rmParams.toString()}`;
|
||||
rmParams.set('_includeSSTC', 'on');
|
||||
rightmove = `https://www.rightmove.co.uk/property-for-sale/find.html?${rmParams.toString()}`;
|
||||
}
|
||||
|
||||
// OnTheMarket — postcode slug in URL path (e.g. "SW1A 1AA" → "sw1a-1aa")
|
||||
const otmSlug = postcode.toLowerCase().replace(/\s+/g, '-');
|
||||
const otmPrices = isRent ? OTM_RENTS : OTM_PRICES;
|
||||
const otmParams = new URLSearchParams();
|
||||
otmParams.set('radius', String(nearestRadius(radiusMiles, OTM_RADII)));
|
||||
if (minPrice !== undefined)
|
||||
otmParams.set('min-price', String(snapToAllowed(minPrice, otmPrices, 'floor')));
|
||||
otmParams.set('min-price', String(snapToAllowed(minPrice, OTM_PRICES, 'floor')));
|
||||
if (maxPrice !== undefined)
|
||||
otmParams.set('max-price', String(snapToAllowed(maxPrice, otmPrices, 'ceil')));
|
||||
otmParams.set('max-price', String(snapToAllowed(maxPrice, OTM_PRICES, 'ceil')));
|
||||
if (selectedTypes.length > 0) {
|
||||
const otmTypes = [
|
||||
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean)),
|
||||
|
|
@ -227,20 +170,17 @@ export function buildPropertySearchUrls({
|
|||
}
|
||||
}
|
||||
otmParams.set('view', 'map-list');
|
||||
const otmPath = isRent ? 'to-rent' : 'for-sale';
|
||||
const onthemarket = `https://www.onthemarket.com/${otmPath}/property/${otmSlug}/?${otmParams.toString()}`;
|
||||
const onthemarket = `https://www.onthemarket.com/for-sale/property/${otmSlug}/?${otmParams.toString()}`;
|
||||
|
||||
// Zoopla
|
||||
const zPrices = isRent ? ZOOPLA_RENTS : ZOOPLA_PRICES;
|
||||
const zParams = new URLSearchParams();
|
||||
zParams.set('q', postcode);
|
||||
const zSearchSource = isRent ? 'to-rent' : 'for-sale';
|
||||
zParams.set('search_source', zSearchSource);
|
||||
zParams.set('search_source', 'for-sale');
|
||||
zParams.set('radius', String(nearestRadius(radiusMiles, ZOOPLA_RADII)));
|
||||
if (minPrice !== undefined)
|
||||
zParams.set('price_min', String(snapToAllowed(minPrice, zPrices, 'floor')));
|
||||
zParams.set('price_min', String(snapToAllowed(minPrice, ZOOPLA_PRICES, 'floor')));
|
||||
if (maxPrice !== undefined)
|
||||
zParams.set('price_max', String(snapToAllowed(maxPrice, zPrices, 'ceil')));
|
||||
zParams.set('price_max', String(snapToAllowed(maxPrice, ZOOPLA_PRICES, 'ceil')));
|
||||
if (selectedTypes.length > 0) {
|
||||
const zTypes = [
|
||||
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.zoopla).filter(Boolean)),
|
||||
|
|
@ -249,28 +189,7 @@ export function buildPropertySearchUrls({
|
|||
zParams.append('property_sub_type', zt!);
|
||||
}
|
||||
}
|
||||
const zoopla = `https://www.zoopla.co.uk/${zSearchSource}/property/?${zParams.toString()}`;
|
||||
const zoopla = `https://www.zoopla.co.uk/for-sale/property/?${zParams.toString()}`;
|
||||
|
||||
// OpenRent — rent mode only
|
||||
let openrent: string | null = null;
|
||||
if (isRent) {
|
||||
const postcodeNoSpaces = postcode.replace(/\s+/g, '');
|
||||
const orSlug = postcodeNoSpaces.toLowerCase();
|
||||
const orParams = new URLSearchParams();
|
||||
orParams.set('term', postcodeNoSpaces.toUpperCase());
|
||||
const radiusKm = Math.round((isPostcode ? 0.25 : radiusMiles) * 1.609);
|
||||
orParams.set('area', String(Math.max(1, radiusKm)));
|
||||
const rentFilter = filters['Asking rent (monthly)'];
|
||||
const minRent =
|
||||
Array.isArray(rentFilter) && typeof rentFilter[0] === 'number' ? rentFilter[0] : undefined;
|
||||
const maxRent =
|
||||
Array.isArray(rentFilter) && typeof rentFilter[1] === 'number' ? rentFilter[1] : undefined;
|
||||
if (minRent !== undefined) orParams.set('prices_min', String(Math.round(minRent)));
|
||||
if (maxRent !== undefined) orParams.set('prices_max', String(Math.round(maxRent)));
|
||||
if (minBedrooms !== undefined) orParams.set('bedrooms_min', String(Math.floor(minBedrooms)));
|
||||
if (maxBedrooms !== undefined) orParams.set('bedrooms_max', String(Math.ceil(maxBedrooms)));
|
||||
openrent = `https://www.openrent.co.uk/properties-to-rent/${orSlug}?${orParams.toString()}`;
|
||||
}
|
||||
|
||||
return { rightmove, onthemarket, zoopla, openrent };
|
||||
return { rightmove, onthemarket, zoopla };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,49 +74,6 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
|
|||
<path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z" />
|
||||
</>
|
||||
),
|
||||
'Asking price': (
|
||||
<>
|
||||
<path d="M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z" />
|
||||
<line x1="7" y1="7" x2="7.01" y2="7" />
|
||||
</>
|
||||
),
|
||||
'Asking rent (monthly)': (
|
||||
<>
|
||||
<circle cx="9" cy="9" r="7" />
|
||||
<path d="M15.58 8.42A7 7 0 0122 15a7 7 0 01-7 7 7 7 0 01-6.58-4.58" />
|
||||
</>
|
||||
),
|
||||
Bedrooms: (
|
||||
<>
|
||||
<path d="M2 4v16" />
|
||||
<path d="M2 8h18a2 2 0 012 2v10" />
|
||||
<path d="M2 17h20" />
|
||||
<path d="M6 4v4" />
|
||||
</>
|
||||
),
|
||||
Bathrooms: (
|
||||
<>
|
||||
<path d="M4 12h16a1 1 0 011 1v3a4 4 0 01-4 4H7a4 4 0 01-4-4v-3a1 1 0 011-1z" />
|
||||
<path d="M6 12V5a2 2 0 012-2h3" />
|
||||
<line x1="14" y1="4" x2="17" y2="4" />
|
||||
</>
|
||||
),
|
||||
'Listing date': (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</>
|
||||
),
|
||||
'Listing status': (
|
||||
<>
|
||||
<line x1="8" y1="6" x2="21" y2="6" />
|
||||
<line x1="8" y1="12" x2="21" y2="12" />
|
||||
<line x1="8" y1="18" x2="21" y2="18" />
|
||||
<line x1="3" y1="6" x2="3.01" y2="6" />
|
||||
<line x1="3" y1="12" x2="3.01" y2="12" />
|
||||
<line x1="3" y1="18" x2="3.01" y2="18" />
|
||||
</>
|
||||
),
|
||||
'Leasehold/Freehold': (
|
||||
<>
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||
|
|
@ -424,7 +381,7 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
|
|||
<path d="M1 1h4l2.68 13.39a2 2 0 002 1.61h9.72a2 2 0 002-1.61L23 6H6" />
|
||||
</>
|
||||
),
|
||||
'Number of parks within 2km': (
|
||||
'Number of parks within 1km': (
|
||||
<>
|
||||
<path d="M12 22v-7" />
|
||||
<path d="M17 15H7l2-4H5l7-9 7 9h-4l2 4z" />
|
||||
|
|
|
|||
|
|
@ -195,9 +195,12 @@ export function emojiToTwemojiUrl(emoji: string): string {
|
|||
}
|
||||
|
||||
/** Look up a discrete color from the enum palette by index (wraps if > palette size). */
|
||||
export function enumIndexToColor(index: number): [number, number, number] {
|
||||
const i = Math.round(Math.max(0, index)) % ENUM_PALETTE.length;
|
||||
return ENUM_PALETTE[i];
|
||||
export function enumIndexToColor(
|
||||
index: number,
|
||||
palette: [number, number, number][] = ENUM_PALETTE
|
||||
): [number, number, number] {
|
||||
const i = Math.round(Math.max(0, index)) % palette.length;
|
||||
return palette[i];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -216,7 +219,8 @@ export function getFeatureFillColor(
|
|||
densityGradient: GradientStop[],
|
||||
isDark: boolean,
|
||||
alpha: number,
|
||||
enumCount: number = 0
|
||||
enumCount: number = 0,
|
||||
enumPalette?: [number, number, number][]
|
||||
): [number, number, number, number] {
|
||||
if (colorRange) {
|
||||
if (value == null)
|
||||
|
|
@ -234,7 +238,7 @@ export function getFeatureFillColor(
|
|||
|
||||
// Discrete coloring for enum features (used as base; PieHexExtension overrides when active)
|
||||
if (enumCount > 0) {
|
||||
const rgb = enumIndexToColor(Math.round(value as number));
|
||||
const rgb = enumIndexToColor(Math.round(value as number), enumPalette);
|
||||
return [...rgb, alpha] as [number, number, number, number];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ export function summarizeParams(queryString: string): string {
|
|||
const colonIdx = entry.indexOf(':');
|
||||
return colonIdx > 0 ? entry.substring(0, colonIdx) : entry;
|
||||
})
|
||||
.filter((n) => n && n !== 'Listing status');
|
||||
.filter((n) => n);
|
||||
if (filterNames.length > 0) {
|
||||
parts.push(
|
||||
filterNames.length <= 2
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export interface FeatureMeta {
|
|||
histogram?: { min: number; max: number; p1: number; p99: number; counts: number[] };
|
||||
// Enum-only fields
|
||||
values?: string[];
|
||||
counts?: Record<string, number>;
|
||||
// Description fields
|
||||
description?: string;
|
||||
detail?: string;
|
||||
|
|
@ -18,9 +19,6 @@ export interface FeatureMeta {
|
|||
suffix?: string;
|
||||
raw?: boolean;
|
||||
absolute?: boolean;
|
||||
// Mode restriction fields
|
||||
modes?: string[];
|
||||
linked?: string;
|
||||
}
|
||||
|
||||
export interface FeatureGroup {
|
||||
|
|
@ -132,8 +130,6 @@ export interface Property {
|
|||
duration?: string;
|
||||
current_energy_rating?: string;
|
||||
potential_energy_rating?: string;
|
||||
listing_status?: string;
|
||||
listing_url?: string;
|
||||
property_sub_type?: string;
|
||||
price_qualifier?: string;
|
||||
former_council_house?: string;
|
||||
|
|
@ -144,7 +140,6 @@ export interface Property {
|
|||
|
||||
is_construction_date_approximate?: boolean;
|
||||
renovation_history?: RenovationEvent[];
|
||||
listing_features?: string[];
|
||||
|
||||
// All other numeric features (dynamic, including construction_age_band)
|
||||
[key: string]: string | number | boolean | RenovationEvent[] | string[] | undefined;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
104
pipeline/download/election_results.py
Normal file
104
pipeline/download/election_results.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import polars as pl
|
||||
|
||||
# UK Parliament publishes candidate-level results for the 2024 General Election.
|
||||
# One row per candidate per constituency — we aggregate to per-constituency stats.
|
||||
URL = "https://electionresults.parliament.uk/general-elections/6/candidacies.csv"
|
||||
|
||||
# Map party names to a smaller set for the enum feature and vote share columns.
|
||||
# Only parties that won seats in England are kept; the rest become "Other parties".
|
||||
PARTY_MAP = {
|
||||
"Labour": "Labour",
|
||||
"Conservative": "Conservative",
|
||||
"Liberal Democrat": "Liberal Democrat",
|
||||
"Reform UK": "Reform UK",
|
||||
"Green Party": "Green",
|
||||
}
|
||||
|
||||
|
||||
def download_and_convert(output_path: Path) -> None:
|
||||
print("Downloading 2024 General Election results...")
|
||||
response = httpx.get(URL, follow_redirects=True, timeout=60)
|
||||
response.raise_for_status()
|
||||
|
||||
df = pl.read_csv(response.content)
|
||||
print(f"Raw shape: {df.shape}")
|
||||
|
||||
# Filter to England only (constituency codes starting with E14)
|
||||
df = df.filter(pl.col("Constituency geographic code").str.starts_with("E14"))
|
||||
|
||||
# Map party names to our output groups
|
||||
df = df.with_columns(
|
||||
pl.col("Main party name")
|
||||
.replace_strict(PARTY_MAP, default="Other parties")
|
||||
.alias("party_group"),
|
||||
)
|
||||
|
||||
# ── Per-constituency winner stats ──
|
||||
winners = df.filter(pl.col("Candidate result position") == 1).select(
|
||||
pl.col("Constituency geographic code").alias("pcon"),
|
||||
pl.col("party_group").alias("winning_party"),
|
||||
(pl.col("Majority") / pl.col("Election valid vote count") * 100)
|
||||
.round(1)
|
||||
.alias("majority_pct"),
|
||||
(pl.col("Election valid vote count") / pl.col("Electorate") * 100)
|
||||
.round(1)
|
||||
.alias("turnout_pct"),
|
||||
)
|
||||
|
||||
# ── Per-party vote share percentages ──
|
||||
# Sum votes per party group per constituency, then pivot to wide format
|
||||
party_votes = (
|
||||
df.group_by("Constituency geographic code", "party_group")
|
||||
.agg(pl.col("Candidate vote count").sum())
|
||||
.rename({"Constituency geographic code": "pcon"})
|
||||
)
|
||||
total_votes = (
|
||||
df.group_by("Constituency geographic code")
|
||||
.agg(pl.col("Candidate vote count").sum().alias("total_votes"))
|
||||
.rename({"Constituency geographic code": "pcon"})
|
||||
)
|
||||
party_pct = (
|
||||
party_votes.join(total_votes, on="pcon")
|
||||
.with_columns(
|
||||
(pl.col("Candidate vote count") / pl.col("total_votes") * 100)
|
||||
.round(1)
|
||||
.alias("vote_pct"),
|
||||
)
|
||||
.pivot(on="party_group", index="pcon", values="vote_pct")
|
||||
)
|
||||
|
||||
# Rename columns to "% Party" format
|
||||
rename_map = {col: f"% {col}" for col in party_pct.columns if col != "pcon"}
|
||||
party_pct = party_pct.rename(rename_map)
|
||||
|
||||
# Join winner stats with party vote shares
|
||||
result = winners.join(party_pct, on="pcon", how="left")
|
||||
|
||||
print(f"Constituencies: {result.height}")
|
||||
print(f"Columns: {result.columns}")
|
||||
print(
|
||||
f"Party breakdown:\n{result['winning_party'].value_counts().sort('count', descending=True)}"
|
||||
)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
result.write_parquet(output_path, compression="zstd")
|
||||
print(f"Saved to {output_path}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Download 2024 General Election results by constituency"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output", type=Path, required=True, help="Output parquet file path"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
download_and_convert(args.output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -57,11 +57,33 @@ def download_and_convert(output_path: Path) -> None:
|
|||
pl.col("Ethnicity").replace_strict(group_map).alias("group"),
|
||||
)
|
||||
|
||||
# Sum percentages within each group per local authority
|
||||
wide = (
|
||||
detailed.group_by("Geography_code", "group")
|
||||
.agg(pl.col("Value1").sum().round(1))
|
||||
.pivot(on="group", index="Geography_code", values="Value1")
|
||||
# Sum percentages within each group per local authority (keep full precision)
|
||||
grouped = detailed.group_by("Geography_code", "group").agg(pl.col("Value1").sum())
|
||||
wide = grouped.pivot(on="group", index="Geography_code", values="Value1")
|
||||
|
||||
# Normalize so each row sums to exactly 100%, then round using largest-remainder
|
||||
# method to preserve the sum. Independent rounding of 6 values can drift ±0.3.
|
||||
group_cols = [c for c in wide.columns if c != "Geography_code"]
|
||||
row_total = sum(pl.col(c) for c in group_cols)
|
||||
# Scale each group so they sum to exactly 100
|
||||
wide = wide.with_columns(
|
||||
[(pl.col(c) / row_total * 100.0).alias(c) for c in group_cols]
|
||||
)
|
||||
# Round to 1 decimal, then adjust the largest group to absorb residual
|
||||
rounded_cols = [pl.col(c).round(1).alias(c) for c in group_cols]
|
||||
wide = wide.with_columns(rounded_cols)
|
||||
rounded_sum = sum(pl.col(c) for c in group_cols)
|
||||
residual = (100.0 - rounded_sum).round(1)
|
||||
# Find which group is largest per row and add the residual there
|
||||
largest_col = pl.concat_list(group_cols).list.arg_max()
|
||||
wide = wide.with_columns(
|
||||
[
|
||||
pl.when(largest_col == i)
|
||||
.then(pl.col(c) + residual)
|
||||
.otherwise(pl.col(c))
|
||||
.alias(c)
|
||||
for i, c in enumerate(group_cols)
|
||||
]
|
||||
)
|
||||
|
||||
# Rename columns to be descriptive
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ STOP_TYPES = {
|
|||
"BCT": "Bus stop",
|
||||
"BCE": "Bus station",
|
||||
"TXR": "Taxi rank",
|
||||
"TMU": "Metro or Tram stop",
|
||||
"MET": "Metro or Tram stop",
|
||||
"TMU": "Tube station",
|
||||
"MET": "Tube station",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ _AREA_COLUMNS = [
|
|||
# Amenities
|
||||
"Number of restaurants within 2km",
|
||||
"Number of grocery shops and supermarkets within 2km",
|
||||
"Number of parks within 2km",
|
||||
"Number of parks within 1km",
|
||||
"Distance to nearest train or tube station (km)",
|
||||
"Distance to nearest park (km)",
|
||||
# Environment
|
||||
|
|
@ -62,6 +62,16 @@ _AREA_COLUMNS = [
|
|||
"Good+ secondary schools within 2km",
|
||||
# Demographics
|
||||
"Median age",
|
||||
# Politics
|
||||
"Winning party",
|
||||
"Voter turnout (%)",
|
||||
"Majority (%)",
|
||||
"% Labour",
|
||||
"% Conservative",
|
||||
"% Liberal Democrat",
|
||||
"% Reform UK",
|
||||
"% Green",
|
||||
"% Other parties",
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -78,6 +88,7 @@ def _build(
|
|||
rental_prices_path: Path,
|
||||
lsoa_population_path: Path,
|
||||
median_age_path: Path,
|
||||
election_results_path: Path,
|
||||
) -> tuple[pl.DataFrame, pl.DataFrame]:
|
||||
"""Build postcode and properties dataframes from epc_pp + auxiliary data.
|
||||
|
||||
|
|
@ -113,6 +124,7 @@ def _build(
|
|||
pl.col("long").alias("lon"),
|
||||
"lsoa21",
|
||||
"oa21",
|
||||
"pcon",
|
||||
)
|
||||
)
|
||||
wide = wide.join(arcgis, on="postcode", how="left")
|
||||
|
|
@ -193,6 +205,9 @@ def _build(
|
|||
median_age = pl.scan_parquet(median_age_path)
|
||||
wide = wide.join(median_age, on="lsoa21", how="left")
|
||||
|
||||
election = pl.scan_parquet(election_results_path)
|
||||
wide = wide.join(election, on="pcon", how="left")
|
||||
|
||||
poi_counts = pl.scan_parquet(poi_proximity_path)
|
||||
wide = wide.join(poi_counts, on="postcode", how="left")
|
||||
|
||||
|
|
@ -304,6 +319,7 @@ def _build(
|
|||
"Barriers to Housing and Services Score",
|
||||
"lsoa21",
|
||||
"oa21",
|
||||
"pcon",
|
||||
"epc_property_type",
|
||||
"pp_property_type",
|
||||
"built_form",
|
||||
|
|
@ -323,7 +339,7 @@ def _build(
|
|||
"property_type": "Property type",
|
||||
"restaurants_2km": "Number of restaurants within 2km",
|
||||
"groceries_2km": "Number of grocery shops and supermarkets within 2km",
|
||||
"parks_2km": "Number of parks within 2km",
|
||||
"parks_1km": "Number of parks within 1km",
|
||||
"train_tube_nearest_km": "Distance to nearest train or tube station (km)",
|
||||
"parks_nearest_km": "Distance to nearest park (km)",
|
||||
"latest_price": "Last known price",
|
||||
|
|
@ -342,6 +358,9 @@ def _build(
|
|||
"floor_height": "Interior height (m)",
|
||||
"was_council_house": "Former council house",
|
||||
"median_age": "Median age",
|
||||
"winning_party": "Winning party",
|
||||
"turnout_pct": "Voter turnout (%)",
|
||||
"majority_pct": "Majority (%)",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
@ -427,6 +446,12 @@ def main():
|
|||
required=True,
|
||||
help="Census 2021 median age by LSOA parquet file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--election-results",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="2024 General Election results by constituency parquet file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-postcodes",
|
||||
type=Path,
|
||||
|
|
@ -454,6 +479,7 @@ def main():
|
|||
rental_prices_path=args.rental_prices,
|
||||
lsoa_population_path=args.lsoa_population,
|
||||
median_age_path=args.median_age,
|
||||
election_results_path=args.election_results,
|
||||
)
|
||||
|
||||
print(f"\nPostcode columns: {postcode_df.columns}")
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ POI_GROUPS_2KM = {
|
|||
|
||||
# Groups for which to compute distance to nearest POI (from filtered POIs)
|
||||
DISTANCE_GROUPS = {
|
||||
"train_tube": ["Metro or Tram stop", "Rail station"],
|
||||
"train_tube": ["Tube station", "Rail station"],
|
||||
}
|
||||
|
||||
# OS Open Greenspace function types used for park counts and distance calculation.
|
||||
|
|
@ -67,8 +67,8 @@ def main():
|
|||
|
||||
# Park counts and distances from OS Open Greenspace
|
||||
greenspace = pl.read_parquet(args.greenspace)
|
||||
park_counts_2km = count_pois_per_postcode(
|
||||
postcodes, greenspace, groups=GREENSPACE_PARK_FUNCTIONS, radius_km=2
|
||||
park_counts_1km = count_pois_per_postcode(
|
||||
postcodes, greenspace, groups=GREENSPACE_PARK_FUNCTIONS, radius_km=1
|
||||
)
|
||||
park_distances = min_distance_per_postcode(
|
||||
postcodes, greenspace, groups=GREENSPACE_PARK_FUNCTIONS
|
||||
|
|
@ -77,7 +77,7 @@ def main():
|
|||
# Join all results on postcode
|
||||
result = (
|
||||
counts_2km.join(distances, on="postcode")
|
||||
.join(park_counts_2km, on="postcode")
|
||||
.join(park_counts_1km, on="postcode")
|
||||
.join(park_distances, on="postcode")
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1054,7 +1054,7 @@ NAPTAN_EMOJIS: dict[str, str] = {
|
|||
"Bus stop": "🚏",
|
||||
"Bus station": "🚌",
|
||||
"Taxi rank": "🚕",
|
||||
"Metro or Tram stop": "🚊",
|
||||
"Tube station": "🚇",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ POI_GROUPS = {
|
|||
"restaurants": ["Restaurant", "Fast Food"],
|
||||
"groceries": ["Supermarket"],
|
||||
"parks": ["Park"],
|
||||
"train_tube": ["Rail station", "Metro or Tram stop"],
|
||||
"train_tube": ["Rail station", "Tube station"],
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -43,13 +43,12 @@ dev = [
|
|||
]
|
||||
|
||||
[tool.deptry]
|
||||
# finder/ has its own pyproject.toml; analyses/ and scripts/ use transitive deps
|
||||
exclude = ["\\.venv", "finder", "analyses", "scripts"]
|
||||
# analyses/ and scripts/ use transitive deps
|
||||
exclude = ["\\.venv", "analyses", "scripts"]
|
||||
|
||||
[tool.deptry.per_rule_ignores]
|
||||
# pyarrow/fastexcel: runtime backends for polars parquet/Excel I/O
|
||||
# jupyter/ipywidgets/pandas/plotly/folium: needed for analysis notebooks (excluded from scan)
|
||||
# flask/fake-useragent: used in finder/ (excluded, has own pyproject.toml)
|
||||
DEP002 = ["pyarrow", "fastexcel", "jupyter", "ipywidgets", "pandas", "plotly", "folium", "flask", "fake-useragent"]
|
||||
DEP002 = ["pyarrow", "fastexcel", "jupyter", "ipywidgets", "pandas", "plotly", "folium"]
|
||||
# pytest is a dev dependency, not a missing one
|
||||
DEP004 = ["pytest"]
|
||||
|
|
|
|||
|
|
@ -166,6 +166,8 @@ pub struct PropertyData {
|
|||
/// For enum features: maps feature index to list of possible string values.
|
||||
/// Index in values list corresponds to the u16 value stored in feature_data.
|
||||
pub enum_values: rustc_hash::FxHashMap<usize, Vec<String>>,
|
||||
/// For enum features: maps feature index to per-value global counts (same order as enum_values).
|
||||
pub enum_counts: rustc_hash::FxHashMap<usize, Vec<u64>>,
|
||||
/// Per-row flag: true = construction date is approximate (from EPC band),
|
||||
/// false = exact (from new-build transaction date).
|
||||
/// Bit-packed: byte `row / 8`, bit `row % 8`. 8x smaller than Vec<bool>.
|
||||
|
|
@ -173,12 +175,6 @@ pub struct PropertyData {
|
|||
/// Per-row renovation events. Keyed by (permuted) row index.
|
||||
/// Only rows with events are present in the map.
|
||||
renovation_history: FxHashMap<u32, Vec<RenovationEvent>>,
|
||||
/// Per-row listing features (key feature bullet points from online listings).
|
||||
/// Only rows with features are present in the map.
|
||||
listing_features: FxHashMap<u32, Vec<String>>,
|
||||
/// Sparse per-row optional string columns from online listings.
|
||||
/// Only rows with non-empty values are stored (saves ~1 GB vs Vec<Option<String>>).
|
||||
listing_url: FxHashMap<u32, String>,
|
||||
property_sub_type: FxHashMap<u32, String>,
|
||||
price_qualifier: FxHashMap<u32, String>,
|
||||
}
|
||||
|
|
@ -215,19 +211,6 @@ impl PropertyData {
|
|||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// Get listing features for a given row (empty slice if none).
|
||||
pub fn listing_features(&self, row: usize) -> &[String] {
|
||||
self.listing_features
|
||||
.get(&(row as u32))
|
||||
.map(|v| v.as_slice())
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// Get listing URL for a given row.
|
||||
pub fn listing_url(&self, row: usize) -> Option<&str> {
|
||||
self.listing_url.get(&(row as u32)).map(String::as_str)
|
||||
}
|
||||
|
||||
/// Get property sub-type for a given row.
|
||||
pub fn property_sub_type(&self, row: usize) -> Option<&str> {
|
||||
self.property_sub_type
|
||||
|
|
@ -534,8 +517,6 @@ impl PropertyData {
|
|||
pub fn load(
|
||||
properties_path: &Path,
|
||||
postcode_features_path: &Path,
|
||||
listings_buy_path: &Path,
|
||||
listings_rent_path: &Path,
|
||||
) -> anyhow::Result<Self> {
|
||||
// Load postcode.parquet
|
||||
tracing::info!(
|
||||
|
|
@ -551,9 +532,8 @@ impl PropertyData {
|
|||
// Load properties.parquet and join with postcode data for lat/lon + area features
|
||||
tracing::info!("Loading properties from {:?}", properties_path);
|
||||
let properties_lf = LazyFrame::scan_parquet(properties_path, Default::default())
|
||||
.context("Failed to scan properties parquet")?
|
||||
.with_columns([lit("Historical sale").alias("Listing status")]);
|
||||
let properties_joined = properties_lf
|
||||
.context("Failed to scan properties parquet")?;
|
||||
let combined = properties_lf
|
||||
.join(
|
||||
postcode_df.clone().lazy(),
|
||||
[col("Postcode")],
|
||||
|
|
@ -562,77 +542,8 @@ impl PropertyData {
|
|||
)
|
||||
.collect()
|
||||
.context("Failed to join properties with postcodes")?;
|
||||
let prop_count = properties_joined.height();
|
||||
tracing::info!(rows = prop_count, "Properties joined with postcodes");
|
||||
|
||||
// Load online listings (buy + rent) — these have their own lat/lon.
|
||||
// Expects the new finder parquet format with human-readable column names.
|
||||
let load_listings = |path: &Path, label: &str| -> anyhow::Result<DataFrame> {
|
||||
tracing::info!("Loading {} listings from {:?}", label, path);
|
||||
let lf = LazyFrame::scan_parquet(path, Default::default())
|
||||
.with_context(|| format!("Failed to scan {label} listings parquet"))?;
|
||||
|
||||
// Join with postcodes for area features (listings have their own lat/lon)
|
||||
let pc_no_coords = postcode_df.clone().lazy().drop(["lat", "lon"]);
|
||||
let joined = lf
|
||||
.join(
|
||||
pc_no_coords,
|
||||
[col("Postcode")],
|
||||
[col("Postcode")],
|
||||
JoinArgs::new(JoinType::Left),
|
||||
)
|
||||
.collect()
|
||||
.with_context(|| format!("Failed to join {label} listings with postcodes"))?;
|
||||
tracing::info!(rows = joined.height(), "{} listings joined", label);
|
||||
Ok(joined)
|
||||
};
|
||||
let listings_buy = load_listings(listings_buy_path, "buy")?;
|
||||
// Derive "Asking price per sqm" if not already present
|
||||
let listings_buy = if listings_buy.schema().get("Asking price per sqm").is_none() {
|
||||
listings_buy
|
||||
.lazy()
|
||||
.with_column(
|
||||
(col("Asking price").cast(DataType::Float64) / col("Total floor area (sqm)"))
|
||||
.round(0)
|
||||
.alias("Asking price per sqm"),
|
||||
)
|
||||
.collect()
|
||||
.context("Failed to derive Asking price per sqm")?
|
||||
} else {
|
||||
listings_buy
|
||||
};
|
||||
let listings_rent = load_listings(listings_rent_path, "rent")?;
|
||||
|
||||
// Concatenate all rows into a single DataFrame
|
||||
tracing::info!("Concatenating all data sources");
|
||||
let buy_count = listings_buy.height();
|
||||
let rent_count = listings_rent.height();
|
||||
let combined = concat(
|
||||
[
|
||||
properties_joined.lazy(),
|
||||
listings_buy.lazy(),
|
||||
listings_rent.lazy(),
|
||||
],
|
||||
UnionArgs {
|
||||
parallel: false,
|
||||
rechunk: true,
|
||||
to_supertypes: true,
|
||||
diagonal: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.context("Failed to concat data sources")?
|
||||
.collect()
|
||||
.context("Failed to collect combined data")?;
|
||||
|
||||
let total_rows = combined.height();
|
||||
tracing::info!(
|
||||
properties = prop_count,
|
||||
buy_listings = buy_count,
|
||||
rent_listings = rent_count,
|
||||
total = total_rows,
|
||||
"All data sources combined"
|
||||
);
|
||||
tracing::info!(rows = total_rows, "Properties joined with postcodes");
|
||||
|
||||
// Get configured feature/enum names in config order
|
||||
let numeric_names = features::all_numeric_feature_names();
|
||||
|
|
@ -703,12 +614,11 @@ impl PropertyData {
|
|||
}
|
||||
}
|
||||
|
||||
// String columns for address/postcode and online listing metadata
|
||||
// String columns for address/postcode and property metadata
|
||||
for &string_col_name in &[
|
||||
"Address per Property Register",
|
||||
"Address per EPC",
|
||||
"Postcode",
|
||||
"Listing URL",
|
||||
"Property sub-type",
|
||||
"Price qualifier",
|
||||
] {
|
||||
|
|
@ -731,11 +641,6 @@ impl PropertyData {
|
|||
if has_renovation_history {
|
||||
select_exprs.push(col("renovation_history"));
|
||||
}
|
||||
let has_listing_features = schema.get("Listing features").is_some();
|
||||
if has_listing_features {
|
||||
select_exprs.push(col("Listing features"));
|
||||
}
|
||||
|
||||
let df = combined
|
||||
.lazy()
|
||||
.select(select_exprs)
|
||||
|
|
@ -827,7 +732,7 @@ impl PropertyData {
|
|||
let address_raw = extract_string_col(&df, "Address per Property Register")?;
|
||||
let postcode_raw = extract_string_col(&df, "Postcode")?;
|
||||
|
||||
// Extract optional string columns for online listing metadata
|
||||
// Extract optional string columns
|
||||
let extract_optional_string_col =
|
||||
|df: &DataFrame, name: &str| -> anyhow::Result<Vec<Option<String>>> {
|
||||
if let Ok(column) = df.column(name) {
|
||||
|
|
@ -852,7 +757,6 @@ impl PropertyData {
|
|||
}
|
||||
};
|
||||
|
||||
let listing_url_raw = extract_optional_string_col(&df, "Listing URL")?;
|
||||
let property_sub_type_raw = extract_optional_string_col(&df, "Property sub-type")?;
|
||||
let price_qualifier_raw = extract_optional_string_col(&df, "Price qualifier")?;
|
||||
|
||||
|
|
@ -996,44 +900,6 @@ impl PropertyData {
|
|||
FxHashMap::default()
|
||||
};
|
||||
|
||||
// Extract listing features: List<String>
|
||||
let mut listing_features_raw: FxHashMap<u32, Vec<String>> = if has_listing_features {
|
||||
tracing::info!("Extracting listing features");
|
||||
let feat_col = df
|
||||
.column("Listing features")
|
||||
.context("Missing Listing features column")?;
|
||||
let list_ca = feat_col
|
||||
.list()
|
||||
.context("Listing features is not a list column")?;
|
||||
|
||||
let mut features_map: FxHashMap<u32, Vec<String>> = FxHashMap::default();
|
||||
for old_row in 0..row_count {
|
||||
if let Some(inner) = list_ca.get_as_series(old_row) {
|
||||
if inner.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let str_ca = inner
|
||||
.str()
|
||||
.context("Listing features inner is not a string series")?;
|
||||
let items: Vec<String> = str_ca
|
||||
.into_iter()
|
||||
.filter_map(|v| v.map(|s| s.to_string()))
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if !items.is_empty() {
|
||||
features_map.insert(old_row as u32, items);
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
properties_with_features = features_map.len(),
|
||||
"Listing features extracted"
|
||||
);
|
||||
features_map
|
||||
} else {
|
||||
FxHashMap::default()
|
||||
};
|
||||
|
||||
// Sort all rows by spatial locality so that grid queries access
|
||||
// contiguous memory (sequential reads instead of random DRAM accesses).
|
||||
tracing::info!("Sorting rows by spatial locality");
|
||||
|
|
@ -1103,28 +969,7 @@ impl PropertyData {
|
|||
map
|
||||
};
|
||||
|
||||
// Re-key listing_features by permuted row index
|
||||
let listing_features: FxHashMap<u32, Vec<String>> = {
|
||||
let mut map =
|
||||
FxHashMap::with_capacity_and_hasher(listing_features_raw.len(), Default::default());
|
||||
for (new_row, &old_row) in perm.iter().enumerate() {
|
||||
if let Some(items) = listing_features_raw.remove(&old_row) {
|
||||
map.insert(new_row as u32, items);
|
||||
}
|
||||
}
|
||||
map
|
||||
};
|
||||
|
||||
// Permute optional string columns into sparse HashMaps
|
||||
let listing_url: FxHashMap<u32, String> = {
|
||||
let mut map = FxHashMap::default();
|
||||
for (new_row, &old_row) in perm.iter().enumerate() {
|
||||
if let Some(ref s) = listing_url_raw[old_row as usize] {
|
||||
map.insert(new_row as u32, s.clone());
|
||||
}
|
||||
}
|
||||
map
|
||||
};
|
||||
let property_sub_type: FxHashMap<u32, String> = {
|
||||
let mut map = FxHashMap::default();
|
||||
for (new_row, &old_row) in perm.iter().enumerate() {
|
||||
|
|
@ -1145,11 +990,24 @@ impl PropertyData {
|
|||
};
|
||||
|
||||
// Build enum_values map: feature_index -> list of string values
|
||||
// and enum_counts map: feature_index -> per-value global counts
|
||||
let mut enum_values: rustc_hash::FxHashMap<usize, Vec<String>> =
|
||||
rustc_hash::FxHashMap::default();
|
||||
for (enum_idx, (values, _)) in enum_col_major.iter().enumerate() {
|
||||
let mut enum_counts: rustc_hash::FxHashMap<usize, Vec<u64>> =
|
||||
rustc_hash::FxHashMap::default();
|
||||
for (enum_idx, (values, encoded)) in enum_col_major.iter().enumerate() {
|
||||
let feature_idx = num_numeric + enum_idx;
|
||||
enum_values.insert(feature_idx, values.clone());
|
||||
let mut counts = vec![0u64; values.len()];
|
||||
for &val in encoded {
|
||||
if val.is_finite() {
|
||||
let idx = val as usize;
|
||||
if idx < counts.len() {
|
||||
counts[idx] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
enum_counts.insert(feature_idx, counts);
|
||||
}
|
||||
|
||||
// Build feature_stats: numeric stats + placeholder stats for enums
|
||||
|
|
@ -1232,10 +1090,9 @@ impl PropertyData {
|
|||
postcode_interner,
|
||||
postcode_keys,
|
||||
enum_values,
|
||||
enum_counts,
|
||||
approx_build_date_bits,
|
||||
renovation_history,
|
||||
listing_features,
|
||||
listing_url,
|
||||
property_sub_type,
|
||||
price_qualifier,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -28,16 +28,12 @@ pub struct FeatureConfig {
|
|||
pub raw: bool,
|
||||
/// If true, the slider uses absolute min/max/step instead of percentile scaling
|
||||
pub absolute: bool,
|
||||
/// Listing modes this feature is available in (empty = all modes)
|
||||
pub modes: &'static [&'static str],
|
||||
/// Name of the linked feature that swaps when switching modes (empty = no link)
|
||||
pub linked: &'static str,
|
||||
}
|
||||
|
||||
/// Features whose histogram bins should be exactly 1 unit wide (one per integer).
|
||||
/// p1/p99 are snapped to integer boundaries before binning.
|
||||
pub const INTEGER_BIN_FEATURES: &[&str] =
|
||||
&["Number of bedrooms & living rooms", "Bedrooms", "Bathrooms"];
|
||||
&["Number of bedrooms & living rooms"];
|
||||
|
||||
pub struct EnumFeatureConfig {
|
||||
pub name: &'static str,
|
||||
|
|
@ -69,13 +65,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
FeatureGroup {
|
||||
name: "Properties",
|
||||
features: &[
|
||||
Feature::Enum(EnumFeatureConfig {
|
||||
name: "Listing status",
|
||||
order: Some(&["Historical sale", "For sale", "For rent"]),
|
||||
description: "Whether the property is from historical sales, currently for sale, or for rent",
|
||||
detail: "Indicates the source of the property record: 'Historical sale' from HM Land Registry Price Paid data, 'For sale' from current online buy listings, or 'For rent' from current online rental listings.",
|
||||
source: "online-listings",
|
||||
}),
|
||||
Feature::Enum(EnumFeatureConfig {
|
||||
name: "Property type",
|
||||
order: Some(&["Detached", "Semi-Detached", "Terraced", "Flats/Maisonettes", "Other"]),
|
||||
|
|
@ -104,8 +93,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: true,
|
||||
modes: &["historical"],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Estimated current price",
|
||||
|
|
@ -121,25 +108,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: true,
|
||||
modes: &["historical"],
|
||||
linked: "Asking price",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Asking price",
|
||||
bounds: Bounds::Fixed {
|
||||
min: 0.0,
|
||||
max: 2_500_000.0,
|
||||
},
|
||||
step: 10000.0,
|
||||
description: "Asking price for properties currently listed for sale",
|
||||
detail: "The advertised asking price from online property portals. Only available for 'For sale' listings.",
|
||||
source: "online-listings",
|
||||
prefix: "£",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: true,
|
||||
modes: &["buy"],
|
||||
linked: "Estimated current price",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Price per sqm",
|
||||
|
|
@ -155,8 +123,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &["historical"],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Est. price per sqm",
|
||||
|
|
@ -172,25 +138,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &["historical"],
|
||||
linked: "Asking price per sqm",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Asking price per sqm",
|
||||
bounds: Bounds::Percentile {
|
||||
low: 0.0,
|
||||
high: 98.0,
|
||||
},
|
||||
step: 100.0,
|
||||
description: "Asking price divided by total floor area",
|
||||
detail: "Calculated by dividing the listed asking price by the total floor area. Only available for properties currently listed for sale where floor area data exists.",
|
||||
source: "online-listings",
|
||||
prefix: "£",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &["buy"],
|
||||
linked: "Est. price per sqm",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Estimated monthly rent",
|
||||
|
|
@ -203,25 +150,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/mo",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &["historical"],
|
||||
linked: "Asking rent (monthly)",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Asking rent (monthly)",
|
||||
bounds: Bounds::Percentile {
|
||||
low: 0.0,
|
||||
high: 98.0,
|
||||
},
|
||||
step: 50.0,
|
||||
description: "Listed monthly rent for properties currently for rent",
|
||||
detail: "The advertised rental price from online property portals, converted to a monthly figure where needed (e.g. weekly or yearly listings). Only available for 'For rent' listings.",
|
||||
source: "online-listings",
|
||||
prefix: "£",
|
||||
suffix: "/mo",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &["rent"],
|
||||
linked: "Estimated monthly rent",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Total floor area (sqm)",
|
||||
|
|
@ -237,8 +165,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: " sqm",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Number of bedrooms & living rooms",
|
||||
|
|
@ -254,42 +180,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: " rooms",
|
||||
raw: false,
|
||||
absolute: true,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Bedrooms",
|
||||
bounds: Bounds::Fixed {
|
||||
min: 0.0,
|
||||
max: 10.0,
|
||||
},
|
||||
step: 1.0,
|
||||
description: "Number of bedrooms from online listing",
|
||||
detail: "Number of bedrooms as advertised in the online property listing. Only populated for online listings (for sale and for rent); null for historical sales.",
|
||||
source: "online-listings",
|
||||
prefix: "",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: true,
|
||||
modes: &["buy", "rent"],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Bathrooms",
|
||||
bounds: Bounds::Fixed {
|
||||
min: 0.0,
|
||||
max: 10.0,
|
||||
},
|
||||
step: 1.0,
|
||||
description: "Number of bathrooms from online listing",
|
||||
detail: "Number of bathrooms as advertised in the online property listing. Only populated for online listings (for sale and for rent); null for historical sales.",
|
||||
source: "online-listings",
|
||||
prefix: "",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: true,
|
||||
modes: &["buy", "rent"],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Construction year",
|
||||
|
|
@ -305,8 +195,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: true,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Date of last transaction",
|
||||
|
|
@ -322,25 +210,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: true,
|
||||
absolute: false,
|
||||
modes: &["historical"],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Listing date",
|
||||
bounds: Bounds::Fixed {
|
||||
min: 2006.0,
|
||||
max: 2026.0,
|
||||
},
|
||||
step: 1.0,
|
||||
description: "Date the property was first listed online",
|
||||
detail: "The date when the property listing first appeared on the online property portal. Stored as a datetime; converted to fractional year for filtering. Only populated for online listings.",
|
||||
source: "online-listings",
|
||||
prefix: "",
|
||||
suffix: "",
|
||||
raw: true,
|
||||
absolute: false,
|
||||
modes: &["buy", "rent"],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Enum(EnumFeatureConfig {
|
||||
name: "Former council house",
|
||||
|
|
@ -377,8 +246,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: " m",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &["historical"],
|
||||
linked: "",
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
|
@ -399,8 +266,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: " km",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
|
@ -421,8 +286,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Good+ secondary schools within 2km",
|
||||
|
|
@ -438,8 +301,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Good+ primary schools within 5km",
|
||||
|
|
@ -455,8 +316,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Good+ secondary schools within 5km",
|
||||
|
|
@ -472,8 +331,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Education, Skills and Training Score",
|
||||
|
|
@ -489,8 +346,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
|
@ -508,8 +363,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Employment Score (rate)",
|
||||
|
|
@ -522,8 +375,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Health Deprivation and Disability Score",
|
||||
|
|
@ -539,8 +390,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Living Environment Score",
|
||||
|
|
@ -556,8 +405,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Indoors Sub-domain Score",
|
||||
|
|
@ -573,8 +420,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Outdoors Sub-domain Score",
|
||||
|
|
@ -590,8 +435,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
|
@ -612,8 +455,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Minor crime per 1k residents (avg/yr)",
|
||||
|
|
@ -629,8 +470,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Serious crime (avg/yr)",
|
||||
|
|
@ -646,8 +485,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Minor crime (avg/yr)",
|
||||
|
|
@ -663,8 +500,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Violence and sexual offences (avg/yr)",
|
||||
|
|
@ -680,8 +515,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Burglary (avg/yr)",
|
||||
|
|
@ -697,8 +530,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Robbery (avg/yr)",
|
||||
|
|
@ -714,8 +545,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Vehicle crime (avg/yr)",
|
||||
|
|
@ -731,8 +560,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Anti-social behaviour (avg/yr)",
|
||||
|
|
@ -748,8 +575,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Criminal damage and arson (avg/yr)",
|
||||
|
|
@ -765,8 +590,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Other theft (avg/yr)",
|
||||
|
|
@ -782,8 +605,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Theft from the person (avg/yr)",
|
||||
|
|
@ -799,8 +620,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Shoplifting (avg/yr)",
|
||||
|
|
@ -816,8 +635,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Bicycle theft (avg/yr)",
|
||||
|
|
@ -833,8 +650,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Drugs (avg/yr)",
|
||||
|
|
@ -850,8 +665,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Possession of weapons (avg/yr)",
|
||||
|
|
@ -867,8 +680,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Public order (avg/yr)",
|
||||
|
|
@ -884,8 +695,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Other crime (avg/yr)",
|
||||
|
|
@ -901,8 +710,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
|
@ -923,8 +730,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: " years",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "% White",
|
||||
|
|
@ -940,8 +745,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "%",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "% South Asian",
|
||||
|
|
@ -957,8 +760,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "%",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "% Black",
|
||||
|
|
@ -974,8 +775,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "%",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "% East Asian",
|
||||
|
|
@ -991,8 +790,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "%",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "% Mixed",
|
||||
|
|
@ -1008,8 +805,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "%",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "% Other",
|
||||
|
|
@ -1025,8 +820,148 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "%",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
],
|
||||
},
|
||||
FeatureGroup {
|
||||
name: "Politics",
|
||||
features: &[
|
||||
Feature::Enum(EnumFeatureConfig {
|
||||
name: "Winning party",
|
||||
order: Some(&[
|
||||
"Labour",
|
||||
"Conservative",
|
||||
"Liberal Democrat",
|
||||
"Reform UK",
|
||||
"Green",
|
||||
"Other parties",
|
||||
]),
|
||||
description:
|
||||
"Party that won the parliamentary constituency in the 2024 General Election",
|
||||
detail: "The political party that won the most votes in the constituency covering this postcode, from the July 2024 UK General Election. Based on first-past-the-post results published by the UK Parliament. Constituencies were redrawn for 2024 using the Boundary Commission's 2023 review.",
|
||||
source: "election-results",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "% Labour",
|
||||
bounds: Bounds::Fixed {
|
||||
min: 0.0,
|
||||
max: 100.0,
|
||||
},
|
||||
step: 1.0,
|
||||
description: "Labour vote share in the 2024 General Election",
|
||||
detail: "Percentage of valid votes cast for the Labour Party in the constituency covering this postcode, from the July 2024 UK General Election. Includes votes for all Labour candidates where multiple stood.",
|
||||
source: "election-results",
|
||||
prefix: "",
|
||||
suffix: "%",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "% Conservative",
|
||||
bounds: Bounds::Fixed {
|
||||
min: 0.0,
|
||||
max: 100.0,
|
||||
},
|
||||
step: 1.0,
|
||||
description: "Conservative vote share in the 2024 General Election",
|
||||
detail: "Percentage of valid votes cast for the Conservative Party in the constituency covering this postcode, from the July 2024 UK General Election.",
|
||||
source: "election-results",
|
||||
prefix: "",
|
||||
suffix: "%",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "% Liberal Democrat",
|
||||
bounds: Bounds::Fixed {
|
||||
min: 0.0,
|
||||
max: 100.0,
|
||||
},
|
||||
step: 1.0,
|
||||
description: "Liberal Democrat vote share in the 2024 General Election",
|
||||
detail: "Percentage of valid votes cast for the Liberal Democrats in the constituency covering this postcode, from the July 2024 UK General Election.",
|
||||
source: "election-results",
|
||||
prefix: "",
|
||||
suffix: "%",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "% Reform UK",
|
||||
bounds: Bounds::Fixed {
|
||||
min: 0.0,
|
||||
max: 100.0,
|
||||
},
|
||||
step: 1.0,
|
||||
description: "Reform UK vote share in the 2024 General Election",
|
||||
detail: "Percentage of valid votes cast for Reform UK in the constituency covering this postcode, from the July 2024 UK General Election.",
|
||||
source: "election-results",
|
||||
prefix: "",
|
||||
suffix: "%",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "% Green",
|
||||
bounds: Bounds::Fixed {
|
||||
min: 0.0,
|
||||
max: 100.0,
|
||||
},
|
||||
step: 1.0,
|
||||
description: "Green Party vote share in the 2024 General Election",
|
||||
detail: "Percentage of valid votes cast for the Green Party in the constituency covering this postcode, from the July 2024 UK General Election.",
|
||||
source: "election-results",
|
||||
prefix: "",
|
||||
suffix: "%",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "% Other parties",
|
||||
bounds: Bounds::Fixed {
|
||||
min: 0.0,
|
||||
max: 100.0,
|
||||
},
|
||||
step: 1.0,
|
||||
description: "Combined vote share of all other parties and independents",
|
||||
detail: "Percentage of valid votes cast for parties other than Labour, Conservative, Liberal Democrat, Reform UK, and Green in the constituency covering this postcode. Includes independents, the Speaker, and smaller parties.",
|
||||
source: "election-results",
|
||||
prefix: "",
|
||||
suffix: "%",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Voter turnout (%)",
|
||||
bounds: Bounds::Percentile {
|
||||
low: 2.0,
|
||||
high: 98.0,
|
||||
},
|
||||
step: 0.5,
|
||||
description:
|
||||
"Percentage of registered voters who voted in the 2024 General Election",
|
||||
detail: "The proportion of the registered electorate who cast a valid vote in the July 2024 UK General Election. Calculated as valid votes divided by electorate size. Higher turnout generally correlates with more affluent areas and closer contests.",
|
||||
source: "election-results",
|
||||
prefix: "",
|
||||
suffix: "%",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Majority (%)",
|
||||
bounds: Bounds::Percentile {
|
||||
low: 2.0,
|
||||
high: 98.0,
|
||||
},
|
||||
step: 0.5,
|
||||
description:
|
||||
"Winning margin as a percentage of valid votes in the 2024 General Election",
|
||||
detail: "The difference in votes between the winning candidate and the runner-up, expressed as a percentage of total valid votes cast. A small majority indicates a marginal seat (competitive); a large majority indicates a safe seat. From the July 2024 UK General Election results published by the UK Parliament.",
|
||||
source: "election-results",
|
||||
prefix: "",
|
||||
suffix: "%",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
|
@ -1047,25 +982,21 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: " km",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Number of parks within 2km",
|
||||
name: "Number of parks within 1km",
|
||||
bounds: Bounds::Percentile {
|
||||
low: 5.0,
|
||||
high: 95.0,
|
||||
},
|
||||
step: 1.0,
|
||||
description: "Number of parks and green spaces within 2km",
|
||||
detail: "Count of public parks, gardens, playing fields, and play spaces with at least one entrance within a 2km radius of the property's postcode centroid. Derived from the OS Open Greenspace dataset (Ordnance Survey), using park entrance locations for accurate proximity matching.",
|
||||
description: "Number of parks and green spaces within 1km",
|
||||
detail: "Count of public parks, gardens, playing fields, and play spaces with at least one entrance within a 1km radius of the property's postcode centroid. Derived from the OS Open Greenspace dataset (Ordnance Survey), using park entrance locations for accurate proximity matching.",
|
||||
source: "os-open-greenspace",
|
||||
prefix: "",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Number of restaurants within 2km",
|
||||
|
|
@ -1081,8 +1012,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Number of grocery shops and supermarkets within 2km",
|
||||
|
|
@ -1098,8 +1027,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Noise (dB)",
|
||||
|
|
@ -1115,8 +1042,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: " dB",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Enum(EnumFeatureConfig {
|
||||
name: "Max available download speed (Mbps)",
|
||||
|
|
|
|||
|
|
@ -51,14 +51,6 @@ struct Cli {
|
|||
#[arg(long)]
|
||||
postcode_features: PathBuf,
|
||||
|
||||
/// Path to online_listings_buy.parquet
|
||||
#[arg(long)]
|
||||
listings_buy: PathBuf,
|
||||
|
||||
/// Path to online_listings_rent.parquet
|
||||
#[arg(long)]
|
||||
listings_rent: PathBuf,
|
||||
|
||||
/// Path to the POI parquet file
|
||||
#[arg(long)]
|
||||
pois: PathBuf,
|
||||
|
|
@ -162,8 +154,6 @@ async fn main() -> anyhow::Result<()> {
|
|||
for (label, path) in [
|
||||
("Properties", &cli.properties),
|
||||
("Postcode features", &cli.postcode_features),
|
||||
("Listings buy", &cli.listings_buy),
|
||||
("Listings rent", &cli.listings_rent),
|
||||
] {
|
||||
if !path.exists() {
|
||||
bail!("{} parquet file not found: {}", label, path.display());
|
||||
|
|
@ -171,17 +161,13 @@ async fn main() -> anyhow::Result<()> {
|
|||
}
|
||||
|
||||
info!(
|
||||
"Loading property data from {}, {}, {}, {}",
|
||||
"Loading property data from {}, {}",
|
||||
cli.properties.display(),
|
||||
cli.postcode_features.display(),
|
||||
cli.listings_buy.display(),
|
||||
cli.listings_rent.display(),
|
||||
);
|
||||
let property_data = data::PropertyData::load(
|
||||
&cli.properties,
|
||||
&cli.postcode_features,
|
||||
&cli.listings_buy,
|
||||
&cli.listings_rent,
|
||||
)?;
|
||||
info!(
|
||||
rows = property_data.lat.len(),
|
||||
|
|
@ -404,13 +390,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
stripe_referral_coupon_id: cli.stripe_referral_coupon_id,
|
||||
};
|
||||
|
||||
let shared = Arc::new(SharedState::new(
|
||||
app_state,
|
||||
cli.properties,
|
||||
cli.postcode_features,
|
||||
cli.listings_buy,
|
||||
cli.listings_rent,
|
||||
));
|
||||
let shared = Arc::new(SharedState::new(app_state));
|
||||
|
||||
// Start background PocketBase metrics poller (users, saved searches/properties counts)
|
||||
pocketbase::start_metrics_poller(shared.clone());
|
||||
|
|
@ -428,8 +408,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
.allow_credentials(true);
|
||||
|
||||
// Handlers use Axum's State extractor to get Arc<SharedState>, then call
|
||||
// load_state() to get the current Arc<AppState>. This enables hot-reload:
|
||||
// the reload endpoint swaps in a new AppState, and subsequent requests pick it up.
|
||||
// load_state() to get the current Arc<AppState>.
|
||||
let s_crawler = shared.clone();
|
||||
|
||||
let reader_tile = tile_reader.clone();
|
||||
|
|
@ -498,7 +477,6 @@ async fn main() -> anyhow::Result<()> {
|
|||
.route("/api/redeem-invite", post(routes::post_redeem_invite))
|
||||
.route("/s/{code}", get(routes::get_short_url))
|
||||
.route("/api/telemetry", post(routes::post_telemetry))
|
||||
.route("/api/reload", post(routes::post_reload))
|
||||
.route(
|
||||
"/pb/{*rest}",
|
||||
any(routes::proxy_to_pocketbase).layer(ConcurrencyLimitLayer::new(10)),
|
||||
|
|
|
|||
|
|
@ -731,6 +731,35 @@ pub async fn ensure_collections(
|
|||
ensure_autodate_fields(client, base_url, &token, "short_urls").await?;
|
||||
}
|
||||
|
||||
if !existing.iter().any(|n| n == "location_logs") {
|
||||
let users_id = find_users_collection_id(client, base_url, &token).await?;
|
||||
create_collection(
|
||||
client,
|
||||
base_url,
|
||||
&token,
|
||||
CreateCollection {
|
||||
name: "location_logs".to_string(),
|
||||
r#type: "base".to_string(),
|
||||
fields: vec![
|
||||
Field::relation("user", &users_id),
|
||||
Field::number("latitude"),
|
||||
Field::number("longitude"),
|
||||
Field::text("postcode", true),
|
||||
Field::autodate("created", true, false),
|
||||
Field::autodate("updated", true, true),
|
||||
],
|
||||
list_rule: None,
|
||||
view_rule: None,
|
||||
create_rule: None,
|
||||
update_rule: None,
|
||||
delete_rule: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
ensure_autodate_fields(client, base_url, &token, "location_logs").await?;
|
||||
}
|
||||
|
||||
if !existing.iter().any(|n| n == "ai_query_logs") {
|
||||
let users_id = find_users_collection_id(client, base_url, &token).await?;
|
||||
create_collection(
|
||||
|
|
@ -743,7 +772,6 @@ pub async fn ensure_collections(
|
|||
fields: vec![
|
||||
Field::relation("user", &users_id),
|
||||
Field::text("query", true),
|
||||
Field::text("listing_type", false),
|
||||
Field::text("response_filters", false),
|
||||
Field::text("response_notes", false),
|
||||
Field::number("tokens_used"),
|
||||
|
|
@ -916,6 +944,48 @@ async fn poll_pocketbase_counts(state: &AppState) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Insert a record into the `location_logs` collection.
|
||||
/// Best-effort — logs warnings on failure but does not propagate errors.
|
||||
pub async fn log_user_location(
|
||||
state: &AppState,
|
||||
user_id: &str,
|
||||
latitude: f64,
|
||||
longitude: f64,
|
||||
postcode: &str,
|
||||
) {
|
||||
let token = match get_superuser_token(state).await {
|
||||
Ok(tk) => tk,
|
||||
Err(err) => {
|
||||
warn!("Failed to auth superuser for location log: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
let url = format!("{pb_url}/api/collections/location_logs/records");
|
||||
let res = state
|
||||
.http_client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({
|
||||
"user": user_id,
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"postcode": postcode,
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(resp) if resp.status().is_success() => {}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
warn!("Failed to log user location ({status})");
|
||||
}
|
||||
Err(err) => warn!("Failed to log user location: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a record into the `ai_query_logs` collection.
|
||||
/// Best-effort — logs warnings on failure but does not propagate errors.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
|
|
@ -923,7 +993,6 @@ pub async fn log_ai_query(
|
|||
state: &AppState,
|
||||
user_id: &str,
|
||||
query: &str,
|
||||
listing_type: &str,
|
||||
response_filters: &str,
|
||||
response_notes: &str,
|
||||
tokens_used: u64,
|
||||
|
|
@ -946,7 +1015,6 @@ pub async fn log_ai_query(
|
|||
.json(&serde_json::json!({
|
||||
"user": user_id,
|
||||
"query": query,
|
||||
"listing_type": listing_type,
|
||||
"response_filters": response_filters,
|
||||
"response_notes": response_notes,
|
||||
"tokens_used": tokens_used,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ mod postcode_stats;
|
|||
mod postcodes;
|
||||
pub(crate) mod pricing;
|
||||
pub(crate) mod properties;
|
||||
mod reload;
|
||||
mod screenshot;
|
||||
mod shorten;
|
||||
mod stats;
|
||||
|
|
@ -48,7 +47,6 @@ pub use postcode_stats::get_postcode_stats;
|
|||
pub use postcodes::{get_nearest_postcode, get_postcode_lookup, get_postcodes};
|
||||
pub use pricing::get_pricing;
|
||||
pub use properties::get_hexagon_properties;
|
||||
pub use reload::post_reload;
|
||||
pub use screenshot::{fetch_screenshot_bytes, get_screenshot};
|
||||
pub use shorten::{get_short_url, post_shorten};
|
||||
pub use streetview::get_streetview;
|
||||
|
|
|
|||
|
|
@ -280,7 +280,7 @@ pub fn build_system_prompt(
|
|||
- Use EXACT feature names from the list — spelling, capitalisation, and punctuation must match.\n\
|
||||
- \"cheap\" / \"affordable\" = lower price range. \"expensive\" = higher price range.\n\
|
||||
- \"low crime\" / \"safe\" = low values on Serious crime and Minor crime summary features. \
|
||||
\"quiet\" = low Noise (dB). \"green\" / \"near parks\" = high Number of parks within 2km.\n\
|
||||
\"quiet\" = low Noise (dB). \"green\" / \"near parks\" = high Number of parks within 1km.\n\
|
||||
- When the user says a number like \"under 400k\", interpret it as 400000.\n\
|
||||
- When the user says \"3 bed\" or \"3 bedroom\", use Number of bedrooms & living rooms \
|
||||
(note: this counts bedrooms + living rooms combined, so 3 bed ~ min 4).\n\
|
||||
|
|
@ -341,7 +341,7 @@ pub fn build_system_prompt(
|
|||
modes_list,
|
||||
));
|
||||
|
||||
// Feature guidance — only historical features are available
|
||||
// Feature guidance
|
||||
parts.push(
|
||||
"\n--- DATA SOURCE ---\n\
|
||||
The data is historical property sales from the Land Registry.\n\
|
||||
|
|
@ -349,11 +349,7 @@ pub fn build_system_prompt(
|
|||
Use these features for price queries:\n\
|
||||
- For purchase price: use \"Estimated current price\" or \"Last known price\"\n\
|
||||
- For price per sqm: use \"Est. price per sqm\"\n\
|
||||
- For rent: use \"Estimated monthly rent\"\n\
|
||||
\n\
|
||||
Features marked with [historical] below are available. \
|
||||
Features marked with [buy] or [rent] are NOT available — do not use them.\n\
|
||||
ONLY use features marked [historical] or unmarked."
|
||||
- For rent estimates: use \"Estimated monthly rent\""
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
|
|
@ -374,17 +370,11 @@ pub fn build_system_prompt(
|
|||
description,
|
||||
prefix,
|
||||
suffix,
|
||||
modes,
|
||||
..
|
||||
} => {
|
||||
let mode_str = if modes.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" [{}]", modes.join("/"))
|
||||
};
|
||||
parts.push(format!(
|
||||
"- \"{}\"{} (numeric, {}{:.0}{} to {}{:.0}{}): {}",
|
||||
name, mode_str, prefix, min, suffix, prefix, max, suffix, description
|
||||
"- \"{}\" (numeric, {}{:.0}{} to {}{:.0}{}): {}",
|
||||
name, prefix, min, suffix, prefix, max, suffix, description
|
||||
));
|
||||
}
|
||||
FeatureInfo::Enum {
|
||||
|
|
@ -393,10 +383,6 @@ pub fn build_system_prompt(
|
|||
description,
|
||||
..
|
||||
} => {
|
||||
// Skip Listing status — auto-injected as "Historical sale"
|
||||
if name == "Listing status" {
|
||||
continue;
|
||||
}
|
||||
parts.push(format!(
|
||||
"- \"{}\" (enum, values: [{}]): {}",
|
||||
name,
|
||||
|
|
@ -433,7 +419,7 @@ pub fn build_system_prompt(
|
|||
{\"name\": \"Noise (dB)\", \"bound\": \"max\", \"value\": 55}, \
|
||||
{\"name\": \"Good+ primary schools within 2km\", \"bound\": \"min\", \"value\": 2}, \
|
||||
{\"name\": \"Good+ secondary schools within 2km\", \"bound\": \"min\", \"value\": 1}, \
|
||||
{\"name\": \"Number of parks within 2km\", \"bound\": \"min\", \"value\": 3}], \
|
||||
{\"name\": \"Number of parks within 1km\", \"bound\": \"min\", \"value\": 3}], \
|
||||
\"enum_filters\": [], \"travel_time_filters\": [], \"notes\": \"\"}"
|
||||
.to_string(),
|
||||
);
|
||||
|
|
@ -935,8 +921,7 @@ pub async fn post_ai_filters(
|
|||
}
|
||||
};
|
||||
|
||||
// Only historical mode is supported — validate features accordingly
|
||||
let mut filters = validate_and_convert(&raw, &state.features_response, "historical");
|
||||
let filters = validate_and_convert(&raw, &state.features_response);
|
||||
let travel_time_filters = validate_travel_time_filters(&raw, &state);
|
||||
let notes = raw
|
||||
.get("notes")
|
||||
|
|
@ -944,11 +929,6 @@ pub async fn post_ai_filters(
|
|||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// Auto-inject Listing status filter for historical mode
|
||||
if let Value::Object(ref mut map) = filters {
|
||||
map.insert("Listing status".to_string(), json!(["Historical sale"]));
|
||||
}
|
||||
|
||||
// Count matching properties and refine if too restrictive
|
||||
let match_count = count_matching_rows(&state, &filters, &travel_time_filters);
|
||||
info!(
|
||||
|
|
@ -1026,7 +1006,6 @@ pub async fn post_ai_filters(
|
|||
let log_state = state.clone();
|
||||
let log_user_id = user.id.clone();
|
||||
let log_query = req.query.clone();
|
||||
let log_listing_type = "historical".to_string();
|
||||
let log_notes = notes.clone();
|
||||
let log_rounds = (round + 1) as u64;
|
||||
tokio::spawn(async move {
|
||||
|
|
@ -1034,7 +1013,6 @@ pub async fn post_ai_filters(
|
|||
&log_state,
|
||||
&log_user_id,
|
||||
&log_query,
|
||||
&log_listing_type,
|
||||
&filters_json,
|
||||
&log_notes,
|
||||
total_tokens_accumulated,
|
||||
|
|
@ -1137,10 +1115,10 @@ fn validate_travel_time_filters(raw: &Value, state: &AppState) -> Vec<TravelTime
|
|||
/// ```json
|
||||
/// { "Last known price": [0, 300000], "Leasehold/Freehold": ["Freehold"] }
|
||||
/// ```
|
||||
fn validate_and_convert(raw: &Value, features: &FeaturesResponse, listing_type: &str) -> Value {
|
||||
fn validate_and_convert(raw: &Value, features: &FeaturesResponse) -> Value {
|
||||
let mut result = serde_json::Map::new();
|
||||
|
||||
// Build lookup maps from feature metadata, filtering by listing mode.
|
||||
// Build lookup maps from feature metadata.
|
||||
// Store both slider bounds (min/max from percentiles) and true data bounds
|
||||
// (histogram.min/max) so one-sided AI filters use the full data range.
|
||||
let mut numeric_features: rustc_hash::FxHashMap<&str, (f32, f32, f32, f32)> =
|
||||
|
|
@ -1156,19 +1134,12 @@ fn validate_and_convert(raw: &Value, features: &FeaturesResponse, listing_type:
|
|||
min,
|
||||
max,
|
||||
histogram,
|
||||
modes,
|
||||
..
|
||||
} => {
|
||||
// Only include features valid for the chosen listing mode
|
||||
if modes.is_empty() || modes.contains(&listing_type) {
|
||||
numeric_features.insert(name, (*min, *max, histogram.min, histogram.max));
|
||||
}
|
||||
numeric_features.insert(name, (*min, *max, histogram.min, histogram.max));
|
||||
}
|
||||
FeatureInfo::Enum { name, values, .. } => {
|
||||
// Skip Listing status — handled via auto-injection
|
||||
if name != "Listing status" {
|
||||
enum_features.insert(name, values);
|
||||
}
|
||||
enum_features.insert(name, values);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::State;
|
||||
|
|
@ -17,10 +18,6 @@ fn is_false(val: &bool) -> bool {
|
|||
!val
|
||||
}
|
||||
|
||||
fn is_empty_slice(val: &&[&str]) -> bool {
|
||||
val.is_empty()
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum FeatureInfo {
|
||||
|
|
@ -42,15 +39,12 @@ pub enum FeatureInfo {
|
|||
raw: bool,
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
absolute: bool,
|
||||
#[serde(skip_serializing_if = "is_empty_slice")]
|
||||
modes: &'static [&'static str],
|
||||
#[serde(skip_serializing_if = "is_empty")]
|
||||
linked: &'static str,
|
||||
},
|
||||
#[serde(rename = "enum")]
|
||||
Enum {
|
||||
name: String,
|
||||
values: Vec<String>,
|
||||
counts: HashMap<String, u64>,
|
||||
description: &'static str,
|
||||
detail: &'static str,
|
||||
source: &'static str,
|
||||
|
|
@ -98,8 +92,6 @@ pub fn build_features_response(data: &PropertyData) -> FeaturesResponse {
|
|||
suffix: config.suffix,
|
||||
raw: config.raw,
|
||||
absolute: config.absolute,
|
||||
modes: config.modes,
|
||||
linked: config.linked,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -110,9 +102,22 @@ pub fn build_features_response(data: &PropertyData) -> FeaturesResponse {
|
|||
.position(|name| name == config.name)
|
||||
{
|
||||
if let Some(values) = data.enum_values.get(&feat_idx) {
|
||||
let counts = data
|
||||
.enum_counts
|
||||
.get(&feat_idx)
|
||||
.map(|c| {
|
||||
values
|
||||
.iter()
|
||||
.zip(c.iter())
|
||||
.filter(|(_, &count)| count > 0)
|
||||
.map(|(v, &count)| (v.clone(), count))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
features.push(FeatureInfo::Enum {
|
||||
name: config.name.to_string(),
|
||||
values: values.clone(),
|
||||
counts,
|
||||
description: config.description,
|
||||
detail: config.detail,
|
||||
source: config.source,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ use tracing::info;
|
|||
use crate::aggregation::{Aggregator, EnumDistConfig};
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::consts::MAX_CELLS_PER_REQUEST;
|
||||
use crate::pocketbase::log_user_location;
|
||||
use crate::data::travel_time::TravelData;
|
||||
use crate::licensing::check_license_bounds;
|
||||
use crate::parsing::{
|
||||
|
|
@ -339,8 +340,10 @@ pub async fn get_postcodes(
|
|||
}
|
||||
|
||||
/// Find the nearest postcode to a given lat/lng coordinate.
|
||||
/// If the user is authenticated, logs their location to PocketBase in the background.
|
||||
pub async fn get_nearest_postcode(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Query(params): Query<NearestPostcodeParams>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
let state = shared.load_state();
|
||||
|
|
@ -368,6 +371,18 @@ pub async fn get_nearest_postcode(
|
|||
let geometry = postcode_data.geometries[idx].clone();
|
||||
let postcode = &postcode_data.postcodes[idx];
|
||||
|
||||
// Log location for authenticated users (best-effort, non-blocking)
|
||||
if let Some(ref pb_user) = user.0 {
|
||||
let state = state.clone();
|
||||
let user_id = pb_user.id.clone();
|
||||
let lat_f64 = params.lat;
|
||||
let lng_f64 = params.lng;
|
||||
let pc = postcode.clone();
|
||||
tokio::spawn(async move {
|
||||
log_user_location(&state, &user_id, lat_f64, lng_f64, &pc).await;
|
||||
});
|
||||
}
|
||||
|
||||
info!(postcode = %postcode, "GET /api/nearest-postcode");
|
||||
Ok(Json(serde_json::json!({
|
||||
"postcode": postcode,
|
||||
|
|
|
|||
|
|
@ -38,8 +38,6 @@ pub struct Property {
|
|||
pub duration: Option<String>,
|
||||
pub current_energy_rating: Option<String>,
|
||||
pub potential_energy_rating: Option<String>,
|
||||
pub listing_status: Option<String>,
|
||||
pub listing_url: Option<String>,
|
||||
pub property_sub_type: Option<String>,
|
||||
pub price_qualifier: Option<String>,
|
||||
pub former_council_house: Option<String>,
|
||||
|
|
@ -53,9 +51,6 @@ pub struct Property {
|
|||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub renovation_history: Vec<RenovationEvent>,
|
||||
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub listing_features: Vec<String>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub features: FxHashMap<String, f32>,
|
||||
}
|
||||
|
|
@ -158,15 +153,6 @@ pub fn build_property(
|
|||
lat: state.data.lat[row],
|
||||
lon: state.data.lon[row],
|
||||
renovation_history: state.data.renovation_history(row).to_vec(),
|
||||
listing_features: state.data.listing_features(row).to_vec(),
|
||||
listing_status: lookup_enum_value(
|
||||
feature_name_to_index,
|
||||
&state.data,
|
||||
enum_values,
|
||||
row,
|
||||
"Listing status",
|
||||
),
|
||||
listing_url: state.data.listing_url(row).map(String::from),
|
||||
property_sub_type: state.data.property_sub_type(row).map(String::from),
|
||||
price_qualifier: state.data.price_qualifier(row).map(String::from),
|
||||
former_council_house: lookup_enum_value(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use parking_lot::RwLock;
|
||||
|
|
@ -14,7 +12,6 @@ use crate::routes::FeaturesResponse;
|
|||
use crate::utils::GridIndex;
|
||||
|
||||
pub struct AppState {
|
||||
// --- Rebuilt on reload ---
|
||||
pub data: PropertyData,
|
||||
pub grid: GridIndex,
|
||||
/// h3_cells[row_idx] = precomputed H3 cell ID at max resolution (12).
|
||||
|
|
@ -33,7 +30,6 @@ pub struct AppState {
|
|||
/// Complete system prompt for AI filters (features + examples + instructions)
|
||||
pub ai_filters_system_prompt: String,
|
||||
|
||||
// --- Shared across reloads (Arc for cheap cloning) ---
|
||||
pub poi_data: Arc<POIData>,
|
||||
pub poi_grid: Arc<GridIndex>,
|
||||
pub place_data: Arc<PlaceData>,
|
||||
|
|
@ -81,34 +77,16 @@ pub struct AppState {
|
|||
pub stripe_referral_coupon_id: String,
|
||||
}
|
||||
|
||||
/// Wraps AppState with atomic swap capability for hot-reloading.
|
||||
/// Wraps AppState for shared access across route handlers.
|
||||
/// Route handlers call `load_state()` to get the current snapshot.
|
||||
/// The reload endpoint builds a new AppState and swaps it in atomically.
|
||||
pub struct SharedState {
|
||||
current: RwLock<Arc<AppState>>,
|
||||
reloading: AtomicBool,
|
||||
/// Paths needed for data reload
|
||||
pub properties_path: PathBuf,
|
||||
pub postcode_features_path: PathBuf,
|
||||
pub listings_buy_path: PathBuf,
|
||||
pub listings_rent_path: PathBuf,
|
||||
}
|
||||
|
||||
impl SharedState {
|
||||
pub fn new(
|
||||
state: AppState,
|
||||
properties_path: PathBuf,
|
||||
postcode_features_path: PathBuf,
|
||||
listings_buy_path: PathBuf,
|
||||
listings_rent_path: PathBuf,
|
||||
) -> Self {
|
||||
pub fn new(state: AppState) -> Self {
|
||||
Self {
|
||||
current: RwLock::new(Arc::new(state)),
|
||||
reloading: AtomicBool::new(false),
|
||||
properties_path,
|
||||
postcode_features_path,
|
||||
listings_buy_path,
|
||||
listings_rent_path,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -116,21 +94,4 @@ impl SharedState {
|
|||
pub fn load_state(&self) -> Arc<AppState> {
|
||||
self.current.read().clone()
|
||||
}
|
||||
|
||||
/// Atomically swap in a new AppState. Old state is dropped when all references are gone.
|
||||
pub fn swap_state(&self, new_state: AppState) {
|
||||
*self.current.write() = Arc::new(new_state);
|
||||
}
|
||||
|
||||
/// Try to mark reload as in-progress. Returns false if already reloading.
|
||||
pub fn try_start_reload(&self) -> bool {
|
||||
self.reloading
|
||||
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Mark reload as complete.
|
||||
pub fn finish_reload(&self) {
|
||||
self.reloading.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue