From 6c6780fc60fdfd0a6efde5051888131ac3c02477 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Mon, 22 Jun 2026 22:02:35 +0100 Subject: [PATCH 1/3] New videos --- Dockerfile | 2 +- Makefile.data | 36 +++++++++++++++---- docker-compose.yml | 3 +- frontend/public/video/ad-01-say-it.jpg | 4 +-- frontend/public/video/ad-01-say-it.mp4 | 4 +-- frontend/public/video/ad-01-say-it.vtt | 14 ++++++++ .../public/video/ad-02-twenty-minute-map.jpg | 4 +-- .../public/video/ad-02-twenty-minute-map.mp4 | 4 +-- .../public/video/ad-02-twenty-minute-map.vtt | 14 ++++++++ .../public/video/ad-03-postcode-files.jpg | 4 +-- .../public/video/ad-03-postcode-files.mp4 | 4 +-- .../public/video/ad-03-postcode-files.vtt | 14 ++++++++ frontend/public/video/ad-04-quiet-streets.jpg | 4 +-- frontend/public/video/ad-04-quiet-streets.mp4 | 4 +-- frontend/public/video/ad-04-quiet-streets.vtt | 14 ++++++++ frontend/public/video/ad-05-school-run.jpg | 4 +-- frontend/public/video/ad-05-school-run.mp4 | 4 +-- frontend/public/video/ad-05-school-run.vtt | 14 ++++++++ frontend/public/video/ad-06-waitrose-test.jpg | 4 +-- frontend/public/video/ad-06-waitrose-test.mp4 | 4 +-- frontend/public/video/ad-06-waitrose-test.vtt | 14 ++++++++ frontend/public/video/ad-07-renters-map.jpg | 4 +-- frontend/public/video/ad-07-renters-map.mp4 | 4 +-- frontend/public/video/ad-07-renters-map.vtt | 14 ++++++++ .../public/video/ad-08-cheap-insurance.jpg | 3 -- .../public/video/ad-08-cheap-insurance.mp4 | 3 -- frontend/public/video/ad-08-value.vtt | 14 ++++++++ frontend/public/video/recording-de-mobile.jpg | 4 +-- frontend/public/video/recording-de-mobile.mp4 | 4 +-- frontend/public/video/recording-de-mobile.vtt | 26 ++++++++++++++ frontend/public/video/recording-de.jpg | 4 +-- frontend/public/video/recording-de.mp4 | 4 +-- frontend/public/video/recording-de.vtt | 26 ++++++++++++++ frontend/public/video/recording-hi-mobile.jpg | 4 +-- frontend/public/video/recording-hi-mobile.mp4 | 4 +-- frontend/public/video/recording-hi-mobile.vtt | 26 ++++++++++++++ frontend/public/video/recording-hi.jpg | 4 +-- frontend/public/video/recording-hi.mp4 | 4 +-- frontend/public/video/recording-hi.vtt | 26 ++++++++++++++ frontend/public/video/recording-mobile.jpg | 4 +-- frontend/public/video/recording-mobile.mp4 | 4 +-- frontend/public/video/recording-mobile.vtt | 26 ++++++++++++++ frontend/public/video/recording-zh-mobile.jpg | 4 +-- frontend/public/video/recording-zh-mobile.mp4 | 4 +-- frontend/public/video/recording-zh-mobile.vtt | 26 ++++++++++++++ frontend/public/video/recording-zh.jpg | 4 +-- frontend/public/video/recording-zh.mp4 | 4 +-- frontend/public/video/recording-zh.vtt | 26 ++++++++++++++ frontend/public/video/recording.jpg | 4 +-- frontend/public/video/recording.mp4 | 4 +-- frontend/public/video/recording.vtt | 26 ++++++++++++++ video/render.sh | 1 + 52 files changed, 414 insertions(+), 74 deletions(-) create mode 100644 frontend/public/video/ad-01-say-it.vtt create mode 100644 frontend/public/video/ad-02-twenty-minute-map.vtt create mode 100644 frontend/public/video/ad-03-postcode-files.vtt create mode 100644 frontend/public/video/ad-04-quiet-streets.vtt create mode 100644 frontend/public/video/ad-05-school-run.vtt create mode 100644 frontend/public/video/ad-06-waitrose-test.vtt create mode 100644 frontend/public/video/ad-07-renters-map.vtt delete mode 100644 frontend/public/video/ad-08-cheap-insurance.jpg delete mode 100644 frontend/public/video/ad-08-cheap-insurance.mp4 create mode 100644 frontend/public/video/ad-08-value.vtt create mode 100644 frontend/public/video/recording-de-mobile.vtt create mode 100644 frontend/public/video/recording-de.vtt create mode 100644 frontend/public/video/recording-hi-mobile.vtt create mode 100644 frontend/public/video/recording-hi.vtt create mode 100644 frontend/public/video/recording-mobile.vtt create mode 100644 frontend/public/video/recording-zh-mobile.vtt create mode 100644 frontend/public/video/recording-zh.vtt create mode 100644 frontend/public/video/recording.vtt diff --git a/Dockerfile b/Dockerfile index 88b85a3..85b1068 100644 --- a/Dockerfile +++ b/Dockerfile @@ -56,4 +56,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", "--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", "--satellite-tiles", "/app/data/satellite.pmtiles", "--satellite-highres-tiles", "/app/data/satellite_highres.pmtiles", "--noise-overlay-tiles", "/app/data/noise_lden_10m.pmtiles", "--crime-hotspot-tiles", "/app/data/crime_hotspots.pmtiles", "--tree-overlay-tiles", "/app/data/trees_outside_woodlands.pmtiles", "--property-border-tiles", "/app/data/property_borders.pmtiles", "--crime-by-year-path", "/app/property-data/crime_by_postcode_by_year.parquet", "--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", "--satellite-tiles", "/app/data/satellite.pmtiles", "--satellite-highres-tiles", "/app/data/satellite_highres.pmtiles", "--noise-overlay-tiles", "/app/data/noise_lden_10m.pmtiles", "--crime-hotspot-tiles", "/app/data/crime_hotspots.pmtiles", "--tree-overlay-tiles", "/app/data/trees_outside_woodlands.pmtiles", "--property-border-tiles", "/app/data/property_borders.pmtiles", "--crime-by-year-path", "/app/property-data/crime_by_postcode_by_year.parquet", "--population-path", "/app/property-data/population_by_postcode.parquet", "--developments", "/app/data/development_sites.parquet", "--dist", "/app/frontend/dist"] diff --git a/Makefile.data b/Makefile.data index c9c9a85..dbcf1c4 100644 --- a/Makefile.data +++ b/Makefile.data @@ -33,15 +33,19 @@ EPC := $(MANUAL_DATA)/domestic-csv.zip ACTUAL_LISTINGS_RAW := $(FINDER_DATA)/online_listings_buy.parquet ACTUAL_LISTINGS_ENRICHED := $(FINDER_DATA)/online_listings_buy_enriched.parquet ETHNICITY := $(DATA_DIR)/ethnicity_by_lsoa.parquet +EDUCATION := $(DATA_DIR)/education_by_lsoa.parquet +TENURE := $(DATA_DIR)/tenure_by_lsoa.parquet CRIME_DIR := $(DATA_DIR)/crime CRIME := $(DATA_DIR)/crime_by_postcode.parquet CRIME_BY_YEAR := $(DATA_DIR)/crime_by_postcode_by_year.parquet +POPULATION := $(DATA_DIR)/population_by_postcode.parquet CRIME_STAMP := $(CRIME_DIR)/.downloaded NOISE := $(DATA_DIR)/road_noise.parquet NOISE_OVERLAY_TILES := $(DATA_DIR)/noise_lden_10m.pmtiles CRIME_HOTSPOT_TILES := $(DATA_DIR)/crime_hotspots.pmtiles TREE_OVERLAY_TILES := $(DATA_DIR)/trees_outside_woodlands.pmtiles PROPERTY_BORDER_TILES := $(DATA_DIR)/property_borders.pmtiles +DEVELOPMENT_SITES := $(DATA_DIR)/development_sites.parquet OFSTED := $(DATA_DIR)/ofsted.parquet GIAS := $(DATA_DIR)/gias.parquet NAPTAN := $(DATA_DIR)/naptan.parquet @@ -107,16 +111,16 @@ MAP_ASSETS_DEPS := pipeline/download/map_assets.py pipeline/transform/transform_ # ── Phony aliases ───────────────────────────────────────────────────────────── .PHONY: prepare merge tiles satellite-tiles satellite-highres-tiles overlay-tiles noise-overlay-tiles crime-hotspot-tiles tree-overlay-tiles property-border-tiles \ - download-arcgis download-price-paid download-deprivation download-ethnicity \ + download-arcgis download-price-paid download-deprivation download-ethnicity download-education download-tenure \ download-naptan download-pois download-grocery-retail-points download-ofsted download-gias download-lsoa-children download-broadband download-conservation-areas download-listed-buildings download-rental-prices \ download-postcodes download-noise download-inspire download-crime \ - download-oa-boundaries download-uprn-lookup download-transit-network download-greenspace download-os-greenspace download-pbf download-fr-tow download-nfi download-ofs-register download-places download-median-age download-england-boundary download-rightmove-outcodes \ + download-oa-boundaries download-uprn-lookup download-transit-network download-greenspace download-os-greenspace download-pbf download-fr-tow download-nfi download-ofs-register download-places download-median-age download-population download-england-boundary download-rightmove-outcodes \ download-map-assets \ transform-pois transform-epc-pp transform-crime transform-poi-proximity \ transform-school-catchments transform-tree-density \ - generate-postcode-boundaries generate-travel-times enrich-actual-listings + generate-postcode-boundaries generate-travel-times enrich-actual-listings download-development-sites -prepare: $(PRICES_STAMP) download-places tiles satellite-tiles overlay-tiles property-border-tiles tree-overlay-tiles crime-hotspot-tiles property-border-tiles generate-postcode-boundaries download-map-assets generate-travel-times | $(POSTCODES_PQ) $(PROPERTIES_PQ) $(PRICE_INDEX) +prepare: $(PRICES_STAMP) download-places tiles satellite-tiles overlay-tiles property-border-tiles tree-overlay-tiles crime-hotspot-tiles property-border-tiles generate-postcode-boundaries download-map-assets generate-travel-times $(DEVELOPMENT_SITES) $(POPULATION) | $(POSTCODES_PQ) $(PROPERTIES_PQ) $(PRICE_INDEX) $(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) --parquet $(PRICE_INDEX) --postcode-boundary-match "$(POSTCODES_PQ)::$(PC_BOUNDARIES)" --postcode-features $(POSTCODES_PQ) --postcode-universe "$(ARCGIS)::$(POSTCODES_PQ)" --properties-subset "$(PROPERTIES_PQ)::$(POSTCODES_PQ)" --price-index $(PRICE_INDEX) merge: $(MERGE_STAMP) | $(POSTCODES_PQ) $(PROPERTIES_PQ) $(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) --postcode-features $(POSTCODES_PQ) --postcode-universe "$(ARCGIS)::$(POSTCODES_PQ)" --properties-subset "$(PROPERTIES_PQ)::$(POSTCODES_PQ)" @@ -133,6 +137,8 @@ download-arcgis: $(ARCGIS) download-price-paid: $(PRICE_PAID) download-deprivation: $(IOD) download-ethnicity: $(ETHNICITY) +download-education: $(EDUCATION) +download-tenure: $(TENURE) download-crime: $(CRIME_STAMP) $(VALIDATE_OUTPUTS) --file $(CRIME_DIR)/archive_manifest.json --glob "$(CRIME_DIR)::**/*-street.csv" download-naptan: $(NAPTAN) @@ -143,6 +149,7 @@ download-gias: $(GIAS) download-lsoa-children: $(LSOA_CHILDREN) download-broadband: $(BROADBAND) download-conservation-areas: $(CONSERVATION_AREAS) +download-development-sites: $(DEVELOPMENT_SITES) download-listed-buildings: $(LISTED_BUILDINGS) download-postcodes: $(POSTCODES_RAW) download-rental-prices: $(RENTAL) @@ -161,6 +168,7 @@ download-nfi: $(NFI) download-ofs-register: $(OFS_REGISTER) download-places: $(PLACES) download-median-age: $(MEDIAN_AGE) +download-population: $(POPULATION) download-election-results: $(ELECTION) download-england-boundary: $(ENGLAND_BOUNDARY) download-rightmove-outcodes: $(RM_OUTCODES) @@ -235,6 +243,12 @@ $(IOD): $(ETHNICITY): pipeline/download/ethnicity.py uv run python -m pipeline.download.ethnicity --output $@ +$(EDUCATION): pipeline/download/education.py + uv run python -m pipeline.download.education --output $@ + +$(TENURE): pipeline/download/tenure.py + uv run python -m pipeline.download.tenure --output $@ + $(CRIME_STAMP): $(CRIME_DOWNLOAD_DEPS) @rm -f $@ uv run python -m pipeline.download.crime --output $(CRIME_DIR) @@ -285,6 +299,9 @@ $(BROADBAND): $(CONSERVATION_AREAS): pipeline/download/conservation_areas.py uv run python -m pipeline.download.conservation_areas --output $@ +$(DEVELOPMENT_SITES): pipeline/download/development_sites.py + uv run python -m pipeline.download.development_sites --output $@ + $(LISTED_BUILDINGS): pipeline/download/listed_buildings.py uv run python -m pipeline.download.listed_buildings --output $@ @@ -340,6 +357,9 @@ $(PLACES): $(PBF) $(ENGLAND_BOUNDARY) $(NAPTAN) $(OFS_REGISTER) $(ARCGIS) $(POIS $(MEDIAN_AGE): uv run python -m pipeline.download.median_age --output $@ +$(POPULATION): pipeline/download/census_population.py + uv run python -m pipeline.download.census_population --output $@ + $(ELECTION): pipeline/download/election_results.py uv run python -m pipeline.download.election_results --output $@ @@ -400,7 +420,7 @@ $(PC_BOUNDARIES): # ── Final merge → postcode.parquet + properties.parquet ────────────────────── $(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \ - $(ETHNICITY) $(CRIME) $(NOISE) $(SCHOOL_CATCH) $(BROADBAND) $(CONSERVATION_AREAS) $(LISTED_BUILDINGS) $(RENTAL) $(MEDIAN_AGE) $(ELECTION) $(TREE_DENSITY_PC) $(MERGE_DEPS) + $(ETHNICITY) $(EDUCATION) $(TENURE) $(CRIME) $(NOISE) $(SCHOOL_CATCH) $(BROADBAND) $(CONSERVATION_AREAS) $(LISTED_BUILDINGS) $(RENTAL) $(MEDIAN_AGE) $(ELECTION) $(TREE_DENSITY_PC) $(MERGE_DEPS) @rm -f $@ uv run python -m pipeline.transform.merge \ --epc-pp $(EPC_PP) \ @@ -408,6 +428,8 @@ $(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \ --iod $(IOD) \ --poi-proximity $(POI_PROXIMITY) \ --ethnicity $(ETHNICITY) \ + --education $(EDUCATION) \ + --tenure $(TENURE) \ --crime $(CRIME) \ --noise $(NOISE) \ --school-catchments $(SCHOOL_CATCH) \ @@ -440,7 +462,7 @@ $(PRICES_STAMP): $(MERGE_STAMP) $(PRICE_INDEX) $(PRICE_ESTIMATE_DEPS) | $(PROPER $(ACTUAL_LISTINGS_ENRICHED): $(ACTUAL_LISTINGS_RAW) $(EPC) \ $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \ - $(ETHNICITY) $(CRIME) $(NOISE) $(SCHOOL_CATCH) $(BROADBAND) \ + $(ETHNICITY) $(EDUCATION) $(TENURE) $(CRIME) $(NOISE) $(SCHOOL_CATCH) $(BROADBAND) \ $(CONSERVATION_AREAS) $(LISTED_BUILDINGS) $(RENTAL) \ $(MEDIAN_AGE) $(ELECTION) $(TREE_DENSITY_PC) \ $(MERGE_DEPS) pipeline/utils/fuzzy_join.py @@ -450,6 +472,8 @@ $(ACTUAL_LISTINGS_ENRICHED): $(ACTUAL_LISTINGS_RAW) $(EPC) \ --iod $(IOD) \ --poi-proximity $(POI_PROXIMITY) \ --ethnicity $(ETHNICITY) \ + --education $(EDUCATION) \ + --tenure $(TENURE) \ --crime $(CRIME) \ --noise $(NOISE) \ --school-catchments $(SCHOOL_CATCH) \ diff --git a/docker-compose.yml b/docker-compose.yml index 9bacb06..81a8413 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: command: > bash -c " cargo install cargo-watch && - cargo watch --poll -i logs/ -x 'run -- --properties /app/property-data/properties.parquet --postcode-features /app/property-data/postcode.parquet --pois /app/property-data/filtered_uk_pois.parquet --places /app/property-data/places.parquet --tiles /app/property-data/uk.pmtiles --postcodes /app/property-data/postcode_boundaries --travel-times /app/property-data/travel-times --satellite-tiles /app/property-data/satellite.pmtiles --satellite-highres-tiles /app/property-data/satellite_highres.pmtiles --noise-overlay-tiles /app/property-data/noise_lden_10m.pmtiles --crime-hotspot-tiles /app/property-data/crime_hotspots.pmtiles --tree-overlay-tiles /app/property-data/trees_outside_woodlands.pmtiles --property-border-tiles /app/property-data/property_borders.pmtiles' + cargo watch --poll -i logs/ -x 'run -- --properties /app/property-data/properties.parquet --postcode-features /app/property-data/postcode.parquet --pois /app/property-data/filtered_uk_pois.parquet --places /app/property-data/places.parquet --tiles /app/property-data/uk.pmtiles --postcodes /app/property-data/postcode_boundaries --travel-times /app/property-data/travel-times --satellite-tiles /app/property-data/satellite.pmtiles --satellite-highres-tiles /app/property-data/satellite_highres.pmtiles --noise-overlay-tiles /app/property-data/noise_lden_10m.pmtiles --crime-hotspot-tiles /app/property-data/crime_hotspots.pmtiles --tree-overlay-tiles /app/property-data/trees_outside_woodlands.pmtiles --property-border-tiles /app/property-data/property_borders.pmtiles --crime-by-year-path /app/property-data/crime_by_postcode_by_year.parquet --population-path /app/property-data/population_by_postcode.parquet' " ports: - "8001:8001" @@ -57,6 +57,7 @@ services: BUGSINK_ENVIRONMENT: ${BUGSINK_ENVIRONMENT:-development} BUGSINK_RELEASE: ${BUGSINK_RELEASE:-} ACTUAL_LISTINGS_PATH: /app/finder/data/online_listings_buy_enriched.parquet + DEVELOPMENTS_PATH: /app/property-data/development_sites.parquet BUGSINK_SEND_DEFAULT_PII: ${BUGSINK_SEND_DEFAULT_PII:-false} depends_on: screenshot: diff --git a/frontend/public/video/ad-01-say-it.jpg b/frontend/public/video/ad-01-say-it.jpg index 156ef4b..25222b1 100644 --- a/frontend/public/video/ad-01-say-it.jpg +++ b/frontend/public/video/ad-01-say-it.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:feda4d907ccc0935216dbf2f70c1c72ad736648fbd8439ae1450f3178c24b6e0 -size 252974 +oid sha256:aced213df65f5898a3197ac304ef1efcacc9c27e050232e07721db00d29eef18 +size 264644 diff --git a/frontend/public/video/ad-01-say-it.mp4 b/frontend/public/video/ad-01-say-it.mp4 index 5cbfb40..3823735 100644 --- a/frontend/public/video/ad-01-say-it.mp4 +++ b/frontend/public/video/ad-01-say-it.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ca42a9f06efe741b444a88f3439a87766d85a813038a11366d4dbe1d1563e7d7 -size 14163191 +oid sha256:5786a2453528d8693f2da1262ac65684209c70afb19f1c2ed620e429d911a2ab +size 16952899 diff --git a/frontend/public/video/ad-01-say-it.vtt b/frontend/public/video/ad-01-say-it.vtt new file mode 100644 index 0000000..38c8e4a --- /dev/null +++ b/frontend/public/video/ad-01-say-it.vtt @@ -0,0 +1,14 @@ +WEBVTT + +00:00:00.201 --> 00:00:03.961 +My whole house brief is one plain sentence. + +00:00:04.361 --> 00:00:08.841 +Every postcode in England that fits, sorted by value. + +00:00:10.141 --> 00:00:14.941 +Same schools, same commute, the price quietly drops nearby. + +00:00:15.591 --> 00:00:19.511 +The underpriced twin is on this map. Find it. + diff --git a/frontend/public/video/ad-02-twenty-minute-map.jpg b/frontend/public/video/ad-02-twenty-minute-map.jpg index 015ac38..d281150 100644 --- a/frontend/public/video/ad-02-twenty-minute-map.jpg +++ b/frontend/public/video/ad-02-twenty-minute-map.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b914fe8cf24470bc35cf0c133ca2b36b6eaa7071456327f02e363883a0291484 -size 332708 +oid sha256:952fbf18c3698dd4d659b51802addef8d1790380f26e47039bd0784fc37fecdf +size 257591 diff --git a/frontend/public/video/ad-02-twenty-minute-map.mp4 b/frontend/public/video/ad-02-twenty-minute-map.mp4 index ae57ff6..6f4fa5c 100644 --- a/frontend/public/video/ad-02-twenty-minute-map.mp4 +++ b/frontend/public/video/ad-02-twenty-minute-map.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2b5c2283b01920830ac53905be9ade0dea1c54266cdb94435e62ae0869da08b3 -size 20973560 +oid sha256:e694a22b1470742e177d3bfcbe8990364812da6313cd0da67a6acb732dbe7321 +size 21701110 diff --git a/frontend/public/video/ad-02-twenty-minute-map.vtt b/frontend/public/video/ad-02-twenty-minute-map.vtt new file mode 100644 index 0000000..610f10c --- /dev/null +++ b/frontend/public/video/ad-02-twenty-minute-map.vtt @@ -0,0 +1,14 @@ +WEBVTT + +00:00:02.121 --> 00:00:05.961 +Every colour on this map is a commute to central London. + +00:00:06.361 --> 00:00:09.801 +Here's what twenty minutes actually leaves you. + +00:00:11.301 --> 00:00:16.741 +Same twenty minutes wherever it's lit, but not the same price. + +00:00:17.391 --> 00:00:21.151 +The commute is priced in. The bargain is not. + diff --git a/frontend/public/video/ad-03-postcode-files.jpg b/frontend/public/video/ad-03-postcode-files.jpg index 8b8b718..eda25aa 100644 --- a/frontend/public/video/ad-03-postcode-files.jpg +++ b/frontend/public/video/ad-03-postcode-files.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:081a2362b7a5064554744ee850a78d15b7e54155543ab3dff4c82d17001d5ac4 -size 194206 +oid sha256:730aef459e371324a263a248be6db784fefb012b0e9982b4fa1ec4a7b1ba509e +size 236198 diff --git a/frontend/public/video/ad-03-postcode-files.mp4 b/frontend/public/video/ad-03-postcode-files.mp4 index 63ea25c..6726fdf 100644 --- a/frontend/public/video/ad-03-postcode-files.mp4 +++ b/frontend/public/video/ad-03-postcode-files.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2766cd3abdf5de15961f502938a85d20390e690ef81dfeb3564eba0140e46fc3 -size 14428541 +oid sha256:b53e1dd6748d3c9cc9b6cf299c02fe07f1a031f370461586fc1ec21a3db10876 +size 17090775 diff --git a/frontend/public/video/ad-03-postcode-files.vtt b/frontend/public/video/ad-03-postcode-files.vtt new file mode 100644 index 0000000..2b34312 --- /dev/null +++ b/frontend/public/video/ad-03-postcode-files.vtt @@ -0,0 +1,14 @@ +WEBVTT + +00:00:01.021 --> 00:00:05.341 +One name costs more. The block next door scores the same. + +00:00:05.741 --> 00:00:11.581 +Sold prices, schools, crime, even Street View, for this exact postcode. + +00:00:12.031 --> 00:00:17.071 +The same evidence a pricey postcode has, sitting quietly cheaper here. + +00:00:17.671 --> 00:00:22.071 +Every postcode, proven on the numbers, not its reputation. + diff --git a/frontend/public/video/ad-04-quiet-streets.jpg b/frontend/public/video/ad-04-quiet-streets.jpg index 061e1b0..ae281e8 100644 --- a/frontend/public/video/ad-04-quiet-streets.jpg +++ b/frontend/public/video/ad-04-quiet-streets.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7f873aa0b7fe358c6b18ddeda02c838ed72f4b926410cff71bd8fde3b9f05e8e -size 236388 +oid sha256:c4d551f400ea58b6d91534ad0549adc01bc7f85c422ae1db5a39e06babbc3674 +size 260265 diff --git a/frontend/public/video/ad-04-quiet-streets.mp4 b/frontend/public/video/ad-04-quiet-streets.mp4 index 8045e53..651ca1a 100644 --- a/frontend/public/video/ad-04-quiet-streets.mp4 +++ b/frontend/public/video/ad-04-quiet-streets.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3443dd382034f0cad340455d84c9402032027a99bf20bf90d99378cdc307f76c -size 13308433 +oid sha256:9b0c5ad0a710ba94666f25f5c8fe704de2319eef3a1b826fc7cfaf0667b38421 +size 16146529 diff --git a/frontend/public/video/ad-04-quiet-streets.vtt b/frontend/public/video/ad-04-quiet-streets.vtt new file mode 100644 index 0000000..f263c9b --- /dev/null +++ b/frontend/public/video/ad-04-quiet-streets.vtt @@ -0,0 +1,14 @@ +WEBVTT + +00:00:00.201 --> 00:00:04.441 +Listing photos are silent. Main roads are not. + +00:00:04.841 --> 00:00:07.881 +This is London under fifty-five decibels. + +00:00:09.181 --> 00:00:13.021 +Nearby, just as quiet, and overlooked. + +00:00:13.671 --> 00:00:16.071 +The quiet street nobody bid up. + diff --git a/frontend/public/video/ad-05-school-run.jpg b/frontend/public/video/ad-05-school-run.jpg index 8456133..b51be70 100644 --- a/frontend/public/video/ad-05-school-run.jpg +++ b/frontend/public/video/ad-05-school-run.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8a176491a7d9b146c5cd2e8785cd00cd3f2eadf41c68326e19e5d2ef4c92ea90 -size 259165 +oid sha256:170f6b39128233c14454100b8f535bbc787700f1baf950a38c8d3cc88a6a71e4 +size 214073 diff --git a/frontend/public/video/ad-05-school-run.mp4 b/frontend/public/video/ad-05-school-run.mp4 index 83c5638..bae5768 100644 --- a/frontend/public/video/ad-05-school-run.mp4 +++ b/frontend/public/video/ad-05-school-run.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f9d123fd1d5f012367bf6818394d1b5601065692962118378f721f8347299d01 -size 16605037 +oid sha256:fe819309968f44f77cb37cf80c85d5b54b4b2bc67f93b9c554632ffb79bb082a +size 18251768 diff --git a/frontend/public/video/ad-05-school-run.vtt b/frontend/public/video/ad-05-school-run.vtt new file mode 100644 index 0000000..b45f582 --- /dev/null +++ b/frontend/public/video/ad-05-school-run.vtt @@ -0,0 +1,14 @@ +WEBVTT + +00:00:00.201 --> 00:00:06.521 +Good primary, low crime, under three hundred and fifty. The actual family brief. + +00:00:06.921 --> 00:00:11.081 +Everything still on the map passes all three filters. + +00:00:12.381 --> 00:00:17.741 +Same primary, same low crime, without the name everyone else is bidding up. + +00:00:18.391 --> 00:00:21.831 +The schools are real. The premium is optional. + diff --git a/frontend/public/video/ad-06-waitrose-test.jpg b/frontend/public/video/ad-06-waitrose-test.jpg index a6865e0..421cf86 100644 --- a/frontend/public/video/ad-06-waitrose-test.jpg +++ b/frontend/public/video/ad-06-waitrose-test.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:062af2bdd4b85e3e213c3211e1981d2582a439d8afc878e6af198563fe8737ef -size 266022 +oid sha256:8d8a8245edb2b260b181137e0c7ed4714985fec06a46446e1183d0c9002a60c0 +size 262604 diff --git a/frontend/public/video/ad-06-waitrose-test.mp4 b/frontend/public/video/ad-06-waitrose-test.mp4 index 3752854..0e773fa 100644 --- a/frontend/public/video/ad-06-waitrose-test.mp4 +++ b/frontend/public/video/ad-06-waitrose-test.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:814f21ee607ef664911dc45ab895d3a6abcbc6ca99beaa1df82b2bb3a08eab58 -size 16350932 +oid sha256:e3da72ea95676b8b1c9eee3fef27c84516964361594b1c85dc509fadf390d4a4 +size 15512217 diff --git a/frontend/public/video/ad-06-waitrose-test.vtt b/frontend/public/video/ad-06-waitrose-test.vtt new file mode 100644 index 0000000..91e2df4 --- /dev/null +++ b/frontend/public/video/ad-06-waitrose-test.vtt @@ -0,0 +1,14 @@ +WEBVTT + +00:00:00.204 --> 00:00:05.724 +A Waitrose, a tube stop, a park nearby. Search by what you want. + +00:00:06.124 --> 00:00:10.204 +London, narrowed to the postcodes that fit the way you live. + +00:00:11.504 --> 00:00:14.704 +Same amenities, lower price nearby. + +00:00:15.354 --> 00:00:19.354 +Same Waitrose. Same tube. Cheaper postcode. + diff --git a/frontend/public/video/ad-07-renters-map.jpg b/frontend/public/video/ad-07-renters-map.jpg index 824a48b..f12da12 100644 --- a/frontend/public/video/ad-07-renters-map.jpg +++ b/frontend/public/video/ad-07-renters-map.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b20a96a63320a873f1fbb6f65924192636d1027cc7e2568f218d6ffc9fcbf7d5 -size 244320 +oid sha256:4d44198496cf8ed08e4eca1c36ba9cc8b4f8b08f0aecc943b1a9364c1391e5a4 +size 194734 diff --git a/frontend/public/video/ad-07-renters-map.mp4 b/frontend/public/video/ad-07-renters-map.mp4 index 398ac0b..d3bea37 100644 --- a/frontend/public/video/ad-07-renters-map.mp4 +++ b/frontend/public/video/ad-07-renters-map.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0b61d1243cfb115d2421b20ea6d2f282530f34bb975e9b70fcf77dbd9df5b6e8 -size 13896671 +oid sha256:e4b18ff5179d719648f6f9475201c29d6d04de426686dde04d0f2a423b1080ab +size 15048624 diff --git a/frontend/public/video/ad-07-renters-map.vtt b/frontend/public/video/ad-07-renters-map.vtt new file mode 100644 index 0000000..0c859e4 --- /dev/null +++ b/frontend/public/video/ad-07-renters-map.vtt @@ -0,0 +1,14 @@ +WEBVTT + +00:00:00.201 --> 00:00:05.561 +Rent under sixteen hundred, half an hour to central, on a quiet street. + +00:00:05.961 --> 00:00:10.201 +Letting sites rank flats. This ranks the streets around them. + +00:00:11.501 --> 00:00:15.821 +Same commute, same quiet, lower rent nearby. + +00:00:16.471 --> 00:00:19.831 +The name costs more. The street does not. + diff --git a/frontend/public/video/ad-08-cheap-insurance.jpg b/frontend/public/video/ad-08-cheap-insurance.jpg deleted file mode 100644 index 63622a0..0000000 --- a/frontend/public/video/ad-08-cheap-insurance.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8fd356f8055c9d1c6b0f039072b7122227cd6de2f80987c78a3e12b7bdb6516c -size 266039 diff --git a/frontend/public/video/ad-08-cheap-insurance.mp4 b/frontend/public/video/ad-08-cheap-insurance.mp4 deleted file mode 100644 index c6fe8f9..0000000 --- a/frontend/public/video/ad-08-cheap-insurance.mp4 +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f0a696e433bbfc980c19ed1a52961981467c9b47893bfa1cfabffeb8d422da8b -size 15165825 diff --git a/frontend/public/video/ad-08-value.vtt b/frontend/public/video/ad-08-value.vtt new file mode 100644 index 0000000..31ee71d --- /dev/null +++ b/frontend/public/video/ad-08-value.vtt @@ -0,0 +1,14 @@ +WEBVTT + +00:00:00.201 --> 00:00:05.641 +Two streets, same schools, same commute, priced tens of thousands apart. + +00:00:06.041 --> 00:00:10.201 +You pay for the postcode's name; the value sits a postcode away. + +00:00:11.501 --> 00:00:15.581 +Pay once, find the bargain, stop overpaying for the name. + +00:00:16.231 --> 00:00:19.351 +Buy value, not reputation. + diff --git a/frontend/public/video/recording-de-mobile.jpg b/frontend/public/video/recording-de-mobile.jpg index 9cbfe1b..184a291 100644 --- a/frontend/public/video/recording-de-mobile.jpg +++ b/frontend/public/video/recording-de-mobile.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d5caf76f9ced3291dfcddf1ce718f23b458962223e6f7acb5de837b75df1f01c -size 173836 +oid sha256:054a48a4778e44b3aac809a35879e55bb8ed2005a76caf4f26a7d0aabae24f3a +size 197996 diff --git a/frontend/public/video/recording-de-mobile.mp4 b/frontend/public/video/recording-de-mobile.mp4 index 5dfade7..6e78e87 100644 --- a/frontend/public/video/recording-de-mobile.mp4 +++ b/frontend/public/video/recording-de-mobile.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:079797376d88e58918e60f6a8f9bfe800b71c3d401281834cdfe0642eac7cc2e -size 28783633 +oid sha256:abccd792e5f315a2ea1a45b0f8fd4d380e14bf3d7572047b3047995a6f006db3 +size 34302816 diff --git a/frontend/public/video/recording-de-mobile.vtt b/frontend/public/video/recording-de-mobile.vtt new file mode 100644 index 0000000..16319b2 --- /dev/null +++ b/frontend/public/video/recording-de-mobile.vtt @@ -0,0 +1,26 @@ +WEBVTT + +00:00:00.241 --> 00:00:04.081 +Der Ruf einer Gegend steckt im Preis. Ihr wahrer Wert nicht. + +00:00:04.431 --> 00:00:10.831 +Also sag, was du suchst. Budget, Arbeitsweg, Schulen, sogar wie ruhig die Straße ist. + +00:00:11.201 --> 00:00:16.641 +Eine Vorgabe, und England schrumpft auf die Postleitzahlen, die dein Geld wert sind. + +00:00:17.191 --> 00:00:21.751 +Verschärf den Arbeitsweg, und in Sekunden bleiben nur die besten übrig. + +00:00:22.351 --> 00:00:26.111 +Auf Straßenebene stechen die besten Ecken von selbst hervor. + +00:00:26.969 --> 00:00:34.649 +Öffne eine, und sie zeigt die Belege. Verkaufspreise, Schulen, Kriminalität, Lärm, Internet. + +00:00:41.505 --> 00:00:47.505 +Behalte die paar mit dem besten Preis-Leistungs-Verhältnis, exportier sie und schau sie dir vor Ort an. + +00:00:48.505 --> 00:00:52.105 +Zahl nicht für den Namen. Finde den Wert. + diff --git a/frontend/public/video/recording-de.jpg b/frontend/public/video/recording-de.jpg index 249cee4..451d540 100644 --- a/frontend/public/video/recording-de.jpg +++ b/frontend/public/video/recording-de.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:37223af7bf5ba60078c8e7cfcd50de4da9e0be04fe83b6a4f0431b9d5ddd6ce1 -size 195896 +oid sha256:feab527c317b1e9274aa7fa0cc2f5d7e774e893f075c491849a1953e683c986f +size 327454 diff --git a/frontend/public/video/recording-de.mp4 b/frontend/public/video/recording-de.mp4 index 8f422b7..0451832 100644 --- a/frontend/public/video/recording-de.mp4 +++ b/frontend/public/video/recording-de.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c86e09504e62ea0c3a14bef1a0b5a4a8aed6bd1215bd4b85b5433cb545c45703 -size 36160710 +oid sha256:f571e40dacb9d81a1ed8e12707b6d8516536d567d3ebadf8d5dc7acc3a4cc5c4 +size 48803541 diff --git a/frontend/public/video/recording-de.vtt b/frontend/public/video/recording-de.vtt new file mode 100644 index 0000000..91238c7 --- /dev/null +++ b/frontend/public/video/recording-de.vtt @@ -0,0 +1,26 @@ +WEBVTT + +00:00:00.244 --> 00:00:04.084 +Der Ruf einer Gegend steckt im Preis. Ihr wahrer Wert nicht. + +00:00:04.434 --> 00:00:10.834 +Also sag, was du suchst. Budget, Arbeitsweg, Schulen, sogar wie ruhig die Straße ist. + +00:00:11.204 --> 00:00:16.644 +Eine Vorgabe, und England schrumpft auf die Postleitzahlen, die dein Geld wert sind. + +00:00:17.194 --> 00:00:21.754 +Verschärf den Arbeitsweg, und in Sekunden bleiben nur die besten übrig. + +00:00:22.354 --> 00:00:26.114 +Auf Straßenebene stechen die besten Ecken von selbst hervor. + +00:00:27.355 --> 00:00:35.035 +Öffne eine, und sie zeigt die Belege. Verkaufspreise, Schulen, Kriminalität, Lärm, Internet. + +00:00:37.235 --> 00:00:43.235 +Behalte die paar mit dem besten Preis-Leistungs-Verhältnis, exportier sie und schau sie dir vor Ort an. + +00:00:44.235 --> 00:00:47.835 +Zahl nicht für den Namen. Finde den Wert. + diff --git a/frontend/public/video/recording-hi-mobile.jpg b/frontend/public/video/recording-hi-mobile.jpg index 794f4b4..cdefb2d 100644 --- a/frontend/public/video/recording-hi-mobile.jpg +++ b/frontend/public/video/recording-hi-mobile.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:80e92ce9fbbf454d28ad274000a81ff55b549c9e05ab7aad05155ee469a705a9 -size 145406 +oid sha256:644b4a444a0e037856f3698d8b063eb6fbc205f902d3bc30cd49713e9591daf8 +size 229103 diff --git a/frontend/public/video/recording-hi-mobile.mp4 b/frontend/public/video/recording-hi-mobile.mp4 index a65f0d1..eb19488 100644 --- a/frontend/public/video/recording-hi-mobile.mp4 +++ b/frontend/public/video/recording-hi-mobile.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c75e8d45d3a17e017d1c9ea4032a29a3bb93f2299421da57b570d60351f66490 -size 26981502 +oid sha256:6f11c0498657879f0780e9b5de96d9a37524aa151948b9dd15e04be73344dc6e +size 35902149 diff --git a/frontend/public/video/recording-hi-mobile.vtt b/frontend/public/video/recording-hi-mobile.vtt new file mode 100644 index 0000000..4880dfd --- /dev/null +++ b/frontend/public/video/recording-hi-mobile.vtt @@ -0,0 +1,26 @@ +WEBVTT + +00:00:00.241 --> 00:00:04.881 +A postcode's reputation is priced in. Its value isn't. + +00:00:05.231 --> 00:00:12.111 +So start with the brief. Budget, commute, schools, even how quiet the street is. + +00:00:12.481 --> 00:00:16.721 +One brief, and England narrows to the postcodes worth your money. + +00:00:17.271 --> 00:00:20.871 +Tighten the commute, and the keepers narrow further in seconds. + +00:00:21.471 --> 00:00:26.111 +Down at street level, the strongest streets start to stand out. + +00:00:26.561 --> 00:00:33.921 +Open one, and it shows its work. Sold prices, schools, crime, noise, broadband. + +00:00:41.054 --> 00:00:46.494 +Keep the best-value few, export them, and scout where it actually counts. + +00:00:47.494 --> 00:00:50.694 +Stop paying for the name. Find the value. + diff --git a/frontend/public/video/recording-hi.jpg b/frontend/public/video/recording-hi.jpg index 0454717..fa06c71 100644 --- a/frontend/public/video/recording-hi.jpg +++ b/frontend/public/video/recording-hi.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c1e8ff477f7cd778ed39e9f121cef11494da6e546648f52c83c77273a1f59a6d -size 198732 +oid sha256:3a7086beb3ac9238b17ea6d0095574595a9252904a8449572d309a47b603ab2a +size 350576 diff --git a/frontend/public/video/recording-hi.mp4 b/frontend/public/video/recording-hi.mp4 index 0d8b3b8..adc6f32 100644 --- a/frontend/public/video/recording-hi.mp4 +++ b/frontend/public/video/recording-hi.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c6869e54fea948e4c2647fc4adbbe7f1e1ecd08efa6d56c27ff7e7cb827c07d9 -size 34566444 +oid sha256:9f804949ba98966c92dc21daba63dbbd96ff8ef6c92030cf778eff85d5dfca9e +size 45041445 diff --git a/frontend/public/video/recording-hi.vtt b/frontend/public/video/recording-hi.vtt new file mode 100644 index 0000000..5bf02e9 --- /dev/null +++ b/frontend/public/video/recording-hi.vtt @@ -0,0 +1,26 @@ +WEBVTT + +00:00:00.245 --> 00:00:04.885 +A postcode's reputation is priced in. Its value isn't. + +00:00:05.235 --> 00:00:12.115 +So start with the brief. Budget, commute, schools, even how quiet the street is. + +00:00:12.485 --> 00:00:16.725 +One brief, and England narrows to the postcodes worth your money. + +00:00:17.275 --> 00:00:20.875 +Tighten the commute, and the keepers narrow further in seconds. + +00:00:21.475 --> 00:00:26.115 +Down at street level, the strongest streets start to stand out. + +00:00:26.565 --> 00:00:33.925 +Open one, and it shows its work. Sold prices, schools, crime, noise, broadband. + +00:00:36.125 --> 00:00:41.565 +Keep the best-value few, export them, and scout where it actually counts. + +00:00:42.565 --> 00:00:45.765 +Stop paying for the name. Find the value. + diff --git a/frontend/public/video/recording-mobile.jpg b/frontend/public/video/recording-mobile.jpg index 40ae2d3..7dfc031 100644 --- a/frontend/public/video/recording-mobile.jpg +++ b/frontend/public/video/recording-mobile.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cc870b0cf79eb73c6b6c17a123a5a7c49524d02a0f051854abdd18b4208dd2ae -size 176463 +oid sha256:776c00b545fe878c1cfae7ff4a1d7a2f646f2fbd68677a2b689136bb52134a9c +size 178290 diff --git a/frontend/public/video/recording-mobile.mp4 b/frontend/public/video/recording-mobile.mp4 index 97cd7e8..5f9b524 100644 --- a/frontend/public/video/recording-mobile.mp4 +++ b/frontend/public/video/recording-mobile.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ecfcad276f92956677161f32af22d2dbc24ca4851b1ca9673af4b37ac3526986 -size 29187720 +oid sha256:97beb4788a3b97ae4e081ea1c6ba20700f1bc1eb5a9b5b8b3732058de802ddd2 +size 37294899 diff --git a/frontend/public/video/recording-mobile.vtt b/frontend/public/video/recording-mobile.vtt new file mode 100644 index 0000000..572ec62 --- /dev/null +++ b/frontend/public/video/recording-mobile.vtt @@ -0,0 +1,26 @@ +WEBVTT + +00:00:00.240 --> 00:00:05.200 +A postcode's reputation is priced in. Its value isn't. + +00:00:05.550 --> 00:00:12.110 +So start with the brief. Budget, commute, schools, even how quiet the street is. + +00:00:12.480 --> 00:00:17.200 +One brief, and England narrows to the postcodes worth your money. + +00:00:17.750 --> 00:00:22.630 +Tighten the commute, and the keepers narrow further in seconds. + +00:00:23.230 --> 00:00:29.150 +Down at street level, the strongest streets start to stand out. + +00:00:29.600 --> 00:00:37.920 +Open one, and it shows its work. Sold prices, schools, crime, noise, broadband. + +00:00:44.031 --> 00:00:49.871 +Keep the best-value few, export them, and scout where it actually counts. + +00:00:50.871 --> 00:00:53.991 +Stop paying for the name. Find the value. + diff --git a/frontend/public/video/recording-zh-mobile.jpg b/frontend/public/video/recording-zh-mobile.jpg index fc22073..8fb1a97 100644 --- a/frontend/public/video/recording-zh-mobile.jpg +++ b/frontend/public/video/recording-zh-mobile.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1ebf4e899dbf18a9aadb87030c7562054fb32cf7564cc81343ffe424d3e2c4d0 -size 178739 +oid sha256:7083e4ced29d996bfe8149b3b13d9cd7887088a3e1b71e3676f089b95678ff33 +size 298627 diff --git a/frontend/public/video/recording-zh-mobile.mp4 b/frontend/public/video/recording-zh-mobile.mp4 index 0ffdd60..44d4110 100644 --- a/frontend/public/video/recording-zh-mobile.mp4 +++ b/frontend/public/video/recording-zh-mobile.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b5dc391a8fefb0e848754b01c963b7e7e9b537dbf1a1afb9ca9910a717026f16 -size 25667060 +oid sha256:6e19eaaa119336b72862e89fbcc2b332039842194b86ea87c4a8d8e6a763144c +size 36198129 diff --git a/frontend/public/video/recording-zh-mobile.vtt b/frontend/public/video/recording-zh-mobile.vtt new file mode 100644 index 0000000..06a2225 --- /dev/null +++ b/frontend/public/video/recording-zh-mobile.vtt @@ -0,0 +1,26 @@ +WEBVTT + +00:00:00.242 --> 00:00:06.322 +一个邮区的名气,早就算进了房价里。它真正的价值,却没有。 + +00:00:06.672 --> 00:00:15.152 +所以,把你的需求告诉它。预算、通勤、学校,甚至这条街够不够安静。 + +00:00:15.522 --> 00:00:20.162 +一份需求,整个英格兰就缩小到那些真正值这个价的邮区。 + +00:00:20.712 --> 00:00:25.672 +把通勤再收紧一点,几秒钟,留下的好选择又少了一批。 + +00:00:26.272 --> 00:00:29.952 +放大到街道层面,好街区自己浮现出来。 + +00:00:30.960 --> 00:00:38.080 +点开一个,它把依据都摆给你看。成交价、学校、治安、噪音、宽带。 + +00:00:45.462 --> 00:00:51.302 +留下最划算的那几个,导出来,去真正值得的地方实地看房。 + +00:00:52.302 --> 00:00:55.422 +别再为名气买单。找到真正的价值。 + diff --git a/frontend/public/video/recording-zh.jpg b/frontend/public/video/recording-zh.jpg index 42a2e4a..46755f2 100644 --- a/frontend/public/video/recording-zh.jpg +++ b/frontend/public/video/recording-zh.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6ba72743b85e80e26f8fe18a1afd860afef91e2a521efd5903f4cfb2a0367a9f -size 184652 +oid sha256:bbc735daf08abdc30d4321947d382f57efb3b83a5a011e38744b19c9c1d5156c +size 304023 diff --git a/frontend/public/video/recording-zh.mp4 b/frontend/public/video/recording-zh.mp4 index c838dcf..7522462 100644 --- a/frontend/public/video/recording-zh.mp4 +++ b/frontend/public/video/recording-zh.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5865bf783150c3d481686972790dcc9c37401f377266d2fd7068f190ee72ddc0 -size 33108202 +oid sha256:bc86a528fbb0bd93bfc32f401d5f6ed8e9be88fc979f4c4ddd3f2268e8195b7e +size 48760725 diff --git a/frontend/public/video/recording-zh.vtt b/frontend/public/video/recording-zh.vtt new file mode 100644 index 0000000..2cc1069 --- /dev/null +++ b/frontend/public/video/recording-zh.vtt @@ -0,0 +1,26 @@ +WEBVTT + +00:00:00.244 --> 00:00:06.324 +一个邮区的名气,早就算进了房价里。它真正的价值,却没有。 + +00:00:06.674 --> 00:00:15.154 +所以,把你的需求告诉它。预算、通勤、学校,甚至这条街够不够安静。 + +00:00:15.524 --> 00:00:20.164 +一份需求,整个英格兰就缩小到那些真正值这个价的邮区。 + +00:00:20.714 --> 00:00:25.674 +把通勤再收紧一点,几秒钟,留下的好选择又少了一批。 + +00:00:26.274 --> 00:00:29.954 +放大到街道层面,好街区自己浮现出来。 + +00:00:31.054 --> 00:00:38.174 +点开一个,它把依据都摆给你看。成交价、学校、治安、噪音、宽带。 + +00:00:40.375 --> 00:00:46.215 +留下最划算的那几个,导出来,去真正值得的地方实地看房。 + +00:00:47.215 --> 00:00:50.335 +别再为名气买单。找到真正的价值。 + diff --git a/frontend/public/video/recording.jpg b/frontend/public/video/recording.jpg index 633284e..1bf13b7 100644 --- a/frontend/public/video/recording.jpg +++ b/frontend/public/video/recording.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:183bc59ec12cefa42afdc2083331f7f08d8e9569798ad9d42d6f3666c24acc8d -size 188821 +oid sha256:740b77c88c67041aa5d93a2cc4974415e79200cc35317e1c20ccbc064d7f7389 +size 338350 diff --git a/frontend/public/video/recording.mp4 b/frontend/public/video/recording.mp4 index de1a3fb..4836378 100644 --- a/frontend/public/video/recording.mp4 +++ b/frontend/public/video/recording.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8dd52ed40bc5c9b6a96535a7e743a2ebac9862eeb83e35b6b49ea9bcac7e611c -size 36765113 +oid sha256:26a4e14323da141a1af372a830fb7ef569d9e19cd124715e608a3affccf2d4ed +size 49203079 diff --git a/frontend/public/video/recording.vtt b/frontend/public/video/recording.vtt new file mode 100644 index 0000000..f36fe6e --- /dev/null +++ b/frontend/public/video/recording.vtt @@ -0,0 +1,26 @@ +WEBVTT + +00:00:00.241 --> 00:00:05.201 +A postcode's reputation is priced in. Its value isn't. + +00:00:05.551 --> 00:00:12.111 +So start with the brief. Budget, commute, schools, even how quiet the street is. + +00:00:12.481 --> 00:00:17.201 +One brief, and England narrows to the postcodes worth your money. + +00:00:17.751 --> 00:00:22.631 +Tighten the commute, and the keepers narrow further in seconds. + +00:00:23.231 --> 00:00:29.151 +Down at street level, the strongest streets start to stand out. + +00:00:29.601 --> 00:00:37.921 +Open one, and it shows its work. Sold prices, schools, crime, noise, broadband. + +00:00:40.021 --> 00:00:45.861 +Keep the best-value few, export them, and scout where it actually counts. + +00:00:46.861 --> 00:00:49.981 +Stop paying for the name. Find the value. + diff --git a/video/render.sh b/video/render.sh index 53a7d86..4e03036 100755 --- a/video/render.sh +++ b/video/render.sh @@ -440,6 +440,7 @@ for sb in "${STORYBOARDS[@]}"; do say "[$sb] Publishing to $PUBLISH_DIR/$sb.{mp4,jpg}" cp "output/$sb/recording.mp4" "$PUBLISH_DIR/$sb.mp4" cp "output/$sb/poster.jpg" "$PUBLISH_DIR/$sb.jpg" + [ -f "output/$sb/narration.vtt" ] && cp "output/$sb/narration.vtt" "$PUBLISH_DIR/$sb.vtt" node dist/verify.js "$sb" "$PUBLISH_DIR/$sb.mp4" "$PUBLISH_DIR/$sb.jpg" fi done From f7e0814a38ba806c712b706629650cbd95411150 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Mon, 22 Jun 2026 22:04:20 +0100 Subject: [PATCH 2/3] videos --- video/src/browser.ts | 7 +- video/src/dom.ts | 31 +++++- video/src/narration.ts | 35 +++++++ video/src/record.ts | 9 +- video/src/routes.ts | 2 +- video/src/runner.ts | 2 +- video/src/script.ts | 9 +- video/src/storyboard.ts | 203 ++++++++++++++++++++++++---------------- 8 files changed, 206 insertions(+), 92 deletions(-) diff --git a/video/src/browser.ts b/video/src/browser.ts index fa838e5..4049743 100644 --- a/video/src/browser.ts +++ b/video/src/browser.ts @@ -148,7 +148,12 @@ async function suppressDevServerNoise(context: BrowserContext) { iframe[id*="webpack"], [id*="webpack-dev-server-client"], [class*="error-overlay"], - [class*="webpack-error"] { + [class*="webpack-error"], + /* Recording-only: hide dashboard chrome that reads as noise/loading + on camera (the "Finding the Perfect Postcode" AI-status CTA). The + data attribute is inert in production — only this injected rule + targets it, and only during a recording. */ + [data-video-hide] { display: none !important; visibility: hidden !important; opacity: 0 !important; diff --git a/video/src/dom.ts b/video/src/dom.ts index 5a9eae8..403cbbd 100644 --- a/video/src/dom.ts +++ b/video/src/dom.ts @@ -146,12 +146,16 @@ export async function installCursor( At the 540-wide CSS viewport this renders ~30px type → 60px in the published 1080-wide mp4: a proper social-video hook size. */ body.__demo-aspect-vertical #__demo-caption { - top: 8%; + top: 14%; max-width: 88vw; font-size: 30px; font-weight: 850; padding: 12px 18px; border-radius: 12px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; } #__demo-caption.visible { opacity: 1; @@ -209,6 +213,13 @@ export async function installCursor( margin-left: auto; margin-right: auto; } + #__demo-outro-subtitle { + font: 500 22px/1.34 "Inter", system-ui, sans-serif; + color: #cbd5e1; + max-width: 30ch; + margin: 2px auto 4px; + text-align: center; + } #__demo-outro-url { display: inline-flex; align-items: center; @@ -228,6 +239,7 @@ export async function installCursor( comfortably inside the platform-safe centre column. */ body.__demo-aspect-vertical #__demo-outro-brand { font-size: 64px; } body.__demo-aspect-vertical #__demo-outro-tagline { font-size: 26px; max-width: 22ch; } + body.__demo-aspect-vertical #__demo-outro-subtitle { font-size: 20px; max-width: 24ch; } body.__demo-aspect-vertical #__demo-outro-url { font-size: 30px; padding: 16px 22px; } .__ad-scene { @@ -816,10 +828,11 @@ export async function showOutro( page: Page, brand: string, tagline: string, - url: string + url: string, + subtitle?: string ): Promise { await page.evaluate( - ({ brand, tagline, url }) => { + ({ brand, tagline, url, subtitle }) => { document.getElementById('__demo-caption')?.classList.remove('visible'); const el = document.createElement('div'); el.id = '__demo-outro'; @@ -835,14 +848,22 @@ export async function showOutro( urlEl.id = '__demo-outro-url'; // Drop the protocol so the CTA reads as a bare domain. urlEl.textContent = url.replace(/^https?:\/\//, '').replace(/\/$/, ''); - card.append(brandEl, taglineEl, urlEl); + // Optional spoken-outro line, burned in for muted social viewers. + if (subtitle && subtitle.length > 0) { + const subtitleEl = document.createElement('div'); + subtitleEl.id = '__demo-outro-subtitle'; + subtitleEl.textContent = subtitle; + card.append(brandEl, taglineEl, subtitleEl, urlEl); + } else { + card.append(brandEl, taglineEl, urlEl); + } el.appendChild(card); document.body.appendChild(el); requestAnimationFrame(() => { requestAnimationFrame(() => el.classList.add('visible')); }); }, - { brand, tagline, url } + { brand, tagline, url, subtitle } ); } diff --git a/video/src/narration.ts b/video/src/narration.ts index c53ad38..84a9d2b 100644 --- a/video/src/narration.ts +++ b/video/src/narration.ts @@ -35,3 +35,38 @@ class NarrationLog { } export const narrationLog = new NarrationLog(); + +function formatVttTimestamp(ms: number): string { + const clamped = Math.max(0, Math.round(ms)); + const totalSeconds = Math.floor(clamped / 1000); + const millis = clamped % 1000; + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + const pad = (n: number, width: number): string => + n.toString().padStart(width, '0'); + return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)}.${pad(millis, 3)}`; +} + +/** + * Render narration cues as a WebVTT document. + * + * Pure helper: takes cues (already ordered by the caller) and produces a + * caption sidecar string. Cue text is trimmed and any internal blank lines + * are collapsed so a single cue stays a single VTT block. + */ +export function cuesToVtt(cues: NarrationCue[]): string { + let out = 'WEBVTT\n\n'; + for (const cue of cues) { + const start = formatVttTimestamp(cue.videoTimeMs); + const end = formatVttTimestamp(cue.videoTimeMs + cue.durationMs); + const text = cue.text + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .join('\n') + .trim(); + out += `${start} --> ${end}\n${text}\n\n`; + } + return out; +} diff --git a/video/src/record.ts b/video/src/record.ts index bc1e2d7..98d91ba 100644 --- a/video/src/record.ts +++ b/video/src/record.ts @@ -1,8 +1,8 @@ -import { existsSync, mkdirSync, statSync } from 'node:fs'; +import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { AUTH_STATE_PATH, LEAD_IN_S, OUTPUT_DIR } from './config.js'; import { assertHardwareWebGL, launchRecordingBrowser } from './browser.js'; -import { narrationLog } from './narration.js'; +import { cuesToVtt, narrationLog } from './narration.js'; import { installDemoRoutes } from './routes.js'; import type { Storyboard } from './script.js'; import { storyboards } from './storyboard.js'; @@ -105,6 +105,11 @@ async function recordOne(storyboard: Storyboard): Promise { console.log( `[${storyboard.name}] wrote ${cues.length} narration cues → ${join(dir, 'narration.json')}` ); + const vttPath = join(dir, 'narration.vtt'); + writeFileSync(vttPath, cuesToVtt(cues)); + console.log( + `[${storyboard.name}] wrote ${cues.length} WebVTT captions → ${vttPath}` + ); } async function main(): Promise { diff --git a/video/src/routes.ts b/video/src/routes.ts index 167496d..ad7ccd5 100644 --- a/video/src/routes.ts +++ b/video/src/routes.ts @@ -38,7 +38,7 @@ async function stubAiFilters(page: Page, storyboard: Storyboard) { filters: storyboard.content.stubbedFilters, travel_time_filters: storyboard.content.stubbedTravelTimeFilters, notes: '', - match_count: 1247, + match_count: storyboard.content.matchCount ?? 1247, }), }); }); diff --git a/video/src/runner.ts b/video/src/runner.ts index 9707586..cd73221 100644 --- a/video/src/runner.ts +++ b/video/src/runner.ts @@ -379,7 +379,7 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise { return; } case 'showOutro': - await showOutro(ctx.page, step.brand, step.tagline, step.url); + await showOutro(ctx.page, step.brand, step.tagline, step.url, step.subtitle); return; case 'showAdScene': await showAdScene(ctx.page, step.scene); diff --git a/video/src/script.ts b/video/src/script.ts index 3a35cfd..93fe710 100644 --- a/video/src/script.ts +++ b/video/src/script.ts @@ -172,7 +172,7 @@ export type Activity = timeoutMs?: number; } /** Reveal the closing brand card. */ - | { kind: 'showOutro'; brand: string; tagline: string; url: string; durationMs: number } + | { kind: 'showOutro'; brand: string; tagline: string; url: string; subtitle?: string; durationMs: number } /** Reveal a full-screen ad-style overlay over the live map. */ | { kind: 'showAdScene'; scene: AdScene; durationMs: number } /** Fade the ad overlay away. */ @@ -318,6 +318,13 @@ export interface ContentConfig { travelTimeDragFromMin: number; travelTimeDragToMin: number; brand: BrandConfig; + /** + * Stubbed "{{count}} properties match" total shown in the AI summary. + * Set PER storyboard so different searches/cities don't all display the + * same number (an identical count across cuts reads as fake). Falls back + * to a default in routes.ts when omitted. + */ + matchCount?: number; } export interface TravelTimeFilter { diff --git a/video/src/storyboard.ts b/video/src/storyboard.ts index 384eff8..0ce057b 100644 --- a/video/src/storyboard.ts +++ b/video/src/storyboard.ts @@ -148,21 +148,21 @@ const RECORDING_LOCALIZATIONS: Record = colourMapTitle: 'Colour map', brand: { name: 'Perfect Postcode', - tagline: 'Find the area before the house.', + tagline: 'Find the hidden-gem postcode.', url: BRAND_URL, }, cues: { - hook: "You can't view your way to the right area.", + hook: "A postcode's reputation is priced in. Its value isn't.", brief: - 'So start with the brief. Price, commute, schools, even how quiet the street is.', - search: 'One search, and England shrinks to the postcodes that fit.', - commute: 'Push the commute down, and the map answers in seconds.', + 'So start with the brief. Budget, commute, schools, even how quiet the street is.', + search: 'One brief, and England narrows to the postcodes worth your money.', + commute: 'Tighten the commute, and the keepers narrow further in seconds.', streets: 'Down at street level, the strongest streets start to stand out.', open: 'Open one, and it shows its work. Sold prices, schools, crime, noise, broadband.', shortlist: - 'Shortlist the winners, export them, and only spend Saturdays where it counts.', - outro: 'Stop guessing postcodes. Start choosing them.', + 'Keep the best-value few, export them, and scout where it actually counts.', + outro: 'Stop paying for the name. Find the value.', }, }, de: { @@ -183,21 +183,22 @@ const RECORDING_LOCALIZATIONS: Record = colourMapTitle: 'Karte einfärben', brand: { name: 'Perfect Postcode', - tagline: 'Erst die Gegend, dann das Haus.', + tagline: 'Finde die unterschätzte Postleitzahl.', url: BRAND_URL, }, cues: { - hook: 'Das richtige Viertel findest du nicht durch Besichtigungen.', + hook: 'Der Ruf einer Gegend steckt im Preis. Ihr wahrer Wert nicht.', brief: - 'Fang mit dem Briefing an. Preis, Pendelzeit, Schulen, sogar wie ruhig die Straße ist.', - search: 'Eine Suche, und England schrumpft auf die Postleitzahlen, die passen.', - commute: 'Verkürze den Arbeitsweg, und die Karte antwortet in Sekunden.', + 'Also sag, was du suchst. Budget, Arbeitsweg, Schulen, sogar wie ruhig die Straße ist.', + search: + 'Eine Vorgabe, und England schrumpft auf die Postleitzahlen, die dein Geld wert sind.', + commute: 'Verschärf den Arbeitsweg, und in Sekunden bleiben nur die besten übrig.', streets: 'Auf Straßenebene stechen die besten Ecken von selbst hervor.', open: - 'Öffne eine, und sie zeigt ihre Daten. Verkaufspreise, Schulen, Kriminalität, Lärm, Internet.', + 'Öffne eine, und sie zeigt die Belege. Verkaufspreise, Schulen, Kriminalität, Lärm, Internet.', shortlist: - 'Speichere die Favoriten, exportiere sie, und verbringe deine Samstage nur dort, wo es zählt.', - outro: 'Hör auf, Postleitzahlen zu raten. Fang an, sie zu wählen.', + 'Behalte die paar mit dem besten Preis-Leistungs-Verhältnis, exportier sie und schau sie dir vor Ort an.', + outro: 'Zahl nicht für den Namen. Finde den Wert.', }, }, zh: { @@ -216,18 +217,18 @@ const RECORDING_LOCALIZATIONS: Record = colourMapTitle: '地图着色', brand: { name: 'Perfect Postcode', - tagline: '先选对街区,再选房子。', + tagline: '发现那个被低估的邮区。', url: BRAND_URL, }, cues: { - hook: '光靠看房,看不出哪个街区适合你。', - brief: '先列条件。预算、通勤、学校,甚至街道安不安静。', - search: '搜索一次,全英格兰只剩下符合条件的邮编。', - commute: '把通勤时间调短,地图几秒内就给出答案。', + hook: '一个邮区的名气,早就算进了房价里。它真正的价值,却没有。', + brief: '所以,把你的需求告诉它。预算、通勤、学校,甚至这条街够不够安静。', + search: '一份需求,整个英格兰就缩小到那些真正值这个价的邮区。', + commute: '把通勤再收紧一点,几秒钟,留下的好选择又少了一批。', streets: '放大到街道层面,好街区自己浮现出来。', - open: '点开一个,它会摆出证据。成交价、学校、犯罪率、噪音、宽带。', - shortlist: '把心仪的区域导出,周末只去值得去的地方。', - outro: '别再猜邮编,开始挑邮编。', + open: '点开一个,它把依据都摆给你看。成交价、学校、治安、噪音、宽带。', + shortlist: '留下最划算的那几个,导出来,去真正值得的地方实地看房。', + outro: '别再为名气买单。找到真正的价值。', }, }, hi: { @@ -247,21 +248,21 @@ const RECORDING_LOCALIZATIONS: Record = colourMapTitle: 'मानचित्र रंगें', brand: { name: 'Perfect Postcode', - tagline: 'Find the area before the house.', + tagline: 'Find the hidden-gem postcode.', url: BRAND_URL, }, cues: { - hook: "You can't view your way to the right area.", + hook: "A postcode's reputation is priced in. Its value isn't.", brief: - 'So start with the brief. Price, commute, schools, even how quiet the street is.', - search: 'One search, and England shrinks to the postcodes that fit.', - commute: 'Push the commute down, and the map answers in seconds.', + 'So start with the brief. Budget, commute, schools, even how quiet the street is.', + search: 'One brief, and England narrows to the postcodes worth your money.', + commute: 'Tighten the commute, and the keepers narrow further in seconds.', streets: 'Down at street level, the strongest streets start to stand out.', open: 'Open one, and it shows its work. Sold prices, schools, crime, noise, broadband.', shortlist: - 'Shortlist the winners, export them, and only spend Saturdays where it counts.', - outro: 'Stop guessing postcodes. Start choosing them.', + 'Keep the best-value few, export them, and scout where it actually counts.', + outro: 'Stop paying for the name. Find the value.', }, }, }; @@ -432,8 +433,8 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard { kind: 'mapZoom', target: mapFocus, - steps: 6, - durationMs: 1800, + steps: 5, + durationMs: 1300, direction: 'out', }, ] @@ -468,17 +469,19 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard }, { text: copy.cues.outro, - gapBeforeMs: 450, + gapBeforeMs: 350, during: [ { kind: 'showOutro', brand: copy.brand.name, tagline: copy.brand.tagline, url: copy.brand.url, + // Burn the spoken closer for sound-off / accessibility. + subtitle: copy.cues.outro, durationMs: 0, }, ], - tail: [{ kind: 'wait', durationMs: 1300 }], + tail: [{ kind: 'wait', durationMs: 1700 }], }, ]; } @@ -523,8 +526,10 @@ function buildVideoConfig(formFactor: FormFactor): VideoConfig { minDurationS: 10, maxDurationS: 75, // Right-pane inspection: London map, filters applied, data pane - // open — the clearest paused-state preview. - posterTimeS: 31, + // FULLY populated (Street View + price history). 31s caught the pane + // mid-load on prod (empty white + spinner); 35s lands a few seconds into + // the "open" cue once the evidence has rendered — the strongest preview. + posterTimeS: 35, }; } @@ -537,11 +542,13 @@ function createRecordingStoryboard( formFactor: FormFactor ): Storyboard { const copy = RECORDING_LOCALIZATIONS[locale]; - // Mobile bumps the initial zoom from 11.5 → 12 so the narrower 540-wide - // viewport still shows an inner-London slice densely populated with - // hexagons (otherwise the visible map gets dominated by the low-density - // outer edges with no matches). - const initialZoom = formFactor === 'mobile' ? 12 : 11.5; + // Both form factors open at zoom 12 so the cold-open frame lands on a + // densely-populated inner-London slice rather than the low-density outer + // edges. Desktop was 11.5, but the opening lean-in panned the visible map + // toward the sparse NW suburbs (Wembley/Ealing) — a soft first frame for + // the hero. 12 keeps central London (rich default-density colouring) in + // shot from t=0. + const initialZoom = 12; // On mobile the MobileBottomSheet covers the bottom ~44% of the // viewport, so the map's geographic centre sits roughly at the seam @@ -609,6 +616,9 @@ function createRecordingStoryboard( travelTimeDragFromMin: TT_DRAG_FROM_MIN, travelTimeDragToMin: TT_DRAG_TO_MIN, brand: copy.brand, + // A tight 7-filter inner-London search; a plausible total (not the + // shared stub default) so the on-screen count reads as real. + matchCount: 1843, }, pre: buildPre(), cues: createCues(locale, formFactor), @@ -665,6 +675,8 @@ interface DemoAdStoryboardConfig { travelTimeFilters?: TravelTimeFilter[]; posterTimeS?: number; initialZoom?: number; + /** Plausible per-city "{{count}} properties match" total for the AI summary. */ + matchCount?: number; /** Activities to run before the cue loop starts (silent — keep short). */ prePrime?: Activity[]; /** Spoken line during the outro card. Must NOT repeat the card's text. */ @@ -688,7 +700,7 @@ const AD_VIDEO: VideoConfig = { const AD_BRAND = { name: 'Perfect Postcode', - tagline: 'Search the area before you search the listings.', + tagline: 'Find the hidden-gem postcode.', url: BRAND_URL, }; @@ -825,6 +837,7 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard { travelTimeDragFromMin: TT_DRAG_FROM_MIN, travelTimeDragToMin: TT_DRAG_TO_MIN, brand: AD_BRAND, + matchCount: ad.matchCount, }, pre: ad.prePrime ?? AD_PRE, cues: [ @@ -844,10 +857,14 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard { brand: AD_BRAND.name, tagline: AD_BRAND.tagline, url: AD_BRAND.url, + // Burn the spoken closer onto the card so muted social viewers + // (the ads' real channel) still read the CTA. + subtitle: ad.outroLine, durationMs: 0, }, ], - tail: [wait(900)], + // Hold the brand card long enough to read the URL + closer with sound off. + tail: [wait(2200)], }, ], }; @@ -865,6 +882,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ // ------------------------------------------------------------------- { name: 'ad-01-say-it', + matchCount: 2410, city: 'london', promptText: 'Flat under £600k, 35 min to central London, low crime, quiet street', filters: { @@ -878,23 +896,25 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ ], initialZoom: 10.2, posterTimeS: 8, - outroLine: 'Your area is on this map. Go find it.', + outroLine: 'The underpriced twin is on this map. Find it.', cues: [ { - text: 'This is my whole house brief. One sentence.', - caption: 'One sentence. Every postcode.', + text: 'My whole house brief is one plain sentence.', + caption: "Reputation is priced in. Value isn't.", during: [ typeAct('Flat under £600k, 35 min to central London, low crime, quiet street', 2600), ], tail: [wait(150)], }, { - text: "And that's every postcode in England that fits it.", + text: 'Every postcode in England that fits, sorted by value.', + caption: 'All of England, by value', during: [submitSettled(1400)], tail: [sheetDown(800), wait(250)], }, { - text: 'Now I scroll a map, not nine hundred listings.', + text: 'Same schools, same commute, the price quietly drops nearby.', + caption: 'The cheaper twin nearby', during: [mapZoomIn(1400, 4)], tail: [wait(400)], }, @@ -907,6 +927,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ // ------------------------------------------------------------------- { name: 'ad-02-twenty-minute-map', + matchCount: 38600, city: 'london', promptText: 'Within an hour of central London', filters: {}, @@ -915,7 +936,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ ], initialZoom: 10.2, posterTimeS: 9, - outroLine: 'Check the commute before you fall in love.', + outroLine: 'The commute is priced in. The bargain is not.', prePrime: [ { kind: 'clearVignette', durationMs: 0 }, { kind: 'cursorScale', scale: 0, durationMs: 0 }, @@ -932,17 +953,19 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ cues: [ { text: 'Every colour on this map is a commute to central London.', - caption: 'The 20-minute map', + caption: 'Same commute, cheaper postcode', during: [wait(400)], tail: [wait(150)], }, { text: "Here's what twenty minutes actually leaves you.", + caption: 'What 20 minutes buys', during: [sheetUp(700), touchShow(), ttDragAct(20, 1700)], tail: [touchHide(), sheetDown(800), wait(300)], }, { - text: "If it's not lit, you'd live on the train. Now you know before you view.", + text: "Same twenty minutes wherever it's lit, but not the same price.", + caption: 'Same ride, lower price', during: [mapZoomIn(1300, 3)], tail: [wait(400)], }, @@ -955,6 +978,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ // ------------------------------------------------------------------- { name: 'ad-03-postcode-files', + matchCount: 1290, city: 'london', initialFilters: { 'Estimated current price': [0, 700000], @@ -962,7 +986,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ }, initialZoom: 10.6, posterTimeS: 11, - outroLine: "Read the area's file before you book the viewing.", + outroLine: 'Every postcode, proven on the numbers, not its reputation.', prePrime: [ { kind: 'clearVignette', durationMs: 0 }, { kind: 'cursorScale', scale: 0, durationMs: 0 }, @@ -971,8 +995,8 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ ], cues: [ { - text: 'Estate agents say, up and coming. The data is more specific.', - caption: 'Every postcode has a file', + text: 'One name costs more. The block next door scores the same.', + caption: 'See the proof, not the postcode', during: [ { // Zoom around the strongest visible match so the destination @@ -991,11 +1015,13 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ }, { text: 'Sold prices, schools, crime, even Street View, for this exact postcode.', + caption: 'The whole postcode file', during: [touchShow(), tapHex(1000), scrollDrawer(420, 850)], tail: [wait(200)], }, { - text: 'Thirty seconds here saves you a Saturday on the wrong street.', + text: 'The same evidence a pricey postcode has, sitting quietly cheaper here.', + caption: 'Same proof, less money', during: [scrollDrawer(820, 900), touchHide()], tail: [wait(350)], }, @@ -1007,6 +1033,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ // ------------------------------------------------------------------- { name: 'ad-04-quiet-streets', + matchCount: 6420, city: 'london', promptText: 'Quiet London street under 55 decibels, low crime', filters: { @@ -1015,21 +1042,23 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ }, initialZoom: 10.6, posterTimeS: 7, - outroLine: 'Quiet is searchable now.', + outroLine: 'The quiet street nobody bid up.', cues: [ { text: 'Listing photos are silent. Main roads are not.', - caption: "You can't hear a photo", + caption: "The quiet that's overlooked", during: [typeAct('Quiet London street under 55 decibels, low crime', 2400)], tail: [wait(150)], }, { text: 'This is London under fifty-five decibels.', + caption: 'Under 55 decibels', during: [submitSettled(1400)], tail: [sheetDown(800), wait(250)], }, { - text: "The quiet streets were always there. Now they're searchable.", + text: 'Nearby, just as quiet, and overlooked.', + caption: 'Quiet, and overlooked', during: [mapZoomIn(1600, 5)], tail: [wait(400)], }, @@ -1041,6 +1070,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ // ------------------------------------------------------------------- { name: 'ad-05-school-run', + matchCount: 3380, city: 'leeds', promptText: 'Family home in Leeds under £350k, good primary schools, low crime', filters: { @@ -1049,24 +1079,26 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ 'Serious crime (avg/yr)': [0, 30], }, initialZoom: 11.0, - posterTimeS: 7, - outroLine: 'Pick the area first. Then the house.', + posterTimeS: 13, + outroLine: 'The schools are real. The premium is optional.', cues: [ { - text: 'Good primary school, low crime, under three hundred and fifty. The actual family brief.', - caption: 'The school-run map', + text: 'Good primary, low crime, under three hundred and fifty. The actual family brief.', + caption: 'Catchment, minus the premium', during: [ typeAct('Family home in Leeds under £350k, good primary schools, low crime', 2800), ], tail: [wait(150)], }, { - text: 'Everything still on the map passes all three.', + text: 'Everything still on the map passes all three filters.', + caption: 'Passes all three', during: [submitSettled(1400)], tail: [sheetDown(800), wait(250)], }, { - text: 'No more falling for a house, then googling the catchment at midnight.', + text: 'Same primary, same low crime, without the name everyone else is bidding up.', + caption: 'Same catchment, less money', during: [mapZoomIn(1600, 5)], tail: [wait(400)], }, @@ -1079,6 +1111,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ // ------------------------------------------------------------------- { name: 'ad-06-waitrose-test', + matchCount: 880, city: 'london', promptText: 'Walking distance to a Waitrose, a tube station and a park', filters: { @@ -1088,21 +1121,23 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ }, initialZoom: 10.4, posterTimeS: 7, - outroLine: 'Filter for the life, not just the floor plan.', + outroLine: 'Same Waitrose. Same tube. Cheaper postcode.', cues: [ { - text: "Near a Waitrose, a tube stop and a park. Yes, that's a real search.", + text: 'A Waitrose, a tube stop, a park nearby. Search by what you want.', caption: 'The Waitrose test', during: [typeAct('Walking distance to a Waitrose, a tube station and a park', 2800)], tail: [wait(150)], }, { - text: 'London, cut down to the postcodes that live the way you do.', + text: 'London, narrowed to the postcodes that fit the way you live.', + caption: 'Fits how you live', during: [submitSettled(1400)], tail: [sheetDown(800), wait(250)], }, { - text: 'Snobby? Maybe. Efficient? Extremely.', + text: 'Same amenities, lower price nearby.', + caption: 'Same life, lower price', during: [mapZoomIn(1400, 4)], tail: [wait(400)], }, @@ -1114,6 +1149,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ // ------------------------------------------------------------------- { name: 'ad-07-renters-map', + matchCount: 2160, city: 'london', promptText: 'Rent under £1,600, 30 min to central London, quiet street', filters: { @@ -1124,22 +1160,24 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ { mode: 'transit', slug: 'london', label: 'Central London', max: 30 }, ], initialZoom: 10.3, - posterTimeS: 7, - outroLine: 'Know the postcode before the viewing queue.', + posterTimeS: 13, + outroLine: 'The name costs more. The street does not.', cues: [ { - text: 'Rent under sixteen hundred, half an hour to work, on a quiet street.', - caption: 'Renters get a map too', + text: 'Rent under sixteen hundred, half an hour to central, on a quiet street.', + caption: 'Stop renting the reputation', during: [typeAct('Rent under £1,600, 30 min to central London, quiet street', 2800)], tail: [wait(150)], }, { - text: 'Every letting site shows you flats. This shows you areas.', + text: 'Letting sites rank flats. This ranks the streets around them.', + caption: 'Areas, not just flats', during: [submitSettled(1400)], tail: [sheetDown(800), wait(250)], }, { - text: 'Turn up to the right viewings, and skip the rest.', + text: 'Same commute, same quiet, lower rent nearby.', + caption: 'Same street, lower rent', during: [mapZoomIn(1600, 5)], tail: [wait(400)], }, @@ -1147,10 +1185,11 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ }, // ------------------------------------------------------------------- - // 08 — Value. £9.99 against the real cost of a wasted viewing. + // 08 — Value. The cost of overpaying for a name vs the one-off fee. // ------------------------------------------------------------------- { - name: 'ad-08-cheap-insurance', + name: 'ad-08-value', + matchCount: 1540, city: 'london', promptText: 'Under £500k, 35 min to central London, low crime, good schools', filters: { @@ -1163,21 +1202,23 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ ], initialZoom: 10.4, posterTimeS: 7, - outroLine: 'Skip the wrong viewings.', + outroLine: 'Buy value, not reputation.', cues: [ { - text: 'A bad viewing costs a train ticket and half a weekend.', - caption: '£9.99 vs a wasted Saturday', + text: 'Two streets, same schools, same commute, priced tens of thousands apart.', + caption: 'Two streets. One is overpriced.', during: [typeAct('Under £500k, 35 min to central London, low crime, good schools', 2600)], tail: [wait(150)], }, { - text: 'This costs nine ninety-nine, and tells you where not to go.', + text: "You pay for the postcode's name; the value sits a postcode away.", + caption: 'You pay for the name', during: [submitSettled(1400)], tail: [sheetDown(800), wait(250)], }, { - text: 'Cheap insurance, for the biggest purchase of your life.', + text: 'Pay once, find the bargain, stop overpaying for the name.', + caption: 'Find the value instead', during: [mapZoomIn(1600, 5)], tail: [wait(400)], }, From fd2860070a7a0af5b7f55dd87cc7eea9166cf39c Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Mon, 22 Jun 2026 22:12:27 +0100 Subject: [PATCH 3/3] lgtm --- frontend/src/components/home/HomePage.tsx | 9 +- frontend/src/components/learn/LearnPage.tsx | 6 +- frontend/src/components/map/AreaPane.tsx | 198 +++++++++- .../src/components/map/DevelopmentPopup.tsx | 72 ++++ frontend/src/components/map/Filters.tsx | 5 + frontend/src/components/map/Map.tsx | 65 +++- frontend/src/components/map/MapPage.tsx | 9 +- .../src/components/map/NumberLine.test.ts | 83 +++++ frontend/src/components/map/NumberLine.tsx | 208 +++++++++++ .../src/components/map/OverlayTileLayers.tsx | 39 +- .../map/filters/ActiveFiltersPanel.tsx | 1 + frontend/src/hooks/useDeckLayers.ts | 16 +- frontend/src/hooks/useDevelopmentLayers.ts | 152 ++++++++ frontend/src/hooks/useDevelopments.ts | 81 +++++ frontend/src/hooks/usePoiLayers.test.ts | 41 +++ frontend/src/hooks/usePoiLayers.ts | 14 +- frontend/src/i18n/descriptions.ts | 77 ++++ frontend/src/i18n/details.ts | 96 +++++ frontend/src/i18n/locales/de.ts | 69 +++- frontend/src/i18n/locales/en.ts | 71 ++-- frontend/src/i18n/locales/fr.ts | 71 ++-- frontend/src/i18n/locales/hi.ts | 69 +++- frontend/src/i18n/locales/hu.ts | 69 +++- frontend/src/i18n/locales/zh.ts | 77 +++- frontend/src/lib/api.ts | 1 + frontend/src/lib/consts.ts | 38 ++ frontend/src/lib/map-utils.test.ts | 30 ++ frontend/src/lib/map-utils.ts | 30 ++ frontend/src/lib/overlays.ts | 9 + frontend/src/lib/qualification-filter.ts | 22 ++ frontend/src/types.ts | 45 +++ pipeline/download/census_population.py | 153 ++++++++ pipeline/download/development_sites.py | 302 +++++++++++++++ pipeline/download/education.py | 177 +++++++++ pipeline/download/tenure.py | 179 +++++++++ pipeline/download/test_development_sites.py | 165 +++++++++ pipeline/download/test_education.py | 107 ++++++ pipeline/download/test_tenure.py | 121 ++++++ pipeline/transform/crime_hotspot_tiles.py | 26 +- pipeline/transform/merge.py | 85 ++++- pipeline/transform/test_merge.py | 10 +- server-rs/src/data.rs | 6 + server-rs/src/data/area_crime_averages.rs | 47 +++ server-rs/src/data/developments.rs | 343 ++++++++++++++++++ server-rs/src/data/postcode_population.rs | 125 +++++++ server-rs/src/data/property/mod.rs | 105 ++++++ server-rs/src/features.rs | 132 +++++++ server-rs/src/main.rs | 58 +++ server-rs/src/routes.rs | 2 + server-rs/src/routes/developments.rs | 70 ++++ server-rs/src/routes/hexagon_stats.rs | 66 ++++ server-rs/src/routes/postcode_stats.rs | 14 + server-rs/src/routes/stats.rs | 142 +++++++- server-rs/src/state.rs | 21 +- server-rs/src/utils.rs | 41 +++ 55 files changed, 4084 insertions(+), 186 deletions(-) create mode 100644 frontend/src/components/map/DevelopmentPopup.tsx create mode 100644 frontend/src/components/map/NumberLine.test.ts create mode 100644 frontend/src/components/map/NumberLine.tsx create mode 100644 frontend/src/hooks/useDevelopmentLayers.ts create mode 100644 frontend/src/hooks/useDevelopments.ts create mode 100644 frontend/src/lib/qualification-filter.ts create mode 100644 pipeline/download/census_population.py create mode 100644 pipeline/download/development_sites.py create mode 100644 pipeline/download/education.py create mode 100644 pipeline/download/tenure.py create mode 100644 pipeline/download/test_development_sites.py create mode 100644 pipeline/download/test_education.py create mode 100644 pipeline/download/test_tenure.py create mode 100644 server-rs/src/data/area_crime_averages.rs create mode 100644 server-rs/src/data/developments.rs create mode 100644 server-rs/src/data/postcode_population.rs create mode 100644 server-rs/src/routes/developments.rs diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx index 8cdaf4a..70cc34d 100644 --- a/frontend/src/components/home/HomePage.tsx +++ b/frontend/src/components/home/HomePage.tsx @@ -146,7 +146,14 @@ function ProductDemoVideo() { onPlay={() => setIsVideoPlaying(true)} onPause={() => setIsVideoPlaying(false)} onEnded={() => setIsVideoPlaying(false)} - /> + > + + {!isVideoPlaying && (
+ +
+ )} {hoverPosition && hoveredHexagonId && hoveredHexagonId !== selectedHexagonId && ( 0 ? fetchedPois : EMPTY_POIS; const overlaysZoomedIn = (mapData.currentView?.zoom ?? 0) >= POSTCODE_ZOOM_THRESHOLD; const actualListingsFilterParam = useMemo( () => buildFilterString(filters, features), diff --git a/frontend/src/components/map/NumberLine.test.ts b/frontend/src/components/map/NumberLine.test.ts new file mode 100644 index 0000000..dd104f2 --- /dev/null +++ b/frontend/src/components/map/NumberLine.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { computeNumberLineLayout, type NumberLinePoint } from './NumberLine'; + +const fmt = (v: number) => v.toFixed(0); + +const points: NumberLinePoint[] = [ + { kind: 'area', label: 'This area', value: 50 }, + { kind: 'national', label: 'National', value: 30 }, + { kind: 'outcode', label: 'SE3', value: 49 }, + { kind: 'sector', label: 'SE3 9', value: 51 }, +]; + +describe('computeNumberLineLayout', () => { + it('returns null when there is nothing to draw', () => { + expect(computeNumberLineLayout(points, 0, fmt)).toBeNull(); + expect(computeNumberLineLayout([], 300, fmt)).toBeNull(); + }); + + it('places ticks ordered by value', () => { + const layout = computeNumberLineLayout(points, 300, fmt)!; + expect(layout).not.toBeNull(); + // Items are sorted by tick position, which mirrors value order. + const ticks = layout.items.map((i) => i.tickX); + for (let i = 1; i < ticks.length; i++) { + expect(ticks[i]).toBeGreaterThanOrEqual(ticks[i - 1]); + } + // The smallest value (national, 30) sits left of the largest (sector, 51). + const national = layout.items.find((i) => i.kind === 'national')!; + const sector = layout.items.find((i) => i.kind === 'sector')!; + expect(national.tickX).toBeLessThan(sector.tickX); + }); + + it('maps the lowest value to the left edge and the highest to the right', () => { + const layout = computeNumberLineLayout( + [ + { kind: 'national', label: 'N', value: 30 }, + { kind: 'area', label: 'A', value: 50 }, + { kind: 'sector', label: 'S', value: 45 }, + ], + 300, + fmt + )!; + const low = layout.items.find((i) => i.kind === 'national')!; // 30 — lowest + const high = layout.items.find((i) => i.kind === 'area')!; // 50 — highest + expect(low.tickX).toBeCloseTo(layout.plotLeft, 5); + expect(high.tickX).toBeCloseTo(layout.plotRight, 5); + }); + + it('centres ticks when all values are equal', () => { + const layout = computeNumberLineLayout( + [ + { kind: 'area', label: 'A', value: 7 }, + { kind: 'national', label: 'N', value: 7 }, + ], + 300, + fmt + )!; + const centre = (layout.plotLeft + layout.plotRight) / 2; + for (const item of layout.items) { + expect(item.tickX).toBeCloseTo(centre, 5); + } + }); + + it('spreads clustered labels so their boxes never overlap', () => { + const layout = computeNumberLineLayout(points, 300, fmt)!; + for (let i = 1; i < layout.items.length; i++) { + const prev = layout.items[i - 1]; + const cur = layout.items[i]; + // Boxes [labelX ± halfWidth] must not overlap (GAP only adds more margin). + expect(cur.labelX - cur.halfWidth).toBeGreaterThanOrEqual( + prev.labelX + prev.halfWidth - 1e-6 + ); + } + }); + + it('keeps labels within the plot bounds', () => { + const layout = computeNumberLineLayout(points, 300, fmt)!; + for (const item of layout.items) { + expect(item.labelX - item.halfWidth).toBeGreaterThanOrEqual(layout.plotLeft - 1e-6); + expect(item.labelX + item.halfWidth).toBeLessThanOrEqual(layout.plotRight + 1e-6); + } + }); +}); diff --git a/frontend/src/components/map/NumberLine.tsx b/frontend/src/components/map/NumberLine.tsx new file mode 100644 index 0000000..aac6ad5 --- /dev/null +++ b/frontend/src/components/map/NumberLine.tsx @@ -0,0 +1,208 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +export type NumberLineKind = 'area' | 'national' | 'outcode' | 'sector'; + +export interface NumberLinePoint { + kind: NumberLineKind; + /** Short label shown above the tick (e.g. "This area", "National", "SE3", "SE3 9"). */ + label: string; + value: number; +} + +interface NumberLineProps { + points: NumberLinePoint[]; + format: (value: number) => string; +} + +const HEIGHT = 54; +const PAD_X = 4; +const NAME_Y = 9; +const VALUE_Y = 19; +const LEADER_TOP = 23; +const BASE_Y = 40; +const GAP = 4; + +// Per-kind tick (stroke) and label (fill) colours. `area` is the subject and +// is emphasised; the rest are reference points. +const KIND_STYLE: Record = { + area: { tick: 'stroke-teal-600 dark:stroke-teal-400', text: 'fill-teal-700 dark:fill-teal-300' }, + national: { + tick: 'stroke-warm-400 dark:stroke-warm-500', + text: 'fill-warm-500 dark:fill-warm-300', + }, + outcode: { + tick: 'stroke-amber-500 dark:stroke-amber-400', + text: 'fill-amber-700 dark:fill-amber-400', + }, + sector: { + tick: 'stroke-indigo-500 dark:stroke-indigo-400', + text: 'fill-indigo-600 dark:fill-indigo-300', + }, +}; + +export interface NumberLineLayoutItem extends NumberLinePoint { + valueText: string; + /** x of the tick (the true value position on the scale). */ + tickX: number; + /** x of the (de-collided) label centre. */ + labelX: number; + /** half the estimated label-box width, used for collision spacing. */ + halfWidth: number; +} + +export interface NumberLineLayout { + items: NumberLineLayoutItem[]; + plotLeft: number; + plotRight: number; +} + +/** + * Pure layout: place each value's tick on a scale spanning the lowest→highest + * value (not anchored at 0, to maximise separation) and spread the labels so + * their boxes never overlap (nested area/sector/outcode values tend to cluster), + * clamped within the plot bounds while preserving order. Returns null when there + * is nothing to draw. Exported for testing. + */ +export function computeNumberLineLayout( + points: NumberLinePoint[], + width: number, + format: (value: number) => string +): NumberLineLayout | null { + if (width <= 0 || points.length === 0) return null; + + const values = points.map((p) => p.value); + const lo = Math.min(...values); + const hi = Math.max(...values); + const span = hi - lo; + const plotLeft = PAD_X; + const plotRight = width - PAD_X; + const plotW = Math.max(1, plotRight - plotLeft); + // Scale spans the lowest→highest value (not anchored at 0) to maximise the + // separation between the clustered ticks. When all values are equal, centre them. + const scaleX = (value: number) => + span > 0 ? plotLeft + ((value - lo) / span) * plotW : plotLeft + plotW / 2; + + const items: NumberLineLayoutItem[] = points.map((point) => { + const valueText = format(point.value); + // Rough text-width estimate at the 9px font (≈5px/char), padded a little. + const chars = Math.max(point.label.length, valueText.length); + const halfWidth = Math.max(9, (chars * 5) / 2 + 2); + const tickX = scaleX(point.value); + return { ...point, valueText, tickX, halfWidth, labelX: tickX }; + }); + + // Spread labels left-to-right so adjacent boxes never overlap, then clamp to + // the plot bounds (right pass, then left pass) preserving order. + items.sort((a, b) => a.tickX - b.tickX); + for (let i = 1; i < items.length; i++) { + const minX = items[i - 1].labelX + items[i - 1].halfWidth + GAP + items[i].halfWidth; + if (items[i].labelX < minX) items[i].labelX = minX; + } + const last = items.length - 1; + if (last >= 0) { + items[last].labelX = Math.min(items[last].labelX, plotRight - items[last].halfWidth); + for (let i = last - 1; i >= 0; i--) { + const maxX = items[i + 1].labelX - items[i + 1].halfWidth - GAP - items[i].halfWidth; + if (items[i].labelX > maxX) items[i].labelX = maxX; + } + items[0].labelX = Math.max(items[0].labelX, plotLeft + items[0].halfWidth); + for (let i = 1; i < items.length; i++) { + const minX = items[i - 1].labelX + items[i - 1].halfWidth + GAP + items[i].halfWidth; + if (items[i].labelX < minX) items[i].labelX = minX; + } + } + + return { items, plotLeft, plotRight }; +} + +/** + * A compact horizontal number line that places one tick per value on a scale + * spanning the lowest→highest value, so the selection can be read against its + * reference points (national / outcode / sector) at a glance. Labels are spread + * to avoid overlap + * — common since the nested area/sector/outcode values cluster — and connected + * back to their tick with a thin leader line. + */ +export default function NumberLine({ points, format }: NumberLineProps) { + const containerRef = useRef(null); + const [width, setWidth] = useState(0); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const observer = new ResizeObserver((entries) => { + const w = entries[0].contentRect.width; + if (w > 0) setWidth(w); + }); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + const laidOut = useMemo( + () => computeNumberLineLayout(points, width, format), + [points, width, format] + ); + + return ( +
+ {laidOut && ( + + + {laidOut.items.map((item) => { + const style = KIND_STYLE[item.kind]; + const emphasized = item.kind === 'area'; + const halfTick = emphasized ? 7 : 5; + return ( + + + + + {item.label} + + + {item.valueText} + + {`${item.label}: ${item.valueText}`} + + ); + })} + + )} +
+ ); +} diff --git a/frontend/src/components/map/OverlayTileLayers.tsx b/frontend/src/components/map/OverlayTileLayers.tsx index 8ca2ff3..673b953 100644 --- a/frontend/src/components/map/OverlayTileLayers.tsx +++ b/frontend/src/components/map/OverlayTileLayers.tsx @@ -79,8 +79,43 @@ export function OverlayTileLayers({ 10, 1, ], - 'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 15, 0.8, 18, 2.2], - 'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 15, 18, 18, 30], + // police.uk snaps incidents to a sparse, fixed set of anonymised + // "map point" anchors (tens-to-hundreds of metres apart), so a + // pixel-fixed blur fragments into isolated dots as you zoom in + // (the ground a pixel covers halves each zoom level). Grow the + // radius ~geometrically with zoom to hold a roughly constant + // ~140m ground footprint, so neighbouring anchors' kernels keep + // overlapping into a connected surface — kernel-density smoothing + // is the honest form of "interpolating between points" for this + // data; we deliberately do NOT triangulate/IDW, which would + // invent crime values across parks/water and over-claim a + // precision the anonymised snap-points don't have. Cap past z16 so + // the blur doesn't saturate the whole screen at street zoom (where + // an honestly-connected crime surface isn't achievable anyway). + // Intensity is lowered to match: wider overlapping kernels pile up + // density, so the old isolated-dot boost would now go all-red. + 'heatmap-intensity': [ + 'interpolate', + ['linear'], + ['zoom'], + 14, + 0.6, + 16, + 0.9, + 18, + 1.2, + ], + 'heatmap-radius': [ + 'interpolate', + ['exponential', 2], + ['zoom'], + 14, + 24, + 16, + 94, + 18, + 120, + ], 'heatmap-opacity': 0.72, 'heatmap-color': [ 'interpolate', diff --git a/frontend/src/components/map/filters/ActiveFiltersPanel.tsx b/frontend/src/components/map/filters/ActiveFiltersPanel.tsx index 3556b0d..ea38d66 100644 --- a/frontend/src/components/map/filters/ActiveFiltersPanel.tsx +++ b/frontend/src/components/map/filters/ActiveFiltersPanel.tsx @@ -170,6 +170,7 @@ export function ActiveFiltersPanel({