good changes

This commit is contained in:
Andras Schmelczer 2026-03-25 08:04:48 +00:00
parent 160283f1a1
commit c997ea46a5
26 changed files with 991 additions and 288 deletions

View file

@ -69,6 +69,14 @@ const DATA_SOURCES = [
url: 'https://download.geofabrik.de/europe/great-britain-latest.osm.pbf',
license: 'Open Data Commons Open Database License (ODbL)',
},
{
id: 'os-open-greenspace',
name: 'OS Open Greenspace',
origin: 'Ordnance Survey',
use: 'Authoritative green space boundaries for Great Britain, including public parks, gardens, playing fields, and play spaces. Polygon centroids are used for park proximity counts and distance-to-nearest-park calculations.',
url: 'https://osdatahub.os.uk/downloads/open/OpenGreenspace',
license: 'Open Government Licence v3.0',
},
{
id: 'naptan',
name: 'NaPTAN (Public Transport Stops)',
@ -101,14 +109,6 @@ const DATA_SOURCES = [
url: 'https://www.ofcom.org.uk/phones-and-broadband/coverage-and-speeds/connected-nations-20252/data-downloads-2025',
license: 'Open Government Licence v3.0',
},
{
id: 'geosure',
name: 'GeoSure Ground Stability',
origin: 'Ordnance Survey',
use: 'Ground stability hazard ratings on a 5km hex grid covering Great Britain. Six risk categories (collapsible deposits, compressible ground, landslides, running sand, shrink-swell, and soluble rocks) rated Low, Moderate, or Significant. Spatial-joined to postcodes via centroid intersection.',
url: 'https://osdatahub.os.uk/downloads/open/GeoSure',
license: 'Open Government Licence v3.0',
},
{
id: 'council-tax',
name: 'Council Tax Levels 2025-26',

View file

@ -13,6 +13,7 @@ const LOADING_MESSAGES = [
'Analysing your query...',
'Searching for destinations...',
'Generating filters...',
'Refining results...',
];
/** Cycle through loading messages to show progress. */
@ -28,9 +29,11 @@ function useLoadingMessage(loading: boolean): string {
// Advance message every 1.5s
timerRef.current = setTimeout(() => setIndex(1), 1500);
const t2 = setTimeout(() => setIndex(2), 3500);
const t3 = setTimeout(() => setIndex(3), 5500);
return () => {
clearTimeout(timerRef.current);
clearTimeout(t2);
clearTimeout(t3);
};
}, [loading]);

View file

@ -1,5 +1,4 @@
import { useMemo, useState } from 'react';
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
import type {
FeatureFilters,
FeatureMeta,
@ -38,6 +37,8 @@ interface AreaPaneProps {
filters: FeatureFilters;
onNavigateToSource?: (slug: string, featureName: string) => void;
travelTimeEntries?: TravelTimeEntry[];
isGroupExpanded: (name: string) => boolean;
onToggleGroup: (name: string) => void;
}
export default function AreaPane({
@ -52,11 +53,12 @@ export default function AreaPane({
filters,
onNavigateToSource,
travelTimeEntries,
isGroupExpanded,
onToggleGroup,
}: AreaPaneProps) {
const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count;
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
const [collapsedGroups, toggleGroup] = useCollapsibleGroups();
const numericByName = useMemo(() => {
if (!stats) return new Map();
@ -165,17 +167,17 @@ export default function AreaPane({
) ?? []
);
const isExpanded = !collapsedGroups.has(group.name);
const expanded = isGroupExpanded(group.name);
return (
<div key={group.name}>
<CollapsibleGroupHeader
name={group.name}
expanded={isExpanded}
onToggle={() => toggleGroup(group.name)}
expanded={expanded}
onToggle={() => onToggleGroup(group.name)}
className="px-3 py-2.5 text-sm font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 z-10 hover:bg-warm-100 dark:hover:bg-warm-800"
/>
{isExpanded && (
{expanded && (
<div className="px-3 py-2 space-y-3">
{stackedCharts
? stackedCharts.map((chart) => {

View file

@ -54,7 +54,7 @@ export default function FeatureBrowser({
const [search, setSearch] = useState('');
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
const [travelInfoMode, setTravelInfoMode] = useState<TransportMode | null>(null);
const [expandedGroups, toggleGroup] = useCollapsibleGroups();
const [isGroupExpanded, toggleGroup] = useCollapsibleGroups(true);
const availableTravelModes = useTravelModes();
useEffect(() => {
@ -106,7 +106,7 @@ export default function FeatureBrowser({
</div>
<div className="md:min-h-0 md:flex-1 md:overflow-y-auto flex flex-col">
{mergedGrouped.map((group) => {
const isExpanded = isSearching || expandedGroups.has(group.name);
const isExpanded = isSearching || isGroupExpanded(group.name);
return (
<div key={group.name} className="shrink-0">
<CollapsibleGroupHeader

View file

@ -22,6 +22,7 @@ import { usePOIData } from '../../hooks/usePOIData';
import { useFilters } from '../../hooks/useFilters';
import { useHexagonSelection } from '../../hooks/useHexagonSelection';
import { usePaneResize } from '../../hooks/usePaneResize';
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
import { useAiFilters } from '../../hooks/useAiFilters';
import { useUrlSync } from '../../hooks/useUrlSync';
import { useTutorial } from '../../hooks/useTutorial';
@ -274,6 +275,7 @@ export default function MapPage({
}, []);
const pois = usePOIData(mapData.bounds, selectedPOICategories);
const [isAreaGroupExpanded, toggleAreaGroup] = useCollapsibleGroups(true);
useUrlSync(
mapData.currentView,
@ -528,6 +530,8 @@ export default function MapPage({
hexagonLocation={hexagonLocation}
filters={filters}
travelTimeEntries={travelTime.activeEntries}
isGroupExpanded={isAreaGroupExpanded}
onToggleGroup={toggleAreaGroup}
/>
);

View file

@ -25,7 +25,7 @@ export default function POIPane({
onNavigateToSource,
}: POIPaneProps) {
const [searchTerm, setSearchTerm] = useState('');
const [collapsedGroups, toggleCollapse] = useCollapsibleGroups();
const [isGroupExpanded, toggleCollapse] = useCollapsibleGroups();
const [showInfo, setShowInfo] = useState(false);
const allCategories = groups.flatMap((g) => g.categories);
@ -150,7 +150,7 @@ export default function POIPane({
const groupSelected = group.categories.filter((c) => selectedCategories.has(c)).length;
const allInGroupSelected = groupSelected === group.categories.length;
const someInGroupSelected = groupSelected > 0 && !allInGroupSelected;
const isCollapsed = collapsedGroups.has(group.name) && !searchTerm;
const isCollapsed = !isGroupExpanded(group.name) && !searchTerm;
return (
<div key={group.name}>

View file

@ -169,15 +169,6 @@ export default function Header({
Pricing
</a>
)}
{user && (
<a
href={PAGE_PATHS.saved}
className={tabClass('saved')}
onClick={(e) => navLink('saved', e)}
>
Saved
</a>
)}
</nav>
)}
</div>
@ -187,20 +178,6 @@ export default function Header({
{/* Desktop-only dashboard actions */}
{!isMobile && activePage === 'dashboard' && (
<>
{onSaveSearch && (
<button
onClick={onSaveSearch}
disabled={savingSearch}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50 disabled:cursor-wait"
>
{savingSearch ? (
<SpinnerIcon className="w-4 h-4 animate-spin" />
) : (
<BookmarkIcon className="w-4 h-4" />
)}
Save
</button>
)}
<button
onClick={handleShare}
disabled={sharing}
@ -232,8 +209,31 @@ export default function Header({
<DownloadIcon className="w-4 h-4" />
{exporting ? 'Exporting...' : 'Export'}
</button>
{onSaveSearch && (
<button
onClick={onSaveSearch}
disabled={savingSearch}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50 disabled:cursor-wait"
>
{savingSearch ? (
<SpinnerIcon className="w-4 h-4 animate-spin" />
) : (
<BookmarkIcon className="w-4 h-4" />
)}
Save
</button>
)}
</>
)}
{!isMobile && user && (
<a
href={PAGE_PATHS.saved}
className={tabClass('saved')}
onClick={(e) => navLink('saved', e)}
>
Saved
</a>
)}
{/* Desktop-only auth */}
{!isMobile && (

View file

@ -90,28 +90,10 @@ export default function MobileMenu({
mobileNavItem('pricing', 'Pricing')}
{user && mobileNavItem('invites', 'Invite Friends')}
{user && mobileNavItem('account', 'Account')}
{user && mobileNavItem('saved', 'Saved')}
{/* Dashboard actions */}
{activePage === 'dashboard' && (
<div className="mt-3 pt-3 border-t border-navy-700 flex flex-col gap-1">
{onSaveSearch && (
<button
onClick={() => {
onSaveSearch();
onClose();
}}
disabled={savingSearch}
className="w-full flex items-center gap-2 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded disabled:opacity-50"
>
{savingSearch ? (
<SpinnerIcon className="w-5 h-5 animate-spin" />
) : (
<BookmarkIcon className="w-5 h-5" />
)}
Save
</button>
)}
<button
onClick={() => {
onShare();
@ -133,8 +115,27 @@ export default function MobileMenu({
<DownloadIcon className="w-5 h-5" />
{exporting ? 'Exporting...' : 'Export'}
</button>
{onSaveSearch && (
<button
onClick={() => {
onSaveSearch();
onClose();
}}
disabled={savingSearch}
className="w-full flex items-center gap-2 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded disabled:opacity-50"
>
{savingSearch ? (
<SpinnerIcon className="w-5 h-5 animate-spin" />
) : (
<BookmarkIcon className="w-5 h-5" />
)}
Save
</button>
)}
{user && mobileNavItem('saved', 'Saved')}
</div>
)}
{activePage !== 'dashboard' && user && mobileNavItem('saved', 'Saved')}
</nav>
{/* Theme toggle + Auth section at bottom */}

View file

@ -19,6 +19,8 @@ export interface AiFiltersResult {
summary: string;
/** The listing mode used (historical/buy/rent) */
listingType: string;
/** Number of properties matching the proposed filters (excludes travel time) */
matchCount: number;
}
export type AiFilterErrorType = 'auth' | 'limit' | 'error';
@ -43,7 +45,11 @@ interface UseAiFiltersResult {
}
/** Build a human-readable summary of the AI result. */
function buildSummary(filters: FeatureFilters, travelTimeFilters: AiTravelTimeFilter[]): string {
function buildSummary(
filters: FeatureFilters,
travelTimeFilters: AiTravelTimeFilter[],
matchCount: number
): string {
const parts: string[] = [];
for (const [name, value] of Object.entries(filters)) {
@ -63,7 +69,8 @@ function buildSummary(filters: FeatureFilters, travelTimeFilters: AiTravelTimeFi
}
if (parts.length === 0) return 'No filters set';
return `Set ${parts.length} filter${parts.length > 1 ? 's' : ''}: ${parts.join(', ')}`;
const countStr = matchCount.toLocaleString();
return `${countStr} properties match · Set ${parts.length} filter${parts.length > 1 ? 's' : ''}: ${parts.join(', ')}`;
}
export function useAiFilters(): UseAiFiltersResult {
@ -137,13 +144,15 @@ export function useAiFilters(): UseAiFiltersResult {
})
);
const filters = json.filters as FeatureFilters;
const summaryText = buildSummary(filters, travelTimeFilters);
const matchCount: number = json.match_count ?? 0;
const summaryText = buildSummary(filters, travelTimeFilters, matchCount);
const result: AiFiltersResult = {
filters,
travelTimeFilters,
notes: json.notes || '',
summary: summaryText,
listingType: json.listing_type || 'historical',
matchCount,
};
setNotes(result.notes || null);
setSummary(summaryText);

View file

@ -1,14 +1,24 @@
import { useState, useCallback } from 'react';
export function useCollapsibleGroups(): [
Set<string>,
/**
* Manages collapsible group state.
* @param defaultCollapsed When true, groups start collapsed (tracks expanded groups).
* When false (default), groups start expanded (tracks collapsed groups).
*/
export function useCollapsibleGroups(defaultCollapsed = false): [
(name: string) => boolean,
(name: string) => void,
(name: string) => void,
] {
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [toggled, setToggled] = useState<Set<string>>(new Set());
const isExpanded = useCallback(
(name: string) => (defaultCollapsed ? toggled.has(name) : !toggled.has(name)),
[toggled, defaultCollapsed]
);
const toggle = useCallback((name: string) => {
setCollapsed((prev) => {
setToggled((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
@ -16,14 +26,24 @@ export function useCollapsibleGroups(): [
});
}, []);
const expand = useCallback((name: string) => {
setCollapsed((prev) => {
if (!prev.has(name)) return prev;
const next = new Set(prev);
next.delete(name);
return next;
});
}, []);
const expand = useCallback(
(name: string) => {
setToggled((prev) => {
if (defaultCollapsed) {
if (prev.has(name)) return prev;
const next = new Set(prev);
next.add(name);
return next;
} else {
if (!prev.has(name)) return prev;
const next = new Set(prev);
next.delete(name);
return next;
}
});
},
[defaultCollapsed]
);
return [collapsed, toggle, expand];
return [isExpanded, toggle, expand];
}

View file

@ -321,7 +321,7 @@ export function useDeckLayers({
ttVal as number,
ttVal as number,
clr,
null,
fr,
0,
densityGradientRef.current,
dark,
@ -422,7 +422,7 @@ export function useDeckLayers({
ttVal as number,
ttVal as number,
clr,
null,
fr,
0,
densityGradientRef.current,
dark,

View file

@ -119,11 +119,14 @@ export function useMapData({
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
const isTravelTimeDrag = activeFeature.startsWith('tt_');
const dragTravelParam = isTravelTimeDrag ? buildTravelParam(activeFeature) : travelParam;
// Travel time fields are computed from the travel param, not regular feature columns.
// Sending a tt_* name as fields would cause a 400 (unknown field). Use empty string instead.
const fieldsParam = isTravelTimeDrag ? '' : activeFeature;
if (usePostcodeView) {
const params = new URLSearchParams({ bounds: boundsStr });
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', activeFeature);
params.set('fields', fieldsParam);
if (dragTravelParam) params.set('travel', dragTravelParam);
fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal }))
@ -140,7 +143,7 @@ export function useMapData({
bounds: boundsStr,
});
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', activeFeature);
params.set('fields', fieldsParam);
if (dragTravelParam) params.set('travel', dragTravelParam);
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))

View file

@ -189,22 +189,7 @@ export const STACKED_ENUM_GROUPS: Record<
valueColors: ['#3b82f6', '#f59e0b'],
},
],
Environment: [
{
label: 'Ground Risk',
feature: 'Environmental risk',
components: [
'Collapsible deposits risk',
'Compressible ground risk',
'Landslide risk',
'Running sand risk',
'Shrink-swell risk',
'Soluble rocks risk',
],
valueOrder: ['Low', 'Moderate', 'Significant'],
valueColors: ['#22c55e', '#eab308', '#ef4444'],
},
],
Environment: [],
};
/**

View file

@ -443,52 +443,6 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
<line x1="12" y1="20" x2="12.01" y2="20" />
</>
),
'Environmental risk': (
<>
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</>
),
'Collapsible deposits risk': (
<>
<polyline points="12 2 2 7 12 12 22 7 12 2" />
<polyline points="2 17 12 22 22 17" />
<polyline points="2 12 12 17 22 12" />
</>
),
'Compressible ground risk': (
<>
<line x1="12" y1="2" x2="12" y2="22" />
<polyline points="16 6 12 2 8 6" />
<polyline points="16 18 12 22 8 18" />
<line x1="4" y1="12" x2="20" y2="12" />
</>
),
'Landslide risk': (
<>
<path d="M8 3l4 8 5-5 5 15H2L8 3z" />
</>
),
'Running sand risk': (
<>
<path d="M2 6c2-1 4-1 6 0s4 1 6 0 4-1 6 0" />
<path d="M2 12c2-1 4-1 6 0s4 1 6 0 4-1 6 0" />
<path d="M2 18c2-1 4-1 6 0s4 1 6 0 4-1 6 0" />
</>
),
'Shrink-swell risk': (
<>
<line x1="2" y1="12" x2="22" y2="12" />
<polyline points="6 8 2 12 6 16" />
<polyline points="18 8 22 12 18 16" />
</>
),
'Soluble rocks risk': (
<>
<path d="M12 2.69l5.66 5.66a8 8 0 11-11.31 0z" />
</>
),
};
/**

View file

@ -186,5 +186,13 @@ export function summarizeParams(queryString: string): string {
}
}
const ttParams = params.getAll('tt');
if (ttParams.length > 0) {
const count = ttParams.filter(Boolean).length;
if (count > 0) {
parts.push(`${count} travel time ${count === 1 ? 'destination' : 'destinations'}`);
}
}
return parts.length > 0 ? parts.join(' + ') : 'No filters';
}