Compare commits
5 commits
0a8cf84365
...
44b4e0d72f
| Author | SHA1 | Date | |
|---|---|---|---|
| 44b4e0d72f | |||
| 3fd64785a2 | |||
| 70ac7b95e6 | |||
| 32e0dc7395 | |||
| 44aca07974 |
7 changed files with 239 additions and 71 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}`,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue