Refactor
This commit is contained in:
parent
2c613dc0d1
commit
a677b9331f
28 changed files with 1647 additions and 1498 deletions
|
|
@ -9,6 +9,7 @@ import DataSources from './components/DataSources';
|
|||
import DataSourcesPage from './components/DataSourcesPage';
|
||||
import FAQPage from './components/FAQPage';
|
||||
import HomePage from './components/HomePage';
|
||||
import Header, { type Page } from './components/Header';
|
||||
import type {
|
||||
FeatureMeta,
|
||||
FeatureGroup,
|
||||
|
|
@ -21,321 +22,31 @@ import type {
|
|||
POIResponse,
|
||||
POICategoriesResponse,
|
||||
POICategoryGroup,
|
||||
ViewState,
|
||||
Property,
|
||||
HexagonPropertiesResponse,
|
||||
HexagonStatsResponse,
|
||||
} from './types';
|
||||
import { fetchWithRetry, getApiBaseUrl, buildFilterString } from './lib/api';
|
||||
import { parseUrlState, DEFAULT_VIEW } from './lib/url-state';
|
||||
import { useTheme } from './hooks/useTheme';
|
||||
import { useUrlSync } from './hooks/useUrlSync';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
declare global {
|
||||
interface Window {
|
||||
__og_ready?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 150;
|
||||
const URL_DEBOUNCE_MS = 300;
|
||||
const INITIAL_RETRY_MS = 1000;
|
||||
const MAX_RETRY_MS = 10000;
|
||||
|
||||
async function fetchWithRetry<T>(
|
||||
url: string,
|
||||
onSuccess: (data: T) => void,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
let delay = INITIAL_RETRY_MS;
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
const res = await fetch(url, { signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
onSuccess(json);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (signal.aborted) return;
|
||||
console.error(`Failed to fetch ${url}, retrying in ${delay}ms:`, err);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
delay = Math.min(delay * 2, MAX_RETRY_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect if running through VS Code web proxy and construct API base URL
|
||||
function getApiBaseUrl(): string {
|
||||
// In production builds, always use same-origin (Rust server serves both API and frontend)
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const { pathname, href } = window.location;
|
||||
|
||||
// Check pathname for /proxy/PORT pattern (VS Code web proxy)
|
||||
const pathMatch = pathname.match(/^(\/proxy\/)(\d+)/);
|
||||
if (pathMatch) {
|
||||
return `${pathMatch[1]}8001`;
|
||||
}
|
||||
|
||||
// Check full href in case proxy rewrites pathname
|
||||
const hrefMatch = href.match(/(\/proxy\/)\d+/);
|
||||
if (hrefMatch) {
|
||||
return `${hrefMatch[1]}8001`;
|
||||
}
|
||||
|
||||
// Default: same origin (works for local dev with webpack proxy)
|
||||
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' | 'area';
|
||||
} {
|
||||
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 or tab=a
|
||||
const tab = params.get('tab');
|
||||
if (tab === 'p') result.tab = 'properties';
|
||||
else if (tab === 'o') result.tab = 'pois';
|
||||
else if (tab === 'a') result.tab = 'area';
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function stateToParams(
|
||||
viewState: { latitude: number; longitude: number; zoom: number } | null,
|
||||
filters: FeatureFilters,
|
||||
features: FeatureMeta[],
|
||||
selectedPOICategories: Set<string>,
|
||||
rightPaneTab: 'pois' | 'properties' | 'area'
|
||||
): 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');
|
||||
} else if (rightPaneTab === 'area') {
|
||||
params.set('tab', 'a');
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
// --- Header ---
|
||||
|
||||
type Page = 'home' | 'dashboard' | 'data-sources' | 'faq';
|
||||
|
||||
function Header({
|
||||
activePage,
|
||||
onPageChange,
|
||||
theme,
|
||||
onToggleTheme,
|
||||
}: {
|
||||
activePage: Page;
|
||||
onPageChange: (page: Page) => void;
|
||||
theme: Theme;
|
||||
onToggleTheme: () => void;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleShare = useCallback(() => {
|
||||
navigator.clipboard.writeText(window.location.href).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const tabClass = (page: Page) =>
|
||||
`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
|
||||
activePage === page
|
||||
? 'bg-navy-700 text-white'
|
||||
: 'text-warm-300 hover:bg-navy-800 hover:text-white'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<header className="h-12 bg-navy-900 text-white flex items-center justify-between px-4 shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
|
||||
onClick={() => onPageChange('home')}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 text-teal-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">Narrowit</span>
|
||||
</button>
|
||||
<nav className="flex items-center gap-2">
|
||||
<button className={tabClass('dashboard')} onClick={() => onPageChange('dashboard')}>
|
||||
Dashboard
|
||||
</button>
|
||||
<button className={tabClass('data-sources')} onClick={() => onPageChange('data-sources')}>
|
||||
Data Sources
|
||||
</button>
|
||||
<button className={tabClass('faq')} onClick={() => onPageChange('faq')}>
|
||||
FAQ
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onToggleTheme}
|
||||
className="flex items-center justify-center w-8 h-8 rounded bg-navy-800 hover:bg-navy-700 transition-colors"
|
||||
title={`Theme: ${theme}`}
|
||||
>
|
||||
{theme === 'light' ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{activePage === 'dashboard' && (
|
||||
<button
|
||||
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 ? (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
// --- App ---
|
||||
|
||||
export default function App() {
|
||||
// Parse URL state once on mount
|
||||
const urlState = useMemo(() => parseUrlState(), []);
|
||||
|
||||
const isScreenshotMode = useMemo(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('screenshot') === '1';
|
||||
}, []);
|
||||
|
||||
const [features, setFeatures] = useState<FeatureMeta[]>([]);
|
||||
const [filters, setFilters] = useState<FeatureFilters>(urlState.filters || {});
|
||||
const [activeFeature, setActiveFeature] = useState<string | null>(null);
|
||||
|
|
@ -351,7 +62,6 @@ export default function App() {
|
|||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const dragAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// View state for URL serialization
|
||||
const [currentView, setCurrentView] = useState<{
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
|
|
@ -366,10 +76,8 @@ export default function App() {
|
|||
: null
|
||||
);
|
||||
|
||||
// Initial view state for Map
|
||||
const initialViewState = useMemo(() => urlState.viewState || DEFAULT_VIEW, []);
|
||||
|
||||
// POI state
|
||||
const [pois, setPois] = useState<POI[]>([]);
|
||||
const [poiCategoryGroups, setPOICategoryGroups] = useState<POICategoryGroup[]>([]);
|
||||
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<string>>(
|
||||
|
|
@ -378,7 +86,6 @@ export default function App() {
|
|||
const poiDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const poiAbortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Hexagon properties state
|
||||
const [selectedHexagon, setSelectedHexagon] = useState<{ h3: string; resolution: number } | null>(
|
||||
null
|
||||
);
|
||||
|
|
@ -386,13 +93,13 @@ export default function App() {
|
|||
const [propertiesTotal, setPropertiesTotal] = useState(0);
|
||||
const [propertiesOffset, setPropertiesOffset] = useState(0);
|
||||
const [loadingProperties, setLoadingProperties] = useState(false);
|
||||
const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties' | 'area'>(urlState.tab || 'pois');
|
||||
const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties' | 'area'>(
|
||||
urlState.tab || 'pois'
|
||||
);
|
||||
|
||||
// Area stats state
|
||||
const [areaStats, setAreaStats] = useState<HexagonStatsResponse | null>(null);
|
||||
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
|
||||
|
||||
// Hover state
|
||||
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
|
||||
const [hoveredAreaStats, setHoveredAreaStats] = useState<HexagonStatsResponse | null>(null);
|
||||
const [hoveredProperties, setHoveredProperties] = useState<Property[] | null>(null);
|
||||
|
|
@ -402,8 +109,9 @@ export default function App() {
|
|||
const hoverAbortRef = useRef<AbortController | null>(null);
|
||||
const hoverDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
|
||||
const [activePage, setActivePage] = useState<Page>(() => {
|
||||
// Restore from history state if available (e.g. back/forward navigation)
|
||||
if (isScreenshotMode) return 'dashboard';
|
||||
if (window.history.state?.page) return window.history.state.page;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.has('v') || params.has('f') || params.has('poi') || params.has('tab')
|
||||
|
|
@ -411,24 +119,21 @@ export default function App() {
|
|||
: 'home';
|
||||
});
|
||||
|
||||
// Feature name to auto-open in the info popup after back navigation
|
||||
const [pendingInfoFeature, setPendingInfoFeature] = useState<string | null>(null);
|
||||
|
||||
// Navigate between pages with history support
|
||||
const navigateTo = useCallback((page: Page, hash?: string, infoFeature?: string) => {
|
||||
// Before pushing, tag the current state with the info feature so back restores it
|
||||
if (infoFeature) {
|
||||
window.history.replaceState({ ...window.history.state, infoFeature }, '');
|
||||
}
|
||||
const url = hash ? `${window.location.pathname}${window.location.search}#${hash}` : `${window.location.pathname}${window.location.search}`;
|
||||
const url = hash
|
||||
? `${window.location.pathname}${window.location.search}#${hash}`
|
||||
: `${window.location.pathname}${window.location.search}`;
|
||||
window.history.pushState({ page }, '', url);
|
||||
setActivePage(page);
|
||||
trackPageview();
|
||||
}, []);
|
||||
|
||||
// Handle browser back/forward
|
||||
useEffect(() => {
|
||||
// Tag the initial state so popstate can restore it
|
||||
if (!window.history.state?.page) {
|
||||
window.history.replaceState({ page: activePage }, '');
|
||||
}
|
||||
|
|
@ -444,37 +149,19 @@ export default function App() {
|
|||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Theme state — defaults to system preference on first visit
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const stored = localStorage.getItem('theme');
|
||||
if (stored === 'light' || stored === 'dark') return stored;
|
||||
return 'light';
|
||||
});
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
// Sync dark class on <html> and persist to localStorage
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
if (isScreenshotMode && !initialLoading && rawData.length > 0) {
|
||||
window.__og_ready = true;
|
||||
}
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
}, [isScreenshotMode, initialLoading, rawData]);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
|
||||
}, []);
|
||||
|
||||
// Derive enabled features from filter keys
|
||||
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: use the filter slider range when a numeric filter is active,
|
||||
// otherwise fall back to the feature's full range from metadata.
|
||||
// For enum features, use ordinal index range [0, values.length - 1].
|
||||
|
||||
const colorRange = useMemo((): [number, number] | null => {
|
||||
if (!viewFeature) return null;
|
||||
const meta = features.find((f) => f.name === viewFeature);
|
||||
|
|
@ -482,7 +169,6 @@ export default function App() {
|
|||
if (meta.type === 'enum' && meta.values && meta.values.length > 0) {
|
||||
return [0, meta.values.length - 1];
|
||||
}
|
||||
// Use live drag values or committed filter range if available
|
||||
if (activeFeature === viewFeature && dragValue) return dragValue;
|
||||
const filterVal = filters[viewFeature];
|
||||
if (filterVal && typeof filterVal[0] === 'number') return filterVal as [number, number];
|
||||
|
|
@ -490,7 +176,6 @@ export default function App() {
|
|||
return null;
|
||||
}, [viewFeature, features, activeFeature, dragValue, filters]);
|
||||
|
||||
// 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;
|
||||
|
|
@ -499,32 +184,8 @@ export default function App() {
|
|||
return null;
|
||||
}, [viewFeature, activeFeature, dragValue, filters]);
|
||||
|
||||
// --- URL sync ---
|
||||
const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useUrlSync(currentView, filters, features, selectedPOICategories, rightPaneTab);
|
||||
|
||||
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({ ...window.history.state }, '', newUrl);
|
||||
}, URL_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (urlDebounceRef.current) clearTimeout(urlDebounceRef.current);
|
||||
};
|
||||
}, [currentView, filters, features, selectedPOICategories, rightPaneTab]);
|
||||
|
||||
// Fetch feature metadata + POI categories on mount with exponential backoff
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let featuresLoaded = false;
|
||||
|
|
@ -560,23 +221,11 @@ export default function App() {
|
|||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
// 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]);
|
||||
const buildFilterParam = useCallback(
|
||||
(): string => buildFilterString(filters, features),
|
||||
[filters, features]
|
||||
);
|
||||
|
||||
// Debounced fetch when resolution/bounds/filters change — always fetch hexagons
|
||||
useEffect(() => {
|
||||
if (!bounds) return;
|
||||
|
||||
|
|
@ -600,7 +249,6 @@ export default function App() {
|
|||
bounds: boundsStr,
|
||||
});
|
||||
if (filtersStr) params.set('filters', filtersStr);
|
||||
// Only request data for the actively viewed feature (reduces bandwidth)
|
||||
if (viewFeature) {
|
||||
params.set('fields', viewFeature);
|
||||
} else {
|
||||
|
|
@ -627,11 +275,8 @@ export default function App() {
|
|||
};
|
||||
}, [resolution, bounds, filters, buildFilterParam, viewFeature]);
|
||||
|
||||
// During slider drag, use the expanded dataset (without active feature filter)
|
||||
// so both narrowing and expanding are visible. Otherwise use server-filtered data.
|
||||
const data = dragData ?? rawData;
|
||||
|
||||
// Fetch POIs when bounds or selected categories change
|
||||
useEffect(() => {
|
||||
if (!bounds || selectedPOICategories.size === 0) {
|
||||
setPois([]);
|
||||
|
|
@ -683,7 +328,6 @@ export default function App() {
|
|||
latitude,
|
||||
longitude,
|
||||
}: ViewChangeParams) => {
|
||||
// Only update bounds/resolution when quantized values actually change
|
||||
const boundsKey = `${newBounds.south},${newBounds.west},${newBounds.north},${newBounds.east},${newRes}`;
|
||||
if (boundsKey !== prevBoundsRef.current) {
|
||||
prevBoundsRef.current = boundsKey;
|
||||
|
|
@ -725,12 +369,11 @@ export default function App() {
|
|||
const handleDragStart = useCallback(
|
||||
(name: string) => {
|
||||
const meta = features.find((f) => f.name === name);
|
||||
if (meta?.type === 'enum') return; // No drag interaction for enum features
|
||||
if (meta?.type === 'enum') return;
|
||||
setActiveFeature(name);
|
||||
const fval = filters[name];
|
||||
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();
|
||||
|
|
@ -751,7 +394,6 @@ export default function App() {
|
|||
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);
|
||||
// Only request the dragged feature's data
|
||||
params.set('fields', name);
|
||||
|
||||
fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, {
|
||||
|
|
@ -799,20 +441,8 @@ export default function App() {
|
|||
h3,
|
||||
resolution: res.toString(),
|
||||
});
|
||||
const filterEntries = Object.entries(filters);
|
||||
if (filterEntries.length > 0) {
|
||||
const filterStr = filterEntries
|
||||
.map(([name, value]) => {
|
||||
const meta = features.find((feature) => feature.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.append('filters', filterStr);
|
||||
}
|
||||
const filterStr = buildFilterString(filters, features);
|
||||
if (filterStr) params.append('filters', filterStr);
|
||||
if (fields) {
|
||||
params.set('fields', fields.join(','));
|
||||
}
|
||||
|
|
@ -833,21 +463,8 @@ export default function App() {
|
|||
offset: offset.toString(),
|
||||
});
|
||||
|
||||
// Add current filters
|
||||
const filterEntries = Object.entries(filters);
|
||||
if (filterEntries.length > 0) {
|
||||
const filterStr = 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.append('filters', filterStr);
|
||||
}
|
||||
const filterStr = buildFilterString(filters, features);
|
||||
if (filterStr) params.append('filters', filterStr);
|
||||
|
||||
const response = await fetch(`${getApiBaseUrl()}/api/hexagon-properties?${params}`);
|
||||
const data: HexagonPropertiesResponse = await response.json();
|
||||
|
|
@ -871,14 +488,13 @@ export default function App() {
|
|||
const handleHexagonClick = useCallback(
|
||||
(h3: string) => {
|
||||
if (selectedHexagon?.h3 === h3) {
|
||||
// Deselect if clicking same hexagon
|
||||
setSelectedHexagon(null);
|
||||
setProperties([]);
|
||||
setAreaStats(null);
|
||||
} else {
|
||||
setSelectedHexagon({ h3, resolution });
|
||||
setPropertiesOffset(0);
|
||||
setRightPaneTab('area'); // Auto-switch to area tab
|
||||
setRightPaneTab('area');
|
||||
setLoadingAreaStats(true);
|
||||
fetchHexagonStats(h3, resolution)
|
||||
.then((stats) => setAreaStats(stats))
|
||||
|
|
@ -914,9 +530,13 @@ export default function App() {
|
|||
try {
|
||||
if (rightPaneTab === 'area') {
|
||||
setLoadingHoveredAreaStats(true);
|
||||
// On hover, only fetch stats for features that have active filters
|
||||
const hoverFields = Object.keys(filters);
|
||||
const stats = await fetchHexagonStats(h3, resolution, signal, hoverFields.length > 0 ? hoverFields : undefined);
|
||||
const stats = await fetchHexagonStats(
|
||||
h3,
|
||||
resolution,
|
||||
signal,
|
||||
hoverFields.length > 0 ? hoverFields : undefined
|
||||
);
|
||||
if (!signal.aborted) setHoveredAreaStats(stats);
|
||||
} else if (rightPaneTab === 'properties') {
|
||||
const params = new URLSearchParams({
|
||||
|
|
@ -925,18 +545,8 @@ export default function App() {
|
|||
limit: '3',
|
||||
offset: '0',
|
||||
});
|
||||
const filterEntries = Object.entries(filters);
|
||||
if (filterEntries.length > 0) {
|
||||
const filterStr = filterEntries
|
||||
.map(([name, value]) => {
|
||||
const meta = features.find((feature) => feature.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.append('filters', filterStr);
|
||||
}
|
||||
const filterStr = buildFilterString(filters, features);
|
||||
if (filterStr) params.append('filters', filterStr);
|
||||
const response = await fetch(`${getApiBaseUrl()}/api/hexagon-properties?${params}`, {
|
||||
signal,
|
||||
});
|
||||
|
|
@ -978,9 +588,38 @@ export default function App() {
|
|||
setAreaStats(null);
|
||||
}, []);
|
||||
|
||||
if (isScreenshotMode) {
|
||||
return (
|
||||
<div className="h-screen w-screen">
|
||||
<Map
|
||||
data={data}
|
||||
pois={pois}
|
||||
onViewChange={handleViewChange}
|
||||
viewFeature={viewFeature}
|
||||
colorRange={colorRange}
|
||||
filterRange={filterRange}
|
||||
viewSource={viewSource}
|
||||
onCancelPin={handleCancelPin}
|
||||
features={features}
|
||||
selectedHexagonId={null}
|
||||
hoveredHexagonId={null}
|
||||
onHexagonClick={() => {}}
|
||||
onHexagonHover={() => {}}
|
||||
initialViewState={initialViewState}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<Header activePage={activePage} onPageChange={navigateTo} theme={theme} onToggleTheme={toggleTheme} />
|
||||
<Header
|
||||
activePage={activePage}
|
||||
onPageChange={navigateTo}
|
||||
theme={theme}
|
||||
onToggleTheme={toggleTheme}
|
||||
/>
|
||||
{activePage === 'home' ? (
|
||||
<HomePage onOpenDashboard={() => navigateTo('dashboard')} theme={theme} />
|
||||
) : activePage === 'data-sources' ? (
|
||||
|
|
@ -1065,7 +704,6 @@ export default function App() {
|
|||
<DataSources onNavigate={() => navigateTo('data-sources')} />
|
||||
</div>
|
||||
<div className="w-72 bg-white dark:bg-navy-950 shadow-lg z-10 flex flex-col">
|
||||
{/* Tab headers */}
|
||||
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
|
||||
<button
|
||||
className={`flex-1 p-3 ${
|
||||
|
|
@ -1099,34 +737,48 @@ export default function App() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{rightPaneTab === 'area' ? (
|
||||
<AreaPane
|
||||
stats={hoverMode && hoveredAreaStats ? hoveredAreaStats : areaStats}
|
||||
globalFeatures={features}
|
||||
loading={hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3 ? loadingHoveredAreaStats : loadingAreaStats}
|
||||
hexagonId={hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3 ? hoveredHexagon : selectedHexagon?.h3 || null}
|
||||
isHoveredPreview={!!(hoverMode && hoveredAreaStats && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3)}
|
||||
loading={
|
||||
hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3
|
||||
? loadingHoveredAreaStats
|
||||
: loadingAreaStats
|
||||
}
|
||||
hexagonId={
|
||||
hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3
|
||||
? hoveredHexagon
|
||||
: selectedHexagon?.h3 || null
|
||||
}
|
||||
isHoveredPreview={
|
||||
!!(
|
||||
hoverMode &&
|
||||
hoveredAreaStats &&
|
||||
hoveredHexagon &&
|
||||
hoveredHexagon !== selectedHexagon?.h3
|
||||
)
|
||||
}
|
||||
hoverMode={hoverMode}
|
||||
onHoverModeChange={setHoverMode}
|
||||
onViewProperties={handleViewPropertiesFromArea}
|
||||
onClose={handleCloseProperties}
|
||||
hexagonLocation={
|
||||
(() => {
|
||||
const hexId = hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3
|
||||
hexagonLocation={(() => {
|
||||
const hexId =
|
||||
hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3
|
||||
? hoveredHexagon
|
||||
: selectedHexagon?.h3;
|
||||
const hex = hexId ? data.find((d) => d.h3 === hexId) : null;
|
||||
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null;
|
||||
return {
|
||||
lat: hex.lat as number,
|
||||
lon: hex.lon as number,
|
||||
postcode: (hex.postcode as string | undefined) ?? null,
|
||||
resolution,
|
||||
};
|
||||
})()
|
||||
}
|
||||
const hex = hexId ? data.find((d) => d.h3 === hexId) : null;
|
||||
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number')
|
||||
return null;
|
||||
return {
|
||||
lat: hex.lat as number,
|
||||
lon: hex.lon as number,
|
||||
postcode: (hex.postcode as string | undefined) ?? null,
|
||||
resolution,
|
||||
};
|
||||
})()}
|
||||
filters={filters}
|
||||
/>
|
||||
) : rightPaneTab === 'properties' ? (
|
||||
|
|
@ -1134,11 +786,20 @@ export default function App() {
|
|||
properties={hoverMode && hoveredProperties ? hoveredProperties : properties}
|
||||
total={hoverMode && hoveredProperties ? hoveredPropertiesTotal : propertiesTotal}
|
||||
loading={loadingProperties}
|
||||
hexagonId={hoverMode && hoveredProperties ? hoveredHexagon : selectedHexagon?.h3 || null}
|
||||
hexagonId={
|
||||
hoverMode && hoveredProperties ? hoveredHexagon : selectedHexagon?.h3 || null
|
||||
}
|
||||
onLoadMore={handleLoadMoreProperties}
|
||||
onClose={handleCloseProperties}
|
||||
onNavigateToSource={(slug) => navigateTo('data-sources', slug)}
|
||||
isHoveredPreview={!!(hoverMode && hoveredProperties && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3)}
|
||||
isHoveredPreview={
|
||||
!!(
|
||||
hoverMode &&
|
||||
hoveredProperties &&
|
||||
hoveredHexagon &&
|
||||
hoveredHexagon !== selectedHexagon?.h3
|
||||
)
|
||||
}
|
||||
hoverMode={hoverMode}
|
||||
onHoverModeChange={setHoverMode}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import { useMemo } from 'react';
|
||||
import type { FeatureFilters, FeatureMeta, HexagonStatsResponse } from '../types';
|
||||
|
||||
interface HexagonLocation {
|
||||
lat: number;
|
||||
lon: number;
|
||||
postcode: string | null;
|
||||
resolution: number;
|
||||
}
|
||||
import type { HexagonLocation } from '../lib/external-search';
|
||||
import { formatValue } from '../lib/format';
|
||||
import { DualHistogram, LoadingSkeleton } from './DualHistogram';
|
||||
import EnumBarChart from './EnumBarChart';
|
||||
import ExternalSearchLinks from './ExternalSearchLinks';
|
||||
|
||||
interface AreaPaneProps {
|
||||
stats: HexagonStatsResponse | null;
|
||||
|
|
@ -22,17 +20,7 @@ interface AreaPaneProps {
|
|||
filters: FeatureFilters;
|
||||
}
|
||||
|
||||
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) return `${(value / 1_000).toFixed(0)}k`;
|
||||
if (Number.isInteger(value)) return value.toLocaleString();
|
||||
return value.toFixed(1);
|
||||
}
|
||||
|
||||
// Group features by their group field from globalFeatures
|
||||
function groupFeatures(
|
||||
globalFeatures: FeatureMeta[]
|
||||
): { name: string; features: FeatureMeta[] }[] {
|
||||
function groupFeatures(globalFeatures: FeatureMeta[]): { name: string; features: FeatureMeta[] }[] {
|
||||
const groups: { name: string; features: FeatureMeta[] }[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const feature of globalFeatures) {
|
||||
|
|
@ -46,302 +34,6 @@ function groupFeatures(
|
|||
return groups;
|
||||
}
|
||||
|
||||
function downsampleBars(counts: number[], targetBars: number): number[] {
|
||||
const step = Math.max(1, Math.floor(counts.length / targetBars));
|
||||
const bars: number[] = [];
|
||||
for (let index = 0; index < counts.length; index += step) {
|
||||
let sum = 0;
|
||||
for (let offset = 0; offset < step && index + offset < counts.length; offset++) {
|
||||
sum += counts[index + offset];
|
||||
}
|
||||
bars.push(sum);
|
||||
}
|
||||
return bars;
|
||||
}
|
||||
|
||||
function DualHistogram({
|
||||
localCounts,
|
||||
globalCounts,
|
||||
min,
|
||||
max,
|
||||
globalMean,
|
||||
}: {
|
||||
localCounts: number[];
|
||||
globalCounts: number[];
|
||||
min: number;
|
||||
max: number;
|
||||
globalMean?: number;
|
||||
}) {
|
||||
const targetBars = 25;
|
||||
const localBars = downsampleBars(localCounts, targetBars);
|
||||
const globalBars = downsampleBars(globalCounts, targetBars);
|
||||
|
||||
const barCount = Math.min(localBars.length, globalBars.length);
|
||||
const localMax = Math.max(...localBars, 1);
|
||||
const globalMax = Math.max(...globalBars, 1);
|
||||
|
||||
const meanFraction =
|
||||
globalMean != null && max > min ? (globalMean - min) / (max - min) : null;
|
||||
|
||||
return (
|
||||
<div className="mt-1">
|
||||
<div className="relative flex items-end gap-px h-10">
|
||||
{Array.from({ length: barCount }).map((_, index) => {
|
||||
const globalHeight = (globalBars[index] / globalMax) * 100;
|
||||
const localHeight = (localBars[index] / localMax) * 100;
|
||||
return (
|
||||
<div key={index} className="flex-1 relative min-w-[2px] h-full flex items-end">
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 bg-warm-300/40 dark:bg-warm-600/40 rounded-t-sm"
|
||||
style={{ height: `${globalHeight}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 bg-teal-500 dark:bg-teal-400 rounded-t-sm"
|
||||
style={{
|
||||
height: `${localHeight}%`,
|
||||
opacity: localBars[index] > 0 ? 1 : 0.1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{meanFraction != null && meanFraction >= 0 && meanFraction <= 1 && (
|
||||
<div
|
||||
className="absolute bottom-0 top-0 w-px border-l border-dashed border-warm-400 dark:border-warm-500"
|
||||
style={{ left: `${meanFraction * 100}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonHistogram() {
|
||||
return (
|
||||
<div className="bg-warm-50 dark:bg-warm-800 rounded p-2 animate-pulse">
|
||||
<div className="flex justify-between items-baseline">
|
||||
<div className="h-3 w-24 bg-warm-200 dark:bg-warm-700 rounded" />
|
||||
<div className="h-3 w-10 bg-warm-200 dark:bg-warm-700 rounded" />
|
||||
</div>
|
||||
<div className="flex items-end gap-px h-10 mt-2">
|
||||
{Array.from({ length: 15 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 bg-warm-200 dark:bg-warm-700 rounded-t-sm min-w-[2px]"
|
||||
style={{ height: `${20 + Math.sin(i * 0.7) * 30 + 30}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between mt-1">
|
||||
<div className="h-2 w-6 bg-warm-200 dark:bg-warm-700 rounded" />
|
||||
<div className="h-2 w-6 bg-warm-200 dark:bg-warm-700 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="p-3 space-y-4">
|
||||
{[0, 1, 2].map((groupIdx) => (
|
||||
<div key={groupIdx}>
|
||||
<div className="h-3 w-20 bg-warm-200 dark:bg-warm-700 rounded animate-pulse mb-2" />
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: groupIdx === 0 ? 3 : 2 }).map((_, i) => (
|
||||
<SkeletonHistogram key={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Map app property types to each site's expected values
|
||||
const PROPERTY_TYPE_MAP: Record<string, { rightmove: string; onthemarket: string; zoopla: string }> = {
|
||||
'House': { rightmove: 'detached,semi-detached,terraced', onthemarket: 'property', zoopla: '' },
|
||||
'Detached': { rightmove: 'detached', onthemarket: 'detached', zoopla: 'detached' },
|
||||
'Semi-Detached': { rightmove: 'semi-detached', onthemarket: 'semi-detached', zoopla: 'semi_detached' },
|
||||
'Mid-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' },
|
||||
'End-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' },
|
||||
'Enclosed Mid-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' },
|
||||
'Enclosed End-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' },
|
||||
'Flat': { rightmove: 'flat', onthemarket: 'flats', zoopla: 'flat' },
|
||||
'Maisonette': { rightmove: 'flat', onthemarket: 'flats', zoopla: 'flat' },
|
||||
'Bungalow': { rightmove: 'bungalow', onthemarket: 'bungalow', zoopla: 'bungalow' },
|
||||
'Park home': { rightmove: 'park-home', onthemarket: 'property', zoopla: '' },
|
||||
};
|
||||
|
||||
// Approximate H3 hex edge length in miles by resolution
|
||||
// See https://h3geo.org/docs/core-library/restable
|
||||
const H3_RADIUS_MILES: Record<number, number> = {
|
||||
4: 15, // ~24km edge → ~15mi
|
||||
5: 6, // ~9km → ~6mi
|
||||
6: 3, // ~3.5km → ~3mi
|
||||
7: 1, // ~1.3km → ~1mi
|
||||
8: 0.5, // ~0.5km → ~0.3mi, round up
|
||||
9: 0.25, // ~0.17km
|
||||
10: 0.25, // ~0.07km
|
||||
11: 0.25, // ~0.025km
|
||||
12: 0.25,
|
||||
};
|
||||
|
||||
// Rightmove only accepts specific radius values
|
||||
const RIGHTMOVE_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40];
|
||||
// OnTheMarket and Zoopla accept similar sets
|
||||
const OTM_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40];
|
||||
const ZOOPLA_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 25, 30];
|
||||
|
||||
function nearestRadius(target: number, allowed: number[]): number {
|
||||
return allowed.reduce((best, r) => (Math.abs(r - target) < Math.abs(best - target) ? r : best));
|
||||
}
|
||||
|
||||
function buildPropertySearchUrls(
|
||||
location: HexagonLocation,
|
||||
filters: FeatureFilters
|
||||
): { rightmove: string; onthemarket: string; zoopla: string } {
|
||||
const { lat, lon, postcode, resolution } = location;
|
||||
const radiusMiles = H3_RADIUS_MILES[resolution] ?? 1;
|
||||
const coordStr = `${lat.toFixed(5)},${lon.toFixed(5)}`;
|
||||
|
||||
// Extract price filters
|
||||
const priceFilter = filters['Last known price'];
|
||||
const minPrice = Array.isArray(priceFilter) && typeof priceFilter[0] === 'number' ? priceFilter[0] : undefined;
|
||||
const maxPrice = Array.isArray(priceFilter) && typeof priceFilter[1] === 'number' ? priceFilter[1] : undefined;
|
||||
|
||||
// Extract property type filters
|
||||
const propertyTypes = filters['Property type'];
|
||||
const selectedTypes = Array.isArray(propertyTypes) && typeof propertyTypes[0] === 'string' ? propertyTypes as string[] : [];
|
||||
|
||||
// --- Rightmove ---
|
||||
// Rightmove accepts both postcodes and lat,lon in searchLocation
|
||||
const rmParams = new URLSearchParams();
|
||||
rmParams.set('searchLocation', postcode || coordStr);
|
||||
rmParams.set('channel', 'BUY');
|
||||
rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII)));
|
||||
if (minPrice !== undefined) rmParams.set('minPrice', String(Math.round(minPrice)));
|
||||
if (maxPrice !== undefined) rmParams.set('maxPrice', String(Math.round(maxPrice)));
|
||||
if (selectedTypes.length > 0) {
|
||||
const rmTypes = [...new Set(selectedTypes.flatMap((t) => {
|
||||
const mapped = PROPERTY_TYPE_MAP[t]?.rightmove;
|
||||
return mapped ? mapped.split(',') : [];
|
||||
}))];
|
||||
if (rmTypes.length > 0) rmParams.set('propertyTypes', rmTypes.join(','));
|
||||
}
|
||||
const rightmove = `https://www.rightmove.co.uk/property-for-sale/find.html?${rmParams.toString()}`;
|
||||
|
||||
// --- OnTheMarket ---
|
||||
let otmType = 'property';
|
||||
if (selectedTypes.length > 0) {
|
||||
const otmTypes = [...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean))];
|
||||
if (otmTypes.length === 1 && otmTypes[0] !== 'property') otmType = otmTypes[0]!;
|
||||
}
|
||||
const otmParams = new URLSearchParams();
|
||||
otmParams.set('radius', String(nearestRadius(radiusMiles, OTM_RADII)));
|
||||
if (minPrice !== undefined) otmParams.set('min-price', String(Math.round(minPrice)));
|
||||
if (maxPrice !== undefined) otmParams.set('max-price', String(Math.round(maxPrice)));
|
||||
let onthemarket: string;
|
||||
if (postcode) {
|
||||
const slug = postcode.replace(/\s+/g, '-').toLowerCase();
|
||||
onthemarket = `https://www.onthemarket.com/for-sale/${otmType}/${slug}/?${otmParams.toString()}`;
|
||||
} else {
|
||||
// Use lat/lon search with geo params for bigger hexagons without a postcode
|
||||
otmParams.set('search-site', 'geo');
|
||||
otmParams.set('geo-lat', String(lat));
|
||||
otmParams.set('geo-lng', String(lon));
|
||||
onthemarket = `https://www.onthemarket.com/for-sale/${otmType}/?${otmParams.toString()}`;
|
||||
}
|
||||
|
||||
// --- Zoopla ---
|
||||
const zParams = new URLSearchParams();
|
||||
zParams.set('q', postcode || coordStr);
|
||||
zParams.set('search_source', 'for-sale');
|
||||
zParams.set('radius', String(nearestRadius(radiusMiles, ZOOPLA_RADII)));
|
||||
if (minPrice !== undefined) zParams.set('price_min', String(Math.round(minPrice)));
|
||||
if (maxPrice !== undefined) zParams.set('price_max', String(Math.round(maxPrice)));
|
||||
if (selectedTypes.length > 0) {
|
||||
const zTypes = [...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.zoopla).filter(Boolean))];
|
||||
for (const zt of zTypes) {
|
||||
zParams.append('property_sub_type', zt!);
|
||||
}
|
||||
}
|
||||
let zoopla: string;
|
||||
if (postcode) {
|
||||
const slug = postcode.replace(/\s+/g, '-').toLowerCase();
|
||||
zoopla = `https://www.zoopla.co.uk/for-sale/property/${slug}/?${zParams.toString()}`;
|
||||
} else {
|
||||
// Use coordinate-based path for bigger hexagons
|
||||
zParams.set('geo_autocomplete_identifier', `geo_${lat}_${lon}`);
|
||||
zoopla = `https://www.zoopla.co.uk/for-sale/property/?${zParams.toString()}`;
|
||||
}
|
||||
|
||||
return { rightmove, onthemarket, zoopla };
|
||||
}
|
||||
|
||||
function ExternalSearchLinks({ location, filters }: { location: HexagonLocation; filters: FeatureFilters }) {
|
||||
const urls = useMemo(() => buildPropertySearchUrls(location, filters), [location, filters]);
|
||||
const radiusMiles = H3_RADIUS_MILES[location.resolution] ?? 1;
|
||||
const label = location.postcode || `${radiusMiles}mi radius`;
|
||||
|
||||
return (
|
||||
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
|
||||
<h3 className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wider mb-2">
|
||||
Search {label} on
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<a
|
||||
href={urls.rightmove}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium"
|
||||
>
|
||||
Rightmove
|
||||
</a>
|
||||
<a
|
||||
href={urls.onthemarket}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium"
|
||||
>
|
||||
OnTheMarket
|
||||
</a>
|
||||
<a
|
||||
href={urls.zoopla}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium"
|
||||
>
|
||||
Zoopla
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EnumBarChart({ counts }: { counts: Record<string, number> }) {
|
||||
const entries = Object.entries(counts).sort(([, countA], [, countB]) => countB - countA);
|
||||
const maxCount = Math.max(...entries.map(([, count]) => count), 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-1 mt-1">
|
||||
{entries.map(([label, count]) => (
|
||||
<div key={label} className="flex items-center gap-2 text-xs">
|
||||
<span className="w-16 truncate text-warm-500 dark:text-warm-400 text-right shrink-0">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex-1 h-3 bg-warm-100 dark:bg-navy-700 rounded overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-teal-500 dark:bg-teal-400 rounded"
|
||||
style={{ width: `${(count / maxCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 text-warm-500 dark:text-warm-400 text-right shrink-0">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AreaPane({
|
||||
stats,
|
||||
globalFeatures,
|
||||
|
|
@ -357,7 +49,6 @@ export default function AreaPane({
|
|||
}: AreaPaneProps) {
|
||||
const featureGroups = useMemo(() => groupFeatures(globalFeatures), [globalFeatures]);
|
||||
|
||||
// Build lookup maps from stats
|
||||
const numericByName = useMemo(() => {
|
||||
if (!stats) return new Map();
|
||||
return new Map(stats.numeric_features.map((feature) => [feature.name, feature]));
|
||||
|
|
@ -368,7 +59,6 @@ export default function AreaPane({
|
|||
return new Map(stats.enum_features.map((feature) => [feature.name, feature]));
|
||||
}, [stats]);
|
||||
|
||||
// Build lookup for global feature metadata (for histogram overlay)
|
||||
const globalFeatureByName = useMemo(
|
||||
() => new Map(globalFeatures.map((f) => [f.name, f])),
|
||||
[globalFeatures]
|
||||
|
|
@ -384,7 +74,6 @@ export default function AreaPane({
|
|||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -403,18 +92,40 @@ export default function AreaPane({
|
|||
? 'text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
||||
: 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
|
||||
}`}
|
||||
title={hoverMode ? 'Live preview on (click to lock)' : 'Live preview off (click to enable)'}
|
||||
title={
|
||||
hoverMode ? 'Live preview on (click to lock)' : 'Live preview off (click to enable)'
|
||||
}
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-1"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
|
@ -435,17 +146,16 @@ export default function AreaPane({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* External search links */}
|
||||
{hexagonLocation && stats && <ExternalSearchLinks location={hexagonLocation} filters={filters} />}
|
||||
{hexagonLocation && stats && (
|
||||
<ExternalSearchLinks location={hexagonLocation} filters={filters} />
|
||||
)}
|
||||
|
||||
{/* Stats content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && !stats ? (
|
||||
<LoadingSkeleton />
|
||||
) : stats ? (
|
||||
<div className="p-3 space-y-4">
|
||||
{featureGroups.map((group) => {
|
||||
// Check if any feature in this group has data
|
||||
const hasData = group.features.some(
|
||||
(feature) => numericByName.has(feature.name) || enumByName.has(feature.name)
|
||||
);
|
||||
|
|
@ -464,14 +174,14 @@ export default function AreaPane({
|
|||
if (numericStats) {
|
||||
const globalFeature = globalFeatureByName.get(feature.name);
|
||||
const globalHistogram = globalFeature?.histogram;
|
||||
// Compute a global mean from the global histogram for the mean line
|
||||
let globalMean: number | undefined;
|
||||
if (globalHistogram && globalHistogram.counts.length > 0) {
|
||||
const totalCount = globalHistogram.counts.reduce((a, b) => a + b, 0);
|
||||
if (totalCount > 0) {
|
||||
let weightedSum = 0;
|
||||
for (let i = 0; i < globalHistogram.counts.length; i++) {
|
||||
const binCenter = globalHistogram.min + (i + 0.5) * globalHistogram.bin_width;
|
||||
const binCenter =
|
||||
globalHistogram.min + (i + 0.5) * globalHistogram.bin_width;
|
||||
weightedSum += binCenter * globalHistogram.counts[i];
|
||||
}
|
||||
globalMean = weightedSum / totalCount;
|
||||
|
|
@ -479,7 +189,10 @@ export default function AreaPane({
|
|||
}
|
||||
|
||||
return (
|
||||
<div key={feature.name} className="bg-warm-50 dark:bg-warm-800 rounded p-2">
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
|
||||
{feature.name}
|
||||
|
|
@ -514,7 +227,10 @@ export default function AreaPane({
|
|||
|
||||
if (enumStats) {
|
||||
return (
|
||||
<div key={feature.name} className="bg-warm-50 dark:bg-warm-800 rounded p-2">
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
{feature.name}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -97,6 +97,14 @@ const DATA_SOURCES = [
|
|||
url: 'https://www.ofcom.org.uk/phones-and-broadband/coverage-and-speeds/connected-nations-20252/data-downloads-2025',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'council-tax',
|
||||
name: 'Council Tax Levels 2025-26',
|
||||
origin: 'Ministry of Housing, Communities & Local Government',
|
||||
use: 'Annual council tax rates for Bands A-H for all 296 billing authorities in England, for a dwelling occupied by two adults. Joined to properties via local authority district code from the NSPL postcode lookup.',
|
||||
url: 'https://www.gov.uk/government/statistics/council-tax-levels-set-by-local-authorities-in-england-2025-to-2026',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
];
|
||||
|
||||
export default function DataSourcesPage() {
|
||||
|
|
@ -135,7 +143,9 @@ export default function DataSourcesPage() {
|
|||
<div
|
||||
key={source.id}
|
||||
id={source.id}
|
||||
ref={(el) => { cardRefs.current[source.id] = el; }}
|
||||
ref={(el) => {
|
||||
cardRefs.current[source.id] = el;
|
||||
}}
|
||||
className={`bg-white dark:bg-navy-800 rounded-lg border p-5 ${
|
||||
highlightedId === source.id
|
||||
? 'border-teal-400 ring-2 ring-teal-400'
|
||||
|
|
@ -143,12 +153,16 @@ export default function DataSourcesPage() {
|
|||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 mb-2">
|
||||
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">{source.name}</h2>
|
||||
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
|
||||
{source.name}
|
||||
</h2>
|
||||
<span className="shrink-0 text-xs bg-warm-100 dark:bg-navy-700 text-warm-600 dark:text-warm-300 px-2 py-1 rounded">
|
||||
{source.license}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-warm-500 dark:text-warm-400 mb-2">Source: {source.origin}</p>
|
||||
<p className="text-sm text-warm-500 dark:text-warm-400 mb-2">
|
||||
Source: {source.origin}
|
||||
</p>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-3">{source.use}</p>
|
||||
<a
|
||||
href={source.url}
|
||||
|
|
|
|||
109
frontend/src/components/DualHistogram.tsx
Normal file
109
frontend/src/components/DualHistogram.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
function downsampleBars(counts: number[], targetBars: number): number[] {
|
||||
const step = Math.max(1, Math.floor(counts.length / targetBars));
|
||||
const bars: number[] = [];
|
||||
for (let index = 0; index < counts.length; index += step) {
|
||||
let sum = 0;
|
||||
for (let offset = 0; offset < step && index + offset < counts.length; offset++) {
|
||||
sum += counts[index + offset];
|
||||
}
|
||||
bars.push(sum);
|
||||
}
|
||||
return bars;
|
||||
}
|
||||
|
||||
export function DualHistogram({
|
||||
localCounts,
|
||||
globalCounts,
|
||||
min,
|
||||
max,
|
||||
globalMean,
|
||||
}: {
|
||||
localCounts: number[];
|
||||
globalCounts: number[];
|
||||
min: number;
|
||||
max: number;
|
||||
globalMean?: number;
|
||||
}) {
|
||||
const targetBars = 25;
|
||||
const localBars = downsampleBars(localCounts, targetBars);
|
||||
const globalBars = downsampleBars(globalCounts, targetBars);
|
||||
|
||||
const barCount = Math.min(localBars.length, globalBars.length);
|
||||
const localMax = Math.max(...localBars, 1);
|
||||
const globalMax = Math.max(...globalBars, 1);
|
||||
|
||||
const meanFraction = globalMean != null && max > min ? (globalMean - min) / (max - min) : null;
|
||||
|
||||
return (
|
||||
<div className="mt-1">
|
||||
<div className="relative flex items-end gap-px h-10">
|
||||
{Array.from({ length: barCount }).map((_, index) => {
|
||||
const globalHeight = (globalBars[index] / globalMax) * 100;
|
||||
const localHeight = (localBars[index] / localMax) * 100;
|
||||
return (
|
||||
<div key={index} className="flex-1 relative min-w-[2px] h-full flex items-end">
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 bg-warm-300/40 dark:bg-warm-600/40 rounded-t-sm"
|
||||
style={{ height: `${globalHeight}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 bg-teal-500 dark:bg-teal-400 rounded-t-sm"
|
||||
style={{
|
||||
height: `${localHeight}%`,
|
||||
opacity: localBars[index] > 0 ? 1 : 0.1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{meanFraction != null && meanFraction >= 0 && meanFraction <= 1 && (
|
||||
<div
|
||||
className="absolute bottom-0 top-0 w-px border-l border-dashed border-warm-400 dark:border-warm-500"
|
||||
style={{ left: `${meanFraction * 100}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonHistogram() {
|
||||
return (
|
||||
<div className="bg-warm-50 dark:bg-warm-800 rounded p-2 animate-pulse">
|
||||
<div className="flex justify-between items-baseline">
|
||||
<div className="h-3 w-24 bg-warm-200 dark:bg-warm-700 rounded" />
|
||||
<div className="h-3 w-10 bg-warm-200 dark:bg-warm-700 rounded" />
|
||||
</div>
|
||||
<div className="flex items-end gap-px h-10 mt-2">
|
||||
{Array.from({ length: 15 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 bg-warm-200 dark:bg-warm-700 rounded-t-sm min-w-[2px]"
|
||||
style={{ height: `${20 + Math.sin(i * 0.7) * 30 + 30}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between mt-1">
|
||||
<div className="h-2 w-6 bg-warm-200 dark:bg-warm-700 rounded" />
|
||||
<div className="h-2 w-6 bg-warm-200 dark:bg-warm-700 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="p-3 space-y-4">
|
||||
{[0, 1, 2].map((groupIdx) => (
|
||||
<div key={groupIdx}>
|
||||
<div className="h-3 w-20 bg-warm-200 dark:bg-warm-700 rounded animate-pulse mb-2" />
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: groupIdx === 0 ? 3 : 2 }).map((_, i) => (
|
||||
<SkeletonHistogram key={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/EnumBarChart.tsx
Normal file
23
frontend/src/components/EnumBarChart.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export default function EnumBarChart({ counts }: { counts: Record<string, number> }) {
|
||||
const entries = Object.entries(counts).sort(([, countA], [, countB]) => countB - countA);
|
||||
const maxCount = Math.max(...entries.map(([, count]) => count), 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-1 mt-1">
|
||||
{entries.map(([label, count]) => (
|
||||
<div key={label} className="flex items-center gap-2 text-xs">
|
||||
<span className="w-16 truncate text-warm-500 dark:text-warm-400 text-right shrink-0">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex-1 h-3 bg-warm-100 dark:bg-navy-700 rounded overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-teal-500 dark:bg-teal-400 rounded"
|
||||
style={{ width: `${(count / maxCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 text-warm-500 dark:text-warm-400 text-right shrink-0">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
frontend/src/components/ExternalSearchLinks.tsx
Normal file
53
frontend/src/components/ExternalSearchLinks.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { useMemo } from 'react';
|
||||
import type { FeatureFilters } from '../types';
|
||||
import {
|
||||
buildPropertySearchUrls,
|
||||
H3_RADIUS_MILES,
|
||||
type HexagonLocation,
|
||||
} from '../lib/external-search';
|
||||
|
||||
export default function ExternalSearchLinks({
|
||||
location,
|
||||
filters,
|
||||
}: {
|
||||
location: HexagonLocation;
|
||||
filters: FeatureFilters;
|
||||
}) {
|
||||
const urls = useMemo(() => buildPropertySearchUrls(location, filters), [location, filters]);
|
||||
const radiusMiles = H3_RADIUS_MILES[location.resolution] ?? 1;
|
||||
const label = location.postcode || `${radiusMiles}mi radius`;
|
||||
|
||||
return (
|
||||
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
|
||||
<h3 className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wider mb-2">
|
||||
Search {label} on
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<a
|
||||
href={urls.rightmove}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium"
|
||||
>
|
||||
Rightmove
|
||||
</a>
|
||||
<a
|
||||
href={urls.onthemarket}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium"
|
||||
>
|
||||
OnTheMarket
|
||||
</a>
|
||||
<a
|
||||
href={urls.zoopla}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium"
|
||||
>
|
||||
Zoopla
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -29,7 +29,7 @@ const FAQ_ITEMS: FAQItem[] = [
|
|||
{
|
||||
question: 'What does the eye icon do on a filter?',
|
||||
answer:
|
||||
'The eye icon pins a feature as the colour source for the hexagon layer. When pinned, hexagons are coloured by that feature\'s value range even when you are not actively dragging its slider. This lets you visualise one feature while filtering on others. Click the eye icon again to unpin.',
|
||||
"The eye icon pins a feature as the colour source for the hexagon layer. When pinned, hexagons are coloured by that feature's value range even when you are not actively dragging its slider. This lets you visualise one feature while filtering on others. Click the eye icon again to unpin.",
|
||||
},
|
||||
{
|
||||
question: 'How fresh is the data?',
|
||||
|
|
@ -39,7 +39,7 @@ const FAQ_ITEMS: FAQItem[] = [
|
|||
{
|
||||
question: 'How are EPC records matched to Land Registry sales?',
|
||||
answer:
|
||||
'EPC and Land Registry records don\'t share a common identifier, so they are fuzzy-joined by address within each postcode bucket. The pipeline uses token-sorted string similarity with special handling for numeric tokens (house numbers, flat numbers). Matches are assigned greedily from highest similarity score downward so each record is used at most once.',
|
||||
"EPC and Land Registry records don't share a common identifier, so they are fuzzy-joined by address within each postcode bucket. The pipeline uses token-sorted string similarity with special handling for numeric tokens (house numbers, flat numbers). Matches are assigned greedily from highest similarity score downward so each record is used at most once.",
|
||||
},
|
||||
{
|
||||
question: 'What are Points of Interest (POIs)?',
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { memo, useState, useRef, useCallback, useMemo, useEffect } from 'react';
|
|||
import { Slider } from './ui/slider';
|
||||
import { Label } from './ui/label';
|
||||
import type { FeatureMeta, FeatureFilters } from '../types';
|
||||
import { formatFilterValue } from '../lib/format';
|
||||
import InfoPopup from './InfoPopup';
|
||||
|
||||
interface FiltersProps {
|
||||
features: FeatureMeta[];
|
||||
|
|
@ -39,68 +41,6 @@ function EyeIcon({ filled, className }: { filled: boolean; className?: string })
|
|||
);
|
||||
}
|
||||
|
||||
function InfoPopup({
|
||||
feature,
|
||||
onClose,
|
||||
onNavigateToSource,
|
||||
}: {
|
||||
feature: FeatureMeta;
|
||||
onClose: () => void;
|
||||
onNavigateToSource?: (slug: string, featureName: string) => void;
|
||||
}) {
|
||||
const popupRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (popupRef.current && !popupRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div
|
||||
ref={popupRef}
|
||||
className="bg-white dark:bg-navy-800 border border-warm-200 dark:border-navy-700 rounded-lg shadow-xl max-w-md w-full mx-4 p-5"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">
|
||||
{feature.name}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{feature.description && (
|
||||
<p className="text-xs text-warm-500 dark:text-warm-400 mb-2">{feature.description}</p>
|
||||
)}
|
||||
{feature.detail && (
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">{feature.detail}</p>
|
||||
)}
|
||||
{feature.source && onNavigateToSource && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onNavigateToSource(feature.source!, feature.name);
|
||||
onClose();
|
||||
}}
|
||||
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
|
||||
>
|
||||
View data source
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureBrowser({
|
||||
availableFeatures,
|
||||
allFeatures,
|
||||
|
|
@ -123,7 +63,6 @@ function FeatureBrowser({
|
|||
const [search, setSearch] = useState('');
|
||||
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
|
||||
// Auto-open info popup when navigating back
|
||||
useEffect(() => {
|
||||
if (openInfoFeature) {
|
||||
const feat = allFeatures.find((f) => f.name === openInfoFeature);
|
||||
|
|
@ -181,7 +120,9 @@ function FeatureBrowser({
|
|||
<div className="min-w-0 mr-2">
|
||||
<span className="text-sm truncate block">{f.name}</span>
|
||||
{f.description && (
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500 truncate block">{f.description}</span>
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500 truncate block">
|
||||
{f.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0 mt-0.5">
|
||||
|
|
@ -191,7 +132,13 @@ function FeatureBrowser({
|
|||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-0.5 rounded"
|
||||
title="Feature info"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path strokeLinecap="round" d="M12 16v-4m0-4h.01" />
|
||||
</svg>
|
||||
|
|
@ -209,7 +156,13 @@ function FeatureBrowser({
|
|||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-0.5 rounded"
|
||||
title="Add filter"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m-7-7h14" />
|
||||
</svg>
|
||||
</button>
|
||||
|
|
@ -227,22 +180,36 @@ function FeatureBrowser({
|
|||
</div>
|
||||
{infoFeature && (
|
||||
<InfoPopup
|
||||
feature={infoFeature}
|
||||
title={infoFeature.name}
|
||||
onClose={() => setInfoFeature(null)}
|
||||
onNavigateToSource={onNavigateToSource}
|
||||
/>
|
||||
sourceLink={
|
||||
infoFeature.source && onNavigateToSource
|
||||
? {
|
||||
label: 'View data source',
|
||||
onClick: () => {
|
||||
onNavigateToSource(infoFeature.source!, infoFeature.name);
|
||||
setInfoFeature(null);
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{infoFeature.description && (
|
||||
<p className="text-xs text-warm-500 dark:text-warm-400 mb-2">
|
||||
{infoFeature.description}
|
||||
</p>
|
||||
)}
|
||||
{infoFeature.detail && (
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
|
||||
{infoFeature.detail}
|
||||
</p>
|
||||
)}
|
||||
</InfoPopup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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) return `${(value / 1_000).toFixed(1)}k`;
|
||||
if (Number.isInteger(value)) return value.toString();
|
||||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
export default memo(function Filters({
|
||||
features,
|
||||
filters,
|
||||
|
|
@ -258,7 +225,7 @@ export default memo(function Filters({
|
|||
zoom,
|
||||
pinnedFeature,
|
||||
onTogglePin,
|
||||
onCancelPin,
|
||||
onCancelPin: _onCancelPin,
|
||||
onNavigateToSource,
|
||||
openInfoFeature,
|
||||
onClearOpenInfoFeature,
|
||||
|
|
@ -270,38 +237,35 @@ export default memo(function Filters({
|
|||
const [splitFraction, setSplitFraction] = useState(0.65);
|
||||
const draggingRef = useRef(false);
|
||||
|
||||
const handleSeparatorPointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
draggingRef.current = true;
|
||||
},
|
||||
[]
|
||||
);
|
||||
const handleSeparatorPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
draggingRef.current = true;
|
||||
}, []);
|
||||
|
||||
const handleSeparatorPointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!draggingRef.current || !containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const y = e.clientY - rect.top;
|
||||
const fraction = Math.min(0.8, Math.max(0.15, y / rect.height));
|
||||
setSplitFraction(fraction);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const handleSeparatorPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!draggingRef.current || !containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const y = e.clientY - rect.top;
|
||||
const fraction = Math.min(0.8, Math.max(0.15, y / rect.height));
|
||||
setSplitFraction(fraction);
|
||||
}, []);
|
||||
|
||||
const handleSeparatorPointerUp = useCallback(() => {
|
||||
draggingRef.current = false;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-80 flex flex-col bg-white dark:bg-navy-950 shadow-lg overflow-hidden">
|
||||
{/* Top: Active filters — user-resizable, scrollable */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-80 flex flex-col bg-white dark:bg-navy-950 shadow-lg overflow-hidden"
|
||||
>
|
||||
<div className="min-h-0 flex flex-col" style={{ height: `${splitFraction * 100}%` }}>
|
||||
{/* Active Filters header */}
|
||||
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">Active Filters</span>
|
||||
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">
|
||||
Active Filters
|
||||
</span>
|
||||
{enabledFeatureList.length > 0 && (
|
||||
<span className="text-xs font-medium px-1.5 py-0.5 rounded-full bg-teal-50 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
|
||||
{enabledFeatureList.length}
|
||||
|
|
@ -314,11 +278,25 @@ export default memo(function Filters({
|
|||
<div className="flex-1 overflow-y-auto p-3 space-y-3">
|
||||
{enabledFeatureList.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<svg className="w-8 h-8 text-warm-300 dark:text-warm-600 mb-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z" />
|
||||
<svg
|
||||
className="w-8 h-8 text-warm-300 dark:text-warm-600 mb-2"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-warm-400 dark:text-warm-500">No active filters</span>
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500 mt-1">Browse features below and click + to add a filter</span>
|
||||
<span className="text-sm font-medium text-warm-400 dark:text-warm-500">
|
||||
No active filters
|
||||
</span>
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500 mt-1">
|
||||
Browse features below and click + to add a filter
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -327,14 +305,21 @@ export default memo(function Filters({
|
|||
const selectedValues = (filters[feature.name] as string[]) || [];
|
||||
const allValues = feature.values || [];
|
||||
return (
|
||||
<div key={feature.name} className={`space-y-1 p-3 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}>
|
||||
<div
|
||||
key={feature.name}
|
||||
className={`space-y-1 p-3 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{feature.name}</Label>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
onClick={() => onTogglePin(feature.name)}
|
||||
className={`p-0.5 rounded ${pinnedFeature === feature.name ? 'text-teal-600 dark:text-teal-400' : 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'}`}
|
||||
title={pinnedFeature === feature.name ? 'Unpin color view' : 'Color map by this feature'}
|
||||
title={
|
||||
pinnedFeature === feature.name
|
||||
? 'Unpin color view'
|
||||
: 'Color map by this feature'
|
||||
}
|
||||
>
|
||||
<EyeIcon filled={pinnedFeature === feature.name} />
|
||||
</button>
|
||||
|
|
@ -363,7 +348,10 @@ export default memo(function Filters({
|
|||
</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-sm cursor-pointer dark:text-warm-300">
|
||||
<label
|
||||
key={val}
|
||||
className="flex items-center gap-1.5 text-sm cursor-pointer dark:text-warm-300"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedValues.includes(val)}
|
||||
|
|
@ -383,7 +371,6 @@ export default memo(function Filters({
|
|||
);
|
||||
}
|
||||
|
||||
// Numeric feature
|
||||
const isActive = activeFeature === feature.name;
|
||||
const isPinned = pinnedFeature === feature.name;
|
||||
const displayValue =
|
||||
|
|
@ -399,7 +386,8 @@ export default memo(function Filters({
|
|||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>
|
||||
{feature.name}: {formatValue(displayValue[0])} - {formatValue(displayValue[1])}
|
||||
{feature.name}: {formatFilterValue(displayValue[0])} -{' '}
|
||||
{formatFilterValue(displayValue[1])}
|
||||
</Label>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
|
|
@ -433,7 +421,6 @@ export default memo(function Filters({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Draggable separator */}
|
||||
<div
|
||||
className="shrink-0 h-1.5 cursor-row-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-y border-warm-200 dark:border-navy-700"
|
||||
onPointerDown={handleSeparatorPointerDown}
|
||||
|
|
@ -443,7 +430,6 @@ export default memo(function Filters({
|
|||
<div className="w-8 h-0.5 rounded bg-warm-300 dark:bg-navy-600" />
|
||||
</div>
|
||||
|
||||
{/* Bottom: Feature browser — fills remaining space */}
|
||||
<div className="min-h-0 flex-1 flex flex-col">
|
||||
<div className="shrink-0 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">Add Filter</span>
|
||||
|
|
|
|||
143
frontend/src/components/Header.tsx
Normal file
143
frontend/src/components/Header.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
|
||||
export type Page = 'home' | 'dashboard' | 'data-sources' | 'faq';
|
||||
|
||||
export default function Header({
|
||||
activePage,
|
||||
onPageChange,
|
||||
theme,
|
||||
onToggleTheme,
|
||||
}: {
|
||||
activePage: Page;
|
||||
onPageChange: (page: Page) => void;
|
||||
theme: 'light' | 'dark';
|
||||
onToggleTheme: () => void;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleShare = useCallback(() => {
|
||||
navigator.clipboard.writeText(window.location.href).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const tabClass = (page: Page) =>
|
||||
`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
|
||||
activePage === page
|
||||
? 'bg-navy-700 text-white'
|
||||
: 'text-warm-300 hover:bg-navy-800 hover:text-white'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<header className="h-12 bg-navy-900 text-white flex items-center justify-between px-4 shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
|
||||
onClick={() => onPageChange('home')}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 text-teal-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">Narrowit</span>
|
||||
</button>
|
||||
<nav className="flex items-center gap-2">
|
||||
<button className={tabClass('dashboard')} onClick={() => onPageChange('dashboard')}>
|
||||
Dashboard
|
||||
</button>
|
||||
<button className={tabClass('data-sources')} onClick={() => onPageChange('data-sources')}>
|
||||
Data Sources
|
||||
</button>
|
||||
<button className={tabClass('faq')} onClick={() => onPageChange('faq')}>
|
||||
FAQ
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onToggleTheme}
|
||||
className="flex items-center justify-center w-8 h-8 rounded bg-navy-800 hover:bg-navy-700 transition-colors"
|
||||
title={`Theme: ${theme}`}
|
||||
>
|
||||
{theme === 'light' ? (
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{activePage === 'dashboard' && (
|
||||
<button
|
||||
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 ? (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
135
frontend/src/components/HexCanvas.tsx
Normal file
135
frontend/src/components/HexCanvas.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { useRef, useEffect } from 'react';
|
||||
|
||||
const HEX_COUNT = 60;
|
||||
const TAU = Math.PI * 2;
|
||||
|
||||
interface Hex {
|
||||
x: number;
|
||||
y: number;
|
||||
baseY: number;
|
||||
size: number;
|
||||
opacity: number;
|
||||
speed: number;
|
||||
phase: number;
|
||||
}
|
||||
|
||||
function initHexes(w: number, h: number): Hex[] {
|
||||
const hexes: Hex[] = [];
|
||||
for (let i = 0; i < HEX_COUNT; i++) {
|
||||
const y = Math.random() * h;
|
||||
hexes.push({
|
||||
x: Math.random() * w,
|
||||
y,
|
||||
baseY: y,
|
||||
size: 8 + Math.random() * 20,
|
||||
opacity: 0.06 + Math.random() * 0.12,
|
||||
speed: 6 + Math.random() * 14,
|
||||
phase: Math.random() * TAU,
|
||||
});
|
||||
}
|
||||
return hexes;
|
||||
}
|
||||
|
||||
function drawHex(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number) {
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const angle = (TAU / 6) * i - Math.PI / 6;
|
||||
const px = cx + r * Math.cos(angle);
|
||||
const py = cy + r * Math.sin(angle);
|
||||
if (i === 0) ctx.moveTo(px, py);
|
||||
else ctx.lineTo(px, py);
|
||||
}
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
export default function HexCanvas({
|
||||
scrollProgress,
|
||||
isDark = false,
|
||||
}: {
|
||||
scrollProgress: number;
|
||||
isDark?: boolean;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const hexesRef = useRef<Hex[]>([]);
|
||||
const animRef = useRef(0);
|
||||
const scrollRef = useRef(scrollProgress);
|
||||
scrollRef.current = scrollProgress;
|
||||
const isDarkRef = useRef(isDark);
|
||||
isDarkRef.current = isDark;
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
let w = 0;
|
||||
let h = 0;
|
||||
|
||||
function resize() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas!.parentElement!.getBoundingClientRect();
|
||||
w = rect.width;
|
||||
h = rect.height;
|
||||
canvas!.width = w * dpr;
|
||||
canvas!.height = h * dpr;
|
||||
canvas!.style.width = `${w}px`;
|
||||
canvas!.style.height = `${h}px`;
|
||||
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
hexesRef.current = initHexes(w, h);
|
||||
}
|
||||
|
||||
resize();
|
||||
const ro = new ResizeObserver(resize);
|
||||
ro.observe(canvas.parentElement!);
|
||||
|
||||
let prev = performance.now();
|
||||
|
||||
function frame(now: number) {
|
||||
const dt = (now - prev) / 1000;
|
||||
prev = now;
|
||||
const scroll = scrollRef.current;
|
||||
ctx!.clearRect(0, 0, w, h);
|
||||
|
||||
const globalAlpha = Math.max(0, 1 - scroll * 2);
|
||||
|
||||
for (const hex of hexesRef.current) {
|
||||
hex.x = (hex.x + hex.speed * dt) % (w + hex.size * 2);
|
||||
const bob = Math.sin(now / 1000 + hex.phase) * 8;
|
||||
const parallax = scroll * h * 0.3 * (hex.speed / 20);
|
||||
hex.y = hex.baseY + bob - parallax;
|
||||
|
||||
if (hex.y < -hex.size * 2) hex.y += h + hex.size * 4;
|
||||
if (hex.y > h + hex.size * 2) hex.y -= h + hex.size * 4;
|
||||
|
||||
const dark = isDarkRef.current;
|
||||
ctx!.globalAlpha = hex.opacity * globalAlpha * (dark ? 0.6 : 1);
|
||||
ctx!.fillStyle = dark ? '#058172' : '#00a28c';
|
||||
drawHex(ctx!, hex.x, hex.y, hex.size);
|
||||
ctx!.fill();
|
||||
|
||||
ctx!.globalAlpha = hex.opacity * 0.5 * globalAlpha * (dark ? 0.6 : 1);
|
||||
ctx!.strokeStyle = dark ? '#0a665b' : '#05c9aa';
|
||||
ctx!.lineWidth = 1;
|
||||
drawHex(ctx!, hex.x, hex.y, hex.size);
|
||||
ctx!.stroke();
|
||||
}
|
||||
|
||||
animRef.current = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
animRef.current = requestAnimationFrame(frame);
|
||||
return () => {
|
||||
cancelAnimationFrame(animRef.current);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{ zIndex: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,164 +1,14 @@
|
|||
import { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import { useFadeInRef } from '../hooks/useFadeIn';
|
||||
import HexCanvas from './HexCanvas';
|
||||
|
||||
// --- Floating hex particle canvas that reacts to scroll ---
|
||||
|
||||
const HEX_COUNT = 60;
|
||||
const TAU = Math.PI * 2;
|
||||
|
||||
interface Hex {
|
||||
x: number;
|
||||
y: number;
|
||||
baseY: number;
|
||||
size: number;
|
||||
opacity: number;
|
||||
speed: number; // horizontal drift px/s
|
||||
phase: number; // for gentle bob
|
||||
}
|
||||
|
||||
function initHexes(w: number, h: number): Hex[] {
|
||||
const hexes: Hex[] = [];
|
||||
for (let i = 0; i < HEX_COUNT; i++) {
|
||||
const y = Math.random() * h;
|
||||
hexes.push({
|
||||
x: Math.random() * w,
|
||||
y,
|
||||
baseY: y,
|
||||
size: 8 + Math.random() * 20,
|
||||
opacity: 0.06 + Math.random() * 0.12,
|
||||
speed: 6 + Math.random() * 14,
|
||||
phase: Math.random() * TAU,
|
||||
});
|
||||
}
|
||||
return hexes;
|
||||
}
|
||||
|
||||
function drawHex(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number) {
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const angle = (TAU / 6) * i - Math.PI / 6;
|
||||
const px = cx + r * Math.cos(angle);
|
||||
const py = cy + r * Math.sin(angle);
|
||||
if (i === 0) ctx.moveTo(px, py);
|
||||
else ctx.lineTo(px, py);
|
||||
}
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function HexCanvas({ scrollProgress, isDark = false }: { scrollProgress: number; isDark?: boolean }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const hexesRef = useRef<Hex[]>([]);
|
||||
const animRef = useRef(0);
|
||||
const scrollRef = useRef(scrollProgress);
|
||||
scrollRef.current = scrollProgress;
|
||||
const isDarkRef = useRef(isDark);
|
||||
isDarkRef.current = isDark;
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
let w = 0;
|
||||
let h = 0;
|
||||
|
||||
function resize() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas!.parentElement!.getBoundingClientRect();
|
||||
w = rect.width;
|
||||
h = rect.height;
|
||||
canvas!.width = w * dpr;
|
||||
canvas!.height = h * dpr;
|
||||
canvas!.style.width = `${w}px`;
|
||||
canvas!.style.height = `${h}px`;
|
||||
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
hexesRef.current = initHexes(w, h);
|
||||
}
|
||||
|
||||
resize();
|
||||
const ro = new ResizeObserver(resize);
|
||||
ro.observe(canvas.parentElement!);
|
||||
|
||||
let prev = performance.now();
|
||||
|
||||
function frame(now: number) {
|
||||
const dt = (now - prev) / 1000;
|
||||
prev = now;
|
||||
const scroll = scrollRef.current;
|
||||
ctx!.clearRect(0, 0, w, h);
|
||||
|
||||
// Teal accent color, fade to 0 as user scrolls down
|
||||
const globalAlpha = Math.max(0, 1 - scroll * 2);
|
||||
|
||||
for (const hex of hexesRef.current) {
|
||||
// drift right, wrap
|
||||
hex.x = (hex.x + hex.speed * dt) % (w + hex.size * 2);
|
||||
// gentle vertical bob + parallax push from scroll
|
||||
const bob = Math.sin(now / 1000 + hex.phase) * 8;
|
||||
const parallax = scroll * h * 0.3 * (hex.speed / 20);
|
||||
hex.y = hex.baseY + bob - parallax;
|
||||
|
||||
// wrap vertically
|
||||
if (hex.y < -hex.size * 2) hex.y += h + hex.size * 4;
|
||||
if (hex.y > h + hex.size * 2) hex.y -= h + hex.size * 4;
|
||||
|
||||
const dark = isDarkRef.current;
|
||||
ctx!.globalAlpha = hex.opacity * globalAlpha * (dark ? 0.6 : 1);
|
||||
ctx!.fillStyle = dark ? '#058172' : '#00a28c';
|
||||
drawHex(ctx!, hex.x, hex.y, hex.size);
|
||||
ctx!.fill();
|
||||
|
||||
ctx!.globalAlpha = hex.opacity * 0.5 * globalAlpha * (dark ? 0.6 : 1);
|
||||
ctx!.strokeStyle = dark ? '#0a665b' : '#05c9aa';
|
||||
ctx!.lineWidth = 1;
|
||||
drawHex(ctx!, hex.x, hex.y, hex.size);
|
||||
ctx!.stroke();
|
||||
}
|
||||
|
||||
animRef.current = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
animRef.current = requestAnimationFrame(frame);
|
||||
return () => {
|
||||
cancelAnimationFrame(animRef.current);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{ zIndex: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Fade-in hook ---
|
||||
|
||||
function useFadeInRef() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
el.classList.add('fade-in-visible');
|
||||
observer.unobserve(el);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.15 }
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
return ref;
|
||||
}
|
||||
|
||||
// --- Page ---
|
||||
|
||||
export default function HomePage({ onOpenDashboard, theme = 'light' }: { onOpenDashboard: () => void; theme?: 'light' | 'dark' }) {
|
||||
export default function HomePage({
|
||||
onOpenDashboard,
|
||||
theme = 'light',
|
||||
}: {
|
||||
onOpenDashboard: () => void;
|
||||
theme?: 'light' | 'dark';
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollProgress, setScrollProgress] = useState(0);
|
||||
|
||||
|
|
@ -268,7 +118,9 @@ export default function HomePage({ onOpenDashboard, theme = 'light' }: { onOpenD
|
|||
className="rounded-xl bg-white dark:bg-navy-800 border border-warm-200 dark:border-navy-700 p-4 shadow-sm hover:shadow-md hover:border-teal-300 dark:hover:border-teal-600 transition-all"
|
||||
>
|
||||
<div className="text-2xl mb-2">{f.icon}</div>
|
||||
<div className="font-semibold text-navy-950 dark:text-warm-100 text-sm">{f.label}</div>
|
||||
<div className="font-semibold text-navy-950 dark:text-warm-100 text-sm">
|
||||
{f.label}
|
||||
</div>
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mt-0.5">{f.example}</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -289,7 +141,9 @@ export default function HomePage({ onOpenDashboard, theme = 'light' }: { onOpenD
|
|||
{i + 1}
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-navy-950 dark:text-warm-100 text-lg">{step.title}</h3>
|
||||
<h3 className="font-semibold text-navy-950 dark:text-warm-100 text-lg">
|
||||
{step.title}
|
||||
</h3>
|
||||
<p className="text-warm-600 dark:text-warm-400 mt-0.5">{step.body}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -315,7 +169,9 @@ export default function HomePage({ onOpenDashboard, theme = 'light' }: { onOpenD
|
|||
{/* Final CTA */}
|
||||
<div className="max-w-3xl mx-auto px-6 pb-24">
|
||||
<div ref={ctaRef} className="fade-in-section text-center">
|
||||
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-3">Ready to narrow it down?</h2>
|
||||
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-3">
|
||||
Ready to narrow it down?
|
||||
</h2>
|
||||
<p className="text-warm-500 dark:text-warm-400 mb-8 max-w-md mx-auto">
|
||||
100% open data. No account required. Just set your filters and go.
|
||||
</p>
|
||||
|
|
@ -332,8 +188,6 @@ export default function HomePage({ onOpenDashboard, theme = 'light' }: { onOpenD
|
|||
);
|
||||
}
|
||||
|
||||
// --- Data ---
|
||||
|
||||
const FILTERS = [
|
||||
{ icon: '\u00A3', label: 'Sale price', example: 'e.g. under \u00A3400k' },
|
||||
{ icon: '\uD83D\uDE86', label: 'Commute time', example: 'e.g. < 45 min to Bank' },
|
||||
|
|
|
|||
55
frontend/src/components/InfoPopup.tsx
Normal file
55
frontend/src/components/InfoPopup.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { useRef, useCallback, type ReactNode } from 'react';
|
||||
import { useClickOutside } from '../hooks/useClickOutside';
|
||||
|
||||
interface InfoPopupProps {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
onClose: () => void;
|
||||
sourceLink?: { label: string; onClick: () => void };
|
||||
}
|
||||
|
||||
export default function InfoPopup({ title, children, onClose, sourceLink }: InfoPopupProps) {
|
||||
const popupRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
useClickOutside(popupRef, handleClose);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div
|
||||
ref={popupRef}
|
||||
className="bg-white dark:bg-navy-800 border border-warm-200 dark:border-navy-700 rounded-lg shadow-xl max-w-md w-full mx-4 p-5"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
{sourceLink && (
|
||||
<button
|
||||
onClick={sourceLink.onClick}
|
||||
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
|
||||
>
|
||||
{sourceLink.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,6 +7,18 @@ import { IconLayer, TextLayer } from '@deck.gl/layers';
|
|||
import type { PickingInfo } from '@deck.gl/core';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import type { HexagonData, ViewState, ViewChangeParams, Bounds, POI, FeatureMeta } from '../types';
|
||||
import {
|
||||
GRADIENT,
|
||||
normalizedToColor,
|
||||
countToColor,
|
||||
zoomToResolution,
|
||||
getBoundsFromViewState,
|
||||
emojiToTwemojiUrl,
|
||||
MAP_STYLE_LIGHT,
|
||||
MAP_STYLE_DARK,
|
||||
} from '../lib/map-utils';
|
||||
import PostcodeSearch from './PostcodeSearch';
|
||||
import MapLegend from './MapLegend';
|
||||
|
||||
interface MapProps {
|
||||
data: HexagonData[];
|
||||
|
|
@ -26,18 +38,6 @@ interface MapProps {
|
|||
theme?: 'light' | 'dark';
|
||||
}
|
||||
|
||||
// Twemoji CDN base URL
|
||||
const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/';
|
||||
|
||||
// Convert emoji to Twemoji URL
|
||||
function emojiToTwemojiUrl(emoji: string): string {
|
||||
// Convert emoji to Unicode codepoint hex
|
||||
const codePoint = emoji.codePointAt(0);
|
||||
if (!codePoint) return `${TWEMOJI_BASE}1f4cd.png`; // Default pin
|
||||
const hex = codePoint.toString(16);
|
||||
return `${TWEMOJI_BASE}${hex}.png`;
|
||||
}
|
||||
|
||||
const INITIAL_VIEW: ViewState = {
|
||||
longitude: -1.5,
|
||||
latitude: 53.5,
|
||||
|
|
@ -45,84 +45,6 @@ const INITIAL_VIEW: ViewState = {
|
|||
pitch: 0,
|
||||
};
|
||||
|
||||
const MAP_STYLE_LIGHT = 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json';
|
||||
const MAP_STYLE_DARK = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
|
||||
|
||||
// Gradient stops for normalized [0,1] values
|
||||
const GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||
{ t: 0, color: [46, 204, 113] }, // Green
|
||||
{ t: 0.33, color: [241, 196, 15] }, // Yellow
|
||||
{ t: 0.66, color: [231, 76, 60] }, // Red
|
||||
{ t: 1, color: [142, 68, 173] }, // Purple
|
||||
];
|
||||
|
||||
function normalizedToColor(t: number): [number, number, number] {
|
||||
if (t <= 0) return GRADIENT[0].color;
|
||||
if (t >= 1) return GRADIENT[GRADIENT.length - 1].color;
|
||||
|
||||
for (let i = 0; i < GRADIENT.length - 1; i++) {
|
||||
const lo = GRADIENT[i];
|
||||
const hi = GRADIENT[i + 1];
|
||||
if (t >= lo.t && t <= hi.t) {
|
||||
const frac = (t - lo.t) / (hi.t - lo.t);
|
||||
return [
|
||||
Math.round(lo.color[0] + (hi.color[0] - lo.color[0]) * frac),
|
||||
Math.round(lo.color[1] + (hi.color[1] - lo.color[1]) * frac),
|
||||
Math.round(lo.color[2] + (hi.color[2] - lo.color[2]) * frac),
|
||||
];
|
||||
}
|
||||
}
|
||||
return GRADIENT[GRADIENT.length - 1].color;
|
||||
}
|
||||
|
||||
function zoomToResolution(zoom: number): number {
|
||||
if (zoom < 6) return 5;
|
||||
if (zoom < 7) return 6;
|
||||
if (zoom < 9.5) return 8;
|
||||
if (zoom < 11) return 9;
|
||||
if (zoom < 13) return 10;
|
||||
if (zoom < 15) return 11;
|
||||
return 12;
|
||||
}
|
||||
|
||||
function getBoundsFromViewState(viewState: ViewState, width: number, height: number): Bounds {
|
||||
const { longitude, latitude, zoom } = viewState;
|
||||
|
||||
// Clamp latitude to valid Mercator range to avoid math errors
|
||||
const clampedLat = Math.max(-85, Math.min(85, latitude));
|
||||
|
||||
// Web Mercator projection math
|
||||
const TILE_SIZE = 256;
|
||||
const scale = Math.pow(2, zoom);
|
||||
const worldSize = TILE_SIZE * scale;
|
||||
|
||||
// Longitude is linear
|
||||
const degreesPerPixelLng = 360 / worldSize;
|
||||
const halfWidthDeg = (width / 2) * degreesPerPixelLng;
|
||||
|
||||
// Latitude uses Mercator projection (non-linear)
|
||||
const latRad = (clampedLat * Math.PI) / 180;
|
||||
const mercatorY = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2;
|
||||
const centerPixelY = mercatorY * worldSize;
|
||||
|
||||
const topPixelY = centerPixelY - height / 2;
|
||||
const bottomPixelY = centerPixelY + height / 2;
|
||||
|
||||
// Convert pixel Y back to latitude
|
||||
const pixelYToLat = (pixelY: number): number => {
|
||||
const mercY = Math.max(0.001, Math.min(0.999, pixelY / worldSize));
|
||||
const latRadians = Math.atan(Math.sinh(Math.PI * (1 - 2 * mercY)));
|
||||
return (latRadians * 180) / Math.PI;
|
||||
};
|
||||
|
||||
const north = Math.min(85, pixelYToLat(topPixelY));
|
||||
const south = Math.max(-85, pixelYToLat(bottomPixelY));
|
||||
const west = Math.max(-180, longitude - halfWidthDeg);
|
||||
const east = Math.min(180, longitude + halfWidthDeg);
|
||||
|
||||
return { south, west, north, east };
|
||||
}
|
||||
|
||||
interface Dimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
|
|
@ -148,176 +70,6 @@ function DeckOverlay({
|
|||
return null;
|
||||
}
|
||||
|
||||
// Vibrant density scale: light cyan → teal → deep indigo
|
||||
const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||
{ t: 0, color: [130, 234, 220] }, // Light cyan (few)
|
||||
{ t: 0.5, color: [20, 140, 180] }, // Ocean blue (moderate)
|
||||
{ t: 1, color: [88, 28, 140] }, // Deep indigo (many)
|
||||
];
|
||||
|
||||
function countToColor(t: number): [number, number, number] {
|
||||
if (t <= 0) return DENSITY_GRADIENT[0].color;
|
||||
if (t >= 1) return DENSITY_GRADIENT[DENSITY_GRADIENT.length - 1].color;
|
||||
|
||||
for (let i = 0; i < DENSITY_GRADIENT.length - 1; i++) {
|
||||
const lo = DENSITY_GRADIENT[i];
|
||||
const hi = DENSITY_GRADIENT[i + 1];
|
||||
if (t >= lo.t && t <= hi.t) {
|
||||
const frac = (t - lo.t) / (hi.t - lo.t);
|
||||
return [
|
||||
Math.round(lo.color[0] + (hi.color[0] - lo.color[0]) * frac),
|
||||
Math.round(lo.color[1] + (hi.color[1] - lo.color[1]) * frac),
|
||||
Math.round(lo.color[2] + (hi.color[2] - lo.color[2]) * frac),
|
||||
];
|
||||
}
|
||||
}
|
||||
return DENSITY_GRADIENT[DENSITY_GRADIENT.length - 1].color;
|
||||
}
|
||||
|
||||
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 dark:bg-navy-800 dark:text-warm-100 dark:placeholder-warm-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-3 py-2 bg-teal-600 text-white text-sm hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '...' : 'Go'}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<span className="text-xs text-red-600 dark:text-red-400 bg-white/90 dark:bg-navy-800/90 rounded px-2 py-0.5 shadow">{error}</span>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function MapLegend({
|
||||
featureLabel,
|
||||
range,
|
||||
showCancel,
|
||||
onCancel,
|
||||
mode,
|
||||
enumValues,
|
||||
}: {
|
||||
featureLabel: string;
|
||||
range: [number, number];
|
||||
showCancel: boolean;
|
||||
onCancel: () => void;
|
||||
mode: 'feature' | 'density';
|
||||
enumValues?: string[];
|
||||
}) {
|
||||
const formatVal = (v: number) => {
|
||||
if (Math.abs(v) >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
||||
if (Math.abs(v) >= 1_000) return `${(v / 1_000).toFixed(1)}k`;
|
||||
if (Number.isInteger(v)) return v.toString();
|
||||
return v.toFixed(1);
|
||||
};
|
||||
|
||||
const gradientStyle =
|
||||
mode === 'density'
|
||||
? 'linear-gradient(to right, rgb(130, 234, 220), rgb(20, 140, 180), rgb(88, 28, 140))'
|
||||
: 'linear-gradient(to right, rgb(46, 204, 113), rgb(241, 196, 15), rgb(231, 76, 60), rgb(142, 68, 173))';
|
||||
|
||||
return (
|
||||
<div className="absolute top-3 right-3 z-10 bg-white dark:bg-navy-800 dark:text-warm-200 rounded shadow-lg p-3 text-xs min-w-[160px]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold text-sm">{featureLabel}</span>
|
||||
{showCancel && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 ml-2"
|
||||
title="Clear color view"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="h-3 rounded"
|
||||
style={{ background: gradientStyle }}
|
||||
/>
|
||||
<div className="flex justify-between mt-1 text-warm-600 dark:text-warm-400">
|
||||
{mode === 'density' ? (
|
||||
<>
|
||||
<span>Few</span>
|
||||
<span>Many</span>
|
||||
</>
|
||||
) : enumValues && enumValues.length > 0 ? (
|
||||
<>
|
||||
<span>{enumValues[0]}</span>
|
||||
<span>{enumValues[enumValues.length - 1]}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>{formatVal(range[0])}</span>
|
||||
<span>{formatVal(range[1])}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(function Map({
|
||||
data,
|
||||
pois,
|
||||
|
|
@ -339,7 +91,6 @@ export default memo(function Map({
|
|||
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW);
|
||||
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
|
||||
|
||||
// Track container dimensions with ResizeObserver
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
|
@ -355,14 +106,12 @@ export default memo(function Map({
|
|||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Notify parent when view or dimensions change
|
||||
useEffect(() => {
|
||||
if (dimensions.width === 0 || dimensions.height === 0) return;
|
||||
|
||||
const raw = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
|
||||
const resolution = zoomToResolution(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,
|
||||
|
|
@ -391,7 +140,6 @@ export default memo(function Map({
|
|||
const themeRef = useRef(theme);
|
||||
themeRef.current = theme;
|
||||
|
||||
// Make place labels more legible over the colored hexagons
|
||||
const handleMapLoad = useCallback(
|
||||
(evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
|
||||
const map = evt.target;
|
||||
|
|
@ -402,7 +150,6 @@ export default memo(function Map({
|
|||
map.setPaintProperty(layer.id, 'text-halo-width', 2);
|
||||
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');
|
||||
|
|
@ -421,7 +168,6 @@ export default memo(function Map({
|
|||
|
||||
const mapStyle = theme === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT;
|
||||
|
||||
// Popup state for POI hover
|
||||
const [popupInfo, setPopupInfo] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
|
|
@ -442,7 +188,6 @@ export default memo(function Map({
|
|||
}
|
||||
}, []);
|
||||
|
||||
// Compute count range for count-based coloring
|
||||
const countRange = useMemo(() => {
|
||||
if (data.length === 0) return { min: 0, max: 1 };
|
||||
let min = Infinity;
|
||||
|
|
@ -456,13 +201,11 @@ export default memo(function Map({
|
|||
return { min, max };
|
||||
}, [data]);
|
||||
|
||||
// Memoize feature lookup to avoid new reference each render
|
||||
const colorFeatureMeta = useMemo(
|
||||
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
|
||||
[viewFeature, features]
|
||||
);
|
||||
|
||||
// Use refs for values that change during drag so layers aren't recreated
|
||||
const viewFeatureRef = useRef(viewFeature);
|
||||
viewFeatureRef.current = viewFeature;
|
||||
const colorRangeRef = useRef(colorRange);
|
||||
|
|
@ -478,7 +221,6 @@ export default memo(function Map({
|
|||
const hoveredHexagonIdRef = useRef(hoveredHexagonId);
|
||||
hoveredHexagonIdRef.current = hoveredHexagonId;
|
||||
|
||||
// Stable click handler using ref
|
||||
const onHexagonClickRef = useRef(onHexagonClick);
|
||||
onHexagonClickRef.current = onHexagonClick;
|
||||
const handleHexagonClick = useCallback((info: PickingInfo<HexagonData>) => {
|
||||
|
|
@ -487,7 +229,6 @@ export default memo(function Map({
|
|||
}
|
||||
}, []);
|
||||
|
||||
// Stable hover handler using ref
|
||||
const onHexagonHoverRef = useRef(onHexagonHover);
|
||||
onHexagonHoverRef.current = onHexagonHover;
|
||||
const handleHexagonHover = useCallback((info: PickingInfo<HexagonData>) => {
|
||||
|
|
@ -498,17 +239,14 @@ export default memo(function Map({
|
|||
}
|
||||
}, []);
|
||||
|
||||
// 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 = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}`;
|
||||
|
||||
// Hexagon layer — only recreated when data or color trigger changes
|
||||
const hexLayer = useMemo(
|
||||
() =>
|
||||
new H3HexagonLayer<HexagonData>({
|
||||
|
|
@ -523,7 +261,6 @@ export default memo(function Map({
|
|||
if (vf && clr && cfm) {
|
||||
const val = d[`min_${vf}`];
|
||||
if (val == null) return [128, 128, 128, 80] as [number, number, number, number];
|
||||
// Gray out hexagons outside filter range
|
||||
if (fr) {
|
||||
const minVal = d[`min_${vf}`] as number;
|
||||
const maxVal = d[`max_${vf}`] as number;
|
||||
|
|
@ -531,7 +268,6 @@ export default memo(function Map({
|
|||
return [180, 180, 180, 60] as [number, number, number, number];
|
||||
}
|
||||
}
|
||||
// Color using full slider range
|
||||
const range = clr[1] - clr[0];
|
||||
if (range === 0) return [...GRADIENT[0].color, 200] as [number, number, number, number];
|
||||
const t = ((val as number) - clr[0]) / range;
|
||||
|
|
@ -549,8 +285,10 @@ export default memo(function Map({
|
|||
];
|
||||
},
|
||||
getLineColor: (d) => {
|
||||
if (d.h3 === selectedHexagonIdRef.current) return [255, 255, 255, 255] as [number, number, number, number];
|
||||
if (d.h3 === hoveredHexagonIdRef.current) return [29, 228, 195, 200] as [number, number, number, number];
|
||||
if (d.h3 === selectedHexagonIdRef.current)
|
||||
return [255, 255, 255, 255] as [number, number, number, number];
|
||||
if (d.h3 === hoveredHexagonIdRef.current)
|
||||
return [29, 228, 195, 200] as [number, number, number, number];
|
||||
return [0, 0, 0, 0] as [number, number, number, number];
|
||||
},
|
||||
getLineWidth: (d) => {
|
||||
|
|
@ -576,7 +314,6 @@ export default memo(function Map({
|
|||
[data, colorTrigger, handleHexagonClick, handleHexagonHover]
|
||||
);
|
||||
|
||||
// POI layer — independent, only recreated when POI data changes
|
||||
const poiLayer = useMemo(
|
||||
() =>
|
||||
new IconLayer<POI>({
|
||||
|
|
@ -597,7 +334,6 @@ export default memo(function Map({
|
|||
[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]
|
||||
|
|
|
|||
66
frontend/src/components/MapLegend.tsx
Normal file
66
frontend/src/components/MapLegend.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { formatValue } from '../lib/format';
|
||||
|
||||
export default function MapLegend({
|
||||
featureLabel,
|
||||
range,
|
||||
showCancel,
|
||||
onCancel,
|
||||
mode,
|
||||
enumValues,
|
||||
}: {
|
||||
featureLabel: string;
|
||||
range: [number, number];
|
||||
showCancel: boolean;
|
||||
onCancel: () => void;
|
||||
mode: 'feature' | 'density';
|
||||
enumValues?: string[];
|
||||
}) {
|
||||
const gradientStyle =
|
||||
mode === 'density'
|
||||
? 'linear-gradient(to right, rgb(130, 234, 220), rgb(20, 140, 180), rgb(88, 28, 140))'
|
||||
: 'linear-gradient(to right, rgb(46, 204, 113), rgb(241, 196, 15), rgb(231, 76, 60), rgb(142, 68, 173))';
|
||||
|
||||
return (
|
||||
<div className="absolute top-3 right-3 z-10 bg-white dark:bg-navy-800 dark:text-warm-200 rounded shadow-lg p-3 text-xs min-w-[160px]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold text-sm">{featureLabel}</span>
|
||||
{showCancel && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 ml-2"
|
||||
title="Clear color view"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-3 rounded" style={{ background: gradientStyle }} />
|
||||
<div className="flex justify-between mt-1 text-warm-600 dark:text-warm-400">
|
||||
{mode === 'density' ? (
|
||||
<>
|
||||
<span>Few</span>
|
||||
<span>Many</span>
|
||||
</>
|
||||
) : enumValues && enumValues.length > 0 ? (
|
||||
<>
|
||||
<span>{enumValues[0]}</span>
|
||||
<span>{enumValues[enumValues.length - 1]}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>{formatValue(range[0])}</span>
|
||||
<span>{formatValue(range[1])}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import type { POICategoryGroup } from '../types';
|
||||
import { useClickOutside } from '../hooks/useClickOutside';
|
||||
import InfoPopup from './InfoPopup';
|
||||
|
||||
interface POIPaneProps {
|
||||
groups: POICategoryGroup[];
|
||||
|
|
@ -21,30 +23,8 @@ export default function POIPane({
|
|||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const infoPopupRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Close info popup when clicking outside
|
||||
useEffect(() => {
|
||||
if (!showInfo) return;
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (infoPopupRef.current && !infoPopupRef.current.contains(e.target as Node)) {
|
||||
setShowInfo(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [showInfo]);
|
||||
useClickOutside(dropdownRef, () => setDropdownOpen(false));
|
||||
|
||||
const allCategories = groups.flatMap((g) => g.categories);
|
||||
|
||||
|
|
@ -96,7 +76,6 @@ export default function POIPane({
|
|||
|
||||
const lowerSearch = searchTerm.toLowerCase();
|
||||
|
||||
// Filter groups and categories by search term
|
||||
const filteredGroups = groups
|
||||
.map((group) => {
|
||||
if (!searchTerm) return group;
|
||||
|
|
@ -119,7 +98,13 @@ export default function POIPane({
|
|||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-0.5 rounded"
|
||||
title="Data source info"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path strokeLinecap="round" d="M12 16v-4m0-4h.01" />
|
||||
</svg>
|
||||
|
|
@ -127,43 +112,28 @@ export default function POIPane({
|
|||
</div>
|
||||
|
||||
{showInfo && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div
|
||||
ref={infoPopupRef}
|
||||
className="bg-white dark:bg-navy-800 border border-warm-200 dark:border-navy-700 rounded-lg shadow-xl max-w-md w-full mx-4 p-5"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">
|
||||
Points of Interest
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowInfo(false)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
|
||||
Points of interest are sourced from OpenStreetMap via Geofabrik extracts.
|
||||
Categories include public transport stops, shops, restaurants, healthcare
|
||||
facilities, leisure venues, and more. Data is filtered and mapped to
|
||||
friendly names with exhaustive category coverage.
|
||||
</p>
|
||||
{onNavigateToSource && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onNavigateToSource('osm-pois');
|
||||
setShowInfo(false);
|
||||
}}
|
||||
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
|
||||
>
|
||||
View data source
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<InfoPopup
|
||||
title="Points of Interest"
|
||||
onClose={() => setShowInfo(false)}
|
||||
sourceLink={
|
||||
onNavigateToSource
|
||||
? {
|
||||
label: 'View data source',
|
||||
onClick: () => {
|
||||
onNavigateToSource('osm-pois');
|
||||
setShowInfo(false);
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
|
||||
Points of interest are sourced from OpenStreetMap via Geofabrik extracts. Categories
|
||||
include public transport stops, shops, restaurants, healthcare facilities, leisure
|
||||
venues, and more. Data is filtered and mapped to friendly names with exhaustive category
|
||||
coverage.
|
||||
</p>
|
||||
</InfoPopup>
|
||||
)}
|
||||
|
||||
<div className="space-y-2" ref={dropdownRef}>
|
||||
|
|
@ -191,11 +161,17 @@ export default function POIPane({
|
|||
{dropdownOpen && (
|
||||
<div className="border border-warm-300 dark:border-navy-700 rounded shadow-lg bg-white dark:bg-navy-800">
|
||||
<div className="flex gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<button onClick={selectAll} className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300">
|
||||
<button
|
||||
onClick={selectAll}
|
||||
className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<span className="text-xs text-warm-300 dark:text-warm-600">|</span>
|
||||
<button onClick={selectNone} className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300">
|
||||
<button
|
||||
onClick={selectNone}
|
||||
className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
>
|
||||
None
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -248,7 +224,9 @@ export default function POIPane({
|
|||
onChange={() => toggleGroup(group.name)}
|
||||
className="rounded accent-teal-600"
|
||||
/>
|
||||
<span className="text-xs font-semibold text-warm-700 dark:text-warm-300">{group.name}</span>
|
||||
<span className="text-xs font-semibold text-warm-700 dark:text-warm-300">
|
||||
{group.name}
|
||||
</span>
|
||||
</label>
|
||||
<span className="text-xs text-warm-400">
|
||||
{groupSelected}/{group.categories.length}
|
||||
|
|
|
|||
72
frontend/src/components/PostcodeSearch.tsx
Normal file
72
frontend/src/components/PostcodeSearch.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
|
||||
export default 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 dark:bg-navy-800 dark:text-warm-100 dark:placeholder-warm-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-3 py-2 bg-teal-600 text-white text-sm hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '...' : 'Go'}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<span className="text-xs text-red-600 dark:text-red-400 bg-white/90 dark:bg-navy-800/90 rounded px-2 py-0.5 shadow">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import React, { useMemo, useState, useRef, useEffect } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Property } from '../types';
|
||||
import { formatDuration, formatAge } from '../lib/format';
|
||||
import InfoPopup from './InfoPopup';
|
||||
|
||||
interface PropertiesPaneProps {
|
||||
properties: Property[];
|
||||
|
|
@ -31,20 +33,7 @@ export function PropertiesPane({
|
|||
const [sortBy, setSortBy] = useState<SortBy>('price');
|
||||
const [search, setSearch] = useState('');
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
const infoPopupRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showInfo) return;
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (infoPopupRef.current && !infoPopupRef.current.contains(e.target as Node)) {
|
||||
setShowInfo(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [showInfo]);
|
||||
|
||||
// Filter and sort properties
|
||||
const filteredAndSorted = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
const filtered = query
|
||||
|
|
@ -76,7 +65,6 @@ export function PropertiesPane({
|
|||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-warm-200 dark:border-navy-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -91,7 +79,13 @@ export function PropertiesPane({
|
|||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-0.5 rounded"
|
||||
title="Data source info"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path strokeLinecap="round" d="M12 16v-4m0-4h.01" />
|
||||
</svg>
|
||||
|
|
@ -106,11 +100,29 @@ export function PropertiesPane({
|
|||
? 'text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
||||
: 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
|
||||
}`}
|
||||
title={hoverMode ? 'Live preview on (click to lock)' : 'Live preview off (click to enable)'}
|
||||
title={
|
||||
hoverMode
|
||||
? 'Live preview on (click to lock)'
|
||||
: 'Live preview off (click to enable)'
|
||||
}
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -118,7 +130,13 @@ export function PropertiesPane({
|
|||
onClick={onClose}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-1"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
|
@ -130,47 +148,31 @@ export function PropertiesPane({
|
|||
: `Showing ${properties.length} of ${total} properties`}
|
||||
</p>
|
||||
{showInfo && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div
|
||||
ref={infoPopupRef}
|
||||
className="bg-white dark:bg-navy-800 border border-warm-200 dark:border-navy-700 rounded-lg shadow-xl max-w-md w-full mx-4 p-5"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">
|
||||
Property Data
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowInfo(false)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
|
||||
Property data combines Energy Performance Certificates (EPC) with HM Land
|
||||
Registry Price Paid records, fuzzy-matched by address within each postcode.
|
||||
Includes floor area, energy ratings, construction age, and tenure from EPC
|
||||
surveys, plus the most recent sale price from the Land Registry.
|
||||
</p>
|
||||
{onNavigateToSource && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onNavigateToSource('epc');
|
||||
setShowInfo(false);
|
||||
}}
|
||||
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
|
||||
>
|
||||
View data source
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<InfoPopup
|
||||
title="Property Data"
|
||||
onClose={() => setShowInfo(false)}
|
||||
sourceLink={
|
||||
onNavigateToSource
|
||||
? {
|
||||
label: 'View data source',
|
||||
onClick: () => {
|
||||
onNavigateToSource('epc');
|
||||
setShowInfo(false);
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
|
||||
Property data combines Energy Performance Certificates (EPC) with HM Land Registry
|
||||
Price Paid records, fuzzy-matched by address within each postcode. Includes floor
|
||||
area, energy ratings, construction age, and tenure from EPC surveys, plus the most
|
||||
recent sale price from the Land Registry.
|
||||
</p>
|
||||
</InfoPopup>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search and sort controls */}
|
||||
<div className="p-2 border-b border-warm-200 dark:border-navy-700 space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -190,7 +192,6 @@ export function PropertiesPane({
|
|||
</select>
|
||||
</div>
|
||||
|
||||
{/* Properties list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && properties.length === 0 ? (
|
||||
<div className="p-4 dark:text-warm-400">Loading...</div>
|
||||
|
|
@ -215,18 +216,6 @@ export function PropertiesPane({
|
|||
);
|
||||
}
|
||||
|
||||
function formatDuration(d: string): string {
|
||||
if (d === 'F') return 'Freehold';
|
||||
if (d === 'L') return 'Leasehold';
|
||||
return d;
|
||||
}
|
||||
|
||||
function formatAge(value: number, approximate = true): string {
|
||||
if (value >= 1000) return approximate ? `~${Math.round(value)}` : `${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];
|
||||
|
|
@ -235,7 +224,6 @@ function getNum(property: Property, ...keys: string[]): number | undefined {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
// Property card component showing all fields
|
||||
function PropertyCard({ property }: { property: Property }) {
|
||||
const fmt = (value: number | undefined, decimals = 0): string => {
|
||||
if (value === undefined) return '';
|
||||
|
|
@ -251,24 +239,27 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
'number_habitable_rooms'
|
||||
);
|
||||
const age = getNum(property, 'Approximate construction age', 'construction_age_band');
|
||||
const councilTaxD = getNum(property, 'Council tax Band D (£/yr)');
|
||||
|
||||
return (
|
||||
<div className="p-4 border-b border-warm-100 dark:border-navy-800 hover:bg-warm-50 dark:hover:bg-navy-800">
|
||||
{/* Address & postcode */}
|
||||
<div className="font-semibold dark:text-warm-100">{property.address || 'Unknown Address'}</div>
|
||||
<div className="font-semibold dark:text-warm-100">
|
||||
{property.address || 'Unknown Address'}
|
||||
</div>
|
||||
<div className="text-sm text-warm-600 dark:text-warm-400">{property.postcode}</div>
|
||||
|
||||
{/* Price */}
|
||||
{price !== undefined && (
|
||||
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
|
||||
£{fmt(price)}
|
||||
{pricePerSqm !== undefined && (
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400"> (£{fmt(pricePerSqm)}/m²)</span>
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
||||
{' '}
|
||||
(£{fmt(pricePerSqm)}/m²)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Property details grid */}
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-sm dark:text-warm-300">
|
||||
{property.property_type && (
|
||||
<div>
|
||||
|
|
@ -277,12 +268,14 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
)}
|
||||
{property.built_form && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Built form:</span> {property.built_form}
|
||||
<span className="text-warm-500 dark:text-warm-400">Built form:</span>{' '}
|
||||
{property.built_form}
|
||||
</div>
|
||||
)}
|
||||
{property.duration && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Tenure:</span> {formatDuration(property.duration)}
|
||||
<span className="text-warm-500 dark:text-warm-400">Tenure:</span>{' '}
|
||||
{formatDuration(property.duration)}
|
||||
</div>
|
||||
)}
|
||||
{floorArea !== undefined && (
|
||||
|
|
@ -297,17 +290,26 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
)}
|
||||
{age !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Built:</span> {formatAge(age, property.is_construction_date_approximate ?? true)}
|
||||
<span className="text-warm-500 dark:text-warm-400">Built:</span>{' '}
|
||||
{formatAge(age, property.is_construction_date_approximate ?? true)}
|
||||
</div>
|
||||
)}
|
||||
{property.current_energy_rating && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">EPC rating:</span> {property.current_energy_rating}
|
||||
<span className="text-warm-500 dark:text-warm-400">EPC rating:</span>{' '}
|
||||
{property.current_energy_rating}
|
||||
</div>
|
||||
)}
|
||||
{property.potential_energy_rating && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">EPC potential:</span> {property.potential_energy_rating}
|
||||
<span className="text-warm-500 dark:text-warm-400">EPC potential:</span>{' '}
|
||||
{property.potential_energy_rating}
|
||||
</div>
|
||||
)}
|
||||
{councilTaxD !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Council tax (D):</span> £
|
||||
{fmt(councilTaxD)}/yr
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ interface LabelProps {
|
|||
|
||||
export function Label({ children, className }: LabelProps) {
|
||||
return (
|
||||
<label className={`text-sm font-medium text-warm-700 dark:text-warm-300 ${className || ''}`}>{children}</label>
|
||||
<label className={`text-sm font-medium text-warm-700 dark:text-warm-300 ${className || ''}`}>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
13
frontend/src/hooks/useClickOutside.ts
Normal file
13
frontend/src/hooks/useClickOutside.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { useEffect, type RefObject } from 'react';
|
||||
|
||||
export function useClickOutside(ref: RefObject<HTMLElement | null>, callback: () => void) {
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [ref, callback]);
|
||||
}
|
||||
21
frontend/src/hooks/useFadeIn.ts
Normal file
21
frontend/src/hooks/useFadeIn.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { useRef, useEffect } from 'react';
|
||||
|
||||
export function useFadeInRef() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
el.classList.add('fade-in-visible');
|
||||
observer.unobserve(el);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.15 }
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
return ref;
|
||||
}
|
||||
27
frontend/src/hooks/useTheme.ts
Normal file
27
frontend/src/hooks/useTheme.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const stored = localStorage.getItem('theme');
|
||||
if (stored === 'light' || stored === 'dark') return stored;
|
||||
return 'light';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
|
||||
}, []);
|
||||
|
||||
return { theme, toggleTheme } as const;
|
||||
}
|
||||
37
frontend/src/hooks/useUrlSync.ts
Normal file
37
frontend/src/hooks/useUrlSync.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import type { FeatureMeta, FeatureFilters } from '../types';
|
||||
import { stateToParams } from '../lib/url-state';
|
||||
|
||||
const URL_DEBOUNCE_MS = 300;
|
||||
|
||||
export function useUrlSync(
|
||||
currentView: { latitude: number; longitude: number; zoom: number } | null,
|
||||
filters: FeatureFilters,
|
||||
features: FeatureMeta[],
|
||||
selectedPOICategories: Set<string>,
|
||||
rightPaneTab: 'pois' | 'properties' | 'area'
|
||||
) {
|
||||
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({ ...window.history.state }, '', newUrl);
|
||||
}, URL_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (urlDebounceRef.current) clearTimeout(urlDebounceRef.current);
|
||||
};
|
||||
}, [currentView, filters, features, selectedPOICategories, rightPaneTab]);
|
||||
}
|
||||
|
|
@ -31,7 +31,10 @@ p,
|
|||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Fade-in animation for homepage sections */
|
||||
|
|
|
|||
61
frontend/src/lib/api.ts
Normal file
61
frontend/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import type { FeatureMeta, FeatureFilters } from '../types';
|
||||
|
||||
const INITIAL_RETRY_MS = 1000;
|
||||
const MAX_RETRY_MS = 10000;
|
||||
|
||||
export async function fetchWithRetry<T>(
|
||||
url: string,
|
||||
onSuccess: (data: T) => void,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
let delay = INITIAL_RETRY_MS;
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
const res = await fetch(url, { signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
onSuccess(json);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (signal.aborted) return;
|
||||
console.error(`Failed to fetch ${url}, retrying in ${delay}ms:`, err);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
delay = Math.min(delay * 2, MAX_RETRY_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getApiBaseUrl(): string {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const { pathname, href } = window.location;
|
||||
|
||||
const pathMatch = pathname.match(/^(\/proxy\/)(\d+)/);
|
||||
if (pathMatch) {
|
||||
return `${pathMatch[1]}8001`;
|
||||
}
|
||||
|
||||
const hrefMatch = href.match(/(\/proxy\/)\d+/);
|
||||
if (hrefMatch) {
|
||||
return `${hrefMatch[1]}8001`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function buildFilterString(filters: FeatureFilters, features: FeatureMeta[]): string {
|
||||
const entries = Object.entries(filters);
|
||||
if (entries.length === 0) return '';
|
||||
return entries
|
||||
.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(',');
|
||||
}
|
||||
136
frontend/src/lib/external-search.ts
Normal file
136
frontend/src/lib/external-search.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import type { FeatureFilters } from '../types';
|
||||
|
||||
export interface HexagonLocation {
|
||||
lat: number;
|
||||
lon: number;
|
||||
postcode: string | null;
|
||||
resolution: number;
|
||||
}
|
||||
|
||||
const PROPERTY_TYPE_MAP: Record<
|
||||
string,
|
||||
{ rightmove: string; onthemarket: string; zoopla: string }
|
||||
> = {
|
||||
House: { rightmove: 'detached,semi-detached,terraced', onthemarket: 'property', zoopla: '' },
|
||||
Detached: { rightmove: 'detached', onthemarket: 'detached', zoopla: 'detached' },
|
||||
'Semi-Detached': {
|
||||
rightmove: 'semi-detached',
|
||||
onthemarket: 'semi-detached',
|
||||
zoopla: 'semi_detached',
|
||||
},
|
||||
'Mid-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' },
|
||||
'End-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' },
|
||||
'Enclosed Mid-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' },
|
||||
'Enclosed End-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' },
|
||||
Flat: { rightmove: 'flat', onthemarket: 'flats', zoopla: 'flat' },
|
||||
Maisonette: { rightmove: 'flat', onthemarket: 'flats', zoopla: 'flat' },
|
||||
Bungalow: { rightmove: 'bungalow', onthemarket: 'bungalow', zoopla: 'bungalow' },
|
||||
'Park home': { rightmove: 'park-home', onthemarket: 'property', zoopla: '' },
|
||||
};
|
||||
|
||||
export const H3_RADIUS_MILES: Record<number, number> = {
|
||||
4: 15,
|
||||
5: 6,
|
||||
6: 3,
|
||||
7: 1,
|
||||
8: 0.5,
|
||||
9: 0.25,
|
||||
10: 0.25,
|
||||
11: 0.25,
|
||||
12: 0.25,
|
||||
};
|
||||
|
||||
const RIGHTMOVE_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40];
|
||||
const OTM_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40];
|
||||
const ZOOPLA_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 25, 30];
|
||||
|
||||
function nearestRadius(target: number, allowed: number[]): number {
|
||||
return allowed.reduce((best, r) => (Math.abs(r - target) < Math.abs(best - target) ? r : best));
|
||||
}
|
||||
|
||||
export function buildPropertySearchUrls(
|
||||
location: HexagonLocation,
|
||||
filters: FeatureFilters
|
||||
): { rightmove: string; onthemarket: string; zoopla: string } {
|
||||
const { lat, lon, postcode, resolution } = location;
|
||||
const radiusMiles = H3_RADIUS_MILES[resolution] ?? 1;
|
||||
const coordStr = `${lat.toFixed(5)},${lon.toFixed(5)}`;
|
||||
|
||||
const priceFilter = filters['Last known price'];
|
||||
const minPrice =
|
||||
Array.isArray(priceFilter) && typeof priceFilter[0] === 'number' ? priceFilter[0] : undefined;
|
||||
const maxPrice =
|
||||
Array.isArray(priceFilter) && typeof priceFilter[1] === 'number' ? priceFilter[1] : undefined;
|
||||
|
||||
const propertyTypes = filters['Property type'];
|
||||
const selectedTypes =
|
||||
Array.isArray(propertyTypes) && typeof propertyTypes[0] === 'string'
|
||||
? (propertyTypes as string[])
|
||||
: [];
|
||||
|
||||
const rmParams = new URLSearchParams();
|
||||
rmParams.set('searchLocation', postcode || coordStr);
|
||||
rmParams.set('channel', 'BUY');
|
||||
rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII)));
|
||||
if (minPrice !== undefined) rmParams.set('minPrice', String(Math.round(minPrice)));
|
||||
if (maxPrice !== undefined) rmParams.set('maxPrice', String(Math.round(maxPrice)));
|
||||
if (selectedTypes.length > 0) {
|
||||
const rmTypes = [
|
||||
...new Set(
|
||||
selectedTypes.flatMap((t) => {
|
||||
const mapped = PROPERTY_TYPE_MAP[t]?.rightmove;
|
||||
return mapped ? mapped.split(',') : [];
|
||||
})
|
||||
),
|
||||
];
|
||||
if (rmTypes.length > 0) rmParams.set('propertyTypes', rmTypes.join(','));
|
||||
}
|
||||
const rightmove = `https://www.rightmove.co.uk/property-for-sale/find.html?${rmParams.toString()}`;
|
||||
|
||||
let otmType = 'property';
|
||||
if (selectedTypes.length > 0) {
|
||||
const otmTypes = [
|
||||
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean)),
|
||||
];
|
||||
if (otmTypes.length === 1 && otmTypes[0] !== 'property') otmType = otmTypes[0]!;
|
||||
}
|
||||
const otmParams = new URLSearchParams();
|
||||
otmParams.set('radius', String(nearestRadius(radiusMiles, OTM_RADII)));
|
||||
if (minPrice !== undefined) otmParams.set('min-price', String(Math.round(minPrice)));
|
||||
if (maxPrice !== undefined) otmParams.set('max-price', String(Math.round(maxPrice)));
|
||||
let onthemarket: string;
|
||||
if (postcode) {
|
||||
const slug = postcode.replace(/\s+/g, '-').toLowerCase();
|
||||
onthemarket = `https://www.onthemarket.com/for-sale/${otmType}/${slug}/?${otmParams.toString()}`;
|
||||
} else {
|
||||
otmParams.set('search-site', 'geo');
|
||||
otmParams.set('geo-lat', String(lat));
|
||||
otmParams.set('geo-lng', String(lon));
|
||||
onthemarket = `https://www.onthemarket.com/for-sale/${otmType}/?${otmParams.toString()}`;
|
||||
}
|
||||
|
||||
const zParams = new URLSearchParams();
|
||||
zParams.set('q', postcode || coordStr);
|
||||
zParams.set('search_source', 'for-sale');
|
||||
zParams.set('radius', String(nearestRadius(radiusMiles, ZOOPLA_RADII)));
|
||||
if (minPrice !== undefined) zParams.set('price_min', String(Math.round(minPrice)));
|
||||
if (maxPrice !== undefined) zParams.set('price_max', String(Math.round(maxPrice)));
|
||||
if (selectedTypes.length > 0) {
|
||||
const zTypes = [
|
||||
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.zoopla).filter(Boolean)),
|
||||
];
|
||||
for (const zt of zTypes) {
|
||||
zParams.append('property_sub_type', zt!);
|
||||
}
|
||||
}
|
||||
let zoopla: string;
|
||||
if (postcode) {
|
||||
const slug = postcode.replace(/\s+/g, '-').toLowerCase();
|
||||
zoopla = `https://www.zoopla.co.uk/for-sale/property/${slug}/?${zParams.toString()}`;
|
||||
} else {
|
||||
zParams.set('geo_autocomplete_identifier', `geo_${lat}_${lon}`);
|
||||
zoopla = `https://www.zoopla.co.uk/for-sale/property/?${zParams.toString()}`;
|
||||
}
|
||||
|
||||
return { rightmove, onthemarket, zoopla };
|
||||
}
|
||||
24
frontend/src/lib/format.ts
Normal file
24
frontend/src/lib/format.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
export 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) return `${(value / 1_000).toFixed(1)}k`;
|
||||
if (Number.isInteger(value)) return value.toLocaleString();
|
||||
return value.toFixed(1);
|
||||
}
|
||||
|
||||
export function formatFilterValue(value: number): string {
|
||||
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 (Number.isInteger(value)) return value.toString();
|
||||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
export function formatDuration(d: string): string {
|
||||
if (d === 'F') return 'Freehold';
|
||||
if (d === 'L') return 'Leasehold';
|
||||
return d;
|
||||
}
|
||||
|
||||
export function formatAge(value: number, approximate = true): string {
|
||||
if (value >= 1000) return approximate ? `~${Math.round(value)}` : `${Math.round(value)}`;
|
||||
return Math.round(value).toString();
|
||||
}
|
||||
109
frontend/src/lib/map-utils.ts
Normal file
109
frontend/src/lib/map-utils.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import type { ViewState, Bounds } from '../types';
|
||||
|
||||
export const MAP_STYLE_LIGHT = 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json';
|
||||
export const MAP_STYLE_DARK = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
|
||||
|
||||
export const GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||
{ t: 0, color: [46, 204, 113] },
|
||||
{ t: 0.33, color: [241, 196, 15] },
|
||||
{ t: 0.66, color: [231, 76, 60] },
|
||||
{ t: 1, color: [142, 68, 173] },
|
||||
];
|
||||
|
||||
export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||
{ t: 0, color: [130, 234, 220] },
|
||||
{ t: 0.5, color: [20, 140, 180] },
|
||||
{ t: 1, color: [88, 28, 140] },
|
||||
];
|
||||
|
||||
export function normalizedToColor(t: number): [number, number, number] {
|
||||
if (t <= 0) return GRADIENT[0].color;
|
||||
if (t >= 1) return GRADIENT[GRADIENT.length - 1].color;
|
||||
|
||||
for (let i = 0; i < GRADIENT.length - 1; i++) {
|
||||
const lo = GRADIENT[i];
|
||||
const hi = GRADIENT[i + 1];
|
||||
if (t >= lo.t && t <= hi.t) {
|
||||
const frac = (t - lo.t) / (hi.t - lo.t);
|
||||
return [
|
||||
Math.round(lo.color[0] + (hi.color[0] - lo.color[0]) * frac),
|
||||
Math.round(lo.color[1] + (hi.color[1] - lo.color[1]) * frac),
|
||||
Math.round(lo.color[2] + (hi.color[2] - lo.color[2]) * frac),
|
||||
];
|
||||
}
|
||||
}
|
||||
return GRADIENT[GRADIENT.length - 1].color;
|
||||
}
|
||||
|
||||
export function countToColor(t: number): [number, number, number] {
|
||||
if (t <= 0) return DENSITY_GRADIENT[0].color;
|
||||
if (t >= 1) return DENSITY_GRADIENT[DENSITY_GRADIENT.length - 1].color;
|
||||
|
||||
for (let i = 0; i < DENSITY_GRADIENT.length - 1; i++) {
|
||||
const lo = DENSITY_GRADIENT[i];
|
||||
const hi = DENSITY_GRADIENT[i + 1];
|
||||
if (t >= lo.t && t <= hi.t) {
|
||||
const frac = (t - lo.t) / (hi.t - lo.t);
|
||||
return [
|
||||
Math.round(lo.color[0] + (hi.color[0] - lo.color[0]) * frac),
|
||||
Math.round(lo.color[1] + (hi.color[1] - lo.color[1]) * frac),
|
||||
Math.round(lo.color[2] + (hi.color[2] - lo.color[2]) * frac),
|
||||
];
|
||||
}
|
||||
}
|
||||
return DENSITY_GRADIENT[DENSITY_GRADIENT.length - 1].color;
|
||||
}
|
||||
|
||||
export function zoomToResolution(zoom: number): number {
|
||||
if (zoom < 6) return 5;
|
||||
if (zoom < 7) return 6;
|
||||
if (zoom < 9.5) return 8;
|
||||
if (zoom < 11) return 9;
|
||||
if (zoom < 13) return 10;
|
||||
if (zoom < 15) return 11;
|
||||
return 12;
|
||||
}
|
||||
|
||||
export function getBoundsFromViewState(
|
||||
viewState: ViewState,
|
||||
width: number,
|
||||
height: number
|
||||
): Bounds {
|
||||
const { longitude, latitude, zoom } = viewState;
|
||||
const clampedLat = Math.max(-85, Math.min(85, latitude));
|
||||
const TILE_SIZE = 256;
|
||||
const scale = Math.pow(2, zoom);
|
||||
const worldSize = TILE_SIZE * scale;
|
||||
|
||||
const degreesPerPixelLng = 360 / worldSize;
|
||||
const halfWidthDeg = (width / 2) * degreesPerPixelLng;
|
||||
|
||||
const latRad = (clampedLat * Math.PI) / 180;
|
||||
const mercatorY = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2;
|
||||
const centerPixelY = mercatorY * worldSize;
|
||||
|
||||
const topPixelY = centerPixelY - height / 2;
|
||||
const bottomPixelY = centerPixelY + height / 2;
|
||||
|
||||
const pixelYToLat = (pixelY: number): number => {
|
||||
const mercY = Math.max(0.001, Math.min(0.999, pixelY / worldSize));
|
||||
const latRadians = Math.atan(Math.sinh(Math.PI * (1 - 2 * mercY)));
|
||||
return (latRadians * 180) / Math.PI;
|
||||
};
|
||||
|
||||
const north = Math.min(85, pixelYToLat(topPixelY));
|
||||
const south = Math.max(-85, pixelYToLat(bottomPixelY));
|
||||
const west = Math.max(-180, longitude - halfWidthDeg);
|
||||
const east = Math.min(180, longitude + halfWidthDeg);
|
||||
|
||||
return { south, west, north, east };
|
||||
}
|
||||
|
||||
const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/';
|
||||
|
||||
export function emojiToTwemojiUrl(emoji: string): string {
|
||||
const codePoint = emoji.codePointAt(0);
|
||||
if (!codePoint) return `${TWEMOJI_BASE}1f4cd.png`;
|
||||
const hex = codePoint.toString(16);
|
||||
return `${TWEMOJI_BASE}${hex}.png`;
|
||||
}
|
||||
113
frontend/src/lib/url-state.ts
Normal file
113
frontend/src/lib/url-state.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import type { FeatureMeta, FeatureFilters, ViewState } from '../types';
|
||||
|
||||
export const DEFAULT_VIEW: ViewState = {
|
||||
longitude: -1.5,
|
||||
latitude: 53.5,
|
||||
zoom: 6,
|
||||
pitch: 0,
|
||||
};
|
||||
|
||||
export function parseUrlState(): {
|
||||
viewState?: ViewState;
|
||||
filters?: FeatureFilters;
|
||||
poiCategories?: Set<string>;
|
||||
tab?: 'pois' | 'properties' | 'area';
|
||||
} {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const result: ReturnType<typeof parseUrlState> = {};
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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(':')) {
|
||||
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('|')) {
|
||||
filters[name] = rest.split('|');
|
||||
} else {
|
||||
filters[name] = [rest];
|
||||
}
|
||||
}
|
||||
if (Object.keys(filters).length > 0) {
|
||||
result.filters = filters;
|
||||
}
|
||||
}
|
||||
|
||||
const poi = params.get('poi');
|
||||
if (poi) {
|
||||
result.poiCategories = new Set(poi.split(',').filter(Boolean));
|
||||
}
|
||||
|
||||
const tab = params.get('tab');
|
||||
if (tab === 'p') result.tab = 'properties';
|
||||
else if (tab === 'o') result.tab = 'pois';
|
||||
else if (tab === 'a') result.tab = 'area';
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function stateToParams(
|
||||
viewState: { latitude: number; longitude: number; zoom: number } | null,
|
||||
filters: FeatureFilters,
|
||||
features: FeatureMeta[],
|
||||
selectedPOICategories: Set<string>,
|
||||
rightPaneTab: 'pois' | 'properties' | 'area'
|
||||
): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (viewState) {
|
||||
params.set(
|
||||
'v',
|
||||
`${viewState.latitude.toFixed(4)},${viewState.longitude.toFixed(4)},${viewState.zoom.toFixed(1)}`
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
if (selectedPOICategories.size > 0) {
|
||||
params.set('poi', Array.from(selectedPOICategories).join(','));
|
||||
}
|
||||
|
||||
if (rightPaneTab === 'properties') {
|
||||
params.set('tab', 'p');
|
||||
} else if (rightPaneTab === 'area') {
|
||||
params.set('tab', 'a');
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue