all good
This commit is contained in:
parent
e9a06417ad
commit
5e5d9f9a1c
16 changed files with 280 additions and 44 deletions
|
|
@ -33,6 +33,7 @@ import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
||||||
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
||||||
import { EmptyState } from '../ui/EmptyState';
|
import { EmptyState } from '../ui/EmptyState';
|
||||||
import { FeatureLabel } from '../ui/FeatureLabel';
|
import { FeatureLabel } from '../ui/FeatureLabel';
|
||||||
|
import { IndeterminateProgressBar } from '../ui/IndeterminateProgressBar';
|
||||||
import StreetViewEmbed from './StreetViewEmbed';
|
import StreetViewEmbed from './StreetViewEmbed';
|
||||||
import HistogramLegend from './HistogramLegend';
|
import HistogramLegend from './HistogramLegend';
|
||||||
import JourneyInstructions from './JourneyInstructions';
|
import JourneyInstructions from './JourneyInstructions';
|
||||||
|
|
@ -164,7 +165,9 @@ export default function AreaPane({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="h-full overflow-y-auto">
|
<div className="relative flex h-full flex-col">
|
||||||
|
<IndeterminateProgressBar show={loading && stats != null} />
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="border-b border-warm-200 bg-white dark:border-navy-700 dark:bg-navy-950">
|
<div className="border-b border-warm-200 bg-white dark:border-navy-700 dark:bg-navy-950">
|
||||||
<div className="space-y-3 p-3">
|
<div className="space-y-3 p-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
|
@ -611,6 +614,7 @@ export default function AreaPane({
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{infoFeature && (
|
{infoFeature && (
|
||||||
|
|
|
||||||
82
frontend/src/components/map/JourneyInstructions.test.tsx
Normal file
82
frontend/src/components/map/JourneyInstructions.test.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { cleanup, render, screen } from '@testing-library/react';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import JourneyInstructions, { googleMapsUrl } from './JourneyInstructions';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, values?: Record<string, string | number>) => {
|
||||||
|
if (key === 'areaPane.to') return `To ${values?.destination}`;
|
||||||
|
if (key === 'areaPane.journeysFrom') return `Journeys from ${values?.label}`;
|
||||||
|
if (key === 'common.min') return 'min';
|
||||||
|
if (key === 'common.loading') return 'Loading';
|
||||||
|
if (key === 'travel.bestCase') return 'Best case';
|
||||||
|
if (key === 'areaPane.walk') return 'Walk';
|
||||||
|
if (key === 'areaPane.cycle') return 'Cycle';
|
||||||
|
if (key === 'areaPane.viewOnGoogleMaps') return 'View on Google Maps';
|
||||||
|
if (key === 'areaPane.noJourneyData') return 'No journey data';
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('JourneyInstructions', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the transit leg breakdown visible when best-case time is selected', () => {
|
||||||
|
render(
|
||||||
|
<JourneyInstructions
|
||||||
|
postcode="E14 2DG"
|
||||||
|
entries={[]}
|
||||||
|
presetJourneys={[
|
||||||
|
{
|
||||||
|
slug: 'bank',
|
||||||
|
label: 'Bank',
|
||||||
|
minutes: 42,
|
||||||
|
bestMinutes: 25,
|
||||||
|
useBest: true,
|
||||||
|
legs: [
|
||||||
|
{ mode: 'walk', minutes: 8 },
|
||||||
|
{
|
||||||
|
mode: 'Jubilee',
|
||||||
|
from: 'Canary Wharf (9400ZZLUCAW)',
|
||||||
|
to: 'London Bridge (9400ZZLULNB)',
|
||||||
|
minutes: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: 'Northern',
|
||||||
|
from: 'London Bridge (9400ZZLULNB)',
|
||||||
|
to: 'Bank (9400ZZLUBNK)',
|
||||||
|
minutes: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
showGoogleMapsLink={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Best case/)).toBeTruthy();
|
||||||
|
expect(screen.getByText('Jubilee line')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Northern line')).toBeTruthy();
|
||||||
|
expect(screen.getByText(/Canary Wharf/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds explicit Google Maps transit directions instead of a path URL', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-05-16T12:00:00Z'));
|
||||||
|
|
||||||
|
const url = googleMapsUrl('NW7 2GA', 'Bank tube station');
|
||||||
|
const parsed = new URL(url);
|
||||||
|
|
||||||
|
expect(parsed.origin + parsed.pathname).toBe('https://www.google.com/maps/dir/');
|
||||||
|
expect(parsed.searchParams.get('api')).toBe('1');
|
||||||
|
expect(parsed.searchParams.get('origin')).toBe('NW7 2GA');
|
||||||
|
expect(parsed.searchParams.get('destination')).toBe('Bank Station, London');
|
||||||
|
expect(parsed.searchParams.get('travelmode')).toBe('transit');
|
||||||
|
expect(parsed.searchParams.get('departure_time')).toBe('1779085800');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -94,15 +94,24 @@ function nextMondayAt730(): number {
|
||||||
return Math.floor(monday.getTime() / 1000);
|
return Math.floor(monday.getTime() / 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function googleMapsUrl(origin: string, destination: string): string {
|
function googleMapsDestination(destination: string): string {
|
||||||
|
const clean = stripId(destination).trim();
|
||||||
|
if (/\btube station$/i.test(clean)) {
|
||||||
|
return `${clean.replace(/\s+tube station$/i, ' Station')}, London`;
|
||||||
|
}
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function googleMapsUrl(origin: string, destination: string): string {
|
||||||
const ts = nextMondayAt730();
|
const ts = nextMondayAt730();
|
||||||
const encodedOrigin = encodeURIComponent(origin);
|
const params = new URLSearchParams({
|
||||||
const encodedDestination = encodeURIComponent(destination);
|
api: '1',
|
||||||
// The official api=1 URL scheme doesn't support departure_time.
|
origin,
|
||||||
// Use the undocumented data= path parameter with protobuf-like encoding:
|
destination: googleMapsDestination(destination),
|
||||||
// !3e3 = transit, !6e0 = "depart at", !7e2 = local time, !8j = timestamp
|
travelmode: 'transit',
|
||||||
const data = `!4m6!4m5!2m3!6e0!7e2!8j${ts}!3e3`;
|
departure_time: ts.toString(),
|
||||||
return `https://www.google.com/maps/dir/${encodedOrigin}/${encodedDestination}/data=${data}`;
|
});
|
||||||
|
return `https://www.google.com/maps/dir/?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function invertLegs(legs: JourneyLeg[]): JourneyLeg[] {
|
function invertLegs(legs: JourneyLeg[]): JourneyLeg[] {
|
||||||
|
|
@ -287,7 +296,7 @@ export default function JourneyInstructions({
|
||||||
const legSum = j.legs ? j.legs.reduce((sum, l) => sum + l.minutes, 0) : 0;
|
const legSum = j.legs ? j.legs.reduce((sum, l) => sum + l.minutes, 0) : 0;
|
||||||
const totalMin = j.useBest && j.bestMinutes != null ? j.bestMinutes : (j.minutes ?? legSum);
|
const totalMin = j.useBest && j.bestMinutes != null ? j.bestMinutes : (j.minutes ?? legSum);
|
||||||
const isBestCase = j.useBest && j.bestMinutes != null;
|
const isBestCase = j.useBest && j.bestMinutes != null;
|
||||||
const displayLegs = !isBestCase && j.legs ? invertLegs(j.legs) : null;
|
const displayLegs = j.legs ? invertLegs(j.legs) : null;
|
||||||
const destination = j.label || j.slug;
|
const destination = j.label || j.slug;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import InfoPopup from '../ui/InfoPopup';
|
||||||
import { SearchInput } from '../ui/SearchInput';
|
import { SearchInput } from '../ui/SearchInput';
|
||||||
import { EmptyState } from '../ui/EmptyState';
|
import { EmptyState } from '../ui/EmptyState';
|
||||||
import { InfoIcon } from '../ui/icons';
|
import { InfoIcon } from '../ui/icons';
|
||||||
|
import { IndeterminateProgressBar } from '../ui/IndeterminateProgressBar';
|
||||||
import { ts } from '../../i18n/server';
|
import { ts } from '../../i18n/server';
|
||||||
|
|
||||||
interface PropertiesPaneProps {
|
interface PropertiesPaneProps {
|
||||||
|
|
@ -57,7 +58,9 @@ export function PropertiesPane({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-y-auto">
|
<div className="relative flex h-full flex-col">
|
||||||
|
<IndeterminateProgressBar show={loading && properties.length > 0} />
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
{showInfo && (
|
{showInfo && (
|
||||||
<InfoPopup
|
<InfoPopup
|
||||||
title={t('propertyCard.propertyData')}
|
title={t('propertyCard.propertyData')}
|
||||||
|
|
@ -116,6 +119,7 @@ export function PropertiesPane({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,14 +110,14 @@ export function AddFilterPanel({
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={onToggleCollapsed}
|
onClick={onToggleCollapsed}
|
||||||
className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-navy-800 dark:border-navy-700 bg-navy-900 dark:bg-navy-900 cursor-pointer hover:bg-navy-800 dark:hover:bg-navy-800"
|
className="shrink-0 flex items-center justify-between border-b border-l-4 border-warm-200 border-l-teal-500 bg-white px-3 py-2 cursor-pointer shadow-sm hover:bg-warm-50 dark:border-navy-700 dark:border-l-teal-400 dark:bg-navy-900 dark:hover:bg-navy-800"
|
||||||
>
|
>
|
||||||
<span className="text-sm font-semibold text-warm-100 dark:text-warm-100">
|
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">
|
||||||
{t('filters.addFilter')}
|
{t('filters.addFilter')}
|
||||||
</span>
|
</span>
|
||||||
<ChevronIcon
|
<ChevronIcon
|
||||||
direction={collapsed ? 'down' : 'up'}
|
direction={collapsed ? 'down' : 'up'}
|
||||||
className="w-4 h-4 text-warm-300 dark:text-warm-300"
|
className="w-4 h-4 text-warm-500 dark:text-warm-300"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
{(!collapsed || !isLicensed) && (
|
{(!collapsed || !isLicensed) && (
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
|
||||||
import type { getTutorialStyles } from '../../../lib/tutorial-styles';
|
import type { getTutorialStyles } from '../../../lib/tutorial-styles';
|
||||||
import type { SearchedLocation } from '../LocationSearch';
|
import type { SearchedLocation } from '../LocationSearch';
|
||||||
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
|
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
|
||||||
|
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
|
||||||
import type { MapFlyTo, PaneResizeHandlers } from './types';
|
import type { MapFlyTo, PaneResizeHandlers } from './types';
|
||||||
import { MapFallback, PaneFallback } from './Fallbacks';
|
import { MapFallback, PaneFallback } from './Fallbacks';
|
||||||
import { LoadingOverlay } from './LoadingOverlay';
|
import { LoadingOverlay } from './LoadingOverlay';
|
||||||
|
|
@ -151,6 +152,7 @@ export function DesktopMapPage({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div data-tutorial="map" className="flex-1 relative">
|
<div data-tutorial="map" className="flex-1 relative">
|
||||||
|
<IndeterminateProgressBar show={mapData.loading && !initialLoading} />
|
||||||
<Suspense fallback={<MapFallback />}>
|
<Suspense fallback={<MapFallback />}>
|
||||||
<Map
|
<Map
|
||||||
data={mapData.data}
|
data={mapData.data}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
|
||||||
import type { SearchedLocation } from '../LocationSearch';
|
import type { SearchedLocation } from '../LocationSearch';
|
||||||
import MobileBottomSheet from '../MobileBottomSheet';
|
import MobileBottomSheet from '../MobileBottomSheet';
|
||||||
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
|
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
|
||||||
|
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
|
||||||
import type { MapFlyTo } from './types';
|
import type { MapFlyTo } from './types';
|
||||||
import { MapFallback, PaneFallback } from './Fallbacks';
|
import { MapFallback, PaneFallback } from './Fallbacks';
|
||||||
import { LoadingOverlay } from './LoadingOverlay';
|
import { LoadingOverlay } from './LoadingOverlay';
|
||||||
|
|
@ -100,6 +101,7 @@ export function MobileMapPage({
|
||||||
<LoadingOverlay show={initialLoading} />
|
<LoadingOverlay show={initialLoading} />
|
||||||
|
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
|
<IndeterminateProgressBar show={mapData.loading && !initialLoading} />
|
||||||
<Suspense fallback={<MapFallback />}>
|
<Suspense fallback={<MapFallback />}>
|
||||||
<Map
|
<Map
|
||||||
data={mapData.data}
|
data={mapData.data}
|
||||||
|
|
|
||||||
22
frontend/src/components/ui/IndeterminateProgressBar.tsx
Normal file
22
frontend/src/components/ui/IndeterminateProgressBar.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
interface IndeterminateProgressBarProps {
|
||||||
|
show: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IndeterminateProgressBar({
|
||||||
|
show,
|
||||||
|
className = '',
|
||||||
|
}: IndeterminateProgressBarProps) {
|
||||||
|
if (!show) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="progressbar"
|
||||||
|
aria-busy="true"
|
||||||
|
aria-valuetext="loading"
|
||||||
|
className={`pointer-events-none absolute top-0 left-0 right-0 z-30 h-0.5 overflow-hidden bg-teal-500/10 dark:bg-teal-400/10 animate-fade-in ${className}`}
|
||||||
|
>
|
||||||
|
<div className="h-full w-1/4 bg-teal-500 dark:bg-teal-400 animate-indeterminate-progress" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -194,6 +194,82 @@ describe('useMapData', () => {
|
||||||
expect(result.current.colorRange?.[1]).toBeCloseTo(95);
|
expect(result.current.colorRange?.[1]).toBeCloseTo(95);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not use metadata min/max while slider preview colour data is loading', async () => {
|
||||||
|
const bounds = { south: 1, west: 1, north: 2, east: 2 };
|
||||||
|
const features: FeatureMeta[] = [
|
||||||
|
{
|
||||||
|
name: 'price',
|
||||||
|
type: 'numeric',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const filters = { price: [20, 80] as [number, number] };
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({
|
||||||
|
viewFeature,
|
||||||
|
activeFeature,
|
||||||
|
}: {
|
||||||
|
viewFeature: string | null;
|
||||||
|
activeFeature: string | null;
|
||||||
|
}) =>
|
||||||
|
useMapData({
|
||||||
|
filters,
|
||||||
|
features,
|
||||||
|
viewFeature,
|
||||||
|
activeFeature,
|
||||||
|
pinnedFeature: null,
|
||||||
|
travelTimeEntries: noTravelTimeEntries,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
initialProps: {
|
||||||
|
viewFeature: null as string | null,
|
||||||
|
activeFeature: null as string | null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.handleViewChange(viewChange(bounds));
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(150);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
requests[0].resolve(
|
||||||
|
response([
|
||||||
|
{ h3: 'density-low', count: 1, lat: 1.25, lon: 1.25 },
|
||||||
|
{ h3: 'density-high', count: 1, lat: 1.75, lon: 1.75 },
|
||||||
|
])
|
||||||
|
);
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
rerender({
|
||||||
|
viewFeature: 'price',
|
||||||
|
activeFeature: 'price',
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.colorRange).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
requests[1].resolve(
|
||||||
|
response([
|
||||||
|
{ h3: 'preview-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 },
|
||||||
|
{ h3: 'preview-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 100 },
|
||||||
|
])
|
||||||
|
);
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.colorRange?.[0]).toBeCloseTo(5);
|
||||||
|
expect(result.current.colorRange?.[1]).toBeCloseTo(95);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not reuse cached drag preview data when the drag request changes', async () => {
|
it('does not reuse cached drag preview data when the drag request changes', async () => {
|
||||||
const bounds = { south: 1, west: 1, north: 2, east: 2 };
|
const bounds = { south: 1, west: 1, north: 2, east: 2 };
|
||||||
const features: FeatureMeta[] = [
|
const features: FeatureMeta[] = [
|
||||||
|
|
|
||||||
|
|
@ -516,9 +516,19 @@ export function useMapData({
|
||||||
return [0, meta.values.length - 1];
|
return [0, meta.values.length - 1];
|
||||||
}
|
}
|
||||||
if (dataRange) return dataRange;
|
if (dataRange) return dataRange;
|
||||||
|
if (activeFeature && !hasMatchingDragData) return null;
|
||||||
|
if (loadedDataKey !== dataRequestKey) return null;
|
||||||
if (meta.min != null && meta.max != null) return [meta.min, meta.max];
|
if (meta.min != null && meta.max != null) return [meta.min, meta.max];
|
||||||
return null;
|
return null;
|
||||||
}, [dataViewFeature, features, dataRange]);
|
}, [
|
||||||
|
activeFeature,
|
||||||
|
dataRequestKey,
|
||||||
|
dataRange,
|
||||||
|
dataViewFeature,
|
||||||
|
features,
|
||||||
|
hasMatchingDragData,
|
||||||
|
loadedDataKey,
|
||||||
|
]);
|
||||||
|
|
||||||
const isEyePreviewingPinnedFeature =
|
const isEyePreviewingPinnedFeature =
|
||||||
!activeFeature && dataViewFeature != null && dataViewFeature === pinnedDataViewFeature;
|
!activeFeature && dataViewFeature != null && dataViewFeature === pinnedDataViewFeature;
|
||||||
|
|
@ -642,13 +652,19 @@ export function useMapData({
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Treat the map as loading whenever the rendered hexagons don't match the
|
||||||
|
// current request — covers the brief window between a slider release and
|
||||||
|
// the main fetch effect actually firing setLoading(true).
|
||||||
|
const isLoading =
|
||||||
|
loading || (bounds != null && !licenseRequired && loadedDataKey !== dataRequestKey);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
committedHexagonData: rawData,
|
committedHexagonData: rawData,
|
||||||
postcodeData: effectivePostcodeData,
|
postcodeData: effectivePostcodeData,
|
||||||
resolution,
|
resolution,
|
||||||
bounds,
|
bounds,
|
||||||
loading,
|
loading: isLoading,
|
||||||
zoom,
|
zoom,
|
||||||
currentView,
|
currentView,
|
||||||
currentVisibleView,
|
currentVisibleView,
|
||||||
|
|
|
||||||
|
|
@ -806,7 +806,7 @@ const en = {
|
||||||
lowerMinTo: 'Lower minimum to {{value}}',
|
lowerMinTo: 'Lower minimum to {{value}}',
|
||||||
raiseMaxTo: 'Raise maximum to {{value}}',
|
raiseMaxTo: 'Raise maximum to {{value}}',
|
||||||
allowCategory: 'Allow {{value}}',
|
allowCategory: 'Allow {{value}}',
|
||||||
missingFilterValue: 'No value for this filter; remove it or allow missing values',
|
missingFilterValue: 'No value for this filter; remove it',
|
||||||
noFilterDataShort: 'No data',
|
noFilterDataShort: 'No data',
|
||||||
travelTo: 'Travel to {{destination}}',
|
travelTo: 'Travel to {{destination}}',
|
||||||
viewProperties: 'View {{count}} Properties',
|
viewProperties: 'View {{count}} Properties',
|
||||||
|
|
|
||||||
|
|
@ -55,9 +55,14 @@ module.exports = {
|
||||||
'0%': { opacity: '0' },
|
'0%': { opacity: '0' },
|
||||||
'100%': { opacity: '1' },
|
'100%': { opacity: '1' },
|
||||||
},
|
},
|
||||||
|
'indeterminate-progress': {
|
||||||
|
'0%': { transform: 'translateX(-100%)' },
|
||||||
|
'100%': { transform: 'translateX(400%)' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
'fade-in': 'fade-in 0.2s ease-out forwards',
|
'fade-in': 'fade-in 0.2s ease-out forwards',
|
||||||
|
'indeterminate-progress': 'indeterminate-progress 1.1s ease-in-out infinite',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ set -euo pipefail
|
||||||
# --demo only compute Bank + TCR, transit only (quick test)
|
# --demo only compute Bank + TCR, transit only (quick test)
|
||||||
|
|
||||||
# --- Defaults ---
|
# --- Defaults ---
|
||||||
THREADS=12
|
THREADS=6
|
||||||
HEAP=48g
|
HEAP=40g
|
||||||
NETWORK_DIR=property-data/r5-network
|
NETWORK_DIR=property-data/r5-network
|
||||||
OUTPUT_BASE=property-data/travel-times
|
OUTPUT_BASE=property-data/travel-times
|
||||||
R5_DIR=r5-java
|
R5_DIR=r5-java
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@ pub const H3_REQUEST_MAX: u8 = 12;
|
||||||
pub const SERVER_ADDRESS: &str = "0.0.0.0:8001";
|
pub const SERVER_ADDRESS: &str = "0.0.0.0:8001";
|
||||||
|
|
||||||
pub const GRID_CELL_SIZE: f32 = 0.01;
|
pub const GRID_CELL_SIZE: f32 = 0.01;
|
||||||
pub const MAX_POIS_PER_REQUEST: usize = 10000;
|
pub const MAX_CELLS_PER_REQUEST: usize = 200000;
|
||||||
|
pub const MAX_POIS_PER_REQUEST: usize = 3000;
|
||||||
|
|
||||||
pub const DEFAULT_PROPERTIES_LIMIT: usize = 100;
|
pub const DEFAULT_PROPERTIES_LIMIT: usize = 100;
|
||||||
pub const MAX_PRICE_HISTORY_POINTS: usize = 5000;
|
pub const MAX_PRICE_HISTORY_POINTS: usize = 5000;
|
||||||
|
|
|
||||||
|
|
@ -216,14 +216,6 @@ struct Cli {
|
||||||
#[arg(long, env = "STRIPE_REFERRAL_COUPON_ID")]
|
#[arg(long, env = "STRIPE_REFERRAL_COUPON_ID")]
|
||||||
stripe_referral_coupon_id: String,
|
stripe_referral_coupon_id: String,
|
||||||
|
|
||||||
/// Bearer token required to scrape /metrics.
|
|
||||||
#[arg(long, env = "METRICS_BEARER_TOKEN")]
|
|
||||||
metrics_bearer_token: Option<String>,
|
|
||||||
|
|
||||||
/// Allow unauthenticated /metrics scraping when no METRICS_BEARER_TOKEN is set.
|
|
||||||
#[arg(long, env = "ALLOW_PUBLIC_METRICS", default_value_t = false)]
|
|
||||||
allow_public_metrics: bool,
|
|
||||||
|
|
||||||
/// Google OAuth client ID for PocketBase SSO
|
/// Google OAuth client ID for PocketBase SSO
|
||||||
#[arg(long, env = "GOOGLE_OAUTH_CLIENT_ID")]
|
#[arg(long, env = "GOOGLE_OAUTH_CLIENT_ID")]
|
||||||
google_oauth_client_id: String,
|
google_oauth_client_id: String,
|
||||||
|
|
@ -255,8 +247,6 @@ async fn main() -> anyhow::Result<()> {
|
||||||
info!("Prometheus metrics initialized");
|
info!("Prometheus metrics initialized");
|
||||||
|
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
let metrics_bearer_token = cli.metrics_bearer_token.clone();
|
|
||||||
let allow_public_metrics = cli.allow_public_metrics;
|
|
||||||
|
|
||||||
for (label, path) in [
|
for (label, path) in [
|
||||||
("Properties", &cli.properties),
|
("Properties", &cli.properties),
|
||||||
|
|
@ -680,13 +670,8 @@ async fn main() -> anyhow::Result<()> {
|
||||||
.route("/health", get(|| async { "ok" }))
|
.route("/health", get(|| async { "ok" }))
|
||||||
.route(
|
.route(
|
||||||
"/metrics",
|
"/metrics",
|
||||||
get(move |headers| {
|
get(move |connect_info| {
|
||||||
metrics::metrics_handler(
|
metrics::metrics_handler(metrics_handle.clone(), connect_info)
|
||||||
metrics_handle.clone(),
|
|
||||||
metrics_bearer_token.clone(),
|
|
||||||
allow_public_metrics,
|
|
||||||
headers,
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.with_state(shared.clone());
|
.with_state(shared.clone());
|
||||||
|
|
@ -732,6 +717,11 @@ async fn main() -> anyhow::Result<()> {
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("Failed to bind to {addr}"))?;
|
.with_context(|| format!("Failed to bind to {addr}"))?;
|
||||||
info!("Server listening on {}", addr);
|
info!("Server listening on {}", addr);
|
||||||
axum::serve(listener, app).await.context("Server error")?;
|
axum::serve(
|
||||||
|
listener,
|
||||||
|
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.context("Server error")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::extract::Request;
|
use axum::extract::{ConnectInfo, Request};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::middleware::Next;
|
use axum::middleware::Next;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use metrics::{counter, gauge, histogram};
|
use metrics::{counter, gauge, histogram};
|
||||||
use metrics_exporter_prometheus::{Matcher, PrometheusBuilder, PrometheusHandle};
|
use metrics_exporter_prometheus::{Matcher, PrometheusBuilder, PrometheusHandle};
|
||||||
|
use std::net::{IpAddr, SocketAddr};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
/// Initialize the Prometheus metrics exporter and return a handle for rendering metrics.
|
/// Initialize the Prometheus metrics exporter and return a handle for rendering metrics.
|
||||||
|
|
@ -144,17 +145,39 @@ fn normalize_path(path: &str) -> String {
|
||||||
"/other".to_string()
|
"/other".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handler for the /metrics endpoint.
|
/// Handler for the /metrics endpoint. Only accepts requests from peers on the
|
||||||
pub async fn metrics_handler(handle: PrometheusHandle) -> impl IntoResponse {
|
/// same private network (loopback, RFC1918, or IPv6 unique/link-local).
|
||||||
// Update process metrics before rendering
|
pub async fn metrics_handler(
|
||||||
|
handle: PrometheusHandle,
|
||||||
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
|
) -> Response {
|
||||||
|
if !is_same_network(peer.ip()) {
|
||||||
|
return StatusCode::FORBIDDEN.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
update_process_metrics();
|
update_process_metrics();
|
||||||
|
|
||||||
match handle.render() {
|
match handle.render() {
|
||||||
output if !output.is_empty() => (StatusCode::OK, output),
|
output if !output.is_empty() => (StatusCode::OK, output).into_response(),
|
||||||
_ => (
|
_ => (
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
"Failed to render metrics".to_string(),
|
"Failed to render metrics".to_string(),
|
||||||
),
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_same_network(ip: IpAddr) -> bool {
|
||||||
|
match ip {
|
||||||
|
IpAddr::V4(v4) => v4.is_loopback() || v4.is_private() || v4.is_link_local(),
|
||||||
|
IpAddr::V6(v6) => {
|
||||||
|
v6.is_loopback()
|
||||||
|
|| (v6.segments()[0] & 0xfe00) == 0xfc00
|
||||||
|
|| (v6.segments()[0] & 0xffc0) == 0xfe80
|
||||||
|
|| v6.to_ipv4_mapped().is_some_and(|v4| {
|
||||||
|
v4.is_loopback() || v4.is_private() || v4.is_link_local()
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue