better transit times
This commit is contained in:
parent
974f005549
commit
205302dbb8
22 changed files with 247 additions and 69 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<TransportMode, ComponentType<{ className?: string }>> = {
|
||||
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"
|
||||
>
|
||||
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||
{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 (
|
||||
<div
|
||||
key={mode}
|
||||
className="flex items-start justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0" onClick={() => onAddTravelTimeEntry(mode)}>
|
||||
<RouteIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0" />
|
||||
<ModeIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
|
||||
{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"
|
||||
>
|
||||
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||
{group.features.length}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||
{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"
|
||||
>
|
||||
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||
{group.features.length}
|
||||
|
|
|
|||
|
|
@ -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<TransportMode, ComponentType<{ className?: string }>> = {
|
||||
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 (
|
||||
<div className={`space-y-2 px-2 py-2 rounded ${isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RouteIcon className="w-4 h-4 text-teal-600 dark:text-teal-400" />
|
||||
<ModeIcon className="w-4 h-4 text-teal-600 dark:text-teal-400" />
|
||||
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
|
||||
Travel Time ({MODE_LABELS[mode]})
|
||||
</span>
|
||||
|
|
|
|||
23
frontend/src/components/ui/icons/BicycleIcon.tsx
Normal file
23
frontend/src/components/ui/icons/BicycleIcon.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BicycleIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="6" cy="17" r="3" />
|
||||
<circle cx="18" cy="17" r="3" />
|
||||
<path d="M6 17l3-7h4l3 7" />
|
||||
<path d="M9 10l3 4h3" />
|
||||
<circle cx="12" cy="7" r="1.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/ui/icons/CarIcon.tsx
Normal file
22
frontend/src/components/ui/icons/CarIcon.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CarIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M5 17h14v-5l-2-6H7L5 12v5z" />
|
||||
<circle cx="7.5" cy="17" r="2" />
|
||||
<circle cx="16.5" cy="17" r="2" />
|
||||
<path d="M5 12h14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
25
frontend/src/components/ui/icons/TransitIcon.tsx
Normal file
25
frontend/src/components/ui/icons/TransitIcon.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TransitIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="6" y="3" width="12" height="14" rx="3" />
|
||||
<path d="M6 12h12" />
|
||||
<circle cx="9" cy="15" r="1" />
|
||||
<circle cx="15" cy="15" r="1" />
|
||||
<path d="M9 20l-2 2" />
|
||||
<path d="M15 20l2 2" />
|
||||
<path d="M9 3V1h6v2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/ui/icons/WalkingIcon.tsx
Normal file
23
frontend/src/components/ui/icons/WalkingIcon.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function WalkingIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="4.5" r="2" />
|
||||
<path d="M13.5 9L15 15l-3 4" />
|
||||
<path d="M10.5 9L9 15l3 4" />
|
||||
<path d="M10 9h4l2 4" />
|
||||
<path d="M8 13l2-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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(';;');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Integer> findRemaining(Path modeDir, String[] names) throws Exception {
|
||||
List<Integer> 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) {
|
||||
|
|
|
|||
|
|
@ -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)");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<DestinationChunk> 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<DestinationChunk> 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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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")?;
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ pub struct ParsedEnumFilter {
|
|||
pub allowed: FxHashSet<u32>,
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ fn extract_filter_feature_names(filters_str: Option<&str>) -> Vec<String> {
|
|||
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())));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ pub struct HexagonsResponse {
|
|||
pub struct HexagonParams {
|
||||
resolution: u8,
|
||||
bounds: Option<String>,
|
||||
/// Comma-separated filters: `name:min:max,...`
|
||||
/// `;;`-separated filters: `name:min:max;;...`
|
||||
filters: Option<String>,
|
||||
/// Comma-separated feature names to include in min/max aggregation.
|
||||
fields: Option<String>,
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ pub struct PostcodesResponse {
|
|||
#[derive(Deserialize)]
|
||||
pub struct PostcodeParams {
|
||||
bounds: Option<String>,
|
||||
/// Comma-separated filters: `name:min:max,...`
|
||||
/// `;;`-separated filters: `name:min:max;;...`
|
||||
filters: Option<String>,
|
||||
/// Comma-separated feature names to include in min/max aggregation.
|
||||
fields: Option<String>,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue