Compare commits

...

5 commits

Author SHA1 Message Date
44b4e0d72f fixes
Some checks failed
CI / Check (push) Failing after 2m0s
Build and publish Docker image / build-and-push (push) Successful in 5m22s
2026-06-05 23:24:45 +01:00
3fd64785a2 group by outcode 2026-06-05 17:42:05 +01:00
70ac7b95e6 Fix flyto 2026-06-05 17:41:55 +01:00
32e0dc7395 add new type 2026-06-05 10:31:00 +01:00
44aca07974 Fix title wrapping 2026-06-05 10:30:56 +01:00
7 changed files with 239 additions and 71 deletions

View file

@ -11,7 +11,7 @@ services:
command: >
bash -c "
cargo install cargo-watch &&
cargo watch --poll -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'
cargo watch --poll -i logs/ -x 'run -- --properties /app/property-data4/properties.parquet --postcode-features /app/property-data4/postcode.parquet --pois /app/property-data4/filtered_uk_pois.parquet --places /app/property-data4/places.parquet --tiles /app/property-data4/uk.pmtiles --postcodes /app/property-data4/postcode_boundaries --travel-times /app/property-data4/travel-times'
"
ports:
- "8001:8001"
@ -29,8 +29,6 @@ services:
- .:/app
- cargo-home:/usr/local/cargo
- cargo-target:/app/server-rs/target
- ./property-data:/app/data:ro
- ./finder/data:/app/finder-data:ro
environment:
# Fallback only — the binary uses jemalloc as its global allocator
# (tuned via a baked-in malloc_conf). Caps glibc to 2 arenas.
@ -53,8 +51,8 @@ services:
BUGSINK_ENVIRONMENT: ${BUGSINK_ENVIRONMENT:-development}
BUGSINK_RELEASE: ${BUGSINK_RELEASE:-}
BUGSINK_SEND_DEFAULT_PII: ${BUGSINK_SEND_DEFAULT_PII:-false}
ACTUAL_LISTINGS_PATH: /app/finder-data/online_listings_buy_enriched.parquet
CRIME_BY_YEAR_PATH: /app/data/crime_by_postcode_by_year.parquet
ACTUAL_LISTINGS_PATH: /app/finder/data/online_listings_buy_enriched.parquet
CRIME_BY_YEAR_PATH: /app/property-data4/crime_by_postcode_by_year.parquet
depends_on:
screenshot:
condition: service_healthy

View file

