Update UI
This commit is contained in:
parent
2ac37ece97
commit
5f311233e4
10 changed files with 663 additions and 408 deletions
|
|
@ -4,8 +4,11 @@ import Filters from './components/Filters';
|
||||||
import POIPane from './components/POIPane';
|
import POIPane from './components/POIPane';
|
||||||
import { PropertiesPane } from './components/PropertiesPane';
|
import { PropertiesPane } from './components/PropertiesPane';
|
||||||
import DataSources from './components/DataSources';
|
import DataSources from './components/DataSources';
|
||||||
|
import DataSourcesPage from './components/DataSourcesPage';
|
||||||
|
import HomePage from './components/HomePage';
|
||||||
import type {
|
import type {
|
||||||
FeatureMeta,
|
FeatureMeta,
|
||||||
|
FeatureGroup,
|
||||||
FeatureFilters,
|
FeatureFilters,
|
||||||
Bounds,
|
Bounds,
|
||||||
HexagonData,
|
HexagonData,
|
||||||
|
|
@ -14,6 +17,7 @@ import type {
|
||||||
POI,
|
POI,
|
||||||
POIResponse,
|
POIResponse,
|
||||||
POICategoriesResponse,
|
POICategoriesResponse,
|
||||||
|
POICategoryGroup,
|
||||||
ViewState,
|
ViewState,
|
||||||
Property,
|
Property,
|
||||||
HexagonPropertiesResponse,
|
HexagonPropertiesResponse,
|
||||||
|
|
@ -171,7 +175,15 @@ function stateToParams(
|
||||||
|
|
||||||
// --- Header ---
|
// --- Header ---
|
||||||
|
|
||||||
function Header() {
|
type Page = 'home' | 'dashboard' | 'data-sources';
|
||||||
|
|
||||||
|
function Header({
|
||||||
|
activePage,
|
||||||
|
onPageChange,
|
||||||
|
}: {
|
||||||
|
activePage: Page;
|
||||||
|
onPageChange: (page: Page) => void;
|
||||||
|
}) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const handleShare = useCallback(() => {
|
const handleShare = useCallback(() => {
|
||||||
|
|
@ -181,60 +193,85 @@ function Header() {
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const tabClass = (page: Page) =>
|
||||||
|
`px-3 py-1.5 rounded text-sm transition-colors ${
|
||||||
|
activePage === page
|
||||||
|
? 'bg-navy-700 font-semibold'
|
||||||
|
: 'text-warm-300 hover:bg-navy-800 hover:text-white'
|
||||||
|
}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="h-12 bg-slate-800 text-white flex items-center justify-between px-4 shrink-0">
|
<header className="h-12 bg-navy-900 text-white flex items-center justify-between px-4 shrink-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-4">
|
||||||
<svg
|
<button
|
||||||
className="w-5 h-5 text-blue-400"
|
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
|
||||||
fill="none"
|
onClick={() => onPageChange('home')}
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
strokeLinecap="round"
|
className="w-5 h-5 text-teal-400"
|
||||||
strokeLinejoin="round"
|
fill="none"
|
||||||
strokeWidth={2}
|
stroke="currentColor"
|
||||||
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"
|
viewBox="0 0 24 24"
|
||||||
/>
|
>
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
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"
|
||||||
/>
|
/>
|
||||||
</svg>
|
<path
|
||||||
<span className="font-semibold text-lg">Property Map</span>
|
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">Narrowit</span>
|
||||||
|
</button>
|
||||||
|
<nav className="flex items-center gap-1">
|
||||||
|
<button className={tabClass('home')} onClick={() => onPageChange('home')}>
|
||||||
|
Home
|
||||||
|
</button>
|
||||||
|
<button className={tabClass('dashboard')} onClick={() => onPageChange('dashboard')}>
|
||||||
|
Dashboard
|
||||||
|
</button>
|
||||||
|
<button className={tabClass('data-sources')} onClick={() => onPageChange('data-sources')}>
|
||||||
|
Data Sources
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<button
|
{activePage === 'dashboard' && (
|
||||||
onClick={handleShare}
|
<button
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-slate-700 hover:bg-slate-600 transition-colors text-sm"
|
onClick={handleShare}
|
||||||
>
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
|
||||||
{copied ? (
|
>
|
||||||
<>
|
{copied ? (
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<>
|
||||||
<path
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
strokeLinecap="round"
|
<path
|
||||||
strokeLinejoin="round"
|
strokeLinecap="round"
|
||||||
strokeWidth={2}
|
strokeLinejoin="round"
|
||||||
d="M5 13l4 4L19 7"
|
strokeWidth={2}
|
||||||
/>
|
d="M5 13l4 4L19 7"
|
||||||
</svg>
|
/>
|
||||||
Copied!
|
</svg>
|
||||||
</>
|
Copied!
|
||||||
) : (
|
</>
|
||||||
<>
|
) : (
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<>
|
||||||
<path
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
strokeLinecap="round"
|
<path
|
||||||
strokeLinejoin="round"
|
strokeLinecap="round"
|
||||||
strokeWidth={2}
|
strokeLinejoin="round"
|
||||||
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"
|
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
|
</svg>
|
||||||
</>
|
Share
|
||||||
)}
|
</>
|
||||||
</button>
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -249,13 +286,16 @@ export default function App() {
|
||||||
const [filters, setFilters] = useState<FeatureFilters>(urlState.filters || {});
|
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 [pinnedFeature, setPinnedFeature] = useState<string | null>(null);
|
||||||
const [rawData, setRawData] = useState<HexagonData[]>([]);
|
const [rawData, setRawData] = useState<HexagonData[]>([]);
|
||||||
|
const [dragData, setDragData] = useState<HexagonData[] | null>(null);
|
||||||
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>(urlState.viewState?.zoom || DEFAULT_VIEW.zoom);
|
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);
|
||||||
|
const dragAbortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
// View state for URL serialization
|
// View state for URL serialization
|
||||||
const [currentView, setCurrentView] = useState<{
|
const [currentView, setCurrentView] = useState<{
|
||||||
|
|
@ -277,7 +317,7 @@ export default function App() {
|
||||||
|
|
||||||
// POI state
|
// POI state
|
||||||
const [pois, setPois] = useState<POI[]>([]);
|
const [pois, setPois] = useState<POI[]>([]);
|
||||||
const [poiCategories, setPOICategories] = useState<string[]>([]);
|
const [poiCategoryGroups, setPOICategoryGroups] = useState<POICategoryGroup[]>([]);
|
||||||
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<string>>(
|
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<string>>(
|
||||||
urlState.poiCategories || new Set()
|
urlState.poiCategories || new Set()
|
||||||
);
|
);
|
||||||
|
|
@ -293,10 +333,31 @@ export default function App() {
|
||||||
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'>(urlState.tab || 'pois');
|
const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties'>(urlState.tab || 'pois');
|
||||||
|
const [activePage, setActivePage] = useState<Page>('home');
|
||||||
|
|
||||||
// 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]);
|
||||||
|
|
||||||
|
// Derive view feature: active drag takes priority over pinned
|
||||||
|
const viewFeature = activeFeature || pinnedFeature;
|
||||||
|
const viewSource: 'drag' | 'eye' | null = activeFeature ? 'drag' : pinnedFeature ? 'eye' : null;
|
||||||
|
// Color range: always the feature's full slider range from metadata
|
||||||
|
const colorRange = useMemo((): [number, number] | null => {
|
||||||
|
if (!viewFeature) return null;
|
||||||
|
const meta = features.find((f) => f.name === viewFeature);
|
||||||
|
if (meta?.min != null && meta?.max != null) return [meta.min, meta.max];
|
||||||
|
return null;
|
||||||
|
}, [viewFeature, features]);
|
||||||
|
|
||||||
|
// Filter range: current drag or committed filter values, used for gray-out
|
||||||
|
const filterRange = useMemo((): [number, number] | null => {
|
||||||
|
if (!viewFeature) return null;
|
||||||
|
if (activeFeature && dragValue) return dragValue;
|
||||||
|
const filterVal = filters[viewFeature];
|
||||||
|
if (filterVal && typeof filterVal[0] === 'number') return filterVal as [number, number];
|
||||||
|
return null;
|
||||||
|
}, [viewFeature, activeFeature, dragValue, filters]);
|
||||||
|
|
||||||
// --- URL sync ---
|
// --- URL sync ---
|
||||||
const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
|
@ -326,20 +387,40 @@ export default function App() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${getApiBaseUrl()}/api/features`)
|
fetch(`${getApiBaseUrl()}/api/features`)
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((json: { features: FeatureMeta[] }) => {
|
.then((json: { groups: FeatureGroup[] }) => {
|
||||||
setFeatures(json.features);
|
// Flatten grouped response into a flat feature list with group annotation
|
||||||
|
const flat: FeatureMeta[] = json.groups.flatMap((g) =>
|
||||||
|
g.features.map((f) => ({ ...f, group: g.name }))
|
||||||
|
);
|
||||||
|
setFeatures(flat);
|
||||||
})
|
})
|
||||||
.catch((err) => console.error('Failed to fetch features:', err));
|
.catch((err) => console.error('Failed to fetch features:', err));
|
||||||
|
|
||||||
fetch(`${getApiBaseUrl()}/api/poi-categories`)
|
fetch(`${getApiBaseUrl()}/api/poi-categories`)
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((json: POICategoriesResponse) => {
|
.then((json: POICategoriesResponse) => {
|
||||||
setPOICategories(json.categories);
|
setPOICategoryGroups(json.groups);
|
||||||
})
|
})
|
||||||
.catch((err) => console.error('Failed to fetch POI categories:', err));
|
.catch((err) => console.error('Failed to fetch POI categories:', err));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Debounced fetch when resolution/bounds/filters change
|
// Build filter query string helper
|
||||||
|
const buildFilterParam = useCallback((): string => {
|
||||||
|
const filterEntries = Object.entries(filters);
|
||||||
|
if (filterEntries.length === 0) return '';
|
||||||
|
return 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(',');
|
||||||
|
}, [filters, features]);
|
||||||
|
|
||||||
|
// Debounced fetch when resolution/bounds/filters change — always fetch hexagons
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!bounds) return;
|
if (!bounds) return;
|
||||||
|
|
||||||
|
|
@ -356,25 +437,13 @@ export default function App() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
||||||
|
const filtersStr = buildFilterParam();
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
resolution: resolution.toString(),
|
resolution: resolution.toString(),
|
||||||
bounds: boundsStr,
|
bounds: boundsStr,
|
||||||
});
|
});
|
||||||
// Build filters param: numeric=name:min:max, enum=name:val1|val2
|
if (filtersStr) params.set('filters', filtersStr);
|
||||||
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('filters', filtersStr);
|
|
||||||
}
|
|
||||||
const res = await fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, {
|
const res = await fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, {
|
||||||
signal: abortControllerRef.current.signal,
|
signal: abortControllerRef.current.signal,
|
||||||
});
|
});
|
||||||
|
|
@ -394,10 +463,11 @@ export default function App() {
|
||||||
clearTimeout(debounceRef.current);
|
clearTimeout(debounceRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [resolution, bounds, filters]);
|
}, [resolution, bounds, filters, buildFilterParam]);
|
||||||
|
|
||||||
// Data passed directly to Map — visual filtering is done via color/opacity in the layer
|
// During slider drag, use the expanded dataset (without active feature filter)
|
||||||
const data = rawData;
|
// so both narrowing and expanding are visible. Otherwise use server-filtered data.
|
||||||
|
const data = dragData ?? rawData;
|
||||||
|
|
||||||
// Fetch POIs when bounds or selected categories change
|
// Fetch POIs when bounds or selected categories change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -444,7 +514,13 @@ export default function App() {
|
||||||
|
|
||||||
const prevBoundsRef = useRef<string>('');
|
const prevBoundsRef = useRef<string>('');
|
||||||
const handleViewChange = useCallback(
|
const handleViewChange = useCallback(
|
||||||
({ resolution: newRes, bounds: newBounds, zoom: newZoom, latitude, longitude }: ViewChangeParams) => {
|
({
|
||||||
|
resolution: newRes,
|
||||||
|
bounds: newBounds,
|
||||||
|
zoom: newZoom,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
}: ViewChangeParams) => {
|
||||||
// Only update bounds/resolution when quantized values actually change
|
// Only update bounds/resolution when quantized values actually change
|
||||||
const boundsKey = `${newBounds.south},${newBounds.west},${newBounds.north},${newBounds.east},${newRes}`;
|
const boundsKey = `${newBounds.south},${newBounds.west},${newBounds.north},${newBounds.east},${newRes}`;
|
||||||
if (boundsKey !== prevBoundsRef.current) {
|
if (boundsKey !== prevBoundsRef.current) {
|
||||||
|
|
@ -471,12 +547,9 @@ export default function App() {
|
||||||
[features]
|
[features]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFilterChange = useCallback(
|
const handleFilterChange = useCallback((name: string, value: [number, number] | string[]) => {
|
||||||
(name: string, value: [number, number] | string[]) => {
|
setFilters((prev) => ({ ...prev, [name]: value }));
|
||||||
setFilters((prev) => ({ ...prev, [name]: value }));
|
}, []);
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRemoveFilter = useCallback((name: string) => {
|
const handleRemoveFilter = useCallback((name: string) => {
|
||||||
setFilters((prev) => {
|
setFilters((prev) => {
|
||||||
|
|
@ -484,6 +557,7 @@ export default function App() {
|
||||||
delete next[name];
|
delete next[name];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setPinnedFeature((prev) => (prev === name ? null : prev));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDragStart = useCallback(
|
const handleDragStart = useCallback(
|
||||||
|
|
@ -493,8 +567,41 @@ export default function App() {
|
||||||
setActiveFeature(name);
|
setActiveFeature(name);
|
||||||
const fval = filters[name];
|
const fval = filters[name];
|
||||||
setDragValue(fval && !Array.isArray(fval[0]) ? (fval as [number, number]) : null);
|
setDragValue(fval && !Array.isArray(fval[0]) ? (fval as [number, number]) : null);
|
||||||
|
|
||||||
|
// Fetch hexagons without this feature's filter so we can expand the range
|
||||||
|
if (!bounds) return;
|
||||||
|
if (dragAbortRef.current) dragAbortRef.current.abort();
|
||||||
|
dragAbortRef.current = new AbortController();
|
||||||
|
|
||||||
|
const otherFilters = Object.entries(filters).filter(([k]) => k !== name);
|
||||||
|
let filtersStr = '';
|
||||||
|
if (otherFilters.length > 0) {
|
||||||
|
filtersStr = otherFilters
|
||||||
|
.map(([n, value]) => {
|
||||||
|
const m = features.find((f) => f.name === n);
|
||||||
|
if (m?.type === 'enum') return `${n}:${(value as string[]).join('|')}`;
|
||||||
|
const [min, max] = value as [number, number];
|
||||||
|
return `${n}:${min}:${max}`;
|
||||||
|
})
|
||||||
|
.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
||||||
|
const params = new URLSearchParams({ resolution: resolution.toString(), bounds: boundsStr });
|
||||||
|
if (filtersStr) params.set('filters', filtersStr);
|
||||||
|
|
||||||
|
fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, {
|
||||||
|
signal: dragAbortRef.current.signal,
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((json: ApiResponse) => setDragData(json.features || []))
|
||||||
|
.catch((err) => {
|
||||||
|
if (err instanceof Error && err.name !== 'AbortError') {
|
||||||
|
console.error('Failed to fetch drag data:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[filters, features]
|
[filters, features, bounds, resolution]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragChange = useCallback((value: [number, number]) => {
|
const handleDragChange = useCallback((value: [number, number]) => {
|
||||||
|
|
@ -507,8 +614,21 @@ export default function App() {
|
||||||
}
|
}
|
||||||
setActiveFeature(null);
|
setActiveFeature(null);
|
||||||
setDragValue(null);
|
setDragValue(null);
|
||||||
|
setDragData(null);
|
||||||
|
if (dragAbortRef.current) {
|
||||||
|
dragAbortRef.current.abort();
|
||||||
|
dragAbortRef.current = null;
|
||||||
|
}
|
||||||
}, [activeFeature, dragValue]);
|
}, [activeFeature, dragValue]);
|
||||||
|
|
||||||
|
const handleTogglePin = useCallback((name: string) => {
|
||||||
|
setPinnedFeature((prev) => (prev === name ? null : name));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCancelPin = useCallback(() => {
|
||||||
|
setPinnedFeature(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fetchHexagonProperties = useCallback(
|
const fetchHexagonProperties = useCallback(
|
||||||
async (h3: string, res: number, offset = 0) => {
|
async (h3: string, res: number, offset = 0) => {
|
||||||
setLoadingProperties(true);
|
setLoadingProperties(true);
|
||||||
|
|
@ -584,84 +704,100 @@ export default function App() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col">
|
<div className="h-screen flex flex-col">
|
||||||
<Header />
|
<Header activePage={activePage} onPageChange={setActivePage} />
|
||||||
<div className="flex-1 flex overflow-hidden">
|
{activePage === 'home' ? (
|
||||||
<Filters
|
<HomePage onOpenDashboard={() => setActivePage('dashboard')} />
|
||||||
features={features}
|
) : activePage === 'data-sources' ? (
|
||||||
filters={filters}
|
<DataSourcesPage />
|
||||||
activeFeature={activeFeature}
|
) : (
|
||||||
dragValue={dragValue}
|
<div className="flex-1 flex overflow-hidden">
|
||||||
enabledFeatures={enabledFeatures}
|
<Filters
|
||||||
onAddFilter={handleAddFilter}
|
features={features}
|
||||||
onRemoveFilter={handleRemoveFilter}
|
filters={filters}
|
||||||
onFilterChange={handleFilterChange}
|
|
||||||
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}
|
||||||
initialViewState={initialViewState}
|
onFilterChange={handleFilterChange}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragChange={handleDragChange}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
zoom={zoom}
|
||||||
|
pinnedFeature={pinnedFeature}
|
||||||
|
onTogglePin={handleTogglePin}
|
||||||
|
onCancelPin={handleCancelPin}
|
||||||
/>
|
/>
|
||||||
{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">
|
viewFeature={viewFeature}
|
||||||
{/* Tab headers */}
|
colorRange={colorRange}
|
||||||
<div className="flex border-b border-gray-200">
|
filterRange={filterRange}
|
||||||
<button
|
viewSource={viewSource}
|
||||||
className={`flex-1 p-3 ${
|
onCancelPin={handleCancelPin}
|
||||||
rightPaneTab === 'pois' ? 'border-b-2 border-blue-500 font-semibold' : 'text-gray-600'
|
features={features}
|
||||||
}`}
|
selectedHexagonId={selectedHexagon?.h3 || null}
|
||||||
onClick={() => setRightPaneTab('pois')}
|
onHexagonClick={handleHexagonClick}
|
||||||
>
|
initialViewState={initialViewState}
|
||||||
POIs {pois.length > 0 && `(${pois.length})`}
|
/>
|
||||||
</button>
|
{loading && (
|
||||||
<button
|
<div className="absolute top-4 right-4 bg-white px-3 py-1 rounded shadow">
|
||||||
className={`flex-1 p-3 ${
|
Loading...
|
||||||
rightPaneTab === 'properties'
|
</div>
|
||||||
? '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 onNavigate={() => setActivePage('data-sources')} />
|
||||||
|
</div>
|
||||||
|
<div className="w-72 bg-white shadow-lg z-10 flex flex-col">
|
||||||
|
{/* Tab headers */}
|
||||||
|
<div className="flex border-b border-warm-200">
|
||||||
|
<button
|
||||||
|
className={`flex-1 p-3 ${
|
||||||
|
rightPaneTab === 'pois'
|
||||||
|
? 'border-b-2 border-teal-500 font-semibold'
|
||||||
|
: 'text-warm-600'
|
||||||
|
}`}
|
||||||
|
onClick={() => setRightPaneTab('pois')}
|
||||||
|
>
|
||||||
|
POIs {pois.length > 0 && `(${pois.length})`}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`flex-1 p-3 ${
|
||||||
|
rightPaneTab === 'properties'
|
||||||
|
? 'border-b-2 border-teal-500 font-semibold'
|
||||||
|
: 'text-warm-600'
|
||||||
|
}`}
|
||||||
|
onClick={() => setRightPaneTab('properties')}
|
||||||
|
>
|
||||||
|
Properties {propertiesTotal > 0 && `(${propertiesTotal})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{rightPaneTab === 'pois' ? (
|
||||||
|
<POIPane
|
||||||
|
groups={poiCategoryGroups}
|
||||||
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ const DATA_SOURCES = [
|
||||||
{
|
{
|
||||||
name: 'TfL Journey Times',
|
name: 'TfL Journey Times',
|
||||||
origin: 'Transport for London',
|
origin: 'Transport for London',
|
||||||
use: 'Journey time calculations from postcodes to central London destinations (Bank, Waterloo, King\'s Cross, etc.) via public transport and cycling.',
|
use: "Journey time calculations from postcodes to central London destinations (Bank, Waterloo, King's Cross, etc.) via public transport and cycling.",
|
||||||
url: 'https://api-portal.tfl.gov.uk/',
|
url: 'https://api-portal.tfl.gov.uk/',
|
||||||
license: 'Powered by TfL Open Data',
|
license: 'Powered by TfL Open Data',
|
||||||
},
|
},
|
||||||
|
|
@ -92,15 +92,12 @@ export default function DataSourcesPage() {
|
||||||
<div className="max-w-5xl mx-auto px-6 py-8">
|
<div className="max-w-5xl mx-auto px-6 py-8">
|
||||||
<h1 className="text-2xl font-bold text-warm-900 mb-2">Data Sources</h1>
|
<h1 className="text-2xl font-bold text-warm-900 mb-2">Data Sources</h1>
|
||||||
<p className="text-warm-600 mb-6">
|
<p className="text-warm-600 mb-6">
|
||||||
This application combines {DATA_SOURCES.length} open datasets covering property prices, energy
|
This application combines {DATA_SOURCES.length} open datasets covering property prices,
|
||||||
performance, transport, demographics, crime, environment, and more.
|
energy performance, transport, demographics, crime, environment, and more.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
{DATA_SOURCES.map((source) => (
|
{DATA_SOURCES.map((source) => (
|
||||||
<div
|
<div key={source.name} className="bg-white rounded-lg border border-warm-200 p-5">
|
||||||
key={source.name}
|
|
||||||
className="bg-white rounded-lg border border-warm-200 p-5"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-4 mb-2">
|
<div className="flex items-start justify-between gap-4 mb-2">
|
||||||
<h2 className="text-lg font-semibold text-warm-900">{source.name}</h2>
|
<h2 className="text-lg font-semibold text-warm-900">{source.name}</h2>
|
||||||
<span className="shrink-0 text-xs bg-warm-100 text-warm-600 px-2 py-1 rounded">
|
<span className="shrink-0 text-xs bg-warm-100 text-warm-600 px-2 py-1 rounded">
|
||||||
|
|
@ -125,11 +122,11 @@ export default function DataSourcesPage() {
|
||||||
|
|
||||||
<footer className="bg-navy-900 text-warm-400 px-6 py-6">
|
<footer className="bg-navy-900 text-warm-400 px-6 py-6">
|
||||||
<div className="max-w-5xl mx-auto">
|
<div className="max-w-5xl mx-auto">
|
||||||
<h2 className="text-sm font-semibold text-warm-300 uppercase tracking-wide mb-3">Attribution</h2>
|
<h2 className="text-sm font-semibold text-warm-300 uppercase tracking-wide mb-3">
|
||||||
|
Attribution
|
||||||
|
</h2>
|
||||||
<ul className="space-y-1.5 text-sm">
|
<ul className="space-y-1.5 text-sm">
|
||||||
<li>
|
<li>Contains HM Land Registry data © Crown copyright and database right 2025.</li>
|
||||||
Contains HM Land Registry data © Crown copyright and database right 2025.
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
Contains public sector information licensed under the{' '}
|
Contains public sector information licensed under the{' '}
|
||||||
<a
|
<a
|
||||||
|
|
@ -142,9 +139,7 @@ export default function DataSourcesPage() {
|
||||||
</a>
|
</a>
|
||||||
.
|
.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>Contains OS data © Crown copyright and database rights 2025.</li>
|
||||||
Contains OS data © Crown copyright and database rights 2025.
|
|
||||||
</li>
|
|
||||||
<li>Powered by TfL Open Data.</li>
|
<li>Powered by TfL Open Data.</li>
|
||||||
<li>
|
<li>
|
||||||
Contains data from{' '}
|
Contains data from{' '}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { memo } from 'react';
|
import { memo, useState, useRef, useEffect, useMemo } 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';
|
||||||
|
|
@ -21,6 +21,83 @@ interface FiltersProps {
|
||||||
onCancelPin: () => void;
|
onCancelPin: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FilterDropdown({
|
||||||
|
availableFeatures,
|
||||||
|
onAddFilter,
|
||||||
|
}: {
|
||||||
|
availableFeatures: FeatureMeta[];
|
||||||
|
onAddFilter: (name: string) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
const keyHandler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
document.addEventListener('keydown', keyHandler);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handler);
|
||||||
|
document.removeEventListener('keydown', keyHandler);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const groups: { name: string; features: FeatureMeta[] }[] = [];
|
||||||
|
const seen = new Map<string, FeatureMeta[]>();
|
||||||
|
for (const f of availableFeatures) {
|
||||||
|
const g = f.group || 'Other';
|
||||||
|
let arr = seen.get(g);
|
||||||
|
if (!arr) {
|
||||||
|
arr = [];
|
||||||
|
seen.set(g, arr);
|
||||||
|
groups.push({ name: g, features: arr });
|
||||||
|
}
|
||||||
|
arr.push(f);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}, [availableFeatures]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button
|
||||||
|
className="w-full p-2 border rounded text-sm bg-white text-left text-warm-500 hover:border-warm-400"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
>
|
||||||
|
+ Add filter...
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute z-50 mt-1 w-full bg-white border rounded shadow-lg max-h-80 overflow-y-auto">
|
||||||
|
{grouped.map((group) => (
|
||||||
|
<div key={group.name}>
|
||||||
|
<div className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 sticky top-0">
|
||||||
|
{group.name}
|
||||||
|
</div>
|
||||||
|
{group.features.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.name}
|
||||||
|
className="w-full text-left px-3 py-1.5 text-sm hover:bg-teal-50 hover:text-teal-700"
|
||||||
|
onClick={() => {
|
||||||
|
onAddFilter(f.name);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{f.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function formatValue(value: number): string {
|
function formatValue(value: number): string {
|
||||||
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||||
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
|
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
|
||||||
|
|
@ -54,22 +131,7 @@ export default memo(function Filters({
|
||||||
|
|
||||||
{/* Add filter dropdown */}
|
{/* Add filter dropdown */}
|
||||||
{availableFeatures.length > 0 && (
|
{availableFeatures.length > 0 && (
|
||||||
<select
|
<FilterDropdown availableFeatures={availableFeatures} onAddFilter={onAddFilter} />
|
||||||
className="w-full p-2 border rounded text-sm bg-white"
|
|
||||||
value=""
|
|
||||||
onChange={(e) => {
|
|
||||||
if (e.target.value) onAddFilter(e.target.value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="" disabled>
|
|
||||||
+ Add filter...
|
|
||||||
</option>
|
|
||||||
{availableFeatures.map((f) => (
|
|
||||||
<option key={f.name} value={f.name}>
|
|
||||||
{f.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Active filters */}
|
{/* Active filters */}
|
||||||
|
|
@ -149,20 +211,17 @@ export default memo(function Filters({
|
||||||
className={`p-0.5 rounded ${isPinned ? 'text-teal-600' : 'text-warm-400 hover:text-warm-700'}`}
|
className={`p-0.5 rounded ${isPinned ? 'text-teal-600' : 'text-warm-400 hover:text-warm-700'}`}
|
||||||
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
|
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
|
||||||
>
|
>
|
||||||
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill={isPinned ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth={2}>
|
<svg
|
||||||
|
className="w-3.5 h-3.5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill={isPinned ? 'currentColor' : 'none'}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||||
<circle cx="12" cy="12" r="3" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{isPinned && (
|
|
||||||
<button
|
|
||||||
onClick={onCancelPin}
|
|
||||||
className="text-warm-400 hover:text-warm-700 text-xs px-0.5"
|
|
||||||
title="Clear color view"
|
|
||||||
>
|
|
||||||
x
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => onRemoveFilter(feature.name)}
|
onClick={() => onRemoveFilter(feature.name)}
|
||||||
className="text-warm-400 hover:text-warm-700 text-sm px-1"
|
className="text-warm-400 hover:text-warm-700 text-sm px-1"
|
||||||
|
|
@ -184,7 +243,6 @@ export default memo(function Filters({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -182,28 +182,29 @@ export default function HomePage({ onOpenDashboard }: { onOpenDashboard: () => v
|
||||||
const ctaRef = useFadeInRef();
|
const ctaRef = useFadeInRef();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div ref={scrollRef} className="flex-1 overflow-y-auto bg-warm-50 relative">
|
||||||
ref={scrollRef}
|
|
||||||
className="flex-1 overflow-y-auto bg-warm-50 relative"
|
|
||||||
>
|
|
||||||
<HexCanvas scrollProgress={scrollProgress} />
|
<HexCanvas scrollProgress={scrollProgress} />
|
||||||
|
|
||||||
<div className="relative" style={{ zIndex: 1 }}>
|
<div className="relative" style={{ zIndex: 1 }}>
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<div className="max-w-3xl mx-auto px-6 pt-20 pb-24">
|
<div className="max-w-3xl mx-auto px-6 pt-20 pb-24">
|
||||||
<div ref={heroRef} className="fade-in-section backdrop-blur-sm bg-warm-50/60 rounded-2xl p-8 -mx-2">
|
<div
|
||||||
|
ref={heroRef}
|
||||||
|
className="fade-in-section backdrop-blur-sm bg-warm-50/60 rounded-2xl p-8 -mx-2"
|
||||||
|
>
|
||||||
<p className="text-teal-600 font-semibold tracking-wide uppercase text-sm mb-4">
|
<p className="text-teal-600 font-semibold tracking-wide uppercase text-sm mb-4">
|
||||||
Find where to live, not just what's for sale
|
Find where to live, not just what's for sale
|
||||||
</p>
|
</p>
|
||||||
<h1 className="text-5xl font-extrabold text-navy-950 mb-6 leading-[1.1] tracking-tight">
|
<h1 className="text-5xl font-extrabold text-navy-950 mb-6 leading-[1.1] tracking-tight">
|
||||||
Every neighbourhood<br />
|
Every neighbourhood
|
||||||
in England & Wales.<br />
|
<br />
|
||||||
|
in England & Wales.
|
||||||
|
<br />
|
||||||
<span className="text-teal-600">One map. Your rules.</span>
|
<span className="text-teal-600">One map. Your rules.</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-warm-600 mb-8 leading-relaxed max-w-xl">
|
<p className="text-xl text-warm-600 mb-8 leading-relaxed max-w-xl">
|
||||||
Set the commute, budget, school rating, noise level, and crime
|
Set the commute, budget, school rating, noise level, and crime threshold you'll
|
||||||
threshold you'll accept. Narrowit shows you every area that
|
accept. Narrowit shows you every area that qualifies — instantly.
|
||||||
qualifies — instantly.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
|
|
@ -225,19 +226,22 @@ export default function HomePage({ onOpenDashboard }: { onOpenDashboard: () => v
|
||||||
<div className="rounded-2xl backdrop-blur-sm bg-warm-50/40 border border-warm-200/50 p-8">
|
<div className="rounded-2xl backdrop-blur-sm bg-warm-50/40 border border-warm-200/50 p-8">
|
||||||
<div className="grid md:grid-cols-2 gap-8">
|
<div className="grid md:grid-cols-2 gap-8">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-warm-400 uppercase tracking-wide mb-2">The old way</h3>
|
<h3 className="text-sm font-semibold text-warm-400 uppercase tracking-wide mb-2">
|
||||||
|
The old way
|
||||||
|
</h3>
|
||||||
<p className="text-warm-700 leading-relaxed">
|
<p className="text-warm-700 leading-relaxed">
|
||||||
Pick a postcode. Google the schools. Check crime stats on
|
Pick a postcode. Google the schools. Check crime stats on another site. Look up
|
||||||
another site. Look up commute times. Realise it's too
|
commute times. Realise it's too expensive. Start over. Repeat 40 times.
|
||||||
expensive. Start over. Repeat 40 times.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-teal-600 uppercase tracking-wide mb-2">With Narrowit</h3>
|
<h3 className="text-sm font-semibold text-teal-600 uppercase tracking-wide mb-2">
|
||||||
|
With Narrowit
|
||||||
|
</h3>
|
||||||
<p className="text-warm-700 leading-relaxed">
|
<p className="text-warm-700 leading-relaxed">
|
||||||
Tell the map what you need. Every hexagon that lights up is
|
Tell the map what you need. Every hexagon that lights up is a place worth
|
||||||
a place worth looking at. Drill into any one to see
|
looking at. Drill into any one to see individual properties, prices, and energy
|
||||||
individual properties, prices, and energy ratings.
|
ratings.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -308,9 +312,7 @@ export default function HomePage({ onOpenDashboard }: { onOpenDashboard: () => v
|
||||||
{/* Final CTA */}
|
{/* Final CTA */}
|
||||||
<div className="max-w-3xl mx-auto px-6 pb-24">
|
<div className="max-w-3xl mx-auto px-6 pb-24">
|
||||||
<div ref={ctaRef} className="fade-in-section text-center">
|
<div ref={ctaRef} className="fade-in-section text-center">
|
||||||
<h2 className="text-3xl font-bold text-navy-950 mb-3">
|
<h2 className="text-3xl font-bold text-navy-950 mb-3">Ready to narrow it down?</h2>
|
||||||
Ready to narrow it down?
|
|
||||||
</h2>
|
|
||||||
<p className="text-warm-500 mb-8 max-w-md mx-auto">
|
<p className="text-warm-500 mb-8 max-w-md mx-auto">
|
||||||
100% open data. No account required. Just set your filters and go.
|
100% open data. No account required. Just set your filters and go.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -3,26 +3,24 @@ 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';
|
||||||
import { H3HexagonLayer } from '@deck.gl/geo-layers';
|
import { H3HexagonLayer } from '@deck.gl/geo-layers';
|
||||||
import { IconLayer, PolygonLayer } from '@deck.gl/layers';
|
import { IconLayer, TextLayer } from '@deck.gl/layers';
|
||||||
import type { PickingInfo } from '@deck.gl/core';
|
import type { PickingInfo } from '@deck.gl/core';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
import type { HexagonData, ViewState, ViewChangeParams, Bounds, POI, FeatureMeta, PostcodeData } from '../types';
|
import type { HexagonData, ViewState, ViewChangeParams, Bounds, POI, FeatureMeta } from '../types';
|
||||||
|
|
||||||
interface MapProps {
|
interface MapProps {
|
||||||
data: HexagonData[];
|
data: HexagonData[];
|
||||||
pois: POI[];
|
pois: POI[];
|
||||||
onViewChange: (params: ViewChangeParams) => void;
|
onViewChange: (params: ViewChangeParams) => void;
|
||||||
viewFeature: string | null;
|
viewFeature: string | null;
|
||||||
viewRange: [number, number] | null;
|
colorRange: [number, number] | null;
|
||||||
|
filterRange: [number, number] | null;
|
||||||
viewSource: 'drag' | 'eye' | null;
|
viewSource: 'drag' | 'eye' | null;
|
||||||
onCancelPin: () => void;
|
onCancelPin: () => void;
|
||||||
features: FeatureMeta[];
|
features: FeatureMeta[];
|
||||||
selectedHexagonId: string | null;
|
selectedHexagonId: string | null;
|
||||||
onHexagonClick: (h3: string) => void;
|
onHexagonClick: (h3: string) => void;
|
||||||
initialViewState?: ViewState;
|
initialViewState?: ViewState;
|
||||||
postcodeData: PostcodeData[];
|
|
||||||
selectedPostcode: string | null;
|
|
||||||
onPostcodeClick: (postcode: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Twemoji CDN base URL
|
// Twemoji CDN base URL
|
||||||
|
|
@ -44,7 +42,7 @@ const INITIAL_VIEW: ViewState = {
|
||||||
pitch: 0,
|
pitch: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json';
|
const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json';
|
||||||
|
|
||||||
// Gradient stops for normalized [0,1] values
|
// Gradient stops for normalized [0,1] values
|
||||||
const GRADIENT: { t: number; color: [number, number, number] }[] = [
|
const GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||||
|
|
@ -78,7 +76,8 @@ function zoomToResolution(zoom: number): number {
|
||||||
if (zoom < 9.5) return 8;
|
if (zoom < 9.5) return 8;
|
||||||
if (zoom < 11) return 9;
|
if (zoom < 11) return 9;
|
||||||
if (zoom < 13) return 10;
|
if (zoom < 13) return 10;
|
||||||
return 11;
|
if (zoom < 15) return 11;
|
||||||
|
return 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBoundsFromViewState(viewState: ViewState, width: number, height: number): Bounds {
|
function getBoundsFromViewState(viewState: ViewState, width: number, height: number): Bounds {
|
||||||
|
|
@ -195,10 +194,7 @@ function PostcodeSearch({
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form onSubmit={handleSubmit} className="absolute top-3 left-3 z-10 flex flex-col gap-1">
|
||||||
onSubmit={handleSubmit}
|
|
||||||
className="absolute top-3 left-3 z-10 flex flex-col gap-1"
|
|
||||||
>
|
|
||||||
<div className="flex shadow-lg rounded overflow-hidden">
|
<div className="flex shadow-lg rounded overflow-hidden">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -219,9 +215,7 @@ function PostcodeSearch({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<span className="text-xs text-red-600 bg-white/90 rounded px-2 py-0.5 shadow">
|
<span className="text-xs text-red-600 bg-white/90 rounded px-2 py-0.5 shadow">{error}</span>
|
||||||
{error}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|
@ -255,7 +249,13 @@ function MapLegend({
|
||||||
className="text-warm-400 hover:text-warm-700 ml-2"
|
className="text-warm-400 hover:text-warm-700 ml-2"
|
||||||
title="Clear color view"
|
title="Clear color view"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -281,16 +281,14 @@ export default memo(function Map({
|
||||||
pois,
|
pois,
|
||||||
onViewChange,
|
onViewChange,
|
||||||
viewFeature,
|
viewFeature,
|
||||||
viewRange,
|
colorRange,
|
||||||
|
filterRange,
|
||||||
viewSource,
|
viewSource,
|
||||||
onCancelPin,
|
onCancelPin,
|
||||||
features,
|
features,
|
||||||
selectedHexagonId,
|
selectedHexagonId,
|
||||||
onHexagonClick,
|
onHexagonClick,
|
||||||
initialViewState,
|
initialViewState,
|
||||||
postcodeData,
|
|
||||||
selectedPostcode,
|
|
||||||
onPostcodeClick,
|
|
||||||
}: MapProps) {
|
}: MapProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW);
|
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW);
|
||||||
|
|
@ -328,7 +326,13 @@ export default memo(function Map({
|
||||||
east: Math.ceil(raw.east / QUANT) * QUANT,
|
east: Math.ceil(raw.east / QUANT) * QUANT,
|
||||||
};
|
};
|
||||||
|
|
||||||
onViewChange({ resolution, bounds, zoom: viewState.zoom, latitude: viewState.latitude, longitude: viewState.longitude });
|
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 }) => {
|
||||||
|
|
@ -349,6 +353,12 @@ export default memo(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');
|
||||||
}
|
}
|
||||||
|
// Make water more prominent
|
||||||
|
for (const layer of map.getStyle().layers || []) {
|
||||||
|
if (layer.id === 'water' || layer.id.startsWith('water')) {
|
||||||
|
map.setPaintProperty(layer.id, 'fill-color', '#6baed6');
|
||||||
|
}
|
||||||
|
}
|
||||||
map.setLayoutProperty('building', 'visibility', 'none');
|
map.setLayoutProperty('building', 'visibility', 'none');
|
||||||
map.setLayoutProperty('building-top', 'visibility', 'none');
|
map.setLayoutProperty('building-top', 'visibility', 'none');
|
||||||
},
|
},
|
||||||
|
|
@ -399,8 +409,10 @@ export default memo(function Map({
|
||||||
// Use refs for values that change during drag so layers aren't recreated
|
// Use refs for values that change during drag so layers aren't recreated
|
||||||
const viewFeatureRef = useRef(viewFeature);
|
const viewFeatureRef = useRef(viewFeature);
|
||||||
viewFeatureRef.current = viewFeature;
|
viewFeatureRef.current = viewFeature;
|
||||||
const viewRangeRef = useRef(viewRange);
|
const colorRangeRef = useRef(colorRange);
|
||||||
viewRangeRef.current = viewRange;
|
colorRangeRef.current = colorRange;
|
||||||
|
const filterRangeRef = useRef(filterRange);
|
||||||
|
filterRangeRef.current = filterRange;
|
||||||
const colorFeatureMetaRef = useRef(colorFeatureMeta);
|
const colorFeatureMetaRef = useRef(colorFeatureMeta);
|
||||||
colorFeatureMetaRef.current = colorFeatureMeta;
|
colorFeatureMetaRef.current = colorFeatureMeta;
|
||||||
const countRangeRef = useRef(countRange);
|
const countRangeRef = useRef(countRange);
|
||||||
|
|
@ -408,32 +420,14 @@ export default memo(function Map({
|
||||||
const selectedHexagonIdRef = useRef(selectedHexagonId);
|
const selectedHexagonIdRef = useRef(selectedHexagonId);
|
||||||
selectedHexagonIdRef.current = selectedHexagonId;
|
selectedHexagonIdRef.current = selectedHexagonId;
|
||||||
|
|
||||||
// Postcode refs
|
|
||||||
const selectedPostcodeRef = useRef(selectedPostcode);
|
|
||||||
selectedPostcodeRef.current = selectedPostcode;
|
|
||||||
|
|
||||||
// Stable click handler using ref
|
// Stable click handler using ref
|
||||||
const onHexagonClickRef = useRef(onHexagonClick);
|
const onHexagonClickRef = useRef(onHexagonClick);
|
||||||
onHexagonClickRef.current = 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) {
|
onHexagonClickRef.current(info.object.h3);
|
||||||
onHexagonClickRef.current(info.object.h3);
|
}
|
||||||
}
|
}, []);
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onPostcodeClickRef = useRef(onPostcodeClick);
|
|
||||||
onPostcodeClickRef.current = onPostcodeClick;
|
|
||||||
const handlePostcodeClick = useCallback(
|
|
||||||
(info: PickingInfo<PostcodeData>) => {
|
|
||||||
if (info.object && 'postcode' in info.object) {
|
|
||||||
onPostcodeClickRef.current(info.object.postcode);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Stable hover handler using ref
|
// Stable hover handler using ref
|
||||||
const handlePoiHoverRef = useRef(handlePoiHover);
|
const handlePoiHoverRef = useRef(handlePoiHover);
|
||||||
|
|
@ -443,7 +437,7 @@ export default memo(function Map({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Derive a trigger value from color-affecting state — avoids useEffect+setState double-render
|
// Derive a trigger value from color-affecting state — avoids useEffect+setState double-render
|
||||||
const colorTrigger = `${viewFeature}|${viewRange?.[0]}|${viewRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}`;
|
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}`;
|
||||||
|
|
||||||
// Hexagon layer — only recreated when data or color trigger changes
|
// Hexagon layer — only recreated when data or color trigger changes
|
||||||
const hexLayer = useMemo(
|
const hexLayer = useMemo(
|
||||||
|
|
@ -454,29 +448,36 @@ export default memo(function Map({
|
||||||
getHexagon: (d) => d.h3,
|
getHexagon: (d) => d.h3,
|
||||||
getFillColor: (d) => {
|
getFillColor: (d) => {
|
||||||
const vf = viewFeatureRef.current;
|
const vf = viewFeatureRef.current;
|
||||||
const vr = viewRangeRef.current;
|
const clr = colorRangeRef.current;
|
||||||
|
const fr = filterRangeRef.current;
|
||||||
const cfm = colorFeatureMetaRef.current;
|
const cfm = colorFeatureMetaRef.current;
|
||||||
if (vf && vr && cfm) {
|
if (vf && clr && cfm) {
|
||||||
const val = d[`min_${vf}`];
|
const val = d[`min_${vf}`];
|
||||||
if (val == null) return [128, 128, 128, 80] as [number, number, number, number];
|
if (val == null) return [128, 128, 128, 80] as [number, number, number, number];
|
||||||
const min = vr[0];
|
// Gray out hexagons outside filter range
|
||||||
const max = vr[1];
|
if (fr) {
|
||||||
const minVal = d[`min_${vf}`] as number;
|
const minVal = d[`min_${vf}`] as number;
|
||||||
const maxVal = d[`max_${vf}`] as number;
|
const maxVal = d[`max_${vf}`] as number;
|
||||||
// Gray out hexagons outside range
|
if (maxVal < fr[0] || minVal > fr[1]) {
|
||||||
if (maxVal < min || minVal > max) {
|
return [180, 180, 180, 60] as [number, number, number, number];
|
||||||
return [180, 180, 180, 60] as [number, number, number, number];
|
}
|
||||||
}
|
}
|
||||||
const range = max - min;
|
// Color using full slider range
|
||||||
|
const range = clr[1] - clr[0];
|
||||||
if (range === 0) return [...GRADIENT[0].color, 200] as [number, number, number, number];
|
if (range === 0) return [...GRADIENT[0].color, 200] as [number, number, number, number];
|
||||||
const t = ((val as number) - min) / range;
|
const t = ((val as number) - clr[0]) / range;
|
||||||
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)));
|
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)));
|
||||||
return [...rgb, 200] as [number, number, number, number];
|
return [...rgb, 200] as [number, number, number, number];
|
||||||
}
|
}
|
||||||
const cr = countRangeRef.current;
|
const cr = countRangeRef.current;
|
||||||
const c = d.count as number;
|
const c = d.count as number;
|
||||||
const t = (c - cr.min) / (cr.max - cr.min);
|
const t = (c - cr.min) / (cr.max - cr.min);
|
||||||
return [...countToColor(Math.max(0, Math.min(1, t))), 200] as [number, number, number, number];
|
return [...countToColor(Math.max(0, Math.min(1, t))), 200] as [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
];
|
||||||
},
|
},
|
||||||
getLineColor: (d) =>
|
getLineColor: (d) =>
|
||||||
(d.h3 === selectedHexagonIdRef.current ? [255, 255, 255, 255] : [0, 0, 0, 0]) as [
|
(d.h3 === selectedHexagonIdRef.current ? [255, 255, 255, 255] : [0, 0, 0, 0]) as [
|
||||||
|
|
@ -498,84 +499,11 @@ export default memo(function Map({
|
||||||
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: "waterway_label",
|
beforeId: 'waterway_label',
|
||||||
}),
|
}),
|
||||||
[data, colorTrigger, handleHexagonClick]
|
[data, colorTrigger, handleHexagonClick]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Postcode count range
|
|
||||||
const postcodeCountRange = useMemo(() => {
|
|
||||||
if (postcodeData.length === 0) return { min: 0, max: 1 };
|
|
||||||
let min = Infinity;
|
|
||||||
let max = -Infinity;
|
|
||||||
for (const d of postcodeData) {
|
|
||||||
const c = d.count as number;
|
|
||||||
if (c < min) min = c;
|
|
||||||
if (c > max) max = c;
|
|
||||||
}
|
|
||||||
if (min === max) return { min, max: min + 1 };
|
|
||||||
return { min, max };
|
|
||||||
}, [postcodeData]);
|
|
||||||
|
|
||||||
const postcodeCountRangeRef = useRef(postcodeCountRange);
|
|
||||||
postcodeCountRangeRef.current = postcodeCountRange;
|
|
||||||
|
|
||||||
// Postcode color trigger
|
|
||||||
const postcodeColorTrigger = `${viewFeature}|${viewRange?.[0]}|${viewRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${selectedPostcode}`;
|
|
||||||
|
|
||||||
// Postcode polygon layer
|
|
||||||
const postcodeLayer = useMemo(
|
|
||||||
() =>
|
|
||||||
new PolygonLayer<PostcodeData>({
|
|
||||||
id: 'postcode-polygons',
|
|
||||||
data: postcodeData,
|
|
||||||
getPolygon: (d) => d.polygon,
|
|
||||||
getFillColor: (d) => {
|
|
||||||
const vf = viewFeatureRef.current;
|
|
||||||
const vr = viewRangeRef.current;
|
|
||||||
const cfm = colorFeatureMetaRef.current;
|
|
||||||
if (vf && vr && cfm) {
|
|
||||||
const val = d[`min_${vf}`];
|
|
||||||
if (val == null) return [128, 128, 128, 80] as [number, number, number, number];
|
|
||||||
const min = vr[0];
|
|
||||||
const max = vr[1];
|
|
||||||
const minVal = d[`min_${vf}`] as number;
|
|
||||||
const maxVal = d[`max_${vf}`] as number;
|
|
||||||
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];
|
|
||||||
}
|
|
||||||
const cr = postcodeCountRangeRef.current;
|
|
||||||
const c = d.count as number;
|
|
||||||
const t = (c - cr.min) / (cr.max - cr.min);
|
|
||||||
return [...countToColor(Math.max(0, Math.min(1, t))), 200] as [number, number, number, number];
|
|
||||||
},
|
|
||||||
getLineColor: (d) =>
|
|
||||||
(d.postcode === selectedPostcodeRef.current
|
|
||||||
? [255, 255, 255, 255]
|
|
||||||
: [160, 160, 160, 200]) as [number, number, number, number],
|
|
||||||
getLineWidth: (d) => (d.postcode === selectedPostcodeRef.current ? 2 : 1),
|
|
||||||
lineWidthUnits: 'pixels' as const,
|
|
||||||
stroked: true,
|
|
||||||
filled: true,
|
|
||||||
pickable: true,
|
|
||||||
updateTriggers: {
|
|
||||||
getFillColor: [postcodeColorTrigger],
|
|
||||||
getLineColor: [postcodeColorTrigger],
|
|
||||||
getLineWidth: [postcodeColorTrigger],
|
|
||||||
},
|
|
||||||
onClick: handlePostcodeClick,
|
|
||||||
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
|
|
||||||
beforeId: "waterway_label",
|
|
||||||
}),
|
|
||||||
[postcodeData, postcodeColorTrigger, handlePostcodeClick]
|
|
||||||
);
|
|
||||||
|
|
||||||
// POI layer — independent, only recreated when POI data changes
|
// POI layer — independent, only recreated when POI data changes
|
||||||
const poiLayer = useMemo(
|
const poiLayer = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -597,9 +525,41 @@ export default memo(function Map({
|
||||||
[pois, stablePoiHover]
|
[pois, stablePoiHover]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Postcode labels on high-res hexagons (resolution 11+, zoom >= 13)
|
||||||
|
const postcodeData = useMemo(
|
||||||
|
() => data.filter((d) => d.postcode && d.lat != null && d.lon != null),
|
||||||
|
[data]
|
||||||
|
);
|
||||||
|
|
||||||
|
const showPostcodes = viewState.zoom >= 13;
|
||||||
|
const postcodeLayer = useMemo(
|
||||||
|
() =>
|
||||||
|
showPostcodes
|
||||||
|
? new TextLayer<HexagonData>({
|
||||||
|
id: 'postcode-labels',
|
||||||
|
data: postcodeData,
|
||||||
|
getPosition: (d) => [d.lon as number, d.lat as number],
|
||||||
|
getText: (d) => d.postcode as string,
|
||||||
|
getSize: 11,
|
||||||
|
getColor: [30, 30, 30, 220],
|
||||||
|
getTextAnchor: 'middle',
|
||||||
|
getAlignmentBaseline: 'center',
|
||||||
|
fontFamily: 'Inter, system-ui, sans-serif',
|
||||||
|
fontWeight: 600,
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineColor: [255, 255, 255, 200],
|
||||||
|
billboard: false,
|
||||||
|
sizeUnits: 'pixels',
|
||||||
|
sizeMinPixels: 10,
|
||||||
|
sizeMaxPixels: 14,
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
[postcodeData, showPostcodes]
|
||||||
|
);
|
||||||
|
|
||||||
const layers = useMemo(
|
const layers = useMemo(
|
||||||
() => (postcodeData.length > 0 ? [postcodeLayer, poiLayer] : [hexLayer, poiLayer]),
|
() => [hexLayer, poiLayer, ...(postcodeLayer ? [postcodeLayer] : [])],
|
||||||
[postcodeData.length, postcodeLayer, hexLayer, poiLayer]
|
[hexLayer, poiLayer, postcodeLayer]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Tooltip uses refs to avoid being a layer dependency
|
// Tooltip uses refs to avoid being a layer dependency
|
||||||
|
|
@ -611,15 +571,9 @@ export default memo(function Map({
|
||||||
({ object }: { object?: any }) => {
|
({ object }: { object?: any }) => {
|
||||||
if (!object) return null;
|
if (!object) return null;
|
||||||
|
|
||||||
// Handle both hexagon and postcode objects
|
if (!('h3' in object)) return null;
|
||||||
const isPostcode = 'postcode' in object;
|
|
||||||
const isHexagon = 'h3' in object;
|
|
||||||
if (!isPostcode && !isHexagon) return null;
|
|
||||||
|
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
if (isPostcode) {
|
|
||||||
lines.push(`<strong>${object.postcode}</strong>`);
|
|
||||||
}
|
|
||||||
lines.push(`<div>${(object.count as number).toLocaleString()} properties</div>`);
|
lines.push(`<div>${(object.count as number).toLocaleString()} properties</div>`);
|
||||||
|
|
||||||
for (const f of featuresRef.current) {
|
for (const f of featuresRef.current) {
|
||||||
|
|
@ -664,10 +618,10 @@ export default memo(function Map({
|
||||||
<DeckOverlay layers={layers} getTooltip={getTooltip as never} />
|
<DeckOverlay layers={layers} getTooltip={getTooltip as never} />
|
||||||
</MapGL>
|
</MapGL>
|
||||||
<PostcodeSearch onFlyTo={handleFlyTo} />
|
<PostcodeSearch onFlyTo={handleFlyTo} />
|
||||||
{viewFeature && viewRange && colorFeatureMeta && (
|
{viewFeature && colorRange && colorFeatureMeta && (
|
||||||
<MapLegend
|
<MapLegend
|
||||||
featureLabel={colorFeatureMeta.name}
|
featureLabel={colorFeatureMeta.name}
|
||||||
range={viewRange}
|
range={colorRange}
|
||||||
showCancel={viewSource === 'eye'}
|
showCancel={viewSource === 'eye'}
|
||||||
onCancel={onCancelPin}
|
onCancel={onCancelPin}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,22 @@
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { Label } from './ui/label';
|
import type { POICategoryGroup } from '../types';
|
||||||
|
|
||||||
interface POIPaneProps {
|
interface POIPaneProps {
|
||||||
categories: string[];
|
groups: POICategoryGroup[];
|
||||||
selectedCategories: Set<string>;
|
selectedCategories: Set<string>;
|
||||||
onCategoriesChange: (categories: Set<string>) => void;
|
onCategoriesChange: (categories: Set<string>) => void;
|
||||||
poiCount: number;
|
poiCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function POIPane({
|
export default function POIPane({
|
||||||
categories,
|
groups,
|
||||||
selectedCategories,
|
selectedCategories,
|
||||||
onCategoriesChange,
|
onCategoriesChange,
|
||||||
poiCount,
|
poiCount,
|
||||||
}: POIPaneProps) {
|
}: POIPaneProps) {
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
// Close dropdown when clicking outside
|
||||||
|
|
@ -29,6 +30,8 @@ export default function POIPane({
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const allCategories = groups.flatMap((g) => g.categories);
|
||||||
|
|
||||||
const toggleCategory = (category: string) => {
|
const toggleCategory = (category: string) => {
|
||||||
const newSet = new Set(selectedCategories);
|
const newSet = new Set(selectedCategories);
|
||||||
if (newSet.has(category)) {
|
if (newSet.has(category)) {
|
||||||
|
|
@ -40,17 +43,55 @@ export default function POIPane({
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectAll = () => {
|
const selectAll = () => {
|
||||||
onCategoriesChange(new Set(categories));
|
onCategoriesChange(new Set(allCategories));
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectNone = () => {
|
const selectNone = () => {
|
||||||
onCategoriesChange(new Set());
|
onCategoriesChange(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredCategories = categories.filter((cat) =>
|
const toggleGroup = useCallback(
|
||||||
cat.toLowerCase().includes(searchTerm.toLowerCase())
|
(groupName: string) => {
|
||||||
|
const group = groups.find((g) => g.name === groupName);
|
||||||
|
if (!group) return;
|
||||||
|
const allSelected = group.categories.every((c) => selectedCategories.has(c));
|
||||||
|
const newSet = new Set(selectedCategories);
|
||||||
|
if (allSelected) {
|
||||||
|
group.categories.forEach((c) => newSet.delete(c));
|
||||||
|
} else {
|
||||||
|
group.categories.forEach((c) => newSet.add(c));
|
||||||
|
}
|
||||||
|
onCategoriesChange(newSet);
|
||||||
|
},
|
||||||
|
[groups, selectedCategories, onCategoriesChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const toggleCollapse = (groupName: string) => {
|
||||||
|
setCollapsedGroups((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(groupName)) {
|
||||||
|
next.delete(groupName);
|
||||||
|
} else {
|
||||||
|
next.add(groupName);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const lowerSearch = searchTerm.toLowerCase();
|
||||||
|
|
||||||
|
// Filter groups and categories by search term
|
||||||
|
const filteredGroups = groups
|
||||||
|
.map((group) => {
|
||||||
|
if (!searchTerm) return group;
|
||||||
|
const matchingCats = group.categories.filter((c) => c.toLowerCase().includes(lowerSearch));
|
||||||
|
const groupMatches = group.name.toLowerCase().includes(lowerSearch);
|
||||||
|
if (groupMatches) return group;
|
||||||
|
if (matchingCats.length === 0) return null;
|
||||||
|
return { ...group, categories: matchingCats };
|
||||||
|
})
|
||||||
|
.filter(Boolean) as POICategoryGroup[];
|
||||||
|
|
||||||
const selectedCount = selectedCategories.size;
|
const selectedCount = selectedCategories.size;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -58,7 +99,6 @@ export default function POIPane({
|
||||||
<h2 className="text-xl font-bold">Points of Interest</h2>
|
<h2 className="text-xl font-bold">Points of Interest</h2>
|
||||||
|
|
||||||
<div className="space-y-2" ref={dropdownRef}>
|
<div className="space-y-2" ref={dropdownRef}>
|
||||||
<Label>Categories</Label>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||||
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-warm-300 rounded hover:border-warm-400 bg-white"
|
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-warm-300 rounded hover:border-warm-400 bg-white"
|
||||||
|
|
@ -66,7 +106,7 @@ export default function POIPane({
|
||||||
<span className="truncate text-left">
|
<span className="truncate text-left">
|
||||||
{selectedCount === 0
|
{selectedCount === 0
|
||||||
? 'Select categories...'
|
? 'Select categories...'
|
||||||
: selectedCount === categories.length
|
: selectedCount === allCategories.length
|
||||||
? 'All categories'
|
? 'All categories'
|
||||||
: `${selectedCount} selected`}
|
: `${selectedCount} selected`}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -101,20 +141,69 @@ export default function POIPane({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-96 overflow-y-auto py-1">
|
<div className="max-h-96 overflow-y-auto py-1">
|
||||||
{filteredCategories.map((category) => (
|
{filteredGroups.map((group) => {
|
||||||
<label
|
const groupSelected = group.categories.filter((c) =>
|
||||||
key={category}
|
selectedCategories.has(c)
|
||||||
className="flex items-center gap-2 px-3 py-1.5 hover:bg-warm-50 cursor-pointer"
|
).length;
|
||||||
>
|
const allInGroupSelected = groupSelected === group.categories.length;
|
||||||
<input
|
const someInGroupSelected = groupSelected > 0 && !allInGroupSelected;
|
||||||
type="checkbox"
|
const isCollapsed = collapsedGroups.has(group.name) && !searchTerm;
|
||||||
checked={selectedCategories.has(category)}
|
|
||||||
onChange={() => toggleCategory(category)}
|
return (
|
||||||
className="rounded"
|
<div key={group.name}>
|
||||||
/>
|
<div className="flex items-center gap-1 px-3 py-1.5 bg-warm-50 border-y border-warm-100">
|
||||||
<span className="text-sm flex-1">{category}</span>
|
<button
|
||||||
</label>
|
onClick={() => toggleCollapse(group.name)}
|
||||||
))}
|
className="p-0.5 text-warm-400 hover:text-warm-600"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`w-3 h-3 transition-transform ${isCollapsed ? '' : 'rotate-90'}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<label className="flex items-center gap-2 flex-1 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allInGroupSelected}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) el.indeterminate = someInGroupSelected;
|
||||||
|
}}
|
||||||
|
onChange={() => toggleGroup(group.name)}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-semibold text-warm-700">{group.name}</span>
|
||||||
|
</label>
|
||||||
|
<span className="text-xs text-warm-400">
|
||||||
|
{groupSelected}/{group.categories.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!isCollapsed &&
|
||||||
|
group.categories.map((category) => (
|
||||||
|
<label
|
||||||
|
key={category}
|
||||||
|
className="flex items-center gap-2 px-3 pl-8 py-1.5 hover:bg-warm-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedCategories.has(category)}
|
||||||
|
onChange={() => toggleCategory(category)}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span className="text-sm flex-1">{category}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ interface PropertiesPaneProps {
|
||||||
total: number;
|
total: number;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
hexagonId: string | null;
|
hexagonId: string | null;
|
||||||
postcodeId?: string | null;
|
|
||||||
onLoadMore: () => void;
|
onLoadMore: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -18,7 +17,6 @@ export function PropertiesPane({
|
||||||
total,
|
total,
|
||||||
loading,
|
loading,
|
||||||
hexagonId,
|
hexagonId,
|
||||||
postcodeId,
|
|
||||||
onLoadMore,
|
onLoadMore,
|
||||||
onClose,
|
onClose,
|
||||||
}: PropertiesPaneProps) {
|
}: PropertiesPaneProps) {
|
||||||
|
|
@ -38,11 +36,10 @@ export function PropertiesPane({
|
||||||
});
|
});
|
||||||
}, [properties, sortBy]);
|
}, [properties, sortBy]);
|
||||||
|
|
||||||
const selectionId = hexagonId || postcodeId;
|
if (!hexagonId) {
|
||||||
if (!selectionId) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-warm-500">
|
<div className="flex items-center justify-center h-full text-warm-500">
|
||||||
Click a hexagon or postcode to view properties
|
Click a hexagon to view properties
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -52,9 +49,7 @@ export function PropertiesPane({
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-4 border-b border-warm-200">
|
<div className="p-4 border-b border-warm-200">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h2 className="text-lg font-semibold">
|
<h2 className="text-lg font-semibold">Properties in Hexagon</h2>
|
||||||
{postcodeId ? `Properties in ${postcodeId}` : 'Properties in Hexagon'}
|
|
||||||
</h2>
|
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-warm-500 hover:text-warm-700 text-2xl leading-none"
|
className="text-warm-500 hover:text-warm-700 text-2xl leading-none"
|
||||||
|
|
@ -111,9 +106,8 @@ function formatDuration(d: string): string {
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAge(value: number): string {
|
function formatAge(value: number, approximate = true): string {
|
||||||
// construction_age_band is a midpoint year, e.g. 1935
|
if (value >= 1000) return approximate ? `~${Math.round(value)}` : `${Math.round(value)}`;
|
||||||
if (value >= 1000) return `~${Math.round(value)}`;
|
|
||||||
return Math.round(value).toString();
|
return Math.round(value).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,7 +130,11 @@ function PropertyCard({ property }: { property: Property }) {
|
||||||
const price = getNum(property, 'Last known price', 'latest_price');
|
const price = getNum(property, 'Last known price', 'latest_price');
|
||||||
const pricePerSqm = getNum(property, 'Price per sqm', 'price_per_sqm');
|
const pricePerSqm = getNum(property, 'Price per sqm', 'price_per_sqm');
|
||||||
const floorArea = getNum(property, 'Total floor area (sqm)', 'total_floor_area');
|
const floorArea = getNum(property, 'Total floor area (sqm)', 'total_floor_area');
|
||||||
const rooms = getNum(property, 'Rooms (including bedrooms & bathrooms)', 'number_habitable_rooms');
|
const rooms = getNum(
|
||||||
|
property,
|
||||||
|
'Rooms (including bedrooms & bathrooms)',
|
||||||
|
'number_habitable_rooms'
|
||||||
|
);
|
||||||
const age = getNum(property, 'Approximate construction age', 'construction_age_band');
|
const age = getNum(property, 'Approximate construction age', 'construction_age_band');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -150,10 +148,7 @@ function PropertyCard({ property }: { property: Property }) {
|
||||||
<div className="mt-2 text-lg font-bold text-teal-700">
|
<div className="mt-2 text-lg font-bold text-teal-700">
|
||||||
£{fmt(price)}
|
£{fmt(price)}
|
||||||
{pricePerSqm !== undefined && (
|
{pricePerSqm !== undefined && (
|
||||||
<span className="text-sm font-normal text-warm-600">
|
<span className="text-sm font-normal text-warm-600"> (£{fmt(pricePerSqm)}/m²)</span>
|
||||||
{' '}
|
|
||||||
(£{fmt(pricePerSqm)}/m²)
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -187,7 +182,7 @@ function PropertyCard({ property }: { property: Property }) {
|
||||||
)}
|
)}
|
||||||
{age !== undefined && (
|
{age !== undefined && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-warm-500">Built:</span> {formatAge(age)}
|
<span className="text-warm-500">Built:</span> {formatAge(age, property.is_construction_date_approximate ?? true)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{property.current_energy_rating && (
|
{property.current_energy_rating && (
|
||||||
|
|
@ -197,8 +192,7 @@ function PropertyCard({ property }: { property: Property }) {
|
||||||
)}
|
)}
|
||||||
{property.potential_energy_rating && (
|
{property.potential_energy_rating && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-warm-500">EPC potential:</span>{' '}
|
<span className="text-warm-500">EPC potential:</span> {property.potential_energy_rating}
|
||||||
{property.potential_energy_rating}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -9,3 +9,17 @@ body,
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fade-in animation for homepage sections */
|
||||||
|
.fade-in-section {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(24px);
|
||||||
|
transition:
|
||||||
|
opacity 0.6s ease-out,
|
||||||
|
transform 0.6s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in-visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>UK Property Prices Map</title>
|
<title>Narrowit</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
export interface FeatureMeta {
|
export interface FeatureMeta {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
|
||||||
type: 'numeric' | 'enum';
|
type: 'numeric' | 'enum';
|
||||||
|
group?: string;
|
||||||
// Numeric-only fields
|
// Numeric-only fields
|
||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
|
|
@ -9,6 +9,11 @@ export interface FeatureMeta {
|
||||||
values?: string[];
|
values?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FeatureGroup {
|
||||||
|
name: string;
|
||||||
|
features: FeatureMeta[];
|
||||||
|
}
|
||||||
|
|
||||||
// Filters: feature name -> [selectedMin, selectedMax] for numeric, string[] for enum
|
// Filters: feature name -> [selectedMin, selectedMax] for numeric, string[] for enum
|
||||||
export type FeatureFilters = Record<string, [number, number] | string[]>;
|
export type FeatureFilters = Record<string, [number, number] | string[]>;
|
||||||
|
|
||||||
|
|
@ -49,6 +54,7 @@ export interface POI {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
category: string;
|
category: string;
|
||||||
|
group: string;
|
||||||
lat: number;
|
lat: number;
|
||||||
lng: number;
|
lng: number;
|
||||||
emoji: string;
|
emoji: string;
|
||||||
|
|
@ -58,10 +64,15 @@ export interface POIResponse {
|
||||||
pois: POI[];
|
pois: POI[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface POICategoriesResponse {
|
export interface POICategoryGroup {
|
||||||
|
name: string;
|
||||||
categories: string[];
|
categories: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface POICategoriesResponse {
|
||||||
|
groups: POICategoryGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface Property {
|
export interface Property {
|
||||||
// String fields
|
// String fields
|
||||||
address?: string;
|
address?: string;
|
||||||
|
|
@ -76,8 +87,10 @@ export interface Property {
|
||||||
lat: number;
|
lat: number;
|
||||||
lon: number;
|
lon: number;
|
||||||
|
|
||||||
|
is_construction_date_approximate?: boolean;
|
||||||
|
|
||||||
// All other numeric features (dynamic, including construction_age_band)
|
// All other numeric features (dynamic, including construction_age_band)
|
||||||
[key: string]: string | number | undefined;
|
[key: string]: string | number | boolean | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HexagonPropertiesResponse {
|
export interface HexagonPropertiesResponse {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue