diff --git a/Taskfile.yml b/Taskfile.yml index eb43540..834e34e 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -16,11 +16,6 @@ tasks: cmds: - uv run python -m pipeline.download.map_assets --output frontend/public/assets - download:places: - desc: Extract place names from OSM PBF - cmds: - - uv run python -m pipeline.download.places --output ./property-data/places.parquet {{.CLI_ARGS}} - test: desc: Run all tests (Python and Rust) cmds: @@ -45,10 +40,6 @@ tasks: cmds: - docker compose up --build - - - - build:server: desc: Build server for production dir: server-rs diff --git a/frontend/src/components/map/FeatureBrowser.tsx b/frontend/src/components/map/FeatureBrowser.tsx index 8d2ca62..3cc449d 100644 --- a/frontend/src/components/map/FeatureBrowser.tsx +++ b/frontend/src/components/map/FeatureBrowser.tsx @@ -9,10 +9,18 @@ import { groupFeaturesByCategory } from '../../lib/features'; import { FeatureInfoPopup } from '../ui/FeatureInfoPopup'; import { FeatureActions } from '../ui/FeatureIcons'; import { FeatureLabel } from '../ui/FeatureLabel'; -import { RouteIcon, PlusIcon, EyeIcon } from '../ui/icons'; +import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon, PlusIcon, EyeIcon } from '../ui/icons'; +import type { ComponentType } from 'react'; import { IconButton } from '../ui/IconButton'; import { TRANSPORT_MODES, MODE_LABELS, travelFieldKey, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime'; +const MODE_ICONS: Record> = { + car: CarIcon, + bicycle: BicycleIcon, + walking: WalkingIcon, + transit: TransitIcon, +}; + interface FeatureBrowserProps { availableFeatures: FeatureMeta[]; allFeatures: FeatureMeta[]; @@ -77,7 +85,7 @@ export default function FeatureBrowser({ name="Travel Time" expanded={isSearching || expandedGroups.has('Travel Time')} onToggle={() => toggleGroup('Travel Time')} - className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800" + className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 hover:bg-warm-200 dark:hover:bg-warm-800" > {TRANSPORT_MODES.length} @@ -87,13 +95,14 @@ export default function FeatureBrowser({ const activeEntry = travelTimeEntries.find((e) => e.mode === mode && e.slug); const fieldKey = activeEntry ? travelFieldKey(activeEntry) : null; const isPinned = fieldKey !== null && pinnedFeature === fieldKey; + const ModeIcon = MODE_ICONS[mode]; return (
onAddTravelTimeEntry(mode)}> - +
{MODE_LABELS[mode]} @@ -131,7 +140,7 @@ export default function FeatureBrowser({ name={group.name} expanded={isExpanded} onToggle={() => toggleGroup(group.name)} - className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800" + className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 hover:bg-warm-200 dark:hover:bg-warm-800" > {group.features.length} diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx index 57c06db..730e8dd 100644 --- a/frontend/src/components/map/Filters.tsx +++ b/frontend/src/components/map/Filters.tsx @@ -254,7 +254,7 @@ export default memo(function Filters({ name="Travel Time" expanded={!collapsedGroups.has('Travel Time')} onToggle={() => toggleGroup('Travel Time')} - className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800" + className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 hover:bg-warm-200 dark:hover:bg-warm-800" > {travelTimeEntries.length} @@ -296,7 +296,7 @@ export default memo(function Filters({ name={group.name} expanded={isExpanded} onToggle={() => toggleGroup(group.name)} - className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800" + className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 hover:bg-warm-200 dark:hover:bg-warm-800" > {group.features.length} diff --git a/frontend/src/components/map/TravelTimeCard.tsx b/frontend/src/components/map/TravelTimeCard.tsx index 424dd98..ce99a62 100644 --- a/frontend/src/components/map/TravelTimeCard.tsx +++ b/frontend/src/components/map/TravelTimeCard.tsx @@ -5,10 +5,21 @@ import { PlaceSearchInput } from '../ui/PlaceSearchInput'; import { CloseIcon } from '../ui/icons/CloseIcon'; import { EyeIcon } from '../ui/icons/EyeIcon'; import { MapPinIcon } from '../ui/icons/MapPinIcon'; -import { RouteIcon } from '../ui/icons/RouteIcon'; +import { CarIcon } from '../ui/icons/CarIcon'; +import { BicycleIcon } from '../ui/icons/BicycleIcon'; +import { WalkingIcon } from '../ui/icons/WalkingIcon'; +import { TransitIcon } from '../ui/icons/TransitIcon'; import { formatFilterValue } from '../../lib/format'; import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch'; import { MODE_LABELS, type TransportMode } from '../../hooks/useTravelTime'; +import type { ComponentType } from 'react'; + +const MODE_ICONS: Record> = { + car: CarIcon, + bicycle: BicycleIcon, + walking: WalkingIcon, + transit: TransitIcon, +}; interface TravelTimeCardProps { mode: TransportMode; @@ -63,12 +74,14 @@ export function TravelTimeCard({ const sliderMax = dataRange ? Math.ceil(dataRange[1]) : 120; const displayRange = timeRange ?? [sliderMin, sliderMax]; + const ModeIcon = MODE_ICONS[mode]; + return (
{/* Header */}
- + Travel Time ({MODE_LABELS[mode]}) diff --git a/frontend/src/components/ui/icons/BicycleIcon.tsx b/frontend/src/components/ui/icons/BicycleIcon.tsx new file mode 100644 index 0000000..6bdd9f4 --- /dev/null +++ b/frontend/src/components/ui/icons/BicycleIcon.tsx @@ -0,0 +1,23 @@ +interface IconProps { + className?: string; +} + +export function BicycleIcon({ className = 'w-4 h-4' }: IconProps) { + return ( + + + + + + + + ); +} diff --git a/frontend/src/components/ui/icons/CarIcon.tsx b/frontend/src/components/ui/icons/CarIcon.tsx new file mode 100644 index 0000000..8f93318 --- /dev/null +++ b/frontend/src/components/ui/icons/CarIcon.tsx @@ -0,0 +1,22 @@ +interface IconProps { + className?: string; +} + +export function CarIcon({ className = 'w-4 h-4' }: IconProps) { + return ( + + + + + + + ); +} diff --git a/frontend/src/components/ui/icons/TransitIcon.tsx b/frontend/src/components/ui/icons/TransitIcon.tsx new file mode 100644 index 0000000..eddbfe0 --- /dev/null +++ b/frontend/src/components/ui/icons/TransitIcon.tsx @@ -0,0 +1,25 @@ +interface IconProps { + className?: string; +} + +export function TransitIcon({ className = 'w-4 h-4' }: IconProps) { + return ( + + + + + + + + + + ); +} diff --git a/frontend/src/components/ui/icons/WalkingIcon.tsx b/frontend/src/components/ui/icons/WalkingIcon.tsx new file mode 100644 index 0000000..b2fcdea --- /dev/null +++ b/frontend/src/components/ui/icons/WalkingIcon.tsx @@ -0,0 +1,23 @@ +interface IconProps { + className?: string; +} + +export function WalkingIcon({ className = 'w-4 h-4' }: IconProps) { + return ( + + + + + + + + ); +} diff --git a/frontend/src/components/ui/icons/index.ts b/frontend/src/components/ui/icons/index.ts index ce62f26..bf79bbd 100644 --- a/frontend/src/components/ui/icons/index.ts +++ b/frontend/src/components/ui/icons/index.ts @@ -7,3 +7,7 @@ export { FilterIcon } from './FilterIcon'; export { LightbulbIcon } from './LightbulbIcon'; export { MenuIcon } from './MenuIcon'; export { RouteIcon } from './RouteIcon'; +export { CarIcon } from './CarIcon'; +export { BicycleIcon } from './BicycleIcon'; +export { WalkingIcon } from './WalkingIcon'; +export { TransitIcon } from './TransitIcon'; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 20956da..ca53331 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -84,5 +84,5 @@ export function buildFilterString(filters: FeatureFilters, features: FeatureMeta const maxStr = meta?.absolute && max === meta.max ? 'inf' : String(max); return `${name}:${min}:${maxStr}`; }) - .join(','); + .join(';;'); } diff --git a/r5-java/run.sh b/r5-java/run.sh index 3382934..968977a 100755 --- a/r5-java/run.sh +++ b/r5-java/run.sh @@ -15,7 +15,7 @@ set -euo pipefail # # Usage: # ./r5-java/run.sh -# ./r5-java/run.sh --threads 8 --heap 24g +# ./r5-java/run.sh --threads 8 --heap 24g --output-dir property-data/travel-times # --- Defaults --- THREADS=16 @@ -30,6 +30,7 @@ while [[ $# -gt 0 ]]; do --threads) THREADS="$2"; shift 2 ;; --heap) HEAP="$2"; shift 2 ;; --network-dir) NETWORK_DIR="$2"; shift 2 ;; + --output-dir) OUTPUT_BASE="$2"; shift 2 ;; *) echo "Unknown: $1"; exit 1 ;; esac done @@ -75,7 +76,7 @@ fi if [ ! -f "$DUCKDB_JAR" ]; then echo "--- Downloading DuckDB JDBC ---" - curl -fL -o "$DUCKDB_JAR" https://repo1.maven.org/maven2/org/duckdb/duckdb_jdbc/1.0.0/duckdb_jdbc-1.0.0.jar + curl -fL -o "$DUCKDB_JAR" https://repo1.maven.org/maven2/org/duckdb/duckdb_jdbc/1.4.4.0/duckdb_jdbc-1.4.4.0.jar fi # --- Step 3: Compile Java source --- @@ -107,8 +108,12 @@ if [ ! -f "$NETWORK_DIR/network.dat" ]; then BUILD_DIR="$NETWORK_DIR/build" echo "--- No cached network — copying transit data to build dir ---" mkdir -p "$BUILD_DIR" - cp property-data/transit/raw/*.osm.pbf "$BUILD_DIR/" 2>/dev/null || true - cp property-data/transit/*.zip "$BUILD_DIR/" 2>/dev/null || true + if ! cp property-data/transit/raw/*.osm.pbf "$BUILD_DIR/" 2>/dev/null; then + echo "Warning: no .osm.pbf files found in property-data/transit/raw/" + fi + if ! cp property-data/transit/*.zip "$BUILD_DIR/" 2>/dev/null; then + echo "Warning: no .zip files found in property-data/transit/" + fi DATA_DIR="$BUILD_DIR" fi diff --git a/r5-java/src/main/java/propertymap/App.java b/r5-java/src/main/java/propertymap/App.java index 1c765f6..f00935a 100644 --- a/r5-java/src/main/java/propertymap/App.java +++ b/r5-java/src/main/java/propertymap/App.java @@ -9,6 +9,7 @@ import java.nio.file.Paths; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -24,7 +25,8 @@ import java.util.concurrent.atomic.AtomicInteger; * postcodes are written (unreachable = absent from file). * * Output per mode: one parquet file per origin in {output-dir}/{mode}/{name}.parquet - * with columns (pcds VARCHAR, travel_minutes SMALLINT). + * with columns (pcds VARCHAR, travel_minutes SMALLINT). Transit mode additionally + * includes a best_minutes SMALLINT column (5th percentile = best-case departure timing). */ public class App { @@ -117,15 +119,14 @@ public class App { c, total, rate, etaH, failed.get()); }, 2, 2, TimeUnit.SECONDS); - // Submit all work, wait for completion via CountDownLatch-like pattern - java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(remaining.size()); + CountDownLatch latch = new CountDownLatch(remaining.size()); for (int idx : remaining) { pool.submit(() -> { try { processOrigin(network, postcodes, postcodeLats, postcodeLons, originLats[idx], originLons[idx], - modeDir, mode, date, originNames[idx], threadConn.get()); + modeDir, mode, date, idx, originNames[idx], threadConn.get()); completed.incrementAndGet(); } catch (Exception e) { failed.incrementAndGet(); @@ -138,6 +139,7 @@ public class App { latch.await(); reporter.shutdown(); + reporter.awaitTermination(5, TimeUnit.SECONDS); double elapsedH = (System.currentTimeMillis() - startMs) / 3_600_000.0; int n = completed.get(); @@ -150,10 +152,10 @@ public class App { TransportNetwork network, String[] postcodes, double[] postcodeLats, double[] postcodeLons, double originLat, double originLon, - Path modeDir, String mode, LocalDate date, String name, + Path modeDir, String mode, LocalDate date, int index, String name, DuckDBConnection conn) throws Exception { - Path outPath = modeDir.resolve(sanitizeFilename(name) + ".parquet"); + Path outPath = modeDir.resolve(originFilename(index, name)); Exception lastError = null; for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) { @@ -168,16 +170,22 @@ public class App { String[] codes = new String[reachable]; short[] times = new short[reachable]; + short[] bestTimes = result.bestTimes() != null ? new short[reachable] : null; int j = 0; for (int i = 0; i < result.times().length; i++) { if (result.times()[i] >= 0) { codes[j] = postcodes[result.originalIndices()[i]]; times[j] = result.times()[i]; + if (bestTimes != null) bestTimes[j] = result.bestTimes()[i]; j++; } } - Parquet.writeTravelTimes(conn, outPath, codes, times); + if (bestTimes != null) { + Parquet.writeTransitTravelTimes(conn, outPath, codes, times, bestTimes); + } else { + Parquet.writeTravelTimes(conn, outPath, codes, times); + } return; } catch (Exception e) { lastError = e; @@ -194,7 +202,7 @@ public class App { private static List findRemaining(Path modeDir, String[] names) throws Exception { List remaining = new ArrayList<>(); for (int i = 0; i < names.length; i++) { - Path f = modeDir.resolve(sanitizeFilename(names[i]) + ".parquet"); + Path f = modeDir.resolve(originFilename(i, names[i])); if (!Files.exists(f) || Files.size(f) == 0) { remaining.add(i); } @@ -202,11 +210,12 @@ public class App { return remaining; } - /** Sanitize a place name into a safe filename (lowercase, spaces to hyphens, strip non-alphanumeric). */ - private static String sanitizeFilename(String name) { - return name.toLowerCase() + /** Build a filename from index + place name (index prefix prevents collisions after sanitization). */ + private static String originFilename(int index, String name) { + String safe = name.toLowerCase() .replaceAll("[^a-z0-9 -]", "") .replaceAll("\\s+", "-"); + return String.format("%04d-%s.parquet", index, safe); } private static String requiredArg(String[] args, String name) { diff --git a/r5-java/src/main/java/propertymap/Parquet.java b/r5-java/src/main/java/propertymap/Parquet.java index e8be1d7..615566c 100644 --- a/r5-java/src/main/java/propertymap/Parquet.java +++ b/r5-java/src/main/java/propertymap/Parquet.java @@ -23,11 +23,16 @@ public class Parquet { catch (ClassNotFoundException e) { throw new RuntimeException(e); } } + /** Escape a file path for safe interpolation into DuckDB SQL (double single quotes). */ + private static String escapePath(String path) { + return path.replace("'", "''"); + } + /** Load England postcodes, write reference parquet, return codes + flat lat/lon arrays. */ static Postcodes loadEnglandPostcodes(String parquetPath, Path refOut) throws Exception { try (DuckDBConnection conn = connect(); Statement stmt = conn.createStatement()) { stmt.execute("CREATE TABLE postcodes AS SELECT pcds, lat, \"long\" FROM read_parquet('" - + parquetPath + "') WHERE ctry = 'E92000001' AND doterm IS NULL"); + + escapePath(parquetPath) + "') WHERE ctry = 'E92000001' AND doterm IS NULL"); copyToParquet(stmt, "SELECT * FROM postcodes", refOut); try (ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM postcodes")) { @@ -56,7 +61,7 @@ public class Parquet { try (DuckDBConnection conn = connect(); Statement stmt = conn.createStatement()) { stmt.execute("CREATE TABLE places AS SELECT * EXCLUDE (rn) FROM (" + "SELECT *, ROW_NUMBER() OVER (PARTITION BY lat, lon) AS rn " - + "FROM read_parquet('" + parquetPath + "')) WHERE rn = 1"); + + "FROM read_parquet('" + escapePath(parquetPath) + "')) WHERE rn = 1"); copyToParquet(stmt, "SELECT * FROM places", refOut); try (ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM places")) { @@ -97,7 +102,30 @@ public class Parquet { } } try (Statement stmt = conn.createStatement()) { - stmt.execute("COPY t TO '" + tmp.toAbsolutePath() + "' (FORMAT PARQUET, COMPRESSION ZSTD)"); + stmt.execute("COPY t TO '" + escapePath(tmp.toAbsolutePath().toString()) + "' (FORMAT PARQUET, COMPRESSION ZSTD)"); + } + Files.move(tmp, outPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } + + /** Write transit travel times with both median and best-case columns. */ + static void writeTransitTravelTimes(DuckDBConnection conn, Path outPath, + String[] postcodes, short[] times, short[] bestTimes) throws Exception { + Path tmp = outPath.resolveSibling(outPath.getFileName() + ".tmp"); + try (Statement stmt = conn.createStatement()) { + stmt.execute("DROP TABLE IF EXISTS t"); + stmt.execute("CREATE TABLE t (pcds VARCHAR, travel_minutes SMALLINT, best_minutes SMALLINT)"); + } + try (DuckDBAppender appender = conn.createAppender("main", "t")) { + for (int i = 0; i < postcodes.length; i++) { + appender.beginRow(); + appender.append(postcodes[i]); + appender.append(times[i]); + appender.append(bestTimes[i]); + appender.endRow(); + } + } + try (Statement stmt = conn.createStatement()) { + stmt.execute("COPY t TO '" + escapePath(tmp.toAbsolutePath().toString()) + "' (FORMAT PARQUET, COMPRESSION ZSTD)"); } Files.move(tmp, outPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); } @@ -108,7 +136,7 @@ public class Parquet { } private static void copyToParquet(Statement stmt, String query, Path outPath) throws Exception { - stmt.execute("COPY (" + query + ") TO '" + outPath.toAbsolutePath() + stmt.execute("COPY (" + query + ") TO '" + escapePath(outPath.toAbsolutePath().toString()) + "' (FORMAT PARQUET, COMPRESSION ZSTD)"); } } diff --git a/r5-java/src/main/java/propertymap/Router.java b/r5-java/src/main/java/propertymap/Router.java index 2418005..108f4ae 100644 --- a/r5-java/src/main/java/propertymap/Router.java +++ b/r5-java/src/main/java/propertymap/Router.java @@ -25,11 +25,14 @@ public class Router { private static final int ZOOM = 9; // R5 enforces range 9-12 private static final int MAX_GRID_CELLS = 4_900_000; // under R5's 5M limit + private static final int DEPARTURE_FROM_TIME = 7 * 3600; // 07:00 + private static final int DEPARTURE_TO_TIME = 9 * 3600; // 09:00 + private static final int MAX_TRIP_DURATION_MINUTES = 120; /** Result of computing travel times for a single origin with spatial pre-filtering. */ - record FilteredResult(int[] originalIndices, short[] times) {} + record FilteredResult(int[] originalIndices, short[] times, short[] bestTimes) {} - /** Max plausible travel radius in km for 120-minute trips. */ + /** Max plausible travel radius in km for {@link #MAX_TRIP_DURATION_MINUTES}-minute trips. */ static double maxRadiusKm(String mode) { return switch (mode) { case "car" -> 150; @@ -40,7 +43,10 @@ public class Router { }; } - /** Load or build the transport network with Kryo caching. */ + /** + * Load or build the transport network with Kryo caching. + * The returned network is read-only after buildDistanceTables — safe for concurrent use. + */ static TransportNetwork loadNetwork(String dataDir, String cacheDir) throws Exception { System.err.println("Loading transport network..."); File cacheFile = new File(cacheDir, "network.dat"); @@ -78,7 +84,7 @@ public class Router { // 1. Filter destinations by bounding box int[] filtered = filterByDistance(allLats, allLons, originLat, originLon, maxRadius); if (filtered.length == 0) { - return new FilteredResult(new int[0], new short[0]); + return new FilteredResult(new int[0], new short[0], null); } // 2. Extract filtered coordinate arrays @@ -93,9 +99,14 @@ public class Router { List chunks = buildDestinationChunks(fLats, fLons); // 4. Compute travel times - short[] times = computeTravelTimes(network, chunks, originLat, originLon, mode, fLats.length, date); + boolean isTransit = mode.equals("transit"); + short[][] allTimes = computeTravelTimes(network, chunks, originLat, originLon, mode, fLats.length, date); - return new FilteredResult(filtered, times); + // For transit: allTimes[0]=best (5th percentile), allTimes[1]=median (50th) + // For others: allTimes[0]=median (50th), no best + short[] medianTimes = isTransit ? allTimes[1] : allTimes[0]; + short[] bestTimes = isTransit ? allTimes[0] : null; + return new FilteredResult(filtered, medianTimes, bestTimes); } /** @@ -175,13 +186,18 @@ public class Router { return chunks; } - /** Compute travel times from one origin to all destinations across all chunks. */ - private static short[] computeTravelTimes( + /** + * Compute travel times from one origin to all destinations across all chunks. + * Returns one short[] per requested percentile (transit gets 2: best + median, others get 1: median). + */ + private static short[][] computeTravelTimes( TransportNetwork network, List chunks, double originLat, double originLon, String mode, int nDest, LocalDate date) { - short[] times = new short[nDest]; - Arrays.fill(times, (short) -1); + boolean isTransit = mode.equals("transit"); + int nPercentiles = isTransit ? 2 : 1; + short[][] allTimes = new short[nPercentiles][nDest]; + for (short[] arr : allTimes) Arrays.fill(arr, (short) -1); for (DestinationChunk chunk : chunks) { RegionalTask task = buildTask(chunk, originLat, originLon, mode, date); @@ -191,14 +207,16 @@ public class Router { TravelTimeResult tt = result.travelTimes; if (tt != null) { int[][] values = tt.getValues(); - for (int i = 0; i < chunk.originalIndices.length && i < values[0].length; i++) { - if (values[0][i] != Integer.MAX_VALUE) { - times[chunk.originalIndices[i]] = (short) values[0][i]; + for (int p = 0; p < nPercentiles && p < values.length; p++) { + for (int i = 0; i < chunk.originalIndices.length && i < values[p].length; i++) { + if (values[p][i] != Integer.MAX_VALUE) { + allTimes[p][chunk.originalIndices[i]] = (short) values[p][i]; + } } } } } - return times; + return allTimes; } // --- Private helpers --- @@ -241,7 +259,7 @@ public class Router { task.fromLat = originLat; task.fromLon = originLon; task.date = date; - task.percentiles = new int[]{50}; + task.percentiles = mode.equals("transit") ? new int[]{5, 50} : new int[]{50}; task.recordTimes = true; task.destinationPointSets = new PointSet[]{chunk.pointSet}; task.zoom = chunk.extents.zoom; @@ -249,9 +267,9 @@ public class Router { task.north = chunk.extents.north; task.width = chunk.extents.width; task.height = chunk.extents.height; - task.fromTime = 8 * 3600; - task.toTime = 8 * 3600 + 60; - task.maxTripDurationMinutes = 120; + task.fromTime = DEPARTURE_FROM_TIME; + task.toTime = DEPARTURE_TO_TIME; + task.maxTripDurationMinutes = MAX_TRIP_DURATION_MINUTES; configureMode(task, mode); return task; @@ -267,13 +285,14 @@ public class Router { task.accessModes = EnumSet.of(LegMode.WALK); task.egressModes = EnumSet.of(LegMode.WALK); task.directModes = EnumSet.of(LegMode.WALK); - task.transitModes = EnumSet.of(TransitModes.TRANSIT); + task.transitModes = EnumSet.allOf(TransitModes.class); } default -> throw new IllegalArgumentException("Unknown mode: " + mode); } } private static void setDirectMode(RegionalTask task, LegMode legMode) { + task.maxRides = 0; task.accessModes = EnumSet.of(legMode); task.egressModes = EnumSet.of(legMode); task.directModes = EnumSet.of(legMode); diff --git a/server-rs/src/auth.rs b/server-rs/src/auth.rs index f84b7a2..e513f44 100644 --- a/server-rs/src/auth.rs +++ b/server-rs/src/auth.rs @@ -95,6 +95,9 @@ async fn validate_token( .ok()?; if !res.status().is_success() { + let status = res.status(); + let body = res.text().await.unwrap_or_default(); + warn!("PocketBase auth-refresh returned {status}: {body}"); return None; } diff --git a/server-rs/src/consts.rs b/server-rs/src/consts.rs index 57d6c4e..a2fb46c 100644 --- a/server-rs/src/consts.rs +++ b/server-rs/src/consts.rs @@ -17,6 +17,9 @@ pub const POSTCODE_SEARCH_OFFSET: f64 = 0.02; pub const AI_FILTERS_MAX_TOKENS: usize = 2000; pub const AI_FILTERS_TEMPERATURE: f32 = 0.0; +/// Timeout for outbound HTTP service calls (seconds). +pub const SERVICE_CALL_TIMEOUT: u64 = 120; + /// Inner London free zone bounds (south, west, north, east) — roughly zones 1–2. /// Users without a license can only query data within these bounds. pub const FREE_ZONE_BOUNDS: (f64, f64, f64, f64) = (51.42, -0.34, 51.60, 0.14); @@ -24,5 +27,5 @@ pub const FREE_ZONE_BOUNDS: (f64, f64, f64, f64) = (51.42, -0.34, 51.60, 0.14); /// Homepage demo center (lat, lng). Unlicensed hexagon requests are allowed /// when the center of the requested bounds is within DEMO_CENTER_TOLERANCE of this point. /// Must match DEMO_VIEW_START in ScrollStory.tsx. -pub const DEMO_CENTER: (f64, f64) = (52.2, -1.9); +pub const DEMO_CENTER: (f64, f64) = (51.51, -0.12); pub const DEMO_CENTER_TOLERANCE: f64 = 1.0; diff --git a/server-rs/src/main.rs b/server-rs/src/main.rs index 09b9ea6..997c81f 100644 --- a/server-rs/src/main.rs +++ b/server-rs/src/main.rs @@ -17,6 +17,7 @@ use std::sync::Arc; use std::time::Duration; use anyhow::{bail, Context}; +use consts::SERVICE_CALL_TIMEOUT; use axum::middleware; use axum::routing::{any, get, patch, post}; use axum::Router; @@ -286,7 +287,7 @@ async fn main() -> anyhow::Result<()> { }; let http_client = reqwest::Client::builder() - .timeout(Duration::from_secs(30)) + .timeout(Duration::from_secs(SERVICE_CALL_TIMEOUT)) .connect_timeout(Duration::from_secs(5)) .build() .context("Failed to build HTTP client")?; diff --git a/server-rs/src/parsing/filters.rs b/server-rs/src/parsing/filters.rs index 11500d4..c7d6bcd 100644 --- a/server-rs/src/parsing/filters.rs +++ b/server-rs/src/parsing/filters.rs @@ -17,7 +17,7 @@ pub struct ParsedEnumFilter { pub allowed: FxHashSet, } -/// Parse comma-separated filter string into numeric and enum filters. +/// Parse `;;`-separated filter string into numeric and enum filters. /// Numeric format: `name:min:max` /// Enum format: `name:val1|val2|val3` (pipe-separated string values) /// @@ -35,7 +35,7 @@ pub fn parse_filters( None => return Ok((numeric, enums)), }; - for entry in input.split(',') { + for entry in input.split(";;") { let parts: Vec<&str> = entry.splitn(2, ':').collect(); if parts.len() != 2 { return Err(format!("Malformed filter entry (missing ':'): '{entry}'")); @@ -234,7 +234,7 @@ mod tests { #[test] fn parse_multiple_numeric_filters() { let (numeric, _enums) = parse_filters( - Some("Price:100000:500000,Area:50:200"), + Some("Price:100000:500000;;Area:50:200"), &extended_feature_map(), &extended_enum_values(), ) @@ -248,7 +248,7 @@ mod tests { #[test] fn parse_mixed_filters() { let (numeric, enums) = parse_filters( - Some("Price:100000:500000,Type:Semi|Terraced"), + Some("Price:100000:500000;;Type:Semi|Terraced"), &extended_feature_map(), &extended_enum_values(), ) @@ -288,7 +288,7 @@ mod tests { #[test] fn parse_filter_with_whitespace() { let (numeric, enums) = parse_filters( - Some("Price : 100000 : 500000 , Type : Detached | Flats/Maisonettes"), + Some("Price : 100000 : 500000 ;; Type : Detached | Flats/Maisonettes"), &extended_feature_map(), &extended_enum_values(), ) diff --git a/server-rs/src/routes/export.rs b/server-rs/src/routes/export.rs index 706e3ea..3eb50cf 100644 --- a/server-rs/src/routes/export.rs +++ b/server-rs/src/routes/export.rs @@ -84,7 +84,7 @@ fn extract_filter_feature_names(filters_str: Option<&str>) -> Vec { None => return Vec::new(), }; let mut names = Vec::new(); - for entry in input.split(',') { + for entry in input.split(";;") { let parts: Vec<&str> = entry.splitn(2, ':').collect(); if parts.len() == 2 { let name = parts[0].trim().to_string(); @@ -110,7 +110,7 @@ fn build_frontend_params( ]; if let Some(fs) = filters_str { if !fs.is_empty() { - for entry in fs.split(',') { + for entry in fs.split(";;") { if !entry.is_empty() { parts.push(format!("filter={}", urlencoding::encode(entry.trim()))); } diff --git a/server-rs/src/routes/hexagons.rs b/server-rs/src/routes/hexagons.rs index dbe035e..2ee26b0 100644 --- a/server-rs/src/routes/hexagons.rs +++ b/server-rs/src/routes/hexagons.rs @@ -30,7 +30,7 @@ pub struct HexagonsResponse { pub struct HexagonParams { resolution: u8, bounds: Option, - /// Comma-separated filters: `name:min:max,...` + /// `;;`-separated filters: `name:min:max;;...` filters: Option, /// Comma-separated feature names to include in min/max aggregation. fields: Option, @@ -191,7 +191,7 @@ pub async fn get_hexagons( require_bounds(params.bounds).map_err(IntoResponse::into_response)?; // Allow the homepage demo: check if the center of the requested bounds - // is near the demo view center (52.2, -1.9). + // is near the demo view center (51.51, -0.12). let center_lat = (south + north) / 2.0; let center_lng = (west + east) / 2.0; let is_demo_view = (center_lat - DEMO_CENTER.0).abs() <= DEMO_CENTER_TOLERANCE diff --git a/server-rs/src/routes/postcodes.rs b/server-rs/src/routes/postcodes.rs index 75057c4..bde73fd 100644 --- a/server-rs/src/routes/postcodes.rs +++ b/server-rs/src/routes/postcodes.rs @@ -27,7 +27,7 @@ pub struct PostcodesResponse { #[derive(Deserialize)] pub struct PostcodeParams { bounds: Option, - /// Comma-separated filters: `name:min:max,...` + /// `;;`-separated filters: `name:min:max;;...` filters: Option, /// Comma-separated feature names to include in min/max aggregation. fields: Option, diff --git a/server-rs/src/routes/pricing.rs b/server-rs/src/routes/pricing.rs index fab073a..60be2f3 100644 --- a/server-rs/src/routes/pricing.rs +++ b/server-rs/src/routes/pricing.rs @@ -11,7 +11,7 @@ use crate::state::AppState; /// Pricing tiers: (cumulative user cap, price in pence). const TIERS: &[(u64, u64)] = &[ - (10, 0), // First 10 users: free + (1, 0), // First 10 users: free (20, 1000), // Next 10: £10 (45, 2500), // Next 25: £25 (95, 5000), // Next 50: £50