@ -146,6 +146,12 @@ export default function LocationSearch({
if (result.type === 'place') {
const zoom = ZOOM_FOR_TYPE[result.place_type] ?? 14;
const flyZoom = result.place_type === 'outcode' ? POSTCODE_SEARCH_ZOOM : zoom;
// Move the camera straight away using the coordinates already in the
// search result. The nearest-postcode lookup below only feeds the side
// panel, so gating the fly on it (and on isCurrentLookup) made the jump
// intermittent whenever that request was slow, failed, or was superseded
// by another keystroke/selection.
if (!isMobile) onFlyTo(result.lat, result.lon, flyZoom);
try {
const params = new URLSearchParams({
lat: String(result.lat),
@ -172,7 +178,6 @@ export default function LocationSearch({
markerLatitude: result.lat,
markerLongitude: result.lon,
});
if (!isMobile) onFlyTo(result.lat, result.lon, flyZoom);
search.saveRecentSearch(result);
search.clear();
if (isMobile) setExpanded(false);
@ -189,6 +194,10 @@ export default function LocationSearch({
}
if (result.type === 'address') {
// Fly from the result's own coordinates immediately; the postcode fetch
// below only resolves the geometry for the side panel. See the note in
// the place branch above.
if (!isMobile) onFlyTo(result.lat, result.lon, 17);
try {
const res = await fetch(
`/api/postcode/${encodeURIComponent(result.postcode)}`,
@ -201,7 +210,6 @@ export default function LocationSearch({
}
const json: PostcodeLookupResponse = await res.json();
if (!isCurrentLookup(requestId, controller)) return;
if (!isMobile) onFlyTo(result.lat, result.lon, 17);
onLocationSearched?.({
postcode: json.postcode,
geometry: json.geometry,
@ -228,7 +236,9 @@ export default function LocationSearch({
return;
}
// Postcode — fetch geometry
// Postcode — fetch geometry. Unlike place/address results, a postcode
// result carries no coordinates, so the camera move genuinely depends on
// this response and stays gated by isCurrentLookup.
try {
const res = await fetch(
`/api/postcode/${encodeURIComponent(result.label)}`,

View file

@ -895,22 +895,36 @@ export default memo(function Map({
const handleFlyTo = useCallback(
(lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => {
setInternalViewState((prev) => {
const targetPoint =
getViewportRelativeVisibleAreaCenter(dimensions, containerRef.current, options) ??
getMapRelativeVisibleAreaCenter(dimensions, options);
const center = getMapCenterForTargetScreenPoint(
lat,
lng,
zoom,
dimensions.width,
dimensions.height,
targetPoint.x,
targetPoint.y
);
const targetPoint =
getViewportRelativeVisibleAreaCenter(dimensions, containerRef.current, options) ??
getMapRelativeVisibleAreaCenter(dimensions, options);
const center = getMapCenterForTargetScreenPoint(
lat,
lng,
zoom,
dimensions.width,
dimensions.height,
targetPoint.x,
targetPoint.y
);
return { ...prev, ...center, zoom };
});
// Drive the camera imperatively rather than only through the controlled
// `viewState` prop. In controlled mode react-map-gl silently DROPS view
// state updates while the map is mid-movement — _updateViewState writes to
// the real transform only `if (!map.isMoving())`. So a fly issued right
// after a scroll-zoom or pan, while inertia is still settling, was being
// ignored, which is why the jump/zoom only landed sometimes. stop() cancels
// any in-flight animation/inertia and jumpTo then applies unconditionally;
// its move events sync `internalViewState` back through handleMove.
const map = mapRef.current;
if (map) {
map.stop();
map.jumpTo({ center: [center.longitude, center.latitude], zoom });
} else {
// Map not mounted yet (e.g. an initial deep-link selection before load):
// seed the controlled state so it applies once react-map-gl initialises.
setInternalViewState((prev) => ({ ...prev, ...center, zoom }));
}
},
[dimensions]
);

View file

@ -231,7 +231,7 @@ export default function Header({
onClick={(e) => navLink('home', e)}
>
<LogoIcon className="w-5 h-5 shrink-0 text-teal-400" />
<span className="max-w-[9rem] truncate whitespace-nowrap text-lg font-semibold text-teal-300 sm:max-w-none">
<span className="truncate whitespace-nowrap text-base font-semibold text-teal-300 sm:text-lg">
{t('header.appName')}
</span>
</a>

View file

@ -501,6 +501,7 @@ _CATEGORIES: list[tuple[str, str, str, list[str]]] = [
[
"shop/furniture",
"shop/garden_centre",
"shop/garden_machinery",
"shop/kitchen",
"shop/bathroom",
"shop/bathroom_furnishing",

View file

@ -114,6 +114,92 @@ impl PostcodeExportAgg {
self.finite_counts[out_idx] += 1;
}
}
/// Fold another postcode's aggregate into this one (used to roll up an
/// outcode summary from its member postcodes).
fn merge_from(&mut self, other: &PostcodeExportAgg) {
self.count += other.count;
for i in 0..self.sums.len() {
self.sums[i] += other.sums[i];
self.finite_counts[i] += other.finite_counts[i];
}
for (&feat_idx, freqs) in &other.enum_freqs {
let entry = self.enum_freqs.entry(feat_idx).or_default();
for (&bits, &count) in freqs {
*entry.entry(bits).or_insert(0) += count;
}
}
}
}
/// The outcode (first part) of a UK postcode, e.g. "E14" from "E14 2DG".
fn outcode_of(postcode: &str) -> &str {
match postcode.find(' ') {
Some(space_idx) => &postcode[..space_idx],
None => postcode,
}
}
/// A set of postcodes sharing the same outcode, with a rolled-up aggregate for
/// the group's summary row. `members` indexes into the flat `postcode_aggs`.
struct OutcodeGroup {
outcode: String,
members: Vec<usize>,
summary: PostcodeExportAgg,
}
/// Write the per-feature cells (numeric mean or enum mode) for a single row,
/// shared between outcode summary rows and individual postcode rows.
#[allow(clippy::too_many_arguments)]
fn write_feature_cells(
sheet: &mut rust_xlsxwriter::Worksheet,
row: u32,
feat_indices: &[usize],
agg: &PostcodeExportAgg,
num_features: usize,
enum_indices: &FxHashMap<usize, ()>,
enum_values: &FxHashMap<usize, Vec<String>>,
integer_feature_indices: &FxHashSet<usize>,
feat_num_fmts: &FxHashMap<usize, Format>,
) -> Result<(), String> {
for (col_offset, &feat_idx) in feat_indices.iter().enumerate() {
let col = (col_offset + 2) as u16;
if feat_idx < num_features && enum_indices.contains_key(&feat_idx) {
if let Some(freqs) = agg.enum_freqs.get(&feat_idx) {
if let Some((&mode_bits, _)) = freqs.iter().max_by_key(|(_, &count)| count) {
let mode_f32 = f32::from_bits(mode_bits);
let mode_idx = mode_f32 as usize;
if let Some(values) = enum_values.get(&feat_idx) {
if mode_idx < values.len() {
sheet
.write_string(row, col, &values[mode_idx])
.map_err(|e| format!("Failed to write enum value: {e}"))?;
}
}
}
}
} else {
let fc = agg.finite_counts[feat_idx];
if fc > 0 {
let mean = if integer_feature_indices.contains(&feat_idx) {
(agg.sums[feat_idx] / fc as f64).round()
} else {
(agg.sums[feat_idx] / fc as f64 * 100.0).round() / 100.0
};
if let Some(fmt) = feat_num_fmts.get(&feat_idx) {
sheet
.write_number_with_format(row, col, mean, fmt)
.map_err(|e| format!("Failed to write numeric value: {e}"))?;
} else {
sheet
.write_number(row, col, mean)
.map_err(|e| format!("Failed to write numeric value: {e}"))?;
}
}
}
}
Ok(())
}
/// Extract feature names referenced in the filters param (preserving order).
@ -689,6 +775,40 @@ pub async fn get_export(
}
}
// Group the postcodes by outcode, preserving the existing relevance order
// (property-count-desc in bounds mode, input order in list mode) for the
// groups themselves; postcodes within a group are sorted alphabetically.
// Each group carries a rolled-up summary aggregate for its header row.
let outcode_groups: Vec<OutcodeGroup> = {
let mut order: Vec<String> = Vec::new();
let mut by_outcode: FxHashMap<String, OutcodeGroup> = FxHashMap::default();
for (i, (pc_idx, agg)) in postcode_aggs.iter().enumerate() {
let outcode = outcode_of(&postcode_data.postcodes[*pc_idx]).to_string();
let group = by_outcode.entry(outcode.clone()).or_insert_with(|| {
order.push(outcode.clone());
OutcodeGroup {
outcode: outcode.clone(),
members: Vec::new(),
summary: PostcodeExportAgg::new(total_export_features),
}
});
group.members.push(i);
group.summary.merge_from(agg);
}
for group in by_outcode.values_mut() {
group
.members
.sort_by(|&a, &b| {
postcode_data.postcodes[postcode_aggs[a].0]
.cmp(&postcode_data.postcodes[postcode_aggs[b].0])
});
}
order
.into_iter()
.map(|outcode| by_outcode.remove(&outcode).unwrap())
.collect()
};
// Build Excel workbook with two sheets
let mut workbook = Workbook::new();
@ -715,6 +835,10 @@ pub async fn get_export(
.set_font_color("#666666")
.set_align(FormatAlign::Left);
// Outcode summary-row formats (the collapsible group header).
let group_label_fmt = Format::new().set_bold().set_font_color("#1F4E79");
let group_count_fmt = Format::new().set_bold();
// Dashboard URL
let dashboard_url = format!(
"{}/dashboard?{}",
@ -821,63 +945,75 @@ pub async fn get_export(
.map_err(|e| format!("Failed to write desc: {e}"))?;
}
// Data rows
// Put the collapse/expand controls above each group so the bold
// outcode summary row acts as the header for its postcodes.
sheet.group_symbols_above(true);
// Data rows — one bold outcode summary row followed by its postcodes,
// the latter wrapped in a collapsible outline group.
let data_start_row = desc_row + 1;
for (row_offset, (pc_idx, agg)) in postcode_aggs.iter().enumerate() {
let row = data_start_row + row_offset as u32;
let mut row = data_start_row;
for group in &outcode_groups {
// Outcode summary row (rolled up from the member postcodes).
let summary_row = row;
sheet
.write_string(row, 0, &postcode_data.postcodes[*pc_idx])
.map_err(|e| format!("Failed to write postcode: {e}"))?;
.write_string_with_format(summary_row, 0, &group.outcode, &group_label_fmt)
.map_err(|e| format!("Failed to write outcode: {e}"))?;
sheet
.write_number(row, 1, agg.count as f64)
.map_err(|e| format!("Failed to write count: {e}"))?;
.write_number_with_format(
summary_row,
1,
group.summary.count as f64,
&group_count_fmt,
)
.map_err(|e| format!("Failed to write outcode count: {e}"))?;
write_feature_cells(
sheet,
summary_row,
feat_indices,
&group.summary,
num_features,
&enum_indices,
enum_values,
&integer_feature_indices,
&feat_num_fmts,
)?;
row += 1;
for (col_offset, &feat_idx) in feat_indices.iter().enumerate() {
let col = (col_offset + 2) as u16;
// Individual postcode rows for this outcode.
let first_detail_row = row;
for &member in &group.members {
let (pc_idx, agg) = &postcode_aggs[member];
sheet
.write_string(row, 0, &postcode_data.postcodes[*pc_idx])
.map_err(|e| format!("Failed to write postcode: {e}"))?;
sheet
.write_number(row, 1, agg.count as f64)
.map_err(|e| format!("Failed to write count: {e}"))?;
write_feature_cells(
sheet,
row,
feat_indices,
agg,
num_features,
&enum_indices,
enum_values,
&integer_feature_indices,
&feat_num_fmts,
)?;
row += 1;
}
if feat_idx < num_features && enum_indices.contains_key(&feat_idx) {
if let Some(freqs) = agg.enum_freqs.get(&feat_idx) {
if let Some((&mode_bits, _)) =
freqs.iter().max_by_key(|(_, &count)| count)
{
let mode_f32 = f32::from_bits(mode_bits);
let mode_idx = mode_f32 as usize;
if let Some(values) = enum_values.get(&feat_idx) {
if mode_idx < values.len() {
sheet.write_string(row, col, &values[mode_idx]).map_err(
|e| format!("Failed to write enum value: {e}"),
)?;
}
}
}
}
} else {
let fc = agg.finite_counts[feat_idx];
if fc > 0 {
let mean = if integer_feature_indices.contains(&feat_idx) {
(agg.sums[feat_idx] / fc as f64).round()
} else {
(agg.sums[feat_idx] / fc as f64 * 100.0).round() / 100.0
};
if let Some(fmt) = feat_num_fmts.get(&feat_idx) {
sheet
.write_number_with_format(row, col, mean, fmt)
.map_err(|e| format!("Failed to write numeric value: {e}"))?;
} else {
sheet
.write_number(row, col, mean)
.map_err(|e| format!("Failed to write numeric value: {e}"))?;
}
}
}
if row > first_detail_row {
sheet
.group_rows(first_detail_row, row - 1)
.map_err(|e| format!("Failed to group rows: {e}"))?;
}
}
// Sample note
if was_sampled {
let note_row = data_start_row + postcode_aggs.len() as u32 + 1;
let note_row = row + 1;
let total_cols = (feat_indices.len() + 2) as u16;
sheet
.merge_range(
@ -985,6 +1121,15 @@ mod tests {
);
}
#[test]
fn outcode_of_splits_on_the_incode_space() {
assert_eq!(outcode_of("E14 2DG"), "E14");
assert_eq!(outcode_of("SW1A 1AA"), "SW1A");
assert_eq!(outcode_of("M1 1AE"), "M1");
// Defensive: a value with no space is treated as its own outcode.
assert_eq!(outcode_of("E14"), "E14");
}
#[test]
fn export_query_deserializes_when_tt_is_a_single_string() {
let uri: Uri = "/api/export?bounds=1,2,3,4&tt=transit%3Abank%3ABank%2520station%3A0%3A52"