diff --git a/.forgejo/workflows/docker-publish.yml b/.forgejo/workflows/docker-publish.yml index df3e5dc..65bf1cf 100644 --- a/.forgejo/workflows/docker-publish.yml +++ b/.forgejo/workflows/docker-publish.yml @@ -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 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 9dc8577..08578c6 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 5ac66af..b956cc7 100644 --- a/CLAUDE.md +++ b/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`, NaN = null -- Enum features: stored as f32 indices (0.0, 1.0, 2.0...) with `enum_values: FxHashMap>` mapping feature index → string values +- Enum features: stored as f32 indices (0.0, 1.0, 2.0...) with `enum_values: FxHashMap>` 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`: `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` — 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 `` 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) diff --git a/Dockerfile b/Dockerfile index 481d385..d9979ee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/Makefile.data b/Makefile.data index a37c45f..207865a 100644 --- a/Makefile.data +++ b/Makefile.data @@ -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 $@ diff --git a/README.md b/README.md index 014987d..29b9d58 100644 --- a/README.md +++ b/README.md @@ -93,4 +93,3 @@ Test on android check rendered index html, -only support new finder.py parquet type \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index af992c4..53ac132 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0560e9d..b871c40 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -288,7 +288,7 @@ export default function App() { - {prop.data.listingUrl && ( - - {t('savedPage.viewListing')} → - - )} ); diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx index 3c114c6..f559817 100644 --- a/frontend/src/components/home/HomePage.tsx +++ b/frontend/src/components/home/HomePage.tsx @@ -201,9 +201,6 @@ export default function HomePage({ - - {t('home.listingPortals')} - {t('home.checkMyPostcode')} @@ -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({ )} - {[row.listings, row.postcode, row.guides].map((has, j) => ( + {[row.postcode, row.guides].map((has, j) => ( = { '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 }) { diff --git a/frontend/src/components/map/AreaPane.tsx b/frontend/src/components/map/AreaPane.tsx index faaf956..cba4960 100644 --- a/frontend/src/components/map/AreaPane.tsx +++ b/frontend/src/components/map/AreaPane.tsx @@ -189,8 +189,7 @@ export default function AreaPane({ /> {expanded && (
- {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)} )} - - {formatValue(total)} - {chart.unit ? ` ${chart.unit}` : ''} - +
+ + {formatValue(displayValue)} + {chart.unit ? ` ${chart.unit}` : ''} + + {globalMean != null && ( +
+ {t('areaPane.nationalAvg')}: {formatValue(globalMean)} +
+ )} +
); - }) - : group.features - .filter((f) => !stackedEnumFeatureNames.has(f.name)) + })} + {(() => { + const stackedFeatureNames = new Set( + 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 (
- +
); } return null; - })} + }); + })()} {stackedEnumCharts?.map((chart) => { const featureMeta = chart.feature ? globalFeatureByName.get(chart.feature) diff --git a/frontend/src/components/map/EnumBarChart.tsx b/frontend/src/components/map/EnumBarChart.tsx index 8b305ae..cced230 100644 --- a/frontend/src/components/map/EnumBarChart.tsx +++ b/frontend/src/components/map/EnumBarChart.tsx @@ -1,23 +1,77 @@ -export default function EnumBarChart({ counts }: { counts: Record }) { +import { getEnumValueColor } from '../../lib/consts'; + +export default function EnumBarChart({ + counts, + globalCounts, + featureName, +}: { + counts: Record; + globalCounts?: Record; + 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 (
- {entries.map(([label, count]) => ( -
- - {label} - -
-
+ {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 ( +
+ + {label} + +
+ {hasGlobal && ( +
+ )} +
+
+ + {count} +
- {count} -
- ))} + ); + })}
); } diff --git a/frontend/src/components/map/ExternalSearchLinks.tsx b/frontend/src/components/map/ExternalSearchLinks.tsx index 4665b1e..203d530 100644 --- a/frontend/src/components/map/ExternalSearchLinks.tsx +++ b/frontend/src/components/map/ExternalSearchLinks.tsx @@ -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({ Zoopla - {urls.openrent && ( - - OpenRent - - )}
); diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx index 465346f..d168f5f 100644 --- a/frontend/src/components/map/Filters.tsx +++ b/frontend/src/components/map/Filters.tsx @@ -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; @@ -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> = {}; - 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(); - 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({}); - - const handleListingSelect = useCallback( - (type: ListingType) => { - // Track what will be active after swaps (to avoid conflicts with restoration) - const activeAfterSwaps = new Set(); - - 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 = { - 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(null); @@ -548,31 +439,6 @@ export default memo(function Filters({ onLoginRequired={onLoginRequired} />
- {isAdmin && ( -
- {(['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 ( - - ); - })} -
- )} )} {isEnum ? ( - + ) : (
{rangeMin} @@ -144,7 +159,7 @@ export default function MapLegend({ )}
{isEnum ? ( - + ) : ( <>
diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index 5f1fe5b..afedb0b 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -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} diff --git a/frontend/src/components/map/PropertiesPane.tsx b/frontend/src/components/map/PropertiesPane.tsx index 3f63f3a..11d9435 100644 --- a/frontend/src/components/map/PropertiesPane.tsx +++ b/frontend/src/components/map/PropertiesPane.tsx @@ -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 (
@@ -193,7 +188,14 @@ function PropertyCard({
{property.address || t('propertyCard.unknownAddress')}
-
{property.postcode}
+
+ {property.postcode} + {property.former_council_house === 'Yes' && ( + + {t('propertyCard.exCouncilBadge')} + + )} +
{onSave && (
)} - {bedrooms !== undefined && ( -
- {t('propertyCard.bedrooms')}{' '} - {formatNumber(bedrooms)} -
- )} - {bathrooms !== undefined && ( -
- {t('propertyCard.bathrooms')}{' '} - {formatNumber(bathrooms)} -
- )} {rooms !== undefined && (
{t('propertyCard.rooms')}{' '} @@ -323,14 +282,6 @@ function PropertyCard({ {formatAge(age, property.is_construction_date_approximate)}
)} - {property.former_council_house === 'Yes' && ( -
- - {t('propertyCard.formerCouncil')} - {' '} - {ts(property.former_council_house)} -
- )} {property.current_energy_rating && (
{t('propertyCard.epcRating')}{' '} @@ -345,32 +296,8 @@ function PropertyCard({ {ts(property.potential_energy_rating)}
)} - {listingDate !== undefined && ( -
- {t('propertyCard.listed')}{' '} - {formatTransactionDate(listingDate)} -
- )}
- {property.listing_features && property.listing_features.length > 0 && ( -
-
- {t('propertyCard.keyFeatures')} -
-
- {property.listing_features.map((feature, idx) => ( - - {feature} - - ))} -
-
- )} - {property.renovation_history && property.renovation_history.length > 0 && (
@@ -390,18 +317,6 @@ function PropertyCard({
)} - {property.listing_url && ( - - )}
); } diff --git a/frontend/src/components/ui/FeatureLabel.tsx b/frontend/src/components/ui/FeatureLabel.tsx index 6777c11..f875b0d 100644 --- a/frontend/src/components/ui/FeatureLabel.tsx +++ b/frontend/src/components/ui/FeatureLabel.tsx @@ -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 = { - 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} - {modeTag && ( - - {modeTag} - - )} {feature.detail && onShowInfo && (