Add filter info
This commit is contained in:
parent
01ec17ff04
commit
f7d586a1e9
5 changed files with 682 additions and 234 deletions
|
|
@ -14,11 +14,13 @@ import type {
|
||||||
POI,
|
POI,
|
||||||
POIResponse,
|
POIResponse,
|
||||||
POICategoriesResponse,
|
POICategoriesResponse,
|
||||||
|
ViewState,
|
||||||
Property,
|
Property,
|
||||||
HexagonPropertiesResponse,
|
HexagonPropertiesResponse,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const DEBOUNCE_MS = 150;
|
const DEBOUNCE_MS = 150;
|
||||||
|
const URL_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
// Detect if running through VS Code web proxy and construct API base URL
|
// Detect if running through VS Code web proxy and construct API base URL
|
||||||
function getApiBaseUrl(): string {
|
function getApiBaseUrl(): string {
|
||||||
|
|
@ -45,23 +47,240 @@ function getApiBaseUrl(): string {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_VIEW: ViewState = {
|
||||||
|
longitude: -1.5,
|
||||||
|
latitude: 53.5,
|
||||||
|
zoom: 6,
|
||||||
|
pitch: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- URL State helpers ---
|
||||||
|
|
||||||
|
function parseUrlState(): {
|
||||||
|
viewState?: ViewState;
|
||||||
|
filters?: FeatureFilters;
|
||||||
|
poiCategories?: Set<string>;
|
||||||
|
tab?: 'pois' | 'properties';
|
||||||
|
} {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const result: ReturnType<typeof parseUrlState> = {};
|
||||||
|
|
||||||
|
// Parse view: v=lat,lng,zoom
|
||||||
|
const v = params.get('v');
|
||||||
|
if (v) {
|
||||||
|
const parts = v.split(',').map(Number);
|
||||||
|
if (parts.length === 3 && parts.every((n) => !isNaN(n))) {
|
||||||
|
result.viewState = {
|
||||||
|
latitude: parts[0],
|
||||||
|
longitude: parts[1],
|
||||||
|
zoom: parts[2],
|
||||||
|
pitch: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse filters: f=name:min:max,name:val1|val2
|
||||||
|
const f = params.get('f');
|
||||||
|
if (f) {
|
||||||
|
const filters: FeatureFilters = {};
|
||||||
|
for (const segment of f.split(',')) {
|
||||||
|
const colonIdx = segment.indexOf(':');
|
||||||
|
if (colonIdx === -1) continue;
|
||||||
|
const name = segment.substring(0, colonIdx);
|
||||||
|
const rest = segment.substring(colonIdx + 1);
|
||||||
|
if (rest.includes(':')) {
|
||||||
|
// Numeric: name:min:max
|
||||||
|
const [minStr, maxStr] = rest.split(':');
|
||||||
|
const min = Number(minStr);
|
||||||
|
const max = Number(maxStr);
|
||||||
|
if (!isNaN(min) && !isNaN(max)) {
|
||||||
|
filters[name] = [min, max];
|
||||||
|
}
|
||||||
|
} else if (rest.includes('|')) {
|
||||||
|
// Enum: name:val1|val2
|
||||||
|
filters[name] = rest.split('|');
|
||||||
|
} else {
|
||||||
|
// Single enum value
|
||||||
|
filters[name] = [rest];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(filters).length > 0) {
|
||||||
|
result.filters = filters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse POI categories: poi=Cafe,Pub,School
|
||||||
|
const poi = params.get('poi');
|
||||||
|
if (poi) {
|
||||||
|
result.poiCategories = new Set(poi.split(',').filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse tab: tab=p or tab=o
|
||||||
|
const tab = params.get('tab');
|
||||||
|
if (tab === 'p') result.tab = 'properties';
|
||||||
|
else if (tab === 'o') result.tab = 'pois';
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateToParams(
|
||||||
|
viewState: { latitude: number; longitude: number; zoom: number } | null,
|
||||||
|
filters: FeatureFilters,
|
||||||
|
features: FeatureMeta[],
|
||||||
|
selectedPOICategories: Set<string>,
|
||||||
|
rightPaneTab: 'pois' | 'properties'
|
||||||
|
): URLSearchParams {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
// View
|
||||||
|
if (viewState) {
|
||||||
|
params.set(
|
||||||
|
'v',
|
||||||
|
`${viewState.latitude.toFixed(4)},${viewState.longitude.toFixed(4)},${viewState.zoom.toFixed(1)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const filterEntries = Object.entries(filters);
|
||||||
|
if (filterEntries.length > 0) {
|
||||||
|
const filtersStr = filterEntries
|
||||||
|
.map(([name, value]) => {
|
||||||
|
const meta = features.find((f) => f.name === name);
|
||||||
|
if (meta?.type === 'enum') {
|
||||||
|
return `${name}:${(value as string[]).join('|')}`;
|
||||||
|
}
|
||||||
|
const [min, max] = value as [number, number];
|
||||||
|
return `${name}:${min}:${max}`;
|
||||||
|
})
|
||||||
|
.join(',');
|
||||||
|
params.set('f', filtersStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POI categories
|
||||||
|
if (selectedPOICategories.size > 0) {
|
||||||
|
params.set('poi', Array.from(selectedPOICategories).join(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab (only if non-default)
|
||||||
|
if (rightPaneTab === 'properties') {
|
||||||
|
params.set('tab', 'p');
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Header ---
|
||||||
|
|
||||||
|
function Header() {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleShare = useCallback(() => {
|
||||||
|
navigator.clipboard.writeText(window.location.href).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="h-12 bg-slate-800 text-white flex items-center justify-between px-4 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-blue-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-semibold text-lg">Property Map</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleShare}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-slate-700 hover:bg-slate-600 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Copied!
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Share
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- App ---
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
// Parse URL state once on mount
|
||||||
|
const urlState = useMemo(() => parseUrlState(), []);
|
||||||
|
|
||||||
const [features, setFeatures] = useState<FeatureMeta[]>([]);
|
const [features, setFeatures] = useState<FeatureMeta[]>([]);
|
||||||
const [filters, setFilters] = useState<FeatureFilters>({});
|
const [filters, setFilters] = useState<FeatureFilters>(urlState.filters || {});
|
||||||
const [activeFeature, setActiveFeature] = useState<string | null>(null);
|
const [activeFeature, setActiveFeature] = useState<string | null>(null);
|
||||||
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
|
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
|
||||||
const [rawData, setRawData] = useState<HexagonData[]>([]);
|
const [rawData, setRawData] = useState<HexagonData[]>([]);
|
||||||
const [resolution, setResolution] = useState<number>(8);
|
const [resolution, setResolution] = useState<number>(8);
|
||||||
const [bounds, setBounds] = useState<Bounds | null>(null);
|
const [bounds, setBounds] = useState<Bounds | null>(null);
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
const [zoom, setZoom] = useState<number>(6);
|
const [zoom, setZoom] = useState<number>(urlState.viewState?.zoom || DEFAULT_VIEW.zoom);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
// View state for URL serialization
|
||||||
|
const [currentView, setCurrentView] = useState<{
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
zoom: number;
|
||||||
|
} | null>(
|
||||||
|
urlState.viewState
|
||||||
|
? {
|
||||||
|
latitude: urlState.viewState.latitude,
|
||||||
|
longitude: urlState.viewState.longitude,
|
||||||
|
zoom: urlState.viewState.zoom,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initial view state for Map
|
||||||
|
const initialViewState = useMemo(() => urlState.viewState || DEFAULT_VIEW, []);
|
||||||
|
|
||||||
// POI state
|
// POI state
|
||||||
const [pois, setPois] = useState<POI[]>([]);
|
const [pois, setPois] = useState<POI[]>([]);
|
||||||
const [poiCategories, setPOICategories] = useState<string[]>([]);
|
const [poiCategories, setPOICategories] = useState<string[]>([]);
|
||||||
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<string>>(new Set());
|
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<string>>(
|
||||||
|
urlState.poiCategories || new Set()
|
||||||
|
);
|
||||||
const poiDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const poiDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const poiAbortControllerRef = useRef<AbortController | null>(null);
|
const poiAbortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
|
@ -73,18 +292,42 @@ export default function App() {
|
||||||
const [propertiesTotal, setPropertiesTotal] = useState(0);
|
const [propertiesTotal, setPropertiesTotal] = useState(0);
|
||||||
const [propertiesOffset, setPropertiesOffset] = useState(0);
|
const [propertiesOffset, setPropertiesOffset] = useState(0);
|
||||||
const [loadingProperties, setLoadingProperties] = useState(false);
|
const [loadingProperties, setLoadingProperties] = useState(false);
|
||||||
const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties'>('pois');
|
const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties'>(urlState.tab || 'pois');
|
||||||
|
|
||||||
// Derive enabled features from filter keys
|
// Derive enabled features from filter keys
|
||||||
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
|
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
|
||||||
|
|
||||||
|
// --- URL sync ---
|
||||||
|
const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (urlDebounceRef.current) {
|
||||||
|
clearTimeout(urlDebounceRef.current);
|
||||||
|
}
|
||||||
|
urlDebounceRef.current = setTimeout(() => {
|
||||||
|
const params = stateToParams(
|
||||||
|
currentView,
|
||||||
|
filters,
|
||||||
|
features,
|
||||||
|
selectedPOICategories,
|
||||||
|
rightPaneTab
|
||||||
|
);
|
||||||
|
const search = params.toString();
|
||||||
|
const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname;
|
||||||
|
window.history.replaceState(null, '', newUrl);
|
||||||
|
}, URL_DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (urlDebounceRef.current) clearTimeout(urlDebounceRef.current);
|
||||||
|
};
|
||||||
|
}, [currentView, filters, features, selectedPOICategories, rightPaneTab]);
|
||||||
|
|
||||||
// Fetch feature metadata + POI categories on mount
|
// Fetch feature metadata + POI categories on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${getApiBaseUrl()}/api/features`)
|
fetch(`${getApiBaseUrl()}/api/features`)
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((json: { features: FeatureMeta[] }) => {
|
.then((json: { features: FeatureMeta[] }) => {
|
||||||
setFeatures(json.features);
|
setFeatures(json.features);
|
||||||
// Start with no filters (empty object)
|
|
||||||
})
|
})
|
||||||
.catch((err) => console.error('Failed to fetch features:', err));
|
.catch((err) => console.error('Failed to fetch features:', err));
|
||||||
|
|
||||||
|
|
@ -117,11 +360,18 @@ export default function App() {
|
||||||
resolution: resolution.toString(),
|
resolution: resolution.toString(),
|
||||||
bounds: boundsStr,
|
bounds: boundsStr,
|
||||||
});
|
});
|
||||||
// Build filters param: name:min:max,...
|
// Build filters param: numeric=name:min:max, enum=name:val1|val2
|
||||||
const filterEntries = Object.entries(filters);
|
const filterEntries = Object.entries(filters);
|
||||||
if (filterEntries.length > 0) {
|
if (filterEntries.length > 0) {
|
||||||
const filtersStr = filterEntries
|
const filtersStr = filterEntries
|
||||||
.map(([name, [min, max]]) => `${name}:${min}:${max}`)
|
.map(([name, value]) => {
|
||||||
|
const meta = features.find((f) => f.name === name);
|
||||||
|
if (meta?.type === 'enum') {
|
||||||
|
return `${name}:${(value as string[]).join('|')}`;
|
||||||
|
}
|
||||||
|
const [min, max] = value as [number, number];
|
||||||
|
return `${name}:${min}:${max}`;
|
||||||
|
})
|
||||||
.join(',');
|
.join(',');
|
||||||
params.set('filters', filtersStr);
|
params.set('filters', filtersStr);
|
||||||
}
|
}
|
||||||
|
|
@ -146,17 +396,8 @@ export default function App() {
|
||||||
};
|
};
|
||||||
}, [resolution, bounds, filters]);
|
}, [resolution, bounds, filters]);
|
||||||
|
|
||||||
// Client-side filtering: only during drag preview
|
// Data passed directly to Map — visual filtering is done via color/opacity in the layer
|
||||||
const data = useMemo(() => {
|
const data = rawData;
|
||||||
if (!activeFeature || !dragValue) return rawData;
|
|
||||||
|
|
||||||
return rawData.filter((hex) => {
|
|
||||||
const minVal = hex[`min_${activeFeature}`];
|
|
||||||
const maxVal = hex[`max_${activeFeature}`];
|
|
||||||
if (minVal == null || maxVal == null) return false;
|
|
||||||
return (minVal as number) <= dragValue[1] && (maxVal as number) >= dragValue[0];
|
|
||||||
});
|
|
||||||
}, [rawData, activeFeature, dragValue]);
|
|
||||||
|
|
||||||
// Fetch POIs when bounds or selected categories change
|
// Fetch POIs when bounds or selected categories change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -201,11 +442,18 @@ export default function App() {
|
||||||
};
|
};
|
||||||
}, [bounds, selectedPOICategories]);
|
}, [bounds, selectedPOICategories]);
|
||||||
|
|
||||||
|
const prevBoundsRef = useRef<string>('');
|
||||||
const handleViewChange = useCallback(
|
const handleViewChange = useCallback(
|
||||||
({ resolution: newRes, bounds: newBounds, zoom: newZoom }: ViewChangeParams) => {
|
({ resolution: newRes, bounds: newBounds, zoom: newZoom, latitude, longitude }: ViewChangeParams) => {
|
||||||
setResolution(newRes);
|
// Only update bounds/resolution when quantized values actually change
|
||||||
setBounds(newBounds);
|
const boundsKey = `${newBounds.south},${newBounds.west},${newBounds.north},${newBounds.east},${newRes}`;
|
||||||
|
if (boundsKey !== prevBoundsRef.current) {
|
||||||
|
prevBoundsRef.current = boundsKey;
|
||||||
|
setResolution(newRes);
|
||||||
|
setBounds(newBounds);
|
||||||
|
}
|
||||||
setZoom(newZoom);
|
setZoom(newZoom);
|
||||||
|
setCurrentView({ latitude, longitude, zoom: newZoom });
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
@ -214,11 +462,22 @@ export default function App() {
|
||||||
(name: string) => {
|
(name: string) => {
|
||||||
const meta = features.find((f) => f.name === name);
|
const meta = features.find((f) => f.name === name);
|
||||||
if (!meta) return;
|
if (!meta) return;
|
||||||
setFilters((prev) => ({ ...prev, [name]: [meta.min, meta.max] }));
|
if (meta.type === 'enum' && meta.values) {
|
||||||
|
setFilters((prev) => ({ ...prev, [name]: [...meta.values!] }));
|
||||||
|
} else if (meta.min != null && meta.max != null) {
|
||||||
|
setFilters((prev) => ({ ...prev, [name]: [meta.min!, meta.max!] }));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[features]
|
[features]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleFilterChange = useCallback(
|
||||||
|
(name: string, value: [number, number] | string[]) => {
|
||||||
|
setFilters((prev) => ({ ...prev, [name]: value }));
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const handleRemoveFilter = useCallback((name: string) => {
|
const handleRemoveFilter = useCallback((name: string) => {
|
||||||
setFilters((prev) => {
|
setFilters((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
|
|
@ -229,10 +488,13 @@ export default function App() {
|
||||||
|
|
||||||
const handleDragStart = useCallback(
|
const handleDragStart = useCallback(
|
||||||
(name: string) => {
|
(name: string) => {
|
||||||
|
const meta = features.find((f) => f.name === name);
|
||||||
|
if (meta?.type === 'enum') return; // No drag interaction for enum features
|
||||||
setActiveFeature(name);
|
setActiveFeature(name);
|
||||||
setDragValue(filters[name] || null);
|
const fval = filters[name];
|
||||||
|
setDragValue(fval && !Array.isArray(fval[0]) ? (fval as [number, number]) : null);
|
||||||
},
|
},
|
||||||
[filters]
|
[filters, features]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragChange = useCallback((value: [number, number]) => {
|
const handleDragChange = useCallback((value: [number, number]) => {
|
||||||
|
|
@ -262,7 +524,14 @@ export default function App() {
|
||||||
const filterEntries = Object.entries(filters);
|
const filterEntries = Object.entries(filters);
|
||||||
if (filterEntries.length > 0) {
|
if (filterEntries.length > 0) {
|
||||||
const filterStr = filterEntries
|
const filterStr = filterEntries
|
||||||
.map(([name, [min, max]]) => `${name}:${min}:${max}`)
|
.map(([name, value]) => {
|
||||||
|
const meta = features.find((f) => f.name === name);
|
||||||
|
if (meta?.type === 'enum') {
|
||||||
|
return `${name}:${(value as string[]).join('|')}`;
|
||||||
|
}
|
||||||
|
const [min, max] = value as [number, number];
|
||||||
|
return `${name}:${min}:${max}`;
|
||||||
|
})
|
||||||
.join(',');
|
.join(',');
|
||||||
params.append('filters', filterStr);
|
params.append('filters', filterStr);
|
||||||
}
|
}
|
||||||
|
|
@ -283,7 +552,7 @@ export default function App() {
|
||||||
setLoadingProperties(false);
|
setLoadingProperties(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[filters]
|
[filters, features]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleHexagonClick = useCallback(
|
const handleHexagonClick = useCallback(
|
||||||
|
|
@ -314,78 +583,83 @@ export default function App() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex">
|
<div className="h-screen flex flex-col">
|
||||||
<Filters
|
<Header />
|
||||||
features={features}
|
<div className="flex-1 flex overflow-hidden">
|
||||||
filters={filters}
|
<Filters
|
||||||
activeFeature={activeFeature}
|
features={features}
|
||||||
dragValue={dragValue}
|
filters={filters}
|
||||||
enabledFeatures={enabledFeatures}
|
|
||||||
onAddFilter={handleAddFilter}
|
|
||||||
onRemoveFilter={handleRemoveFilter}
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
onDragChange={handleDragChange}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
zoom={zoom}
|
|
||||||
/>
|
|
||||||
<div className="flex-1 relative">
|
|
||||||
<Map
|
|
||||||
data={data}
|
|
||||||
pois={pois}
|
|
||||||
onViewChange={handleViewChange}
|
|
||||||
activeFeature={activeFeature}
|
activeFeature={activeFeature}
|
||||||
dragValue={dragValue}
|
dragValue={dragValue}
|
||||||
features={features}
|
enabledFeatures={enabledFeatures}
|
||||||
selectedHexagonId={selectedHexagon?.h3 || null}
|
onAddFilter={handleAddFilter}
|
||||||
onHexagonClick={handleHexagonClick}
|
onRemoveFilter={handleRemoveFilter}
|
||||||
|
onFilterChange={handleFilterChange}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragChange={handleDragChange}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
zoom={zoom}
|
||||||
/>
|
/>
|
||||||
{loading && (
|
<div className="flex-1 relative">
|
||||||
<div className="absolute top-4 right-4 bg-white px-3 py-1 rounded shadow">Loading...</div>
|
<Map
|
||||||
)}
|
data={data}
|
||||||
<DataSources />
|
pois={pois}
|
||||||
</div>
|
onViewChange={handleViewChange}
|
||||||
<div className="w-72 bg-white shadow-lg z-10 flex flex-col">
|
activeFeature={activeFeature}
|
||||||
{/* Tab headers */}
|
dragValue={dragValue}
|
||||||
<div className="flex border-b border-gray-200">
|
features={features}
|
||||||
<button
|
selectedHexagonId={selectedHexagon?.h3 || null}
|
||||||
className={`flex-1 p-3 ${
|
onHexagonClick={handleHexagonClick}
|
||||||
rightPaneTab === 'pois' ? 'border-b-2 border-blue-500 font-semibold' : 'text-gray-600'
|
initialViewState={initialViewState}
|
||||||
}`}
|
/>
|
||||||
onClick={() => setRightPaneTab('pois')}
|
{loading && (
|
||||||
>
|
<div className="absolute top-4 right-4 bg-white px-3 py-1 rounded shadow">Loading...</div>
|
||||||
POIs {pois.length > 0 && `(${pois.length})`}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`flex-1 p-3 ${
|
|
||||||
rightPaneTab === 'properties'
|
|
||||||
? 'border-b-2 border-blue-500 font-semibold'
|
|
||||||
: 'text-gray-600'
|
|
||||||
}`}
|
|
||||||
onClick={() => setRightPaneTab('properties')}
|
|
||||||
>
|
|
||||||
Properties {propertiesTotal > 0 && `(${propertiesTotal})`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab content */}
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
{rightPaneTab === 'pois' ? (
|
|
||||||
<POIPane
|
|
||||||
categories={poiCategories}
|
|
||||||
selectedCategories={selectedPOICategories}
|
|
||||||
onCategoriesChange={setSelectedPOICategories}
|
|
||||||
poiCount={pois.length}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<PropertiesPane
|
|
||||||
properties={properties}
|
|
||||||
total={propertiesTotal}
|
|
||||||
loading={loadingProperties}
|
|
||||||
hexagonId={selectedHexagon?.h3 || null}
|
|
||||||
onLoadMore={handleLoadMoreProperties}
|
|
||||||
onClose={handleCloseProperties}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
<DataSources />
|
||||||
|
</div>
|
||||||
|
<div className="w-72 bg-white shadow-lg z-10 flex flex-col">
|
||||||
|
{/* Tab headers */}
|
||||||
|
<div className="flex border-b border-gray-200">
|
||||||
|
<button
|
||||||
|
className={`flex-1 p-3 ${
|
||||||
|
rightPaneTab === 'pois' ? 'border-b-2 border-blue-500 font-semibold' : 'text-gray-600'
|
||||||
|
}`}
|
||||||
|
onClick={() => setRightPaneTab('pois')}
|
||||||
|
>
|
||||||
|
POIs {pois.length > 0 && `(${pois.length})`}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`flex-1 p-3 ${
|
||||||
|
rightPaneTab === 'properties'
|
||||||
|
? 'border-b-2 border-blue-500 font-semibold'
|
||||||
|
: 'text-gray-600'
|
||||||
|
}`}
|
||||||
|
onClick={() => setRightPaneTab('properties')}
|
||||||
|
>
|
||||||
|
Properties {propertiesTotal > 0 && `(${propertiesTotal})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{rightPaneTab === 'pois' ? (
|
||||||
|
<POIPane
|
||||||
|
categories={poiCategories}
|
||||||
|
selectedCategories={selectedPOICategories}
|
||||||
|
onCategoriesChange={setSelectedPOICategories}
|
||||||
|
poiCount={pois.length}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PropertiesPane
|
||||||
|
properties={properties}
|
||||||
|
total={propertiesTotal}
|
||||||
|
loading={loadingProperties}
|
||||||
|
hexagonId={selectedHexagon?.h3 || null}
|
||||||
|
onLoadMore={handleLoadMoreProperties}
|
||||||
|
onClose={handleCloseProperties}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { memo } from 'react';
|
||||||
import { Slider } from './ui/slider';
|
import { Slider } from './ui/slider';
|
||||||
import { Label } from './ui/label';
|
import { Label } from './ui/label';
|
||||||
import type { FeatureMeta, FeatureFilters } from '../types';
|
import type { FeatureMeta, FeatureFilters } from '../types';
|
||||||
|
|
@ -10,6 +11,7 @@ interface FiltersProps {
|
||||||
enabledFeatures: Set<string>;
|
enabledFeatures: Set<string>;
|
||||||
onAddFilter: (name: string) => void;
|
onAddFilter: (name: string) => void;
|
||||||
onRemoveFilter: (name: string) => void;
|
onRemoveFilter: (name: string) => void;
|
||||||
|
onFilterChange: (name: string, value: [number, number] | string[]) => void;
|
||||||
onDragStart: (name: string) => void;
|
onDragStart: (name: string) => void;
|
||||||
onDragChange: (value: [number, number]) => void;
|
onDragChange: (value: [number, number]) => void;
|
||||||
onDragEnd: () => void;
|
onDragEnd: () => void;
|
||||||
|
|
@ -23,7 +25,7 @@ function formatValue(value: number): string {
|
||||||
return value.toFixed(2);
|
return value.toFixed(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Filters({
|
export default memo(function Filters({
|
||||||
features,
|
features,
|
||||||
filters,
|
filters,
|
||||||
activeFeature,
|
activeFeature,
|
||||||
|
|
@ -31,6 +33,7 @@ export default function Filters({
|
||||||
enabledFeatures,
|
enabledFeatures,
|
||||||
onAddFilter,
|
onAddFilter,
|
||||||
onRemoveFilter,
|
onRemoveFilter,
|
||||||
|
onFilterChange,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
onDragChange,
|
onDragChange,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
|
|
@ -40,9 +43,7 @@ export default function Filters({
|
||||||
const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name));
|
const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-72 p-4 bg-white shadow-lg space-y-4 overflow-y-auto max-h-screen">
|
<div className="w-72 p-4 bg-white shadow-lg space-y-4 overflow-y-auto">
|
||||||
<h1 className="text-xl font-bold">UK Property Prices</h1>
|
|
||||||
|
|
||||||
<div className="text-sm text-slate-500">Zoom: {zoom.toFixed(1)}</div>
|
<div className="text-sm text-slate-500">Zoom: {zoom.toFixed(1)}</div>
|
||||||
|
|
||||||
{/* Add filter dropdown */}
|
{/* Add filter dropdown */}
|
||||||
|
|
@ -67,10 +68,64 @@ export default function Filters({
|
||||||
|
|
||||||
{/* Active filters */}
|
{/* Active filters */}
|
||||||
{enabledFeatureList.map((feature) => {
|
{enabledFeatureList.map((feature) => {
|
||||||
|
if (feature.type === 'enum') {
|
||||||
|
const selectedValues = (filters[feature.name] as string[]) || [];
|
||||||
|
const allValues = feature.values || [];
|
||||||
|
return (
|
||||||
|
<div key={feature.name} className="space-y-1 p-2 rounded">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">{feature.label}</Label>
|
||||||
|
<button
|
||||||
|
onClick={() => onRemoveFilter(feature.name)}
|
||||||
|
className="text-slate-400 hover:text-slate-700 text-sm px-1"
|
||||||
|
title="Remove filter"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 text-xs mb-1">
|
||||||
|
<button
|
||||||
|
className="text-blue-600 hover:underline"
|
||||||
|
onClick={() => onFilterChange(feature.name, [...allValues])}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="text-blue-600 hover:underline"
|
||||||
|
onClick={() => onFilterChange(feature.name, [])}
|
||||||
|
>
|
||||||
|
None
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5 max-h-40 overflow-y-auto">
|
||||||
|
{allValues.map((val) => (
|
||||||
|
<label key={val} className="flex items-center gap-1.5 text-xs cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedValues.includes(val)}
|
||||||
|
onChange={() => {
|
||||||
|
const next = selectedValues.includes(val)
|
||||||
|
? selectedValues.filter((v) => v !== val)
|
||||||
|
: [...selectedValues, val];
|
||||||
|
onFilterChange(feature.name, next);
|
||||||
|
}}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
{val}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numeric feature
|
||||||
const isActive = activeFeature === feature.name;
|
const isActive = activeFeature === feature.name;
|
||||||
const displayValue =
|
const displayValue =
|
||||||
isActive && dragValue ? dragValue : filters[feature.name] || [feature.min, feature.max];
|
isActive && dragValue
|
||||||
const step = (feature.max - feature.min) / 100;
|
? dragValue
|
||||||
|
: (filters[feature.name] as [number, number]) || [feature.min!, feature.max!];
|
||||||
|
const step = (feature.max! - feature.min!) / 100;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -90,8 +145,8 @@ export default function Filters({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
min={feature.min}
|
min={feature.min!}
|
||||||
max={feature.max}
|
max={feature.max!}
|
||||||
step={step}
|
step={step}
|
||||||
value={[displayValue[0], displayValue[1]]}
|
value={[displayValue[0], displayValue[1]]}
|
||||||
onValueChange={([min, max]) => onDragChange([min, max])}
|
onValueChange={([min, max]) => onDragChange([min, max])}
|
||||||
|
|
@ -135,4 +190,4 @@ export default function Filters({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useRef, useEffect, useState, useMemo } from 'react';
|
import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
|
||||||
import { Map as MapGL, useControl } from 'react-map-gl/maplibre';
|
import { Map as MapGL, useControl } from 'react-map-gl/maplibre';
|
||||||
import type { MapRef } from 'react-map-gl/maplibre';
|
import type { MapRef } from 'react-map-gl/maplibre';
|
||||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||||
|
|
@ -17,6 +17,7 @@ interface MapProps {
|
||||||
features: FeatureMeta[];
|
features: FeatureMeta[];
|
||||||
selectedHexagonId: string | null;
|
selectedHexagonId: string | null;
|
||||||
onHexagonClick: (h3: string) => void;
|
onHexagonClick: (h3: string) => void;
|
||||||
|
initialViewState?: ViewState;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Twemoji CDN base URL
|
// Twemoji CDN base URL
|
||||||
|
|
@ -118,9 +119,6 @@ interface Dimensions {
|
||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// First label layer in the Carto Positron style — hexagons render below this
|
|
||||||
const LABEL_LAYER_ID = 'waterway_label';
|
|
||||||
|
|
||||||
function DeckOverlay({
|
function DeckOverlay({
|
||||||
layers,
|
layers,
|
||||||
getTooltip,
|
getTooltip,
|
||||||
|
|
@ -130,7 +128,13 @@ function DeckOverlay({
|
||||||
getTooltip: any;
|
getTooltip: any;
|
||||||
}) {
|
}) {
|
||||||
const overlay = useControl(() => new MapboxOverlay({ interleaved: true }));
|
const overlay = useControl(() => new MapboxOverlay({ interleaved: true }));
|
||||||
overlay.setProps({ layers, getTooltip });
|
const prevLayersRef = useRef(layers);
|
||||||
|
const prevTooltipRef = useRef(getTooltip);
|
||||||
|
if (layers !== prevLayersRef.current || getTooltip !== prevTooltipRef.current) {
|
||||||
|
prevLayersRef.current = layers;
|
||||||
|
prevTooltipRef.current = getTooltip;
|
||||||
|
overlay.setProps({ layers, getTooltip });
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,7 +147,81 @@ function countToColor(t: number): [number, number, number] {
|
||||||
return [r, g, b];
|
return [r, g, b];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Map({
|
function PostcodeSearch({
|
||||||
|
onFlyTo,
|
||||||
|
}: {
|
||||||
|
onFlyTo: (lat: number, lng: number, zoom: number) => void;
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://api.postcodes.io/postcodes/${encodeURIComponent(trimmed)}`
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
setError('Postcode not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.status === 200 && json.result) {
|
||||||
|
onFlyTo(json.result.latitude, json.result.longitude, 14);
|
||||||
|
setQuery('');
|
||||||
|
} else {
|
||||||
|
setError('Postcode not found');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Lookup failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[query, onFlyTo]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="absolute top-3 left-3 z-10 flex flex-col gap-1"
|
||||||
|
>
|
||||||
|
<div className="flex shadow-lg rounded overflow-hidden">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
placeholder="Search postcode..."
|
||||||
|
className="px-3 py-2 text-sm w-40 border-none outline-none bg-white"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="px-3 py-2 bg-blue-500 text-white text-sm hover:bg-blue-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? '...' : 'Go'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<span className="text-xs text-red-600 bg-white/90 rounded px-2 py-0.5 shadow">
|
||||||
|
{error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(function Map({
|
||||||
data,
|
data,
|
||||||
pois,
|
pois,
|
||||||
onViewChange,
|
onViewChange,
|
||||||
|
|
@ -152,9 +230,10 @@ export default function Map({
|
||||||
features,
|
features,
|
||||||
selectedHexagonId,
|
selectedHexagonId,
|
||||||
onHexagonClick,
|
onHexagonClick,
|
||||||
|
initialViewState,
|
||||||
}: MapProps) {
|
}: MapProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [viewState, setViewState] = useState<ViewState>(INITIAL_VIEW);
|
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW);
|
||||||
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
|
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
|
||||||
|
|
||||||
// Track container dimensions with ResizeObserver
|
// Track container dimensions with ResizeObserver
|
||||||
|
|
@ -177,16 +256,29 @@ export default function Map({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dimensions.width === 0 || dimensions.height === 0) return;
|
if (dimensions.width === 0 || dimensions.height === 0) return;
|
||||||
|
|
||||||
const bounds = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
|
const raw = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
|
||||||
const resolution = zoomToResolution(viewState.zoom);
|
const resolution = zoomToResolution(viewState.zoom);
|
||||||
|
|
||||||
onViewChange({ resolution, bounds, zoom: viewState.zoom });
|
// Quantize bounds to 0.01° to reduce state churn and improve backend cache hits
|
||||||
|
const QUANT = 0.01;
|
||||||
|
const bounds: Bounds = {
|
||||||
|
south: Math.floor(raw.south / QUANT) * QUANT,
|
||||||
|
west: Math.floor(raw.west / QUANT) * QUANT,
|
||||||
|
north: Math.ceil(raw.north / QUANT) * QUANT,
|
||||||
|
east: Math.ceil(raw.east / QUANT) * QUANT,
|
||||||
|
};
|
||||||
|
|
||||||
|
onViewChange({ resolution, bounds, zoom: viewState.zoom, latitude: viewState.latitude, longitude: viewState.longitude });
|
||||||
}, [viewState, dimensions, onViewChange]);
|
}, [viewState, dimensions, onViewChange]);
|
||||||
|
|
||||||
const handleMove = useCallback((evt: { viewState: ViewState }) => {
|
const handleMove = useCallback((evt: { viewState: ViewState }) => {
|
||||||
setViewState(evt.viewState);
|
setViewState(evt.viewState);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => {
|
||||||
|
setViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Make place labels more legible over the colored hexagons
|
// Make place labels more legible over the colored hexagons
|
||||||
const handleMapLoad = useCallback(
|
const handleMapLoad = useCallback(
|
||||||
(evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
|
(evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
|
||||||
|
|
@ -197,6 +289,8 @@ export default function Map({
|
||||||
map.setPaintProperty(layer.id, 'text-halo-width', 2);
|
map.setPaintProperty(layer.id, 'text-halo-width', 2);
|
||||||
map.setPaintProperty(layer.id, 'text-color', '#222');
|
map.setPaintProperty(layer.id, 'text-color', '#222');
|
||||||
}
|
}
|
||||||
|
map.setLayoutProperty('building', 'visibility', 'none');
|
||||||
|
map.setLayoutProperty('building-top', 'visibility', 'none');
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
@ -236,63 +330,107 @@ export default function Map({
|
||||||
return { min, max };
|
return { min, max };
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
// Determine color mode
|
// Memoize feature lookup to avoid new reference each render
|
||||||
const colorFeatureMeta = activeFeature
|
const colorFeatureMeta = useMemo(
|
||||||
? features.find((f) => f.name === activeFeature) || null
|
() => (activeFeature ? features.find((f) => f.name === activeFeature) || null : null),
|
||||||
: null;
|
[activeFeature, features]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use refs for values that change during drag so layers aren't recreated
|
||||||
|
const activeFeatureRef = useRef(activeFeature);
|
||||||
|
activeFeatureRef.current = activeFeature;
|
||||||
|
const dragValueRef = useRef(dragValue);
|
||||||
|
dragValueRef.current = dragValue;
|
||||||
|
const colorFeatureMetaRef = useRef(colorFeatureMeta);
|
||||||
|
colorFeatureMetaRef.current = colorFeatureMeta;
|
||||||
|
const countRangeRef = useRef(countRange);
|
||||||
|
countRangeRef.current = countRange;
|
||||||
|
const selectedHexagonIdRef = useRef(selectedHexagonId);
|
||||||
|
selectedHexagonIdRef.current = selectedHexagonId;
|
||||||
|
|
||||||
|
// Stable click handler using ref
|
||||||
|
const onHexagonClickRef = useRef(onHexagonClick);
|
||||||
|
onHexagonClickRef.current = onHexagonClick;
|
||||||
const handleHexagonClick = useCallback(
|
const handleHexagonClick = useCallback(
|
||||||
(info: PickingInfo<HexagonData>) => {
|
(info: PickingInfo<HexagonData>) => {
|
||||||
if (info.object && 'h3' in info.object) {
|
if (info.object && 'h3' in info.object) {
|
||||||
onHexagonClick(info.object.h3);
|
onHexagonClickRef.current(info.object.h3);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onHexagonClick]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const layers = useMemo(
|
// Stable hover handler using ref
|
||||||
() => [
|
const handlePoiHoverRef = useRef(handlePoiHover);
|
||||||
|
handlePoiHoverRef.current = handlePoiHover;
|
||||||
|
const stablePoiHover = useCallback((info: PickingInfo<POI>) => {
|
||||||
|
handlePoiHoverRef.current(info);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Derive a trigger value from color-affecting state — avoids useEffect+setState double-render
|
||||||
|
const colorTrigger = `${activeFeature}|${dragValue?.[0]}|${dragValue?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}`;
|
||||||
|
|
||||||
|
// Hexagon layer — only recreated when data or color trigger changes
|
||||||
|
const hexLayer = useMemo(
|
||||||
|
() =>
|
||||||
new H3HexagonLayer<HexagonData>({
|
new H3HexagonLayer<HexagonData>({
|
||||||
id: 'h3-hexagons',
|
id: 'h3-hexagons',
|
||||||
data,
|
data,
|
||||||
getHexagon: (d) => d.h3,
|
getHexagon: (d) => d.h3,
|
||||||
getFillColor: (d) => {
|
getFillColor: (d) => {
|
||||||
if (activeFeature && dragValue && colorFeatureMeta) {
|
const af = activeFeatureRef.current;
|
||||||
// Drag mode: color by feature value using gradient
|
const dv = dragValueRef.current;
|
||||||
const val = d[`min_${activeFeature}`];
|
const cfm = colorFeatureMetaRef.current;
|
||||||
if (val == null) return [128, 128, 128] as [number, number, number];
|
if (af && dv && cfm) {
|
||||||
const range = dragValue[1] - dragValue[0];
|
const val = d[`min_${af}`];
|
||||||
if (range === 0) return GRADIENT[0].color;
|
if (val == null) return [128, 128, 128, 80] as [number, number, number, number];
|
||||||
const t = ((val as number) - dragValue[0]) / range;
|
const min = dv[0];
|
||||||
return normalizedToColor(Math.max(0, Math.min(1, t)));
|
const max = dv[1];
|
||||||
|
const minVal = d[`min_${af}`] as number;
|
||||||
|
const maxVal = d[`max_${af}`] as number;
|
||||||
|
// Gray out hexagons outside drag range
|
||||||
|
if (maxVal < min || minVal > max) {
|
||||||
|
return [180, 180, 180, 60] as [number, number, number, number];
|
||||||
|
}
|
||||||
|
const range = max - min;
|
||||||
|
if (range === 0) return [...GRADIENT[0].color, 200] as [number, number, number, number];
|
||||||
|
const t = ((val as number) - min) / range;
|
||||||
|
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)));
|
||||||
|
return [...rgb, 200] as [number, number, number, number];
|
||||||
}
|
}
|
||||||
// Normal mode: color by count using blue scale
|
const cr = countRangeRef.current;
|
||||||
const c = d.count as number;
|
const c = d.count as number;
|
||||||
const t = (c - countRange.min) / (countRange.max - countRange.min);
|
const t = (c - cr.min) / (cr.max - cr.min);
|
||||||
return countToColor(Math.max(0, Math.min(1, t)));
|
return [...countToColor(Math.max(0, Math.min(1, t))), 200] as [number, number, number, number];
|
||||||
},
|
},
|
||||||
getLineColor: (d) =>
|
getLineColor: (d) =>
|
||||||
(d.h3 === selectedHexagonId ? [255, 255, 255, 255] : [0, 0, 0, 0]) as [
|
(d.h3 === selectedHexagonIdRef.current ? [255, 255, 255, 255] : [0, 0, 0, 0]) as [
|
||||||
number,
|
number,
|
||||||
number,
|
number,
|
||||||
number,
|
number,
|
||||||
number,
|
number,
|
||||||
],
|
],
|
||||||
getLineWidth: (d) => (d.h3 === selectedHexagonId ? 2 : 0),
|
getLineWidth: (d) => (d.h3 === selectedHexagonIdRef.current ? 2 : 0),
|
||||||
lineWidthUnits: 'pixels',
|
lineWidthUnits: 'pixels',
|
||||||
updateTriggers: {
|
updateTriggers: {
|
||||||
getFillColor: [activeFeature, dragValue, countRange, colorFeatureMeta],
|
getFillColor: [colorTrigger],
|
||||||
getLineColor: [selectedHexagonId],
|
getLineColor: [colorTrigger],
|
||||||
getLineWidth: [selectedHexagonId],
|
getLineWidth: [colorTrigger],
|
||||||
},
|
},
|
||||||
extruded: false,
|
extruded: false,
|
||||||
pickable: true,
|
pickable: true,
|
||||||
opacity: 0.5,
|
opacity: 1,
|
||||||
highPrecision: true,
|
highPrecision: true,
|
||||||
onClick: handleHexagonClick,
|
onClick: handleHexagonClick,
|
||||||
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
|
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
|
||||||
beforeId: LABEL_LAYER_ID,
|
beforeId: "waterway_label",
|
||||||
}),
|
}),
|
||||||
|
[data, colorTrigger, handleHexagonClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
// POI layer — independent, only recreated when POI data changes
|
||||||
|
const poiLayer = useMemo(
|
||||||
|
() =>
|
||||||
new IconLayer<POI>({
|
new IconLayer<POI>({
|
||||||
id: 'poi-icons',
|
id: 'poi-icons',
|
||||||
data: pois,
|
data: pois,
|
||||||
|
|
@ -306,22 +444,17 @@ export default function Map({
|
||||||
sizeMinPixels: 20,
|
sizeMinPixels: 20,
|
||||||
sizeMaxPixels: 40,
|
sizeMaxPixels: 40,
|
||||||
pickable: true,
|
pickable: true,
|
||||||
onHover: handlePoiHover,
|
onHover: stablePoiHover,
|
||||||
}),
|
}),
|
||||||
],
|
[pois, stablePoiHover]
|
||||||
[
|
|
||||||
data,
|
|
||||||
pois,
|
|
||||||
handlePoiHover,
|
|
||||||
handleHexagonClick,
|
|
||||||
activeFeature,
|
|
||||||
dragValue,
|
|
||||||
countRange,
|
|
||||||
colorFeatureMeta,
|
|
||||||
selectedHexagonId,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const layers = useMemo(() => [hexLayer, poiLayer], [hexLayer, poiLayer]);
|
||||||
|
|
||||||
|
// Tooltip uses refs to avoid being a layer dependency
|
||||||
|
const featuresRef = useRef(features);
|
||||||
|
featuresRef.current = features;
|
||||||
|
|
||||||
const getTooltip = useCallback(
|
const getTooltip = useCallback(
|
||||||
({ object }: { object?: HexagonData }) => {
|
({ object }: { object?: HexagonData }) => {
|
||||||
if (!object || !('h3' in object)) return null;
|
if (!object || !('h3' in object)) return null;
|
||||||
|
|
@ -330,7 +463,7 @@ export default function Map({
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
lines.push(`<strong>${(hex.count as number).toLocaleString()} properties</strong>`);
|
lines.push(`<strong>${(hex.count as number).toLocaleString()} properties</strong>`);
|
||||||
|
|
||||||
for (const f of features) {
|
for (const f of featuresRef.current) {
|
||||||
const minVal = hex[`min_${f.name}`];
|
const minVal = hex[`min_${f.name}`];
|
||||||
const maxVal = hex[`max_${f.name}`];
|
const maxVal = hex[`max_${f.name}`];
|
||||||
if (minVal != null && maxVal != null) {
|
if (minVal != null && maxVal != null) {
|
||||||
|
|
@ -342,7 +475,7 @@ export default function Map({
|
||||||
typeof maxVal === 'number'
|
typeof maxVal === 'number'
|
||||||
? maxVal.toLocaleString(undefined, { maximumFractionDigits: 1 })
|
? maxVal.toLocaleString(undefined, { maximumFractionDigits: 1 })
|
||||||
: String(maxVal);
|
: String(maxVal);
|
||||||
const highlight = f.name === activeFeature ? 'font-weight: bold;' : '';
|
const highlight = f.name === activeFeatureRef.current ? 'font-weight: bold;' : '';
|
||||||
lines.push(`<div style="${highlight}">${f.label}: ${minStr} - ${maxStr}</div>`);
|
lines.push(`<div style="${highlight}">${f.label}: ${minStr} - ${maxStr}</div>`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -356,7 +489,7 @@ export default function Map({
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[features, activeFeature]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -371,6 +504,7 @@ export default function Map({
|
||||||
>
|
>
|
||||||
<DeckOverlay layers={layers} getTooltip={getTooltip as never} />
|
<DeckOverlay layers={layers} getTooltip={getTooltip as never} />
|
||||||
</MapGL>
|
</MapGL>
|
||||||
|
<PostcodeSearch onFlyTo={handleFlyTo} />
|
||||||
{popupInfo && (
|
{popupInfo && (
|
||||||
<div
|
<div
|
||||||
className="absolute pointer-events-none bg-white rounded shadow-lg p-2 text-sm"
|
className="absolute pointer-events-none bg-white rounded shadow-lg p-2 text-sm"
|
||||||
|
|
@ -387,4 +521,4 @@ export default function Map({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
|
||||||
|
|
@ -100,122 +100,100 @@ export function PropertiesPane({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDuration(d: string): string {
|
||||||
|
if (d === 'F') return 'Freehold';
|
||||||
|
if (d === 'L') return 'Leasehold';
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAge(value: number): string {
|
||||||
|
// construction_age_band is a midpoint year, e.g. 1935
|
||||||
|
if (value >= 1000) return `~${Math.round(value)}`;
|
||||||
|
return Math.round(value).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get a numeric value from a property, trying multiple field names
|
||||||
|
function getNum(property: Property, ...keys: string[]): number | undefined {
|
||||||
|
for (const key of keys) {
|
||||||
|
const v = property[key];
|
||||||
|
if (v !== undefined && v !== null && typeof v === 'number') return v;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// Property card component showing all fields
|
// Property card component showing all fields
|
||||||
function PropertyCard({ property }: { property: Property }) {
|
function PropertyCard({ property }: { property: Property }) {
|
||||||
const formatNumber = (value: number | undefined, decimals = 0): string => {
|
const fmt = (value: number | undefined, decimals = 0): string => {
|
||||||
if (value === undefined) return '';
|
if (value === undefined) return '';
|
||||||
return decimals > 0 ? value.toFixed(decimals) : Math.round(value).toLocaleString();
|
return decimals > 0 ? value.toFixed(decimals) : Math.round(value).toLocaleString();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const price = getNum(property, 'Last known price', 'latest_price');
|
||||||
|
const pricePerSqm = getNum(property, 'Price per sqm', 'price_per_sqm');
|
||||||
|
const floorArea = getNum(property, 'Total floor area (sqm)', 'total_floor_area');
|
||||||
|
const rooms = getNum(property, 'Rooms (including bedrooms & bathrooms)', 'number_habitable_rooms');
|
||||||
|
const age = getNum(property, 'Approximate construction age', 'construction_age_band');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 border-b border-gray-100 hover:bg-gray-50">
|
<div className="p-4 border-b border-gray-100 hover:bg-gray-50">
|
||||||
{/* Address */}
|
{/* Address & postcode */}
|
||||||
<div className="font-semibold">{property.address || 'Unknown Address'}</div>
|
<div className="font-semibold">{property.address || 'Unknown Address'}</div>
|
||||||
<div className="text-sm text-gray-600">{property.postcode}</div>
|
<div className="text-sm text-gray-600">{property.postcode}</div>
|
||||||
|
|
||||||
{/* Price */}
|
{/* Price */}
|
||||||
{property.latest_price && (
|
{price !== undefined && (
|
||||||
<div className="mt-2 text-lg font-bold text-green-700">
|
<div className="mt-2 text-lg font-bold text-green-700">
|
||||||
£{formatNumber(property.latest_price as number)}
|
£{fmt(price)}
|
||||||
{property.price_per_sqm && (
|
{pricePerSqm !== undefined && (
|
||||||
<span className="text-sm font-normal text-gray-600">
|
<span className="text-sm font-normal text-gray-600">
|
||||||
{' '}
|
{' '}
|
||||||
(£{formatNumber(property.price_per_sqm as number)}/m²)
|
(£{fmt(pricePerSqm)}/m²)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Property details grid */}
|
{/* Property details grid */}
|
||||||
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
||||||
{property.property_type && (
|
{property.property_type && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-600">Type:</span> {property.property_type}
|
<span className="text-gray-500">Type:</span> {property.property_type}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{property.built_form && (
|
{property.built_form && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-600">Form:</span> {property.built_form}
|
<span className="text-gray-500">Built form:</span> {property.built_form}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{property.total_floor_area && (
|
{property.duration && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-600">Area:</span>{' '}
|
<span className="text-gray-500">Tenure:</span> {formatDuration(property.duration)}
|
||||||
{formatNumber(property.total_floor_area as number)}m²
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{property.number_habitable_rooms && (
|
{floorArea !== undefined && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-600">Rooms:</span>{' '}
|
<span className="text-gray-500">Floor area:</span> {fmt(floorArea)}m²
|
||||||
{formatNumber(property.number_habitable_rooms as number)}
|
</div>
|
||||||
|
)}
|
||||||
|
{rooms !== undefined && (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Rooms:</span> {fmt(rooms)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{age !== undefined && (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Built:</span> {formatAge(age)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{property.current_energy_rating && (
|
{property.current_energy_rating && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-600">Energy:</span> {property.current_energy_rating}
|
<span className="text-gray-500">EPC rating:</span> {property.current_energy_rating}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{property.potential_energy_rating && (
|
{property.potential_energy_rating && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-600">Potential:</span> {property.potential_energy_rating}
|
<span className="text-gray-500">EPC potential:</span>{' '}
|
||||||
</div>
|
{property.potential_energy_rating}
|
||||||
)}
|
|
||||||
{property.construction_age_band !== undefined && (
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-600">Built (age):</span>{' '}
|
|
||||||
{formatNumber(property.construction_age_band as number)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Journey times */}
|
|
||||||
{property.public_transport_easy_minutes && (
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-600">PT (easy):</span>{' '}
|
|
||||||
{formatNumber(property.public_transport_easy_minutes as number)}min
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{property.public_transport_quick_minutes && (
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-600">PT (quick):</span>{' '}
|
|
||||||
{formatNumber(property.public_transport_quick_minutes as number)}min
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{property.cycling_minutes && (
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-600">Cycling:</span>{' '}
|
|
||||||
{formatNumber(property.cycling_minutes as number)}min
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Deprivation scores */}
|
|
||||||
{property.income_score !== undefined && (
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-600">Income:</span>{' '}
|
|
||||||
{formatNumber(property.income_score as number, 1)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{property.employment_score !== undefined && (
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-600">Employment:</span>{' '}
|
|
||||||
{formatNumber(property.employment_score as number, 1)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{property.education_score !== undefined && (
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-600">Education:</span>{' '}
|
|
||||||
{formatNumber(property.education_score as number, 1)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{property.health_score !== undefined && (
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-600">Health:</span>{' '}
|
|
||||||
{formatNumber(property.health_score as number, 1)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{property.crime_score !== undefined && (
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-600">Crime:</span>{' '}
|
|
||||||
{formatNumber(property.crime_score as number, 1)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
export interface FeatureMeta {
|
export interface FeatureMeta {
|
||||||
name: string;
|
name: string;
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
label: string;
|
label: string;
|
||||||
|
type: 'numeric' | 'enum';
|
||||||
|
// Numeric-only fields
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
// Enum-only fields
|
||||||
|
values?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filters: feature name -> [selectedMin, selectedMax]
|
// Filters: feature name -> [selectedMin, selectedMax] for numeric, string[] for enum
|
||||||
export type FeatureFilters = Record<string, [number, number]>;
|
export type FeatureFilters = Record<string, [number, number] | string[]>;
|
||||||
|
|
||||||
export interface HexagonData {
|
export interface HexagonData {
|
||||||
h3: string;
|
h3: string;
|
||||||
|
|
@ -33,6 +37,8 @@ export interface ViewChangeParams {
|
||||||
resolution: number;
|
resolution: number;
|
||||||
bounds: Bounds;
|
bounds: Bounds;
|
||||||
zoom: number;
|
zoom: number;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiResponse {
|
export interface ApiResponse {
|
||||||
|
|
@ -62,6 +68,7 @@ export interface Property {
|
||||||
postcode?: string;
|
postcode?: string;
|
||||||
property_type?: string;
|
property_type?: string;
|
||||||
built_form?: string;
|
built_form?: string;
|
||||||
|
duration?: string;
|
||||||
current_energy_rating?: string;
|
current_energy_rating?: string;
|
||||||
potential_energy_rating?: string;
|
potential_energy_rating?: string;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue