From 5e5d9f9a1cf45d49ca60d9a5e7c8dcf64fcd1886 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 16 May 2026 16:26:36 +0100 Subject: [PATCH] all good --- frontend/src/components/map/AreaPane.tsx | 6 +- .../map/JourneyInstructions.test.tsx | 82 +++++++++++++++++++ .../components/map/JourneyInstructions.tsx | 27 ++++-- .../src/components/map/PropertiesPane.tsx | 6 +- .../components/map/filters/AddFilterPanel.tsx | 6 +- .../map/map-page/DesktopMapPage.tsx | 2 + .../components/map/map-page/MobileMapPage.tsx | 2 + .../ui/IndeterminateProgressBar.tsx | 22 +++++ frontend/src/hooks/useMapData.test.ts | 76 +++++++++++++++++ frontend/src/hooks/useMapData.ts | 20 ++++- frontend/src/i18n/locales/en.ts | 2 +- frontend/tailwind.config.js | 5 ++ r5-java/run.sh | 4 +- server-rs/src/consts.rs | 3 +- server-rs/src/main.rs | 26 ++---- server-rs/src/metrics.rs | 35 ++++++-- 16 files changed, 280 insertions(+), 44 deletions(-) create mode 100644 frontend/src/components/map/JourneyInstructions.test.tsx create mode 100644 frontend/src/components/ui/IndeterminateProgressBar.tsx diff --git a/frontend/src/components/map/AreaPane.tsx b/frontend/src/components/map/AreaPane.tsx index 28c5e2b..e2a6f55 100644 --- a/frontend/src/components/map/AreaPane.tsx +++ b/frontend/src/components/map/AreaPane.tsx @@ -33,6 +33,7 @@ import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader'; import { FeatureInfoPopup } from '../ui/FeatureInfoPopup'; import { EmptyState } from '../ui/EmptyState'; import { FeatureLabel } from '../ui/FeatureLabel'; +import { IndeterminateProgressBar } from '../ui/IndeterminateProgressBar'; import StreetViewEmbed from './StreetViewEmbed'; import HistogramLegend from './HistogramLegend'; import JourneyInstructions from './JourneyInstructions'; @@ -164,7 +165,9 @@ export default function AreaPane({ return ( <> -
+
+ +
@@ -611,6 +614,7 @@ export default function AreaPane({ })}
) : null} +
{infoFeature && ( diff --git a/frontend/src/components/map/JourneyInstructions.test.tsx b/frontend/src/components/map/JourneyInstructions.test.tsx new file mode 100644 index 0000000..e958892 --- /dev/null +++ b/frontend/src/components/map/JourneyInstructions.test.tsx @@ -0,0 +1,82 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import JourneyInstructions, { googleMapsUrl } from './JourneyInstructions'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, values?: Record) => { + if (key === 'areaPane.to') return `To ${values?.destination}`; + if (key === 'areaPane.journeysFrom') return `Journeys from ${values?.label}`; + if (key === 'common.min') return 'min'; + if (key === 'common.loading') return 'Loading'; + if (key === 'travel.bestCase') return 'Best case'; + if (key === 'areaPane.walk') return 'Walk'; + if (key === 'areaPane.cycle') return 'Cycle'; + if (key === 'areaPane.viewOnGoogleMaps') return 'View on Google Maps'; + if (key === 'areaPane.noJourneyData') return 'No journey data'; + return key; + }, + }), +})); + +describe('JourneyInstructions', () => { + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it('keeps the transit leg breakdown visible when best-case time is selected', () => { + render( + + ); + + expect(screen.getByText(/Best case/)).toBeTruthy(); + expect(screen.getByText('Jubilee line')).toBeTruthy(); + expect(screen.getByText('Northern line')).toBeTruthy(); + expect(screen.getByText(/Canary Wharf/)).toBeTruthy(); + }); + + it('builds explicit Google Maps transit directions instead of a path URL', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-16T12:00:00Z')); + + const url = googleMapsUrl('NW7 2GA', 'Bank tube station'); + const parsed = new URL(url); + + expect(parsed.origin + parsed.pathname).toBe('https://www.google.com/maps/dir/'); + expect(parsed.searchParams.get('api')).toBe('1'); + expect(parsed.searchParams.get('origin')).toBe('NW7 2GA'); + expect(parsed.searchParams.get('destination')).toBe('Bank Station, London'); + expect(parsed.searchParams.get('travelmode')).toBe('transit'); + expect(parsed.searchParams.get('departure_time')).toBe('1779085800'); + }); +}); diff --git a/frontend/src/components/map/JourneyInstructions.tsx b/frontend/src/components/map/JourneyInstructions.tsx index 792fa15..8c8fdda 100644 --- a/frontend/src/components/map/JourneyInstructions.tsx +++ b/frontend/src/components/map/JourneyInstructions.tsx @@ -94,15 +94,24 @@ function nextMondayAt730(): number { return Math.floor(monday.getTime() / 1000); } -function googleMapsUrl(origin: string, destination: string): string { +function googleMapsDestination(destination: string): string { + const clean = stripId(destination).trim(); + if (/\btube station$/i.test(clean)) { + return `${clean.replace(/\s+tube station$/i, ' Station')}, London`; + } + return clean; +} + +export function googleMapsUrl(origin: string, destination: string): string { const ts = nextMondayAt730(); - const encodedOrigin = encodeURIComponent(origin); - const encodedDestination = encodeURIComponent(destination); - // The official api=1 URL scheme doesn't support departure_time. - // Use the undocumented data= path parameter with protobuf-like encoding: - // !3e3 = transit, !6e0 = "depart at", !7e2 = local time, !8j = timestamp - const data = `!4m6!4m5!2m3!6e0!7e2!8j${ts}!3e3`; - return `https://www.google.com/maps/dir/${encodedOrigin}/${encodedDestination}/data=${data}`; + const params = new URLSearchParams({ + api: '1', + origin, + destination: googleMapsDestination(destination), + travelmode: 'transit', + departure_time: ts.toString(), + }); + return `https://www.google.com/maps/dir/?${params.toString()}`; } function invertLegs(legs: JourneyLeg[]): JourneyLeg[] { @@ -287,7 +296,7 @@ export default function JourneyInstructions({ const legSum = j.legs ? j.legs.reduce((sum, l) => sum + l.minutes, 0) : 0; const totalMin = j.useBest && j.bestMinutes != null ? j.bestMinutes : (j.minutes ?? legSum); const isBestCase = j.useBest && j.bestMinutes != null; - const displayLegs = !isBestCase && j.legs ? invertLegs(j.legs) : null; + const displayLegs = j.legs ? invertLegs(j.legs) : null; const destination = j.label || j.slug; return ( diff --git a/frontend/src/components/map/PropertiesPane.tsx b/frontend/src/components/map/PropertiesPane.tsx index 73eb302..f81f21e 100644 --- a/frontend/src/components/map/PropertiesPane.tsx +++ b/frontend/src/components/map/PropertiesPane.tsx @@ -7,6 +7,7 @@ import InfoPopup from '../ui/InfoPopup'; import { SearchInput } from '../ui/SearchInput'; import { EmptyState } from '../ui/EmptyState'; import { InfoIcon } from '../ui/icons'; +import { IndeterminateProgressBar } from '../ui/IndeterminateProgressBar'; import { ts } from '../../i18n/server'; interface PropertiesPaneProps { @@ -57,7 +58,9 @@ export function PropertiesPane({ } return ( -
+
+ 0} /> +
{showInfo && ( )}
+
); } diff --git a/frontend/src/components/map/filters/AddFilterPanel.tsx b/frontend/src/components/map/filters/AddFilterPanel.tsx index e3c2a87..f6129a3 100644 --- a/frontend/src/components/map/filters/AddFilterPanel.tsx +++ b/frontend/src/components/map/filters/AddFilterPanel.tsx @@ -110,14 +110,14 @@ export function AddFilterPanel({ > {(!collapsed || !isLicensed) && ( diff --git a/frontend/src/components/map/map-page/DesktopMapPage.tsx b/frontend/src/components/map/map-page/DesktopMapPage.tsx index 10c3c32..65d11cd 100644 --- a/frontend/src/components/map/map-page/DesktopMapPage.tsx +++ b/frontend/src/components/map/map-page/DesktopMapPage.tsx @@ -8,6 +8,7 @@ import type { TravelTimeEntry } from '../../../hooks/useTravelTime'; import type { getTutorialStyles } from '../../../lib/tutorial-styles'; import type { SearchedLocation } from '../LocationSearch'; import { MapPinIcon } from '../../ui/icons/MapPinIcon'; +import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar'; import type { MapFlyTo, PaneResizeHandlers } from './types'; import { MapFallback, PaneFallback } from './Fallbacks'; import { LoadingOverlay } from './LoadingOverlay'; @@ -151,6 +152,7 @@ export function DesktopMapPage({
+ }>
+ }> +
+
+ ); +} 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() + }) + } } }