+
}>
+ );
+}
diff --git a/frontend/src/hooks/useMapData.test.ts b/frontend/src/hooks/useMapData.test.ts
index 5364ae3..51f7c63 100644
--- a/frontend/src/hooks/useMapData.test.ts
+++ b/frontend/src/hooks/useMapData.test.ts
@@ -194,6 +194,82 @@ describe('useMapData', () => {
expect(result.current.colorRange?.[1]).toBeCloseTo(95);
});
+ it('does not use metadata min/max while slider preview colour data is loading', async () => {
+ const bounds = { south: 1, west: 1, north: 2, east: 2 };
+ const features: FeatureMeta[] = [
+ {
+ name: 'price',
+ type: 'numeric',
+ min: 0,
+ max: 100,
+ },
+ ];
+ const filters = { price: [20, 80] as [number, number] };
+
+ const { result, rerender } = renderHook(
+ ({
+ viewFeature,
+ activeFeature,
+ }: {
+ viewFeature: string | null;
+ activeFeature: string | null;
+ }) =>
+ useMapData({
+ filters,
+ features,
+ viewFeature,
+ activeFeature,
+ pinnedFeature: null,
+ travelTimeEntries: noTravelTimeEntries,
+ }),
+ {
+ initialProps: {
+ viewFeature: null as string | null,
+ activeFeature: null as string | null,
+ },
+ }
+ );
+
+ await act(async () => {
+ result.current.handleViewChange(viewChange(bounds));
+ });
+ await act(async () => {
+ vi.advanceTimersByTime(150);
+ });
+ await act(async () => {
+ requests[0].resolve(
+ response([
+ { h3: 'density-low', count: 1, lat: 1.25, lon: 1.25 },
+ { h3: 'density-high', count: 1, lat: 1.75, lon: 1.75 },
+ ])
+ );
+ await flushPromises();
+ });
+
+ await act(async () => {
+ rerender({
+ viewFeature: 'price',
+ activeFeature: 'price',
+ });
+ await flushPromises();
+ });
+
+ expect(result.current.colorRange).toBeNull();
+
+ await act(async () => {
+ requests[1].resolve(
+ response([
+ { h3: 'preview-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 },
+ { h3: 'preview-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 100 },
+ ])
+ );
+ await flushPromises();
+ });
+
+ expect(result.current.colorRange?.[0]).toBeCloseTo(5);
+ expect(result.current.colorRange?.[1]).toBeCloseTo(95);
+ });
+
it('does not reuse cached drag preview data when the drag request changes', async () => {
const bounds = { south: 1, west: 1, north: 2, east: 2 };
const features: FeatureMeta[] = [
diff --git a/frontend/src/hooks/useMapData.ts b/frontend/src/hooks/useMapData.ts
index b6f5b03..912b7a0 100644
--- a/frontend/src/hooks/useMapData.ts
+++ b/frontend/src/hooks/useMapData.ts
@@ -516,9 +516,19 @@ export function useMapData({
return [0, meta.values.length - 1];
}
if (dataRange) return dataRange;
+ if (activeFeature && !hasMatchingDragData) return null;
+ if (loadedDataKey !== dataRequestKey) return null;
if (meta.min != null && meta.max != null) return [meta.min, meta.max];
return null;
- }, [dataViewFeature, features, dataRange]);
+ }, [
+ activeFeature,
+ dataRequestKey,
+ dataRange,
+ dataViewFeature,
+ features,
+ hasMatchingDragData,
+ loadedDataKey,
+ ]);
const isEyePreviewingPinnedFeature =
!activeFeature && dataViewFeature != null && dataViewFeature === pinnedDataViewFeature;
@@ -642,13 +652,19 @@ export function useMapData({
[]
);
+ // Treat the map as loading whenever the rendered hexagons don't match the
+ // current request — covers the brief window between a slider release and
+ // the main fetch effect actually firing setLoading(true).
+ const isLoading =
+ loading || (bounds != null && !licenseRequired && loadedDataKey !== dataRequestKey);
+
return {
data,
committedHexagonData: rawData,
postcodeData: effectivePostcodeData,
resolution,
bounds,
- loading,
+ loading: isLoading,
zoom,
currentView,
currentVisibleView,
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index dc53a27..c008480 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -806,7 +806,7 @@ const en = {
lowerMinTo: 'Lower minimum to {{value}}',
raiseMaxTo: 'Raise maximum to {{value}}',
allowCategory: 'Allow {{value}}',
- missingFilterValue: 'No value for this filter; remove it or allow missing values',
+ missingFilterValue: 'No value for this filter; remove it',
noFilterDataShort: 'No data',
travelTo: 'Travel to {{destination}}',
viewProperties: 'View {{count}} Properties',
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
index ecdbe57..6f30390 100644
--- a/frontend/tailwind.config.js
+++ b/frontend/tailwind.config.js
@@ -55,9 +55,14 @@ module.exports = {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
+ 'indeterminate-progress': {
+ '0%': { transform: 'translateX(-100%)' },
+ '100%': { transform: 'translateX(400%)' },
+ },
},
animation: {
'fade-in': 'fade-in 0.2s ease-out forwards',
+ 'indeterminate-progress': 'indeterminate-progress 1.1s ease-in-out infinite',
},
},
},
diff --git a/r5-java/run.sh b/r5-java/run.sh
index da85bd4..9cd17e2 100755
--- a/r5-java/run.sh
+++ b/r5-java/run.sh
@@ -21,8 +21,8 @@ set -euo pipefail
# --demo only compute Bank + TCR, transit only (quick test)
# --- Defaults ---
-THREADS=12
-HEAP=48g
+THREADS=6
+HEAP=40g
NETWORK_DIR=property-data/r5-network
OUTPUT_BASE=property-data/travel-times
R5_DIR=r5-java
diff --git a/server-rs/src/consts.rs b/server-rs/src/consts.rs
index 99fe867..a95dd2a 100644
--- a/server-rs/src/consts.rs
+++ b/server-rs/src/consts.rs
@@ -10,7 +10,8 @@ pub const H3_REQUEST_MAX: u8 = 12;
pub const SERVER_ADDRESS: &str = "0.0.0.0:8001";
pub const GRID_CELL_SIZE: f32 = 0.01;
-pub const MAX_POIS_PER_REQUEST: usize = 10000;
+pub const MAX_CELLS_PER_REQUEST: usize = 200000;
+pub const MAX_POIS_PER_REQUEST: usize = 3000;
pub const DEFAULT_PROPERTIES_LIMIT: usize = 100;
pub const MAX_PRICE_HISTORY_POINTS: usize = 5000;
diff --git a/server-rs/src/main.rs b/server-rs/src/main.rs
index 01e8552..7f28e29 100644
--- a/server-rs/src/main.rs
+++ b/server-rs/src/main.rs
@@ -216,14 +216,6 @@ struct Cli {
#[arg(long, env = "STRIPE_REFERRAL_COUPON_ID")]
stripe_referral_coupon_id: String,
- /// Bearer token required to scrape /metrics.
- #[arg(long, env = "METRICS_BEARER_TOKEN")]
- metrics_bearer_token: Option
,
-
- /// Allow unauthenticated /metrics scraping when no METRICS_BEARER_TOKEN is set.
- #[arg(long, env = "ALLOW_PUBLIC_METRICS", default_value_t = false)]
- allow_public_metrics: bool,
-
/// Google OAuth client ID for PocketBase SSO
#[arg(long, env = "GOOGLE_OAUTH_CLIENT_ID")]
google_oauth_client_id: String,
@@ -255,8 +247,6 @@ async fn main() -> anyhow::Result<()> {
info!("Prometheus metrics initialized");
let cli = Cli::parse();
- let metrics_bearer_token = cli.metrics_bearer_token.clone();
- let allow_public_metrics = cli.allow_public_metrics;
for (label, path) in [
("Properties", &cli.properties),
@@ -680,13 +670,8 @@ async fn main() -> anyhow::Result<()> {
.route("/health", get(|| async { "ok" }))
.route(
"/metrics",
- get(move |headers| {
- metrics::metrics_handler(
- metrics_handle.clone(),
- metrics_bearer_token.clone(),
- allow_public_metrics,
- headers,
- )
+ get(move |connect_info| {
+ metrics::metrics_handler(metrics_handle.clone(), connect_info)
}),
)
.with_state(shared.clone());
@@ -732,6 +717,11 @@ async fn main() -> anyhow::Result<()> {
.await
.with_context(|| format!("Failed to bind to {addr}"))?;
info!("Server listening on {}", addr);
- axum::serve(listener, app).await.context("Server error")?;
+ axum::serve(
+ listener,
+ app.into_make_service_with_connect_info::(),
+ )
+ .await
+ .context("Server error")?;
Ok(())
}
diff --git a/server-rs/src/metrics.rs b/server-rs/src/metrics.rs
index 156645c..e80ef5f 100644
--- a/server-rs/src/metrics.rs
+++ b/server-rs/src/metrics.rs
@@ -1,10 +1,11 @@
use axum::body::Body;
-use axum::extract::Request;
+use axum::extract::{ConnectInfo, Request};
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use metrics::{counter, gauge, histogram};
use metrics_exporter_prometheus::{Matcher, PrometheusBuilder, PrometheusHandle};
+use std::net::{IpAddr, SocketAddr};
use std::time::Instant;
/// Initialize the Prometheus metrics exporter and return a handle for rendering metrics.
@@ -144,17 +145,39 @@ fn normalize_path(path: &str) -> String {
"/other".to_string()
}
-/// Handler for the /metrics endpoint.
-pub async fn metrics_handler(handle: PrometheusHandle) -> impl IntoResponse {
- // Update process metrics before rendering
+/// Handler for the /metrics endpoint. Only accepts requests from peers on the
+/// same private network (loopback, RFC1918, or IPv6 unique/link-local).
+pub async fn metrics_handler(
+ handle: PrometheusHandle,
+ ConnectInfo(peer): ConnectInfo,
+) -> Response {
+ if !is_same_network(peer.ip()) {
+ return StatusCode::FORBIDDEN.into_response();
+ }
+
update_process_metrics();
match handle.render() {
- output if !output.is_empty() => (StatusCode::OK, output),
+ output if !output.is_empty() => (StatusCode::OK, output).into_response(),
_ => (
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to render metrics".to_string(),
- ),
+ )
+ .into_response(),
+ }
+}
+
+fn is_same_network(ip: IpAddr) -> bool {
+ match ip {
+ IpAddr::V4(v4) => v4.is_loopback() || v4.is_private() || v4.is_link_local(),
+ IpAddr::V6(v6) => {
+ v6.is_loopback()
+ || (v6.segments()[0] & 0xfe00) == 0xfc00
+ || (v6.segments()[0] & 0xffc0) == 0xfe80
+ || v6.to_ipv4_mapped().is_some_and(|v4| {
+ v4.is_loopback() || v4.is_private() || v4.is_link_local()
+ })
+ }
}
}