Add dark mode
This commit is contained in:
parent
5e210e14bd
commit
7235df0a97
14 changed files with 304 additions and 139 deletions
|
|
@ -23,6 +23,8 @@ import type {
|
||||||
HexagonPropertiesResponse,
|
HexagonPropertiesResponse,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
const DEBOUNCE_MS = 150;
|
const DEBOUNCE_MS = 150;
|
||||||
const URL_DEBOUNCE_MS = 300;
|
const URL_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
|
@ -180,9 +182,13 @@ type Page = 'home' | 'dashboard' | 'data-sources';
|
||||||
function Header({
|
function Header({
|
||||||
activePage,
|
activePage,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
|
theme,
|
||||||
|
onToggleTheme,
|
||||||
}: {
|
}: {
|
||||||
activePage: Page;
|
activePage: Page;
|
||||||
onPageChange: (page: Page) => void;
|
onPageChange: (page: Page) => void;
|
||||||
|
theme: Theme;
|
||||||
|
onToggleTheme: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
|
@ -240,7 +246,23 @@ function Header({
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
{activePage === 'dashboard' && (
|
<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
|
<button
|
||||||
onClick={handleShare}
|
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"
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
|
||||||
|
|
@ -271,7 +293,8 @@ function Header({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -335,6 +358,28 @@ export default function App() {
|
||||||
const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties'>(urlState.tab || 'pois');
|
const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties'>(urlState.tab || 'pois');
|
||||||
const [activePage, setActivePage] = useState<Page>('home');
|
const [activePage, setActivePage] = useState<Page>('home');
|
||||||
|
|
||||||
|
// 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';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
}
|
||||||
|
localStorage.setItem('theme', theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const toggleTheme = useCallback(() => {
|
||||||
|
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Derive enabled features from filter keys
|
// Derive enabled features from filter keys
|
||||||
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
|
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
|
||||||
|
|
||||||
|
|
@ -704,9 +749,9 @@ export default function App() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col">
|
<div className="h-screen flex flex-col">
|
||||||
<Header activePage={activePage} onPageChange={setActivePage} />
|
<Header activePage={activePage} onPageChange={setActivePage} theme={theme} onToggleTheme={toggleTheme} />
|
||||||
{activePage === 'home' ? (
|
{activePage === 'home' ? (
|
||||||
<HomePage onOpenDashboard={() => setActivePage('dashboard')} />
|
<HomePage onOpenDashboard={() => setActivePage('dashboard')} theme={theme} />
|
||||||
) : activePage === 'data-sources' ? (
|
) : activePage === 'data-sources' ? (
|
||||||
<DataSourcesPage />
|
<DataSourcesPage />
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -742,22 +787,23 @@ export default function App() {
|
||||||
selectedHexagonId={selectedHexagon?.h3 || null}
|
selectedHexagonId={selectedHexagon?.h3 || null}
|
||||||
onHexagonClick={handleHexagonClick}
|
onHexagonClick={handleHexagonClick}
|
||||||
initialViewState={initialViewState}
|
initialViewState={initialViewState}
|
||||||
|
theme={theme}
|
||||||
/>
|
/>
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="absolute top-4 right-4 bg-white px-3 py-1 rounded shadow">
|
<div className="absolute top-4 right-4 bg-white dark:bg-warm-800 dark:text-warm-200 px-3 py-1 rounded shadow">
|
||||||
Loading...
|
Loading...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<DataSources onNavigate={() => setActivePage('data-sources')} />
|
<DataSources onNavigate={() => setActivePage('data-sources')} />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-72 bg-white shadow-lg z-10 flex flex-col">
|
<div className="w-72 bg-white dark:bg-warm-900 shadow-lg z-10 flex flex-col">
|
||||||
{/* Tab headers */}
|
{/* Tab headers */}
|
||||||
<div className="flex border-b border-warm-200">
|
<div className="flex border-b border-warm-200 dark:border-warm-700">
|
||||||
<button
|
<button
|
||||||
className={`flex-1 p-3 ${
|
className={`flex-1 p-3 ${
|
||||||
rightPaneTab === 'pois'
|
rightPaneTab === 'pois'
|
||||||
? 'border-b-2 border-teal-500 font-semibold'
|
? 'border-b-2 border-teal-500 font-semibold dark:text-warm-100'
|
||||||
: 'text-warm-600'
|
: 'text-warm-600 dark:text-warm-400'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setRightPaneTab('pois')}
|
onClick={() => setRightPaneTab('pois')}
|
||||||
>
|
>
|
||||||
|
|
@ -766,8 +812,8 @@ export default function App() {
|
||||||
<button
|
<button
|
||||||
className={`flex-1 p-3 ${
|
className={`flex-1 p-3 ${
|
||||||
rightPaneTab === 'properties'
|
rightPaneTab === 'properties'
|
||||||
? 'border-b-2 border-teal-500 font-semibold'
|
? 'border-b-2 border-teal-500 font-semibold dark:text-warm-100'
|
||||||
: 'text-warm-600'
|
: 'text-warm-600 dark:text-warm-400'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setRightPaneTab('properties')}
|
onClick={() => setRightPaneTab('properties')}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ export default function DataSources({ onNavigate }: { onNavigate: () => void })
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onNavigate}
|
onClick={onNavigate}
|
||||||
className="absolute bottom-2 right-2 bg-white/90 backdrop-blur-sm px-3 py-2 rounded shadow-lg text-xs text-teal-600 hover:text-teal-800 hover:underline font-semibold transition-colors"
|
className="absolute bottom-2 right-2 bg-white/90 dark:bg-warm-800/90 backdrop-blur-sm px-3 py-2 rounded shadow-lg text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline font-semibold transition-colors"
|
||||||
>
|
>
|
||||||
Data Sources
|
Data Sources
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -87,30 +87,30 @@ const DATA_SOURCES = [
|
||||||
|
|
||||||
export default function DataSourcesPage() {
|
export default function DataSourcesPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto bg-warm-50 flex flex-col">
|
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-warm-900 flex flex-col">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="max-w-5xl mx-auto px-6 py-8">
|
<div className="max-w-5xl mx-auto px-6 py-8">
|
||||||
<h1 className="text-2xl font-bold text-warm-900 mb-2">Data Sources</h1>
|
<h1 className="text-2xl font-bold text-warm-900 dark:text-warm-100 mb-2">Data Sources</h1>
|
||||||
<p className="text-warm-600 mb-6">
|
<p className="text-warm-600 dark:text-warm-400 mb-6">
|
||||||
This application combines {DATA_SOURCES.length} open datasets covering property prices,
|
This application combines {DATA_SOURCES.length} open datasets covering property prices,
|
||||||
energy performance, transport, demographics, crime, environment, and more.
|
energy performance, transport, demographics, crime, environment, and more.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
{DATA_SOURCES.map((source) => (
|
{DATA_SOURCES.map((source) => (
|
||||||
<div key={source.name} className="bg-white rounded-lg border border-warm-200 p-5">
|
<div key={source.name} className="bg-white dark:bg-warm-800 rounded-lg border border-warm-200 dark:border-warm-700 p-5">
|
||||||
<div className="flex items-start justify-between gap-4 mb-2">
|
<div className="flex items-start justify-between gap-4 mb-2">
|
||||||
<h2 className="text-lg font-semibold text-warm-900">{source.name}</h2>
|
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">{source.name}</h2>
|
||||||
<span className="shrink-0 text-xs bg-warm-100 text-warm-600 px-2 py-1 rounded">
|
<span className="shrink-0 text-xs bg-warm-100 dark:bg-warm-700 text-warm-600 dark:text-warm-300 px-2 py-1 rounded">
|
||||||
{source.license}
|
{source.license}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-warm-500 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 mb-3">{source.use}</p>
|
<p className="text-sm text-warm-700 dark:text-warm-300 mb-3">{source.use}</p>
|
||||||
<a
|
<a
|
||||||
href={source.url}
|
href={source.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-sm text-teal-600 hover:text-teal-800 hover:underline break-all"
|
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline break-all"
|
||||||
>
|
>
|
||||||
{source.url}
|
{source.url}
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -66,22 +66,22 @@ function FilterDropdown({
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="relative">
|
<div ref={ref} className="relative">
|
||||||
<button
|
<button
|
||||||
className="w-full p-2 border rounded text-sm bg-white text-left text-warm-500 hover:border-warm-400"
|
className="w-full p-2 border rounded text-sm bg-white dark:bg-warm-800 dark:border-warm-700 text-left text-warm-500 dark:text-warm-400 hover:border-warm-400"
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
>
|
>
|
||||||
+ Add filter...
|
+ Add filter...
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute z-50 mt-1 w-full bg-white border rounded shadow-lg max-h-80 overflow-y-auto">
|
<div className="absolute z-50 mt-1 w-full bg-white dark:bg-warm-800 border dark:border-warm-700 rounded shadow-lg max-h-80 overflow-y-auto">
|
||||||
{grouped.map((group) => (
|
{grouped.map((group) => (
|
||||||
<div key={group.name}>
|
<div key={group.name}>
|
||||||
<div className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 sticky top-0">
|
<div className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0">
|
||||||
{group.name}
|
{group.name}
|
||||||
</div>
|
</div>
|
||||||
{group.features.map((f) => (
|
{group.features.map((f) => (
|
||||||
<button
|
<button
|
||||||
key={f.name}
|
key={f.name}
|
||||||
className="w-full text-left px-3 py-1.5 text-sm hover:bg-teal-50 hover:text-teal-700"
|
className="w-full text-left px-3 py-1.5 text-sm hover:bg-teal-50 dark:hover:bg-teal-900/30 hover:text-teal-700 dark:hover:text-teal-400 dark:text-warm-300"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onAddFilter(f.name);
|
onAddFilter(f.name);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|
@ -126,8 +126,8 @@ export default memo(function Filters({
|
||||||
const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name));
|
const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-72 p-4 bg-white shadow-lg space-y-4 overflow-y-auto">
|
<div className="w-72 p-4 bg-white dark:bg-warm-900 shadow-lg space-y-4 overflow-y-auto">
|
||||||
<div className="text-sm text-warm-500">Zoom: {zoom.toFixed(1)}</div>
|
<div className="text-sm text-warm-500 dark:text-warm-400">Zoom: {zoom.toFixed(1)}</div>
|
||||||
|
|
||||||
{/* Add filter dropdown */}
|
{/* Add filter dropdown */}
|
||||||
{availableFeatures.length > 0 && (
|
{availableFeatures.length > 0 && (
|
||||||
|
|
@ -145,7 +145,7 @@ export default memo(function Filters({
|
||||||
<Label className="text-xs">{feature.name}</Label>
|
<Label className="text-xs">{feature.name}</Label>
|
||||||
<button
|
<button
|
||||||
onClick={() => onRemoveFilter(feature.name)}
|
onClick={() => onRemoveFilter(feature.name)}
|
||||||
className="text-warm-400 hover:text-warm-700 text-sm px-1"
|
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 text-sm px-1"
|
||||||
title="Remove filter"
|
title="Remove filter"
|
||||||
>
|
>
|
||||||
x
|
x
|
||||||
|
|
@ -153,13 +153,13 @@ export default memo(function Filters({
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 text-xs mb-1">
|
<div className="flex gap-2 text-xs mb-1">
|
||||||
<button
|
<button
|
||||||
className="text-teal-600 hover:underline"
|
className="text-teal-600 dark:text-teal-400 hover:underline"
|
||||||
onClick={() => onFilterChange(feature.name, [...allValues])}
|
onClick={() => onFilterChange(feature.name, [...allValues])}
|
||||||
>
|
>
|
||||||
All
|
All
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="text-teal-600 hover:underline"
|
className="text-teal-600 dark:text-teal-400 hover:underline"
|
||||||
onClick={() => onFilterChange(feature.name, [])}
|
onClick={() => onFilterChange(feature.name, [])}
|
||||||
>
|
>
|
||||||
None
|
None
|
||||||
|
|
@ -167,7 +167,7 @@ export default memo(function Filters({
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5 max-h-40 overflow-y-auto">
|
<div className="space-y-0.5 max-h-40 overflow-y-auto">
|
||||||
{allValues.map((val) => (
|
{allValues.map((val) => (
|
||||||
<label key={val} className="flex items-center gap-1.5 text-xs cursor-pointer">
|
<label key={val} className="flex items-center gap-1.5 text-xs cursor-pointer dark:text-warm-300">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedValues.includes(val)}
|
checked={selectedValues.includes(val)}
|
||||||
|
|
@ -177,7 +177,7 @@ export default memo(function Filters({
|
||||||
: [...selectedValues, val];
|
: [...selectedValues, val];
|
||||||
onFilterChange(feature.name, next);
|
onFilterChange(feature.name, next);
|
||||||
}}
|
}}
|
||||||
className="rounded"
|
className="rounded accent-teal-600"
|
||||||
/>
|
/>
|
||||||
{val}
|
{val}
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -199,7 +199,7 @@ export default memo(function Filters({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={feature.name}
|
key={feature.name}
|
||||||
className={`space-y-1 p-2 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50' : ''}`}
|
className={`space-y-1 p-2 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs">
|
<Label className="text-xs">
|
||||||
|
|
@ -208,7 +208,7 @@ export default memo(function Filters({
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => onTogglePin(feature.name)}
|
onClick={() => onTogglePin(feature.name)}
|
||||||
className={`p-0.5 rounded ${isPinned ? 'text-teal-600' : 'text-warm-400 hover:text-warm-700'}`}
|
className={`p-0.5 rounded ${isPinned ? 'text-teal-600 dark:text-teal-400' : 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'}`}
|
||||||
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
|
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -224,7 +224,7 @@ export default memo(function Filters({
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onRemoveFilter(feature.name)}
|
onClick={() => onRemoveFilter(feature.name)}
|
||||||
className="text-warm-400 hover:text-warm-700 text-sm px-1"
|
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 text-sm px-1"
|
||||||
title="Remove filter"
|
title="Remove filter"
|
||||||
>
|
>
|
||||||
x
|
x
|
||||||
|
|
|
||||||
|
|
@ -44,12 +44,14 @@ function drawHex(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: numbe
|
||||||
ctx.closePath();
|
ctx.closePath();
|
||||||
}
|
}
|
||||||
|
|
||||||
function HexCanvas({ scrollProgress }: { scrollProgress: number }) {
|
function HexCanvas({ scrollProgress, isDark = false }: { scrollProgress: number; isDark?: boolean }) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const hexesRef = useRef<Hex[]>([]);
|
const hexesRef = useRef<Hex[]>([]);
|
||||||
const animRef = useRef(0);
|
const animRef = useRef(0);
|
||||||
const scrollRef = useRef(scrollProgress);
|
const scrollRef = useRef(scrollProgress);
|
||||||
scrollRef.current = scrollProgress;
|
scrollRef.current = scrollProgress;
|
||||||
|
const isDarkRef = useRef(isDark);
|
||||||
|
isDarkRef.current = isDark;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
|
|
@ -100,13 +102,14 @@ function HexCanvas({ scrollProgress }: { scrollProgress: number }) {
|
||||||
if (hex.y < -hex.size * 2) hex.y += h + hex.size * 4;
|
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;
|
if (hex.y > h + hex.size * 2) hex.y -= h + hex.size * 4;
|
||||||
|
|
||||||
ctx!.globalAlpha = hex.opacity * globalAlpha;
|
const dark = isDarkRef.current;
|
||||||
ctx!.fillStyle = '#00a28c';
|
ctx!.globalAlpha = hex.opacity * globalAlpha * (dark ? 0.6 : 1);
|
||||||
|
ctx!.fillStyle = dark ? '#058172' : '#00a28c';
|
||||||
drawHex(ctx!, hex.x, hex.y, hex.size);
|
drawHex(ctx!, hex.x, hex.y, hex.size);
|
||||||
ctx!.fill();
|
ctx!.fill();
|
||||||
|
|
||||||
ctx!.globalAlpha = hex.opacity * 0.5 * globalAlpha;
|
ctx!.globalAlpha = hex.opacity * 0.5 * globalAlpha * (dark ? 0.6 : 1);
|
||||||
ctx!.strokeStyle = '#05c9aa';
|
ctx!.strokeStyle = dark ? '#0a665b' : '#05c9aa';
|
||||||
ctx!.lineWidth = 1;
|
ctx!.lineWidth = 1;
|
||||||
drawHex(ctx!, hex.x, hex.y, hex.size);
|
drawHex(ctx!, hex.x, hex.y, hex.size);
|
||||||
ctx!.stroke();
|
ctx!.stroke();
|
||||||
|
|
@ -155,7 +158,7 @@ function useFadeInRef() {
|
||||||
|
|
||||||
// --- Page ---
|
// --- Page ---
|
||||||
|
|
||||||
export default function HomePage({ onOpenDashboard }: { onOpenDashboard: () => void }) {
|
export default function HomePage({ onOpenDashboard, theme = 'light' }: { onOpenDashboard: () => void; theme?: 'light' | 'dark' }) {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const [scrollProgress, setScrollProgress] = useState(0);
|
const [scrollProgress, setScrollProgress] = useState(0);
|
||||||
|
|
||||||
|
|
@ -182,27 +185,27 @@ export default function HomePage({ onOpenDashboard }: { onOpenDashboard: () => v
|
||||||
const ctaRef = useFadeInRef();
|
const ctaRef = useFadeInRef();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={scrollRef} className="flex-1 overflow-y-auto bg-warm-50 relative">
|
<div ref={scrollRef} className="flex-1 overflow-y-auto bg-warm-50 dark:bg-warm-900 relative">
|
||||||
<HexCanvas scrollProgress={scrollProgress} />
|
<HexCanvas scrollProgress={scrollProgress} isDark={theme === 'dark'} />
|
||||||
|
|
||||||
<div className="relative" style={{ zIndex: 1 }}>
|
<div className="relative" style={{ zIndex: 1 }}>
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<div className="max-w-3xl mx-auto px-6 pt-20 pb-24">
|
<div className="max-w-3xl mx-auto px-6 pt-20 pb-24">
|
||||||
<div
|
<div
|
||||||
ref={heroRef}
|
ref={heroRef}
|
||||||
className="fade-in-section backdrop-blur-sm bg-warm-50/60 rounded-2xl p-8 -mx-2"
|
className="fade-in-section backdrop-blur-sm bg-warm-50/60 dark:bg-warm-900/60 rounded-2xl p-8 -mx-2"
|
||||||
>
|
>
|
||||||
<p className="text-teal-600 font-semibold tracking-wide uppercase text-sm mb-4">
|
<p className="text-teal-600 font-semibold tracking-wide uppercase text-sm mb-4">
|
||||||
Find where to live, not just what's for sale
|
Find where to live, not just what's for sale
|
||||||
</p>
|
</p>
|
||||||
<h1 className="text-5xl font-extrabold text-navy-950 mb-6 leading-[1.1] tracking-tight">
|
<h1 className="text-5xl font-extrabold text-navy-950 dark:text-warm-100 mb-6 leading-[1.1] tracking-tight">
|
||||||
Every neighbourhood
|
Every neighbourhood
|
||||||
<br />
|
<br />
|
||||||
in England & Wales.
|
in England & Wales.
|
||||||
<br />
|
<br />
|
||||||
<span className="text-teal-600">One map. Your rules.</span>
|
<span className="text-teal-600">One map. Your rules.</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-warm-600 mb-8 leading-relaxed max-w-xl">
|
<p className="text-xl text-warm-600 dark:text-warm-400 mb-8 leading-relaxed max-w-xl">
|
||||||
Set the commute, budget, school rating, noise level, and crime threshold you'll
|
Set the commute, budget, school rating, noise level, and crime threshold you'll
|
||||||
accept. Narrowit shows you every area that qualifies — instantly.
|
accept. Narrowit shows you every area that qualifies — instantly.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -223,13 +226,13 @@ export default function HomePage({ onOpenDashboard }: { onOpenDashboard: () => v
|
||||||
{/* The flip */}
|
{/* The flip */}
|
||||||
<div className="max-w-3xl mx-auto px-6 pb-20">
|
<div className="max-w-3xl mx-auto px-6 pb-20">
|
||||||
<div ref={problemRef} className="fade-in-section">
|
<div ref={problemRef} className="fade-in-section">
|
||||||
<div className="rounded-2xl backdrop-blur-sm bg-warm-50/40 border border-warm-200/50 p-8">
|
<div className="rounded-2xl backdrop-blur-sm bg-warm-50/40 dark:bg-warm-800/40 border border-warm-200/50 dark:border-warm-700/50 p-8">
|
||||||
<div className="grid md:grid-cols-2 gap-8">
|
<div className="grid md:grid-cols-2 gap-8">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-warm-400 uppercase tracking-wide mb-2">
|
<h3 className="text-sm font-semibold text-warm-400 uppercase tracking-wide mb-2">
|
||||||
The old way
|
The old way
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-warm-700 leading-relaxed">
|
<p className="text-warm-700 dark:text-warm-300 leading-relaxed">
|
||||||
Pick a postcode. Google the schools. Check crime stats on another site. Look up
|
Pick a postcode. Google the schools. Check crime stats on another site. Look up
|
||||||
commute times. Realise it's too expensive. Start over. Repeat 40 times.
|
commute times. Realise it's too expensive. Start over. Repeat 40 times.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -238,7 +241,7 @@ export default function HomePage({ onOpenDashboard }: { onOpenDashboard: () => v
|
||||||
<h3 className="text-sm font-semibold text-teal-600 uppercase tracking-wide mb-2">
|
<h3 className="text-sm font-semibold text-teal-600 uppercase tracking-wide mb-2">
|
||||||
With Narrowit
|
With Narrowit
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-warm-700 leading-relaxed">
|
<p className="text-warm-700 dark:text-warm-300 leading-relaxed">
|
||||||
Tell the map what you need. Every hexagon that lights up is a place worth
|
Tell the map what you need. Every hexagon that lights up is a place worth
|
||||||
looking at. Drill into any one to see individual properties, prices, and energy
|
looking at. Drill into any one to see individual properties, prices, and energy
|
||||||
ratings.
|
ratings.
|
||||||
|
|
@ -252,21 +255,21 @@ export default function HomePage({ onOpenDashboard }: { onOpenDashboard: () => v
|
||||||
{/* Filter showcase */}
|
{/* Filter showcase */}
|
||||||
<div className="max-w-4xl mx-auto px-6 pb-20">
|
<div className="max-w-4xl mx-auto px-6 pb-20">
|
||||||
<div ref={filtersRef} className="fade-in-section">
|
<div ref={filtersRef} className="fade-in-section">
|
||||||
<h2 className="text-3xl font-bold text-navy-950 mb-2 text-center">
|
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-2 text-center">
|
||||||
12 datasets. One slider each.
|
12 datasets. One slider each.
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-warm-500 text-center mb-10 max-w-lg mx-auto">
|
<p className="text-warm-500 dark:text-warm-400 text-center mb-10 max-w-lg mx-auto">
|
||||||
Every filter narrows the map in real time. Combine as many as you like.
|
Every filter narrows the map in real time. Combine as many as you like.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
{FILTERS.map((f) => (
|
{FILTERS.map((f) => (
|
||||||
<div
|
<div
|
||||||
key={f.label}
|
key={f.label}
|
||||||
className="rounded-xl bg-white border border-warm-200 p-4 shadow-sm hover:shadow-md hover:border-teal-300 transition-all"
|
className="rounded-xl bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-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="text-2xl mb-2">{f.icon}</div>
|
||||||
<div className="font-semibold text-navy-950 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 mt-0.5">{f.example}</div>
|
<div className="text-xs text-warm-500 dark:text-warm-400 mt-0.5">{f.example}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -276,7 +279,7 @@ export default function HomePage({ onOpenDashboard }: { onOpenDashboard: () => v
|
||||||
{/* How it works */}
|
{/* How it works */}
|
||||||
<div className="max-w-3xl mx-auto px-6 pb-20">
|
<div className="max-w-3xl mx-auto px-6 pb-20">
|
||||||
<div ref={howRef} className="fade-in-section">
|
<div ref={howRef} className="fade-in-section">
|
||||||
<h2 className="text-3xl font-bold text-navy-950 mb-10 text-center">
|
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-10 text-center">
|
||||||
Three clicks to clarity
|
Three clicks to clarity
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -286,8 +289,8 @@ export default function HomePage({ onOpenDashboard }: { onOpenDashboard: () => v
|
||||||
{i + 1}
|
{i + 1}
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-navy-950 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 mt-0.5">{step.body}</p>
|
<p className="text-warm-600 dark:text-warm-400 mt-0.5">{step.body}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -302,7 +305,7 @@ export default function HomePage({ onOpenDashboard }: { onOpenDashboard: () => v
|
||||||
{STATS.map((s) => (
|
{STATS.map((s) => (
|
||||||
<div key={s.label}>
|
<div key={s.label}>
|
||||||
<div className="text-3xl font-extrabold text-teal-600">{s.value}</div>
|
<div className="text-3xl font-extrabold text-teal-600">{s.value}</div>
|
||||||
<div className="text-sm text-warm-500 mt-1">{s.label}</div>
|
<div className="text-sm text-warm-500 dark:text-warm-400 mt-1">{s.label}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -312,8 +315,8 @@ export default function HomePage({ onOpenDashboard }: { onOpenDashboard: () => v
|
||||||
{/* Final CTA */}
|
{/* Final CTA */}
|
||||||
<div className="max-w-3xl mx-auto px-6 pb-24">
|
<div className="max-w-3xl mx-auto px-6 pb-24">
|
||||||
<div ref={ctaRef} className="fade-in-section text-center">
|
<div ref={ctaRef} className="fade-in-section text-center">
|
||||||
<h2 className="text-3xl font-bold text-navy-950 mb-3">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 mb-8 max-w-md mx-auto">
|
<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.
|
100% open data. No account required. Just set your filters and go.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ interface MapProps {
|
||||||
selectedHexagonId: string | null;
|
selectedHexagonId: string | null;
|
||||||
onHexagonClick: (h3: string) => void;
|
onHexagonClick: (h3: string) => void;
|
||||||
initialViewState?: ViewState;
|
initialViewState?: ViewState;
|
||||||
|
theme?: 'light' | 'dark';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Twemoji CDN base URL
|
// Twemoji CDN base URL
|
||||||
|
|
@ -42,7 +43,8 @@ const INITIAL_VIEW: ViewState = {
|
||||||
pitch: 0,
|
pitch: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json';
|
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
|
// Gradient stops for normalized [0,1] values
|
||||||
const GRADIENT: { t: number; color: [number, number, number] }[] = [
|
const GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||||
|
|
@ -204,7 +206,7 @@ function PostcodeSearch({
|
||||||
setError(null);
|
setError(null);
|
||||||
}}
|
}}
|
||||||
placeholder="Search postcode..."
|
placeholder="Search postcode..."
|
||||||
className="px-3 py-2 text-sm w-40 border-none outline-none bg-white"
|
className="px-3 py-2 text-sm w-40 border-none outline-none bg-white dark:bg-warm-800 dark:text-warm-100 dark:placeholder-warm-500"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
@ -215,7 +217,7 @@ function PostcodeSearch({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<span className="text-xs text-red-600 bg-white/90 rounded px-2 py-0.5 shadow">{error}</span>
|
<span className="text-xs text-red-600 dark:text-red-400 bg-white/90 dark:bg-warm-800/90 rounded px-2 py-0.5 shadow">{error}</span>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|
@ -240,7 +242,7 @@ function MapLegend({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute top-3 right-3 z-10 bg-white rounded shadow-lg p-3 text-xs min-w-[160px]">
|
<div className="absolute top-3 right-3 z-10 bg-white dark:bg-warm-800 dark:text-warm-200 rounded shadow-lg p-3 text-xs min-w-[160px]">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="font-semibold text-sm">{featureLabel}</span>
|
<span className="font-semibold text-sm">{featureLabel}</span>
|
||||||
{showCancel && (
|
{showCancel && (
|
||||||
|
|
@ -268,7 +270,7 @@ function MapLegend({
|
||||||
'linear-gradient(to right, rgb(46, 204, 113), rgb(241, 196, 15), rgb(231, 76, 60), rgb(142, 68, 173))',
|
'linear-gradient(to right, rgb(46, 204, 113), rgb(241, 196, 15), rgb(231, 76, 60), rgb(142, 68, 173))',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between mt-1 text-warm-600">
|
<div className="flex justify-between mt-1 text-warm-600 dark:text-warm-400">
|
||||||
<span>{formatVal(range[0])}</span>
|
<span>{formatVal(range[0])}</span>
|
||||||
<span>{formatVal(range[1])}</span>
|
<span>{formatVal(range[1])}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -289,6 +291,7 @@ export default memo(function Map({
|
||||||
selectedHexagonId,
|
selectedHexagonId,
|
||||||
onHexagonClick,
|
onHexagonClick,
|
||||||
initialViewState,
|
initialViewState,
|
||||||
|
theme = 'light',
|
||||||
}: MapProps) {
|
}: MapProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW);
|
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW);
|
||||||
|
|
@ -343,28 +346,39 @@ export default memo(function Map({
|
||||||
setViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom }));
|
setViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const themeRef = useRef(theme);
|
||||||
|
themeRef.current = theme;
|
||||||
|
|
||||||
// Make place labels more legible over the colored hexagons
|
// Make place labels more legible over the colored hexagons
|
||||||
const handleMapLoad = useCallback(
|
const handleMapLoad = useCallback(
|
||||||
(evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
|
(evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
|
||||||
const map = evt.target;
|
const map = evt.target;
|
||||||
for (const layer of map.getStyle().layers || []) {
|
if (themeRef.current === 'light') {
|
||||||
if (layer.type !== 'symbol') continue;
|
for (const layer of map.getStyle().layers || []) {
|
||||||
map.setPaintProperty(layer.id, 'text-halo-color', 'rgba(255,255,255,1)');
|
if (layer.type !== 'symbol') continue;
|
||||||
map.setPaintProperty(layer.id, 'text-halo-width', 2);
|
map.setPaintProperty(layer.id, 'text-halo-color', 'rgba(255,255,255,1)');
|
||||||
map.setPaintProperty(layer.id, 'text-color', '#222');
|
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 || []) {
|
// Make water more prominent
|
||||||
if (layer.id === 'water' || layer.id.startsWith('water')) {
|
for (const layer of map.getStyle().layers || []) {
|
||||||
map.setPaintProperty(layer.id, 'fill-color', '#6baed6');
|
if (layer.id === 'water' || layer.id.startsWith('water')) {
|
||||||
|
map.setPaintProperty(layer.id, 'fill-color', '#6baed6');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
map.setLayoutProperty('building', 'visibility', 'none');
|
try {
|
||||||
map.setLayoutProperty('building-top', 'visibility', 'none');
|
map.setLayoutProperty('building', 'visibility', 'none');
|
||||||
|
map.setLayoutProperty('building-top', 'visibility', 'none');
|
||||||
|
} catch {
|
||||||
|
// layers may not exist in dark style
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const mapStyle = theme === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT;
|
||||||
|
|
||||||
// Popup state for POI hover
|
// Popup state for POI hover
|
||||||
const [popupInfo, setPopupInfo] = useState<{
|
const [popupInfo, setPopupInfo] = useState<{
|
||||||
x: number;
|
x: number;
|
||||||
|
|
@ -541,20 +555,20 @@ export default memo(function Map({
|
||||||
getPosition: (d) => [d.lon as number, d.lat as number],
|
getPosition: (d) => [d.lon as number, d.lat as number],
|
||||||
getText: (d) => d.postcode as string,
|
getText: (d) => d.postcode as string,
|
||||||
getSize: 11,
|
getSize: 11,
|
||||||
getColor: [30, 30, 30, 220],
|
getColor: theme === 'dark' ? [220, 220, 220, 220] : [30, 30, 30, 220],
|
||||||
getTextAnchor: 'middle',
|
getTextAnchor: 'middle',
|
||||||
getAlignmentBaseline: 'center',
|
getAlignmentBaseline: 'center',
|
||||||
fontFamily: 'Inter, system-ui, sans-serif',
|
fontFamily: 'Inter, system-ui, sans-serif',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
outlineWidth: 2,
|
outlineWidth: 2,
|
||||||
outlineColor: [255, 255, 255, 200],
|
outlineColor: theme === 'dark' ? [30, 30, 30, 200] : [255, 255, 255, 200],
|
||||||
billboard: false,
|
billboard: false,
|
||||||
sizeUnits: 'pixels',
|
sizeUnits: 'pixels',
|
||||||
sizeMinPixels: 10,
|
sizeMinPixels: 10,
|
||||||
sizeMaxPixels: 14,
|
sizeMaxPixels: 14,
|
||||||
})
|
})
|
||||||
: null,
|
: null,
|
||||||
[postcodeData, showPostcodes]
|
[postcodeData, showPostcodes, theme]
|
||||||
);
|
);
|
||||||
|
|
||||||
const layers = useMemo(
|
const layers = useMemo(
|
||||||
|
|
@ -593,12 +607,14 @@ export default memo(function Map({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isDark = themeRef.current === 'dark';
|
||||||
return {
|
return {
|
||||||
html: `<div style="padding: 8px; font-size: 12px;">${lines.join('')}</div>`,
|
html: `<div style="padding: 8px; font-size: 12px;">${lines.join('')}</div>`,
|
||||||
style: {
|
style: {
|
||||||
backgroundColor: 'white',
|
backgroundColor: isDark ? '#292524' : 'white',
|
||||||
|
color: isDark ? '#e7e5e4' : 'inherit',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
|
boxShadow: isDark ? '0 2px 4px rgba(0,0,0,0.5)' : '0 2px 4px rgba(0,0,0,0.2)',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
@ -611,9 +627,14 @@ export default memo(function Map({
|
||||||
{...viewState}
|
{...viewState}
|
||||||
onMove={handleMove}
|
onMove={handleMove}
|
||||||
onLoad={handleMapLoad as never}
|
onLoad={handleMapLoad as never}
|
||||||
mapStyle={MAP_STYLE}
|
mapStyle={mapStyle}
|
||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
|
dragRotate={false}
|
||||||
|
touchZoomRotate={true}
|
||||||
|
touchPitch={false}
|
||||||
|
keyboard={true}
|
||||||
|
pitchWithRotate={false}
|
||||||
>
|
>
|
||||||
<DeckOverlay layers={layers} getTooltip={getTooltip as never} />
|
<DeckOverlay layers={layers} getTooltip={getTooltip as never} />
|
||||||
</MapGL>
|
</MapGL>
|
||||||
|
|
@ -628,7 +649,7 @@ export default memo(function Map({
|
||||||
)}
|
)}
|
||||||
{popupInfo && (
|
{popupInfo && (
|
||||||
<div
|
<div
|
||||||
className="absolute pointer-events-none bg-white rounded shadow-lg p-2 text-sm"
|
className="absolute pointer-events-none bg-white dark:bg-warm-800 rounded shadow-lg p-2 text-sm dark:text-warm-200"
|
||||||
style={{
|
style={{
|
||||||
left: popupInfo.x,
|
left: popupInfo.x,
|
||||||
top: popupInfo.y - 40,
|
top: popupInfo.y - 40,
|
||||||
|
|
@ -637,7 +658,7 @@ export default memo(function Map({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<strong>{popupInfo.name}</strong>
|
<strong>{popupInfo.name}</strong>
|
||||||
<div className="text-gray-500 text-xs">{popupInfo.category}</div>
|
<div className="text-gray-500 dark:text-warm-400 text-xs">{popupInfo.category}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -95,13 +95,13 @@ export default function POIPane({
|
||||||
const selectedCount = selectedCategories.size;
|
const selectedCount = selectedCategories.size;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-72 p-4 bg-white shadow-lg space-y-4 overflow-y-auto max-h-screen">
|
<div className="w-72 p-4 bg-white dark:bg-warm-900 shadow-lg space-y-4 overflow-y-auto max-h-screen">
|
||||||
<h2 className="text-xl font-bold">Points of Interest</h2>
|
<h2 className="text-xl font-bold dark:text-warm-100">Points of Interest</h2>
|
||||||
|
|
||||||
<div className="space-y-2" ref={dropdownRef}>
|
<div className="space-y-2" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||||
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-warm-300 rounded hover:border-warm-400 bg-white"
|
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-warm-300 dark:border-warm-700 rounded hover:border-warm-400 bg-white dark:bg-warm-800 dark:text-warm-200"
|
||||||
>
|
>
|
||||||
<span className="truncate text-left">
|
<span className="truncate text-left">
|
||||||
{selectedCount === 0
|
{selectedCount === 0
|
||||||
|
|
@ -121,23 +121,23 @@ export default function POIPane({
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{dropdownOpen && (
|
{dropdownOpen && (
|
||||||
<div className="border border-warm-300 rounded shadow-lg bg-white">
|
<div className="border border-warm-300 dark:border-warm-700 rounded shadow-lg bg-white dark:bg-warm-800">
|
||||||
<div className="flex gap-2 px-3 py-2 border-b border-warm-200">
|
<div className="flex gap-2 px-3 py-2 border-b border-warm-200 dark:border-warm-700">
|
||||||
<button onClick={selectAll} className="text-xs text-teal-600 hover:text-teal-800">
|
<button onClick={selectAll} className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300">
|
||||||
All
|
All
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs text-warm-300">|</span>
|
<span className="text-xs text-warm-300 dark:text-warm-600">|</span>
|
||||||
<button onClick={selectNone} className="text-xs text-teal-600 hover:text-teal-800">
|
<button onClick={selectNone} className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300">
|
||||||
None
|
None
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-2 border-b border-warm-200">
|
<div className="px-3 py-2 border-b border-warm-200 dark:border-warm-700">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search categories..."
|
placeholder="Search categories..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="w-full px-2 py-1 text-sm border border-warm-300 rounded"
|
className="w-full px-2 py-1 text-sm border border-warm-300 dark:border-warm-700 rounded bg-white dark:bg-warm-900 dark:text-warm-200 dark:placeholder-warm-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-96 overflow-y-auto py-1">
|
<div className="max-h-96 overflow-y-auto py-1">
|
||||||
|
|
@ -151,7 +151,7 @@ export default function POIPane({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={group.name}>
|
<div key={group.name}>
|
||||||
<div className="flex items-center gap-1 px-3 py-1.5 bg-warm-50 border-y border-warm-100">
|
<div className="flex items-center gap-1 px-3 py-1.5 bg-warm-50 dark:bg-warm-900 border-y border-warm-100 dark:border-warm-700">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleCollapse(group.name)}
|
onClick={() => toggleCollapse(group.name)}
|
||||||
className="p-0.5 text-warm-400 hover:text-warm-600"
|
className="p-0.5 text-warm-400 hover:text-warm-600"
|
||||||
|
|
@ -178,9 +178,9 @@ export default function POIPane({
|
||||||
if (el) el.indeterminate = someInGroupSelected;
|
if (el) el.indeterminate = someInGroupSelected;
|
||||||
}}
|
}}
|
||||||
onChange={() => toggleGroup(group.name)}
|
onChange={() => toggleGroup(group.name)}
|
||||||
className="rounded"
|
className="rounded accent-teal-600"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs font-semibold text-warm-700">{group.name}</span>
|
<span className="text-xs font-semibold text-warm-700 dark:text-warm-300">{group.name}</span>
|
||||||
</label>
|
</label>
|
||||||
<span className="text-xs text-warm-400">
|
<span className="text-xs text-warm-400">
|
||||||
{groupSelected}/{group.categories.length}
|
{groupSelected}/{group.categories.length}
|
||||||
|
|
@ -190,13 +190,13 @@ export default function POIPane({
|
||||||
group.categories.map((category) => (
|
group.categories.map((category) => (
|
||||||
<label
|
<label
|
||||||
key={category}
|
key={category}
|
||||||
className="flex items-center gap-2 px-3 pl-8 py-1.5 hover:bg-warm-50 cursor-pointer"
|
className="flex items-center gap-2 px-3 pl-8 py-1.5 hover:bg-warm-50 dark:hover:bg-warm-700 cursor-pointer dark:text-warm-300"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedCategories.has(category)}
|
checked={selectedCategories.has(category)}
|
||||||
onChange={() => toggleCategory(category)}
|
onChange={() => toggleCategory(category)}
|
||||||
className="rounded"
|
className="rounded accent-teal-600"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm flex-1">{category}</span>
|
<span className="text-sm flex-1">{category}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -210,17 +210,17 @@ export default function POIPane({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedCount > 0 && (
|
{selectedCount > 0 && (
|
||||||
<div className="p-3 bg-teal-50 rounded text-sm">
|
<div className="p-3 bg-teal-50 dark:bg-teal-900/30 rounded text-sm">
|
||||||
<div className="font-medium text-teal-900">
|
<div className="font-medium text-teal-900 dark:text-teal-300">
|
||||||
{poiCount.toLocaleString()} POI{poiCount !== 1 ? 's' : ''} visible
|
{poiCount.toLocaleString()} POI{poiCount !== 1 ? 's' : ''} visible
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-teal-700 mt-1">
|
<div className="text-xs text-teal-700 dark:text-teal-400 mt-1">
|
||||||
{selectedCount} categor{selectedCount !== 1 ? 'ies' : 'y'} selected
|
{selectedCount} categor{selectedCount !== 1 ? 'ies' : 'y'} selected
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="p-3 bg-warm-100 rounded text-xs text-warm-600">
|
<div className="p-3 bg-warm-100 dark:bg-warm-800 rounded text-xs text-warm-600 dark:text-warm-400">
|
||||||
<p>Select categories to display POIs on the map.</p>
|
<p>Select categories to display POIs on the map.</p>
|
||||||
<p className="mt-2">Zoom in for better visibility of individual locations.</p>
|
<p className="mt-2">Zoom in for better visibility of individual locations.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,19 @@ export function PropertiesPane({
|
||||||
onClose,
|
onClose,
|
||||||
}: PropertiesPaneProps) {
|
}: PropertiesPaneProps) {
|
||||||
const [sortBy, setSortBy] = useState<SortBy>('price');
|
const [sortBy, setSortBy] = useState<SortBy>('price');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
// Sort properties
|
// Filter and sort properties
|
||||||
const sortedProperties = useMemo(() => {
|
const filteredAndSorted = useMemo(() => {
|
||||||
return [...properties].sort((a, b) => {
|
const query = search.trim().toLowerCase();
|
||||||
|
const filtered = query
|
||||||
|
? properties.filter((p) => {
|
||||||
|
const addr = (p.address || '').toLowerCase();
|
||||||
|
const pc = (p.postcode || '').toLowerCase();
|
||||||
|
return addr.includes(query) || pc.includes(query);
|
||||||
|
})
|
||||||
|
: properties;
|
||||||
|
return [...filtered].sort((a, b) => {
|
||||||
switch (sortBy) {
|
switch (sortBy) {
|
||||||
case 'price':
|
case 'price':
|
||||||
return ((b.latest_price as number) || 0) - ((a.latest_price as number) || 0);
|
return ((b.latest_price as number) || 0) - ((a.latest_price as number) || 0);
|
||||||
|
|
@ -34,11 +43,11 @@ export function PropertiesPane({
|
||||||
return (a.current_energy_rating || 'Z').localeCompare(b.current_energy_rating || 'Z');
|
return (a.current_energy_rating || 'Z').localeCompare(b.current_energy_rating || 'Z');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [properties, sortBy]);
|
}, [properties, sortBy, search]);
|
||||||
|
|
||||||
if (!hexagonId) {
|
if (!hexagonId) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-warm-500">
|
<div className="flex items-center justify-center h-full text-warm-500 dark:text-warm-400">
|
||||||
Click a hexagon to view properties
|
Click a hexagon to view properties
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -47,27 +56,36 @@ export function PropertiesPane({
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-4 border-b border-warm-200">
|
<div className="p-4 border-b border-warm-200 dark:border-warm-700">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h2 className="text-lg font-semibold">Properties in Hexagon</h2>
|
<h2 className="text-lg font-semibold dark:text-warm-100">Properties in Hexagon</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-warm-500 hover:text-warm-700 text-2xl leading-none"
|
className="text-warm-500 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200 text-2xl leading-none"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-warm-600">
|
<p className="text-sm text-warm-600 dark:text-warm-400">
|
||||||
Showing {properties.length} of {total} properties
|
{search.trim()
|
||||||
|
? `${filteredAndSorted.length} match${filteredAndSorted.length !== 1 ? 'es' : ''} in ${properties.length} loaded`
|
||||||
|
: `Showing ${properties.length} of ${total} properties`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sort controls */}
|
{/* Search and sort controls */}
|
||||||
<div className="p-2 border-b border-warm-200">
|
<div className="p-2 border-b border-warm-200 dark:border-warm-700 space-y-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search by address or postcode..."
|
||||||
|
className="w-full p-2 border border-warm-300 dark:border-warm-700 rounded text-sm bg-white dark:bg-warm-800 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500"
|
||||||
|
/>
|
||||||
<select
|
<select
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onChange={(e) => setSortBy(e.target.value as SortBy)}
|
onChange={(e) => setSortBy(e.target.value as SortBy)}
|
||||||
className="w-full p-2 border border-warm-300 rounded text-sm"
|
className="w-full p-2 border border-warm-300 dark:border-warm-700 rounded text-sm bg-white dark:bg-warm-800 dark:text-warm-200"
|
||||||
>
|
>
|
||||||
<option value="price">Price (High to Low)</option>
|
<option value="price">Price (High to Low)</option>
|
||||||
<option value="size">Size (Large to Small)</option>
|
<option value="size">Size (Large to Small)</option>
|
||||||
|
|
@ -78,17 +96,17 @@ export function PropertiesPane({
|
||||||
{/* Properties list */}
|
{/* Properties list */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{loading && properties.length === 0 ? (
|
{loading && properties.length === 0 ? (
|
||||||
<div className="p-4">Loading...</div>
|
<div className="p-4 dark:text-warm-400">Loading...</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{sortedProperties.map((property, idx) => (
|
{filteredAndSorted.map((property, idx) => (
|
||||||
<PropertyCard key={idx} property={property} />
|
<PropertyCard key={idx} property={property} />
|
||||||
))}
|
))}
|
||||||
{properties.length < total && (
|
{properties.length < total && (
|
||||||
<button
|
<button
|
||||||
onClick={onLoadMore}
|
onClick={onLoadMore}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full p-4 text-teal-600 hover:bg-teal-50 disabled:opacity-50"
|
className="w-full p-4 text-teal-600 dark:text-teal-400 hover:bg-teal-50 dark:hover:bg-teal-900/30 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loading ? 'Loading...' : `Load More (${total - properties.length} remaining)`}
|
{loading ? 'Loading...' : `Load More (${total - properties.length} remaining)`}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -138,61 +156,61 @@ function PropertyCard({ property }: { property: Property }) {
|
||||||
const age = getNum(property, 'Approximate construction age', 'construction_age_band');
|
const age = getNum(property, 'Approximate construction age', 'construction_age_band');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 border-b border-warm-100 hover:bg-warm-50">
|
<div className="p-4 border-b border-warm-100 dark:border-warm-800 hover:bg-warm-50 dark:hover:bg-warm-800">
|
||||||
{/* Address & postcode */}
|
{/* Address & postcode */}
|
||||||
<div className="font-semibold">{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">{property.postcode}</div>
|
<div className="text-sm text-warm-600 dark:text-warm-400">{property.postcode}</div>
|
||||||
|
|
||||||
{/* Price */}
|
{/* Price */}
|
||||||
{price !== undefined && (
|
{price !== undefined && (
|
||||||
<div className="mt-2 text-lg font-bold text-teal-700">
|
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
|
||||||
£{fmt(price)}
|
£{fmt(price)}
|
||||||
{pricePerSqm !== undefined && (
|
{pricePerSqm !== undefined && (
|
||||||
<span className="text-sm font-normal text-warm-600"> (£{fmt(pricePerSqm)}/m²)</span>
|
<span className="text-sm font-normal text-warm-600 dark:text-warm-400"> (£{fmt(pricePerSqm)}/m²)</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Property details grid */}
|
{/* Property details grid */}
|
||||||
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-sm dark:text-warm-300">
|
||||||
{property.property_type && (
|
{property.property_type && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-warm-500">Type:</span> {property.property_type}
|
<span className="text-warm-500 dark:text-warm-400">Type:</span> {property.property_type}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{property.built_form && (
|
{property.built_form && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-warm-500">Built form:</span> {property.built_form}
|
<span className="text-warm-500 dark:text-warm-400">Built form:</span> {property.built_form}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{property.duration && (
|
{property.duration && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-warm-500">Tenure:</span> {formatDuration(property.duration)}
|
<span className="text-warm-500 dark:text-warm-400">Tenure:</span> {formatDuration(property.duration)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{floorArea !== undefined && (
|
{floorArea !== undefined && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-warm-500">Floor area:</span> {fmt(floorArea)}m²
|
<span className="text-warm-500 dark:text-warm-400">Floor area:</span> {fmt(floorArea)}m²
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{rooms !== undefined && (
|
{rooms !== undefined && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-warm-500">Rooms:</span> {fmt(rooms)}
|
<span className="text-warm-500 dark:text-warm-400">Rooms:</span> {fmt(rooms)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{age !== undefined && (
|
{age !== undefined && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-warm-500">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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{property.current_energy_rating && (
|
{property.current_energy_rating && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-warm-500">EPC rating:</span> {property.current_energy_rating}
|
<span className="text-warm-500 dark:text-warm-400">EPC rating:</span> {property.current_energy_rating}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{property.potential_energy_rating && (
|
{property.potential_energy_rating && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-warm-500">EPC potential:</span> {property.potential_energy_rating}
|
<span className="text-warm-500 dark:text-warm-400">EPC potential:</span> {property.potential_energy_rating}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,6 @@ interface LabelProps {
|
||||||
|
|
||||||
export function Label({ children, className }: LabelProps) {
|
export function Label({ children, className }: LabelProps) {
|
||||||
return (
|
return (
|
||||||
<label className={`text-sm font-medium text-warm-700 ${className || ''}`}>{children}</label>
|
<label className={`text-sm font-medium text-warm-700 dark:text-warm-300 ${className || ''}`}>{children}</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,13 @@ export function Slider({ className, ...props }: SliderProps) {
|
||||||
className={cn('relative flex w-full touch-none select-none items-center', className)}
|
className={cn('relative flex w-full touch-none select-none items-center', className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-warm-200">
|
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-warm-200 dark:bg-warm-700">
|
||||||
<SliderPrimitive.Range className="absolute h-full bg-teal-600" />
|
<SliderPrimitive.Range className="absolute h-full bg-teal-600" />
|
||||||
</SliderPrimitive.Track>
|
</SliderPrimitive.Track>
|
||||||
{props.value?.map((_, i) => (
|
{props.value?.map((_, i) => (
|
||||||
<SliderPrimitive.Thumb
|
<SliderPrimitive.Thumb
|
||||||
key={i}
|
key={i}
|
||||||
className="block h-5 w-5 rounded-full border-2 border-teal-600 bg-white ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
className="block h-5 w-5 rounded-full border-2 border-teal-600 dark:border-teal-500 bg-white dark:bg-warm-800 ring-offset-white dark:ring-offset-warm-900 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SliderPrimitive.Root>
|
</SliderPrimitive.Root>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,30 @@ body,
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html.dark {
|
||||||
|
background-color: #1c1917;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth theme transitions (scoped to avoid map performance issues) */
|
||||||
|
body,
|
||||||
|
div,
|
||||||
|
aside,
|
||||||
|
section,
|
||||||
|
header,
|
||||||
|
nav,
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
label,
|
||||||
|
span,
|
||||||
|
p,
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
/* Fade-in animation for homepage sections */
|
/* Fade-in animation for homepage sections */
|
||||||
.fade-in-section {
|
.fade-in-section {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,13 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Narrowit</title>
|
<title>Narrowit</title>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
if (localStorage.getItem('theme') === 'dark') {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
// No hardcoded filter constants - features are discovered dynamically from the API.
|
|
||||||
|
|
@ -1,7 +1,54 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
darkMode: 'class',
|
||||||
content: ['./src/**/*.{js,jsx,ts,tsx,html}'],
|
content: ['./src/**/*.{js,jsx,ts,tsx,html}'],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {
|
||||||
|
colors: {
|
||||||
|
navy: {
|
||||||
|
50: '#eef1f8',
|
||||||
|
100: '#d9dff0',
|
||||||
|
200: '#b3bfe1',
|
||||||
|
300: '#8d9fd2',
|
||||||
|
400: '#677fc3',
|
||||||
|
500: '#4a63a8',
|
||||||
|
600: '#2a3f6b',
|
||||||
|
700: '#1e2d50',
|
||||||
|
800: '#141e38',
|
||||||
|
900: '#0f1528',
|
||||||
|
950: '#0a0e1a',
|
||||||
|
},
|
||||||
|
teal: {
|
||||||
|
50: '#effefb',
|
||||||
|
100: '#c7fff4',
|
||||||
|
200: '#90ffe9',
|
||||||
|
300: '#51f7d9',
|
||||||
|
400: '#1de4c3',
|
||||||
|
500: '#05c9aa',
|
||||||
|
600: '#00a28c',
|
||||||
|
700: '#058172',
|
||||||
|
800: '#0a665b',
|
||||||
|
900: '#0d544c',
|
||||||
|
950: '#003330',
|
||||||
|
},
|
||||||
|
coral: {
|
||||||
|
400: '#fb923c',
|
||||||
|
500: '#f97316',
|
||||||
|
600: '#ea580c',
|
||||||
|
},
|
||||||
|
warm: {
|
||||||
|
50: '#fafaf9',
|
||||||
|
100: '#f5f5f4',
|
||||||
|
200: '#e7e5e4',
|
||||||
|
300: '#d6d3d1',
|
||||||
|
400: '#a8a29e',
|
||||||
|
500: '#78716c',
|
||||||
|
600: '#57534e',
|
||||||
|
700: '#44403c',
|
||||||
|
800: '#292524',
|
||||||
|
900: '#1c1917',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [require('tailwindcss-animate')],
|
plugins: [require('tailwindcss-animate')],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue