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:
|
cmds:
|
||||||
- uv run python -m pipeline.download.map_assets --output frontend/public/assets
|
- 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:
|
test:
|
||||||
desc: Run all tests (Python and Rust)
|
desc: Run all tests (Python and Rust)
|
||||||
cmds:
|
cmds:
|
||||||
|
|
@ -45,10 +40,6 @@ tasks:
|
||||||
cmds:
|
cmds:
|
||||||
- docker compose up --build
|
- docker compose up --build
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
build:server:
|
build:server:
|
||||||
desc: Build server for production
|
desc: Build server for production
|
||||||
dir: server-rs
|
dir: server-rs
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,18 @@ import { groupFeaturesByCategory } from '../../lib/features';
|
||||||
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
||||||
import { FeatureActions } from '../ui/FeatureIcons';
|
import { FeatureActions } from '../ui/FeatureIcons';
|
||||||
import { FeatureLabel } from '../ui/FeatureLabel';
|
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 { IconButton } from '../ui/IconButton';
|
||||||
import { TRANSPORT_MODES, MODE_LABELS, travelFieldKey, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
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 {
|
interface FeatureBrowserProps {
|
||||||
availableFeatures: FeatureMeta[];
|
availableFeatures: FeatureMeta[];
|
||||||
allFeatures: FeatureMeta[];
|
allFeatures: FeatureMeta[];
|
||||||
|
|
@ -77,7 +85,7 @@ export default function FeatureBrowser({
|
||||||
name="Travel Time"
|
name="Travel Time"
|
||||||
expanded={isSearching || expandedGroups.has('Travel Time')}
|
expanded={isSearching || expandedGroups.has('Travel Time')}
|
||||||
onToggle={() => toggleGroup('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">
|
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||||
{TRANSPORT_MODES.length}
|
{TRANSPORT_MODES.length}
|
||||||
|
|
@ -87,13 +95,14 @@ export default function FeatureBrowser({
|
||||||
const activeEntry = travelTimeEntries.find((e) => e.mode === mode && e.slug);
|
const activeEntry = travelTimeEntries.find((e) => e.mode === mode && e.slug);
|
||||||
const fieldKey = activeEntry ? travelFieldKey(activeEntry) : null;
|
const fieldKey = activeEntry ? travelFieldKey(activeEntry) : null;
|
||||||
const isPinned = fieldKey !== null && pinnedFeature === fieldKey;
|
const isPinned = fieldKey !== null && pinnedFeature === fieldKey;
|
||||||
|
const ModeIcon = MODE_ICONS[mode];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={mode}
|
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"
|
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)}>
|
<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">
|
<div className="min-w-0">
|
||||||
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
|
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
|
||||||
{MODE_LABELS[mode]}
|
{MODE_LABELS[mode]}
|
||||||
|
|
@ -131,7 +140,7 @@ export default function FeatureBrowser({
|
||||||
name={group.name}
|
name={group.name}
|
||||||
expanded={isExpanded}
|
expanded={isExpanded}
|
||||||
onToggle={() => toggleGroup(group.name)}
|
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">
|
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||||
{group.features.length}
|
{group.features.length}
|
||||||
|
|
|
||||||
|
|
@ -254,7 +254,7 @@ export default memo(function Filters({
|
||||||
name="Travel Time"
|
name="Travel Time"
|
||||||
expanded={!collapsedGroups.has('Travel Time')}
|
expanded={!collapsedGroups.has('Travel Time')}
|
||||||
onToggle={() => toggleGroup('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">
|
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||||
{travelTimeEntries.length}
|
{travelTimeEntries.length}
|
||||||
|
|
@ -296,7 +296,7 @@ export default memo(function Filters({
|
||||||
name={group.name}
|
name={group.name}
|
||||||
expanded={isExpanded}
|
expanded={isExpanded}
|
||||||
onToggle={() => toggleGroup(group.name)}
|
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">
|
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||||
{group.features.length}
|
{group.features.length}
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,21 @@ import { PlaceSearchInput } from '../ui/PlaceSearchInput';
|
||||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||||
import { EyeIcon } from '../ui/icons/EyeIcon';
|
import { EyeIcon } from '../ui/icons/EyeIcon';
|
||||||
import { MapPinIcon } from '../ui/icons/MapPinIcon';
|
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 { formatFilterValue } from '../../lib/format';
|
||||||
import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch';
|
import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch';
|
||||||
import { MODE_LABELS, type TransportMode } from '../../hooks/useTravelTime';
|
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 {
|
interface TravelTimeCardProps {
|
||||||
mode: TransportMode;
|
mode: TransportMode;
|
||||||
|
|
@ -63,12 +74,14 @@ export function TravelTimeCard({
|
||||||
const sliderMax = dataRange ? Math.ceil(dataRange[1]) : 120;
|
const sliderMax = dataRange ? Math.ceil(dataRange[1]) : 120;
|
||||||
const displayRange = timeRange ?? [sliderMin, sliderMax];
|
const displayRange = timeRange ?? [sliderMin, sliderMax];
|
||||||
|
|
||||||
|
const ModeIcon = MODE_ICONS[mode];
|
||||||
|
|
||||||
return (
|
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' : ''}`}>
|
<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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-1.5">
|
<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">
|
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
|
||||||
Travel Time ({MODE_LABELS[mode]})
|
Travel Time ({MODE_LABELS[mode]})
|
||||||
</span>
|
</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 { LightbulbIcon } from './LightbulbIcon';
|
||||||
export { MenuIcon } from './MenuIcon';
|
export { MenuIcon } from './MenuIcon';
|
||||||
export { RouteIcon } from './RouteIcon';
|
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);
|
const maxStr = meta?.absolute && max === meta.max ? 'inf' : String(max);
|
||||||
return `${name}:${min}:${maxStr}`;
|
return `${name}:${min}:${maxStr}`;
|
||||||
})
|
})
|
||||||
.join(',');
|
.join(';;');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ set -euo pipefail
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./r5-java/run.sh
|
# ./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 ---
|
# --- Defaults ---
|
||||||
THREADS=16
|
THREADS=16
|
||||||
|
|
@ -30,6 +30,7 @@ while [[ $# -gt 0 ]]; do
|
||||||
--threads) THREADS="$2"; shift 2 ;;
|
--threads) THREADS="$2"; shift 2 ;;
|
||||||
--heap) HEAP="$2"; shift 2 ;;
|
--heap) HEAP="$2"; shift 2 ;;
|
||||||
--network-dir) NETWORK_DIR="$2"; shift 2 ;;
|
--network-dir) NETWORK_DIR="$2"; shift 2 ;;
|
||||||
|
--output-dir) OUTPUT_BASE="$2"; shift 2 ;;
|
||||||
*) echo "Unknown: $1"; exit 1 ;;
|
*) echo "Unknown: $1"; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
@ -75,7 +76,7 @@ fi
|
||||||
|
|
||||||
if [ ! -f "$DUCKDB_JAR" ]; then
|
if [ ! -f "$DUCKDB_JAR" ]; then
|
||||||
echo "--- Downloading DuckDB JDBC ---"
|
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
|
fi
|
||||||
|
|
||||||
# --- Step 3: Compile Java source ---
|
# --- Step 3: Compile Java source ---
|
||||||
|
|
@ -107,8 +108,12 @@ if [ ! -f "$NETWORK_DIR/network.dat" ]; then
|
||||||
BUILD_DIR="$NETWORK_DIR/build"
|
BUILD_DIR="$NETWORK_DIR/build"
|
||||||
echo "--- No cached network — copying transit data to build dir ---"
|
echo "--- No cached network — copying transit data to build dir ---"
|
||||||
mkdir -p "$BUILD_DIR"
|
mkdir -p "$BUILD_DIR"
|
||||||
cp property-data/transit/raw/*.osm.pbf "$BUILD_DIR/" 2>/dev/null || true
|
if ! cp property-data/transit/raw/*.osm.pbf "$BUILD_DIR/" 2>/dev/null; then
|
||||||
cp property-data/transit/*.zip "$BUILD_DIR/" 2>/dev/null || true
|
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"
|
DATA_DIR="$BUILD_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import java.nio.file.Paths;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.ScheduledExecutorService;
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
|
@ -24,7 +25,8 @@ import java.util.concurrent.atomic.AtomicInteger;
|
||||||
* postcodes are written (unreachable = absent from file).
|
* postcodes are written (unreachable = absent from file).
|
||||||
*
|
*
|
||||||
* Output per mode: one parquet file per origin in {output-dir}/{mode}/{name}.parquet
|
* 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 {
|
public class App {
|
||||||
|
|
||||||
|
|
@ -117,15 +119,14 @@ public class App {
|
||||||
c, total, rate, etaH, failed.get());
|
c, total, rate, etaH, failed.get());
|
||||||
}, 2, 2, TimeUnit.SECONDS);
|
}, 2, 2, TimeUnit.SECONDS);
|
||||||
|
|
||||||
// Submit all work, wait for completion via CountDownLatch-like pattern
|
CountDownLatch latch = new CountDownLatch(remaining.size());
|
||||||
java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(remaining.size());
|
|
||||||
|
|
||||||
for (int idx : remaining) {
|
for (int idx : remaining) {
|
||||||
pool.submit(() -> {
|
pool.submit(() -> {
|
||||||
try {
|
try {
|
||||||
processOrigin(network, postcodes, postcodeLats, postcodeLons,
|
processOrigin(network, postcodes, postcodeLats, postcodeLons,
|
||||||
originLats[idx], originLons[idx],
|
originLats[idx], originLons[idx],
|
||||||
modeDir, mode, date, originNames[idx], threadConn.get());
|
modeDir, mode, date, idx, originNames[idx], threadConn.get());
|
||||||
completed.incrementAndGet();
|
completed.incrementAndGet();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
failed.incrementAndGet();
|
failed.incrementAndGet();
|
||||||
|
|
@ -138,6 +139,7 @@ public class App {
|
||||||
|
|
||||||
latch.await();
|
latch.await();
|
||||||
reporter.shutdown();
|
reporter.shutdown();
|
||||||
|
reporter.awaitTermination(5, TimeUnit.SECONDS);
|
||||||
|
|
||||||
double elapsedH = (System.currentTimeMillis() - startMs) / 3_600_000.0;
|
double elapsedH = (System.currentTimeMillis() - startMs) / 3_600_000.0;
|
||||||
int n = completed.get();
|
int n = completed.get();
|
||||||
|
|
@ -150,10 +152,10 @@ public class App {
|
||||||
TransportNetwork network,
|
TransportNetwork network,
|
||||||
String[] postcodes, double[] postcodeLats, double[] postcodeLons,
|
String[] postcodes, double[] postcodeLats, double[] postcodeLons,
|
||||||
double originLat, double originLon,
|
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 {
|
DuckDBConnection conn) throws Exception {
|
||||||
|
|
||||||
Path outPath = modeDir.resolve(sanitizeFilename(name) + ".parquet");
|
Path outPath = modeDir.resolve(originFilename(index, name));
|
||||||
Exception lastError = null;
|
Exception lastError = null;
|
||||||
|
|
||||||
for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||||
|
|
@ -168,16 +170,22 @@ public class App {
|
||||||
|
|
||||||
String[] codes = new String[reachable];
|
String[] codes = new String[reachable];
|
||||||
short[] times = new short[reachable];
|
short[] times = new short[reachable];
|
||||||
|
short[] bestTimes = result.bestTimes() != null ? new short[reachable] : null;
|
||||||
int j = 0;
|
int j = 0;
|
||||||
for (int i = 0; i < result.times().length; i++) {
|
for (int i = 0; i < result.times().length; i++) {
|
||||||
if (result.times()[i] >= 0) {
|
if (result.times()[i] >= 0) {
|
||||||
codes[j] = postcodes[result.originalIndices()[i]];
|
codes[j] = postcodes[result.originalIndices()[i]];
|
||||||
times[j] = result.times()[i];
|
times[j] = result.times()[i];
|
||||||
|
if (bestTimes != null) bestTimes[j] = result.bestTimes()[i];
|
||||||
j++;
|
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;
|
return;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
lastError = e;
|
lastError = e;
|
||||||
|
|
@ -194,7 +202,7 @@ public class App {
|
||||||
private static List<Integer> findRemaining(Path modeDir, String[] names) throws Exception {
|
private static List<Integer> findRemaining(Path modeDir, String[] names) throws Exception {
|
||||||
List<Integer> remaining = new ArrayList<>();
|
List<Integer> remaining = new ArrayList<>();
|
||||||
for (int i = 0; i < names.length; i++) {
|
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) {
|
if (!Files.exists(f) || Files.size(f) == 0) {
|
||||||
remaining.add(i);
|
remaining.add(i);
|
||||||
}
|
}
|
||||||
|
|
@ -202,11 +210,12 @@ public class App {
|
||||||
return remaining;
|
return remaining;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sanitize a place name into a safe filename (lowercase, spaces to hyphens, strip non-alphanumeric). */
|
/** Build a filename from index + place name (index prefix prevents collisions after sanitization). */
|
||||||
private static String sanitizeFilename(String name) {
|
private static String originFilename(int index, String name) {
|
||||||
return name.toLowerCase()
|
String safe = name.toLowerCase()
|
||||||
.replaceAll("[^a-z0-9 -]", "")
|
.replaceAll("[^a-z0-9 -]", "")
|
||||||
.replaceAll("\\s+", "-");
|
.replaceAll("\\s+", "-");
|
||||||
|
return String.format("%04d-%s.parquet", index, safe);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String requiredArg(String[] args, String name) {
|
private static String requiredArg(String[] args, String name) {
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,16 @@ public class Parquet {
|
||||||
catch (ClassNotFoundException e) { throw new RuntimeException(e); }
|
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. */
|
/** Load England postcodes, write reference parquet, return codes + flat lat/lon arrays. */
|
||||||
static Postcodes loadEnglandPostcodes(String parquetPath, Path refOut) throws Exception {
|
static Postcodes loadEnglandPostcodes(String parquetPath, Path refOut) throws Exception {
|
||||||
try (DuckDBConnection conn = connect(); Statement stmt = conn.createStatement()) {
|
try (DuckDBConnection conn = connect(); Statement stmt = conn.createStatement()) {
|
||||||
stmt.execute("CREATE TABLE postcodes AS SELECT pcds, lat, \"long\" FROM read_parquet('"
|
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);
|
copyToParquet(stmt, "SELECT * FROM postcodes", refOut);
|
||||||
|
|
||||||
try (ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM postcodes")) {
|
try (ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM postcodes")) {
|
||||||
|
|
@ -56,7 +61,7 @@ public class Parquet {
|
||||||
try (DuckDBConnection conn = connect(); Statement stmt = conn.createStatement()) {
|
try (DuckDBConnection conn = connect(); Statement stmt = conn.createStatement()) {
|
||||||
stmt.execute("CREATE TABLE places AS SELECT * EXCLUDE (rn) FROM ("
|
stmt.execute("CREATE TABLE places AS SELECT * EXCLUDE (rn) FROM ("
|
||||||
+ "SELECT *, ROW_NUMBER() OVER (PARTITION BY lat, lon) AS rn "
|
+ "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);
|
copyToParquet(stmt, "SELECT * FROM places", refOut);
|
||||||
|
|
||||||
try (ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM places")) {
|
try (ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM places")) {
|
||||||
|
|
@ -97,7 +102,30 @@ public class Parquet {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try (Statement stmt = conn.createStatement()) {
|
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);
|
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 {
|
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)");
|
+ "' (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 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 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. */
|
/** 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) {
|
static double maxRadiusKm(String mode) {
|
||||||
return switch (mode) {
|
return switch (mode) {
|
||||||
case "car" -> 150;
|
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 {
|
static TransportNetwork loadNetwork(String dataDir, String cacheDir) throws Exception {
|
||||||
System.err.println("Loading transport network...");
|
System.err.println("Loading transport network...");
|
||||||
File cacheFile = new File(cacheDir, "network.dat");
|
File cacheFile = new File(cacheDir, "network.dat");
|
||||||
|
|
@ -78,7 +84,7 @@ public class Router {
|
||||||
// 1. Filter destinations by bounding box
|
// 1. Filter destinations by bounding box
|
||||||
int[] filtered = filterByDistance(allLats, allLons, originLat, originLon, maxRadius);
|
int[] filtered = filterByDistance(allLats, allLons, originLat, originLon, maxRadius);
|
||||||
if (filtered.length == 0) {
|
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
|
// 2. Extract filtered coordinate arrays
|
||||||
|
|
@ -93,9 +99,14 @@ public class Router {
|
||||||
List<DestinationChunk> chunks = buildDestinationChunks(fLats, fLons);
|
List<DestinationChunk> chunks = buildDestinationChunks(fLats, fLons);
|
||||||
|
|
||||||
// 4. Compute travel times
|
// 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;
|
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,
|
TransportNetwork network, List<DestinationChunk> chunks,
|
||||||
double originLat, double originLon, String mode, int nDest, LocalDate date) {
|
double originLat, double originLon, String mode, int nDest, LocalDate date) {
|
||||||
|
|
||||||
short[] times = new short[nDest];
|
boolean isTransit = mode.equals("transit");
|
||||||
Arrays.fill(times, (short) -1);
|
int nPercentiles = isTransit ? 2 : 1;
|
||||||
|
short[][] allTimes = new short[nPercentiles][nDest];
|
||||||
|
for (short[] arr : allTimes) Arrays.fill(arr, (short) -1);
|
||||||
|
|
||||||
for (DestinationChunk chunk : chunks) {
|
for (DestinationChunk chunk : chunks) {
|
||||||
RegionalTask task = buildTask(chunk, originLat, originLon, mode, date);
|
RegionalTask task = buildTask(chunk, originLat, originLon, mode, date);
|
||||||
|
|
@ -191,14 +207,16 @@ public class Router {
|
||||||
TravelTimeResult tt = result.travelTimes;
|
TravelTimeResult tt = result.travelTimes;
|
||||||
if (tt != null) {
|
if (tt != null) {
|
||||||
int[][] values = tt.getValues();
|
int[][] values = tt.getValues();
|
||||||
for (int i = 0; i < chunk.originalIndices.length && i < values[0].length; i++) {
|
for (int p = 0; p < nPercentiles && p < values.length; p++) {
|
||||||
if (values[0][i] != Integer.MAX_VALUE) {
|
for (int i = 0; i < chunk.originalIndices.length && i < values[p].length; i++) {
|
||||||
times[chunk.originalIndices[i]] = (short) values[0][i];
|
if (values[p][i] != Integer.MAX_VALUE) {
|
||||||
|
allTimes[p][chunk.originalIndices[i]] = (short) values[p][i];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return times;
|
return allTimes;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Private helpers ---
|
// --- Private helpers ---
|
||||||
|
|
@ -241,7 +259,7 @@ public class Router {
|
||||||
task.fromLat = originLat;
|
task.fromLat = originLat;
|
||||||
task.fromLon = originLon;
|
task.fromLon = originLon;
|
||||||
task.date = date;
|
task.date = date;
|
||||||
task.percentiles = new int[]{50};
|
task.percentiles = mode.equals("transit") ? new int[]{5, 50} : new int[]{50};
|
||||||
task.recordTimes = true;
|
task.recordTimes = true;
|
||||||
task.destinationPointSets = new PointSet[]{chunk.pointSet};
|
task.destinationPointSets = new PointSet[]{chunk.pointSet};
|
||||||
task.zoom = chunk.extents.zoom;
|
task.zoom = chunk.extents.zoom;
|
||||||
|
|
@ -249,9 +267,9 @@ public class Router {
|
||||||
task.north = chunk.extents.north;
|
task.north = chunk.extents.north;
|
||||||
task.width = chunk.extents.width;
|
task.width = chunk.extents.width;
|
||||||
task.height = chunk.extents.height;
|
task.height = chunk.extents.height;
|
||||||
task.fromTime = 8 * 3600;
|
task.fromTime = DEPARTURE_FROM_TIME;
|
||||||
task.toTime = 8 * 3600 + 60;
|
task.toTime = DEPARTURE_TO_TIME;
|
||||||
task.maxTripDurationMinutes = 120;
|
task.maxTripDurationMinutes = MAX_TRIP_DURATION_MINUTES;
|
||||||
|
|
||||||
configureMode(task, mode);
|
configureMode(task, mode);
|
||||||
return task;
|
return task;
|
||||||
|
|
@ -267,13 +285,14 @@ public class Router {
|
||||||
task.accessModes = EnumSet.of(LegMode.WALK);
|
task.accessModes = EnumSet.of(LegMode.WALK);
|
||||||
task.egressModes = EnumSet.of(LegMode.WALK);
|
task.egressModes = EnumSet.of(LegMode.WALK);
|
||||||
task.directModes = 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);
|
default -> throw new IllegalArgumentException("Unknown mode: " + mode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void setDirectMode(RegionalTask task, LegMode legMode) {
|
private static void setDirectMode(RegionalTask task, LegMode legMode) {
|
||||||
|
task.maxRides = 0;
|
||||||
task.accessModes = EnumSet.of(legMode);
|
task.accessModes = EnumSet.of(legMode);
|
||||||
task.egressModes = EnumSet.of(legMode);
|
task.egressModes = EnumSet.of(legMode);
|
||||||
task.directModes = EnumSet.of(legMode);
|
task.directModes = EnumSet.of(legMode);
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,9 @@ async fn validate_token(
|
||||||
.ok()?;
|
.ok()?;
|
||||||
|
|
||||||
if !res.status().is_success() {
|
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;
|
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_MAX_TOKENS: usize = 2000;
|
||||||
pub const AI_FILTERS_TEMPERATURE: f32 = 0.0;
|
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.
|
/// Inner London free zone bounds (south, west, north, east) — roughly zones 1–2.
|
||||||
/// Users without a license can only query data within these bounds.
|
/// 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);
|
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
|
/// 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.
|
/// when the center of the requested bounds is within DEMO_CENTER_TOLERANCE of this point.
|
||||||
/// Must match DEMO_VIEW_START in ScrollStory.tsx.
|
/// 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;
|
pub const DEMO_CENTER_TOLERANCE: f64 = 1.0;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{bail, Context};
|
use anyhow::{bail, Context};
|
||||||
|
use consts::SERVICE_CALL_TIMEOUT;
|
||||||
use axum::middleware;
|
use axum::middleware;
|
||||||
use axum::routing::{any, get, patch, post};
|
use axum::routing::{any, get, patch, post};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
|
@ -286,7 +287,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
};
|
};
|
||||||
|
|
||||||
let http_client = reqwest::Client::builder()
|
let http_client = reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(30))
|
.timeout(Duration::from_secs(SERVICE_CALL_TIMEOUT))
|
||||||
.connect_timeout(Duration::from_secs(5))
|
.connect_timeout(Duration::from_secs(5))
|
||||||
.build()
|
.build()
|
||||||
.context("Failed to build HTTP client")?;
|
.context("Failed to build HTTP client")?;
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ pub struct ParsedEnumFilter {
|
||||||
pub allowed: FxHashSet<u32>,
|
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`
|
/// Numeric format: `name:min:max`
|
||||||
/// Enum format: `name:val1|val2|val3` (pipe-separated string values)
|
/// Enum format: `name:val1|val2|val3` (pipe-separated string values)
|
||||||
///
|
///
|
||||||
|
|
@ -35,7 +35,7 @@ pub fn parse_filters(
|
||||||
None => return Ok((numeric, enums)),
|
None => return Ok((numeric, enums)),
|
||||||
};
|
};
|
||||||
|
|
||||||
for entry in input.split(',') {
|
for entry in input.split(";;") {
|
||||||
let parts: Vec<&str> = entry.splitn(2, ':').collect();
|
let parts: Vec<&str> = entry.splitn(2, ':').collect();
|
||||||
if parts.len() != 2 {
|
if parts.len() != 2 {
|
||||||
return Err(format!("Malformed filter entry (missing ':'): '{entry}'"));
|
return Err(format!("Malformed filter entry (missing ':'): '{entry}'"));
|
||||||
|
|
@ -234,7 +234,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_multiple_numeric_filters() {
|
fn parse_multiple_numeric_filters() {
|
||||||
let (numeric, _enums) = parse_filters(
|
let (numeric, _enums) = parse_filters(
|
||||||
Some("Price:100000:500000,Area:50:200"),
|
Some("Price:100000:500000;;Area:50:200"),
|
||||||
&extended_feature_map(),
|
&extended_feature_map(),
|
||||||
&extended_enum_values(),
|
&extended_enum_values(),
|
||||||
)
|
)
|
||||||
|
|
@ -248,7 +248,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_mixed_filters() {
|
fn parse_mixed_filters() {
|
||||||
let (numeric, enums) = parse_filters(
|
let (numeric, enums) = parse_filters(
|
||||||
Some("Price:100000:500000,Type:Semi|Terraced"),
|
Some("Price:100000:500000;;Type:Semi|Terraced"),
|
||||||
&extended_feature_map(),
|
&extended_feature_map(),
|
||||||
&extended_enum_values(),
|
&extended_enum_values(),
|
||||||
)
|
)
|
||||||
|
|
@ -288,7 +288,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_filter_with_whitespace() {
|
fn parse_filter_with_whitespace() {
|
||||||
let (numeric, enums) = parse_filters(
|
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_feature_map(),
|
||||||
&extended_enum_values(),
|
&extended_enum_values(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ fn extract_filter_feature_names(filters_str: Option<&str>) -> Vec<String> {
|
||||||
None => return Vec::new(),
|
None => return Vec::new(),
|
||||||
};
|
};
|
||||||
let mut names = 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();
|
let parts: Vec<&str> = entry.splitn(2, ':').collect();
|
||||||
if parts.len() == 2 {
|
if parts.len() == 2 {
|
||||||
let name = parts[0].trim().to_string();
|
let name = parts[0].trim().to_string();
|
||||||
|
|
@ -110,7 +110,7 @@ fn build_frontend_params(
|
||||||
];
|
];
|
||||||
if let Some(fs) = filters_str {
|
if let Some(fs) = filters_str {
|
||||||
if !fs.is_empty() {
|
if !fs.is_empty() {
|
||||||
for entry in fs.split(',') {
|
for entry in fs.split(";;") {
|
||||||
if !entry.is_empty() {
|
if !entry.is_empty() {
|
||||||
parts.push(format!("filter={}", urlencoding::encode(entry.trim())));
|
parts.push(format!("filter={}", urlencoding::encode(entry.trim())));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ pub struct HexagonsResponse {
|
||||||
pub struct HexagonParams {
|
pub struct HexagonParams {
|
||||||
resolution: u8,
|
resolution: u8,
|
||||||
bounds: Option<String>,
|
bounds: Option<String>,
|
||||||
/// Comma-separated filters: `name:min:max,...`
|
/// `;;`-separated filters: `name:min:max;;...`
|
||||||
filters: Option<String>,
|
filters: Option<String>,
|
||||||
/// Comma-separated feature names to include in min/max aggregation.
|
/// Comma-separated feature names to include in min/max aggregation.
|
||||||
fields: Option<String>,
|
fields: Option<String>,
|
||||||
|
|
@ -191,7 +191,7 @@ pub async fn get_hexagons(
|
||||||
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
||||||
|
|
||||||
// Allow the homepage demo: check if the center of the requested bounds
|
// 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_lat = (south + north) / 2.0;
|
||||||
let center_lng = (west + east) / 2.0;
|
let center_lng = (west + east) / 2.0;
|
||||||
let is_demo_view = (center_lat - DEMO_CENTER.0).abs() <= DEMO_CENTER_TOLERANCE
|
let is_demo_view = (center_lat - DEMO_CENTER.0).abs() <= DEMO_CENTER_TOLERANCE
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ pub struct PostcodesResponse {
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct PostcodeParams {
|
pub struct PostcodeParams {
|
||||||
bounds: Option<String>,
|
bounds: Option<String>,
|
||||||
/// Comma-separated filters: `name:min:max,...`
|
/// `;;`-separated filters: `name:min:max;;...`
|
||||||
filters: Option<String>,
|
filters: Option<String>,
|
||||||
/// Comma-separated feature names to include in min/max aggregation.
|
/// Comma-separated feature names to include in min/max aggregation.
|
||||||
fields: Option<String>,
|
fields: Option<String>,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ use crate::state::AppState;
|
||||||
|
|
||||||
/// Pricing tiers: (cumulative user cap, price in pence).
|
/// Pricing tiers: (cumulative user cap, price in pence).
|
||||||
const TIERS: &[(u64, u64)] = &[
|
const TIERS: &[(u64, u64)] = &[
|
||||||
(10, 0), // First 10 users: free
|
(1, 0), // First 10 users: free
|
||||||
(20, 1000), // Next 10: £10
|
(20, 1000), // Next 10: £10
|
||||||
(45, 2500), // Next 25: £25
|
(45, 2500), // Next 25: £25
|
||||||
(95, 5000), // Next 50: £50
|
(95, 5000), // Next 50: £50
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue