Lots of frontend changes
This commit is contained in:
parent
ec29631c44
commit
555ba7cf53
38 changed files with 1508 additions and 648 deletions
|
|
@ -1,15 +1,17 @@
|
|||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { trackPageview } from './hooks/usePlausible';
|
||||
import MapPage from './components/map/MapPage';
|
||||
import MapPage, { type ExportState } from './components/map/MapPage';
|
||||
import DataSourcesPage from './components/data-sources/DataSourcesPage';
|
||||
import FAQPage from './components/faq/FAQPage';
|
||||
import HomePage from './components/home/HomePage';
|
||||
import Header, { type Page } from './components/ui/Header';
|
||||
import AuthModal from './components/ui/AuthModal';
|
||||
import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
|
||||
import { fetchWithRetry, apiUrl } from './lib/api';
|
||||
import { parseUrlState } from './lib/url-state';
|
||||
import { INITIAL_VIEW_STATE } from './lib/consts';
|
||||
import { useTheme } from './hooks/useTheme';
|
||||
import { useAuth } from './hooks/useAuth';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
|
|
@ -43,6 +45,16 @@ export default function App() {
|
|||
});
|
||||
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const {
|
||||
user,
|
||||
loading: authLoading,
|
||||
error: authError,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
clearError,
|
||||
} = useAuth();
|
||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
||||
|
||||
// Load features and POI categories on mount
|
||||
useEffect(() => {
|
||||
|
|
@ -116,6 +128,8 @@ export default function App() {
|
|||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const [exportState, setExportState] = useState<ExportState | null>(null);
|
||||
|
||||
if (isScreenshotMode) {
|
||||
return (
|
||||
<MapPage
|
||||
|
|
@ -142,6 +156,11 @@ export default function App() {
|
|||
onPageChange={navigateTo}
|
||||
theme={theme}
|
||||
onToggleTheme={toggleTheme}
|
||||
onExport={exportState?.onExport ?? null}
|
||||
exporting={exportState?.exporting ?? false}
|
||||
user={user}
|
||||
onLoginClick={() => setShowAuthModal(true)}
|
||||
onLogout={logout}
|
||||
/>
|
||||
{activePage === 'home' ? (
|
||||
<HomePage onOpenDashboard={() => navigateTo('dashboard')} theme={theme} />
|
||||
|
|
@ -162,6 +181,17 @@ export default function App() {
|
|||
pendingInfoFeature={pendingInfoFeature}
|
||||
onClearPendingInfoFeature={() => setPendingInfoFeature(null)}
|
||||
onNavigateTo={navigateTo}
|
||||
onExportStateChange={setExportState}
|
||||
/>
|
||||
)}
|
||||
{showAuthModal && (
|
||||
<AuthModal
|
||||
onClose={() => setShowAuthModal(false)}
|
||||
onLogin={login}
|
||||
onRegister={register}
|
||||
loading={authLoading}
|
||||
error={authError}
|
||||
onClearError={clearError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ export default function DataSources({ onNavigate }: { onNavigate: () => void })
|
|||
return (
|
||||
<button
|
||||
onClick={onNavigate}
|
||||
className="absolute bottom-2 right-2 bg-white/90 dark:bg-navy-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"
|
||||
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
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState } from 'react';
|
||||
import { ChevronIcon } from '../ui/icons/ChevronIcon';
|
||||
|
||||
interface FAQItem {
|
||||
question: string;
|
||||
|
|
@ -78,15 +79,10 @@ function FAQItemCard({ item }: { item: FAQItem }) {
|
|||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">{item.question}</span>
|
||||
<svg
|
||||
<ChevronIcon
|
||||
direction="down"
|
||||
className={`w-5 h-5 shrink-0 text-warm-400 dark:text-warm-500 transform ${open ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-5 pb-4">
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import type { FeatureFilters, FeatureMeta, HexagonStatsResponse, PostcodeFeature } from '../../types';
|
||||
import type { HexagonLocation } from '../../lib/external-search';
|
||||
import { formatValue, calculateHistogramMean } from '../../lib/format';
|
||||
import { formatValue, formatFilterValue, calculateHistogramMean, FEATURE_FORMATS } from '../../lib/format';
|
||||
import { groupFeaturesByCategory } from '../../lib/features';
|
||||
import { STACKED_GROUPS } from '../../lib/consts';
|
||||
import { STACKED_GROUPS, STACKED_ENUM_GROUPS } from '../../lib/consts';
|
||||
import { DualHistogram, LoadingSkeleton } from './DualHistogram';
|
||||
import EnumBarChart from './EnumBarChart';
|
||||
import StackedBarChart from './StackedBarChart';
|
||||
import StackedEnumChart from './StackedEnumChart';
|
||||
import PriceHistoryChart from './PriceHistoryChart';
|
||||
import ExternalSearchLinks from './ExternalSearchLinks';
|
||||
import { InfoIcon, CloseIcon } from '../ui/icons';
|
||||
|
|
@ -126,6 +127,14 @@ export default function AreaPane({
|
|||
if (!hasData) return null;
|
||||
|
||||
const stackedCharts = STACKED_GROUPS[group.name];
|
||||
const stackedEnumCharts = STACKED_ENUM_GROUPS[group.name];
|
||||
|
||||
// Features that are part of a stacked enum config (rendered as compact charts)
|
||||
const stackedEnumFeatureNames = new Set(
|
||||
stackedEnumCharts?.flatMap((c) =>
|
||||
[c.feature, ...c.components].filter(Boolean)
|
||||
) as string[] ?? []
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={group.name}>
|
||||
|
|
@ -183,75 +192,157 @@ export default function AreaPane({
|
|||
</div>
|
||||
);
|
||||
})
|
||||
: // Default: render each feature individually
|
||||
group.features.map((feature) => {
|
||||
const numericStats = numericByName.get(feature.name);
|
||||
const enumStats = enumByName.get(feature.name);
|
||||
: // Default: render each feature individually (skip stacked enum features)
|
||||
group.features
|
||||
.filter((f) => !stackedEnumFeatureNames.has(f.name))
|
||||
.map((feature) => {
|
||||
const numericStats = numericByName.get(feature.name);
|
||||
const enumStats = enumByName.get(feature.name);
|
||||
|
||||
if (numericStats) {
|
||||
const globalFeature = globalFeatureByName.get(feature.name);
|
||||
const globalHistogram = globalFeature?.histogram;
|
||||
const globalMean = globalHistogram
|
||||
? calculateHistogramMean(globalHistogram)
|
||||
: undefined;
|
||||
if (numericStats) {
|
||||
const globalFeature = globalFeatureByName.get(feature.name);
|
||||
const globalHistogram = globalFeature?.histogram;
|
||||
const globalMean = globalHistogram
|
||||
? calculateHistogramMean(globalHistogram)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline">
|
||||
<FeatureLabel
|
||||
feature={feature}
|
||||
onShowInfo={setInfoFeature}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(numericStats.mean)}
|
||||
</span>
|
||||
</div>
|
||||
{numericStats.histogram && (
|
||||
<>
|
||||
<div className="flex justify-between text-[10px] text-warm-400 dark:text-warm-500 mt-0.5">
|
||||
<span>{formatValue(numericStats.histogram.min)}</span>
|
||||
<span>{formatValue(numericStats.histogram.max)}</span>
|
||||
</div>
|
||||
{globalHistogram ? (
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline">
|
||||
<FeatureLabel
|
||||
feature={feature}
|
||||
onShowInfo={setInfoFeature}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(numericStats.mean, FEATURE_FORMATS[feature.name])}
|
||||
</span>
|
||||
</div>
|
||||
{numericStats.histogram && (
|
||||
globalHistogram ? (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={globalHistogram.counts}
|
||||
min={numericStats.histogram.min}
|
||||
max={numericStats.histogram.max}
|
||||
p1={numericStats.histogram.p1}
|
||||
p99={numericStats.histogram.p99}
|
||||
globalMean={globalMean}
|
||||
formatLabel={formatFilterValue}
|
||||
/>
|
||||
) : (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={numericStats.histogram.counts}
|
||||
min={numericStats.histogram.min}
|
||||
max={numericStats.histogram.max}
|
||||
p1={numericStats.histogram.p1}
|
||||
p99={numericStats.histogram.p99}
|
||||
formatLabel={formatFilterValue}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (enumStats) {
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
|
||||
<EnumBarChart counts={enumStats.counts} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (enumStats) {
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
|
||||
<EnumBarChart counts={enumStats.counts} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
return null;
|
||||
})}
|
||||
{/* Stacked enum charts */}
|
||||
{stackedEnumCharts?.map((chart) => {
|
||||
const featureMeta = chart.feature
|
||||
? globalFeatureByName.get(chart.feature)
|
||||
: undefined;
|
||||
|
||||
// Single component: render as a stacked bar (like crime charts)
|
||||
if (chart.components.length === 1) {
|
||||
const stats = enumByName.get(chart.components[0]);
|
||||
if (!stats) return null;
|
||||
|
||||
const segments = chart.valueOrder
|
||||
.map((value) => ({ name: value, value: stats.counts[value] ?? 0 }))
|
||||
.filter((s) => s.value > 0);
|
||||
const total = segments.reduce((sum, s) => sum + s.value, 0);
|
||||
if (total === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={chart.label}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline mb-1.5">
|
||||
{featureMeta ? (
|
||||
<FeatureLabel
|
||||
feature={featureMeta}
|
||||
onShowInfo={setInfoFeature}
|
||||
className="mr-2"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
|
||||
{chart.label}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{total.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<StackedBarChart
|
||||
segments={segments}
|
||||
total={total}
|
||||
colorMap={Object.fromEntries(
|
||||
chart.valueOrder.map((v, i) => [v, chart.valueColors[i]])
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Multi-component: render as compact multi-row chart (like risk features)
|
||||
const components = chart.components
|
||||
.map((name) => {
|
||||
const stats = enumByName.get(name);
|
||||
return stats ? { label: name, stats } : null;
|
||||
})
|
||||
.filter((c): c is NonNullable<typeof c> => c !== null);
|
||||
|
||||
if (components.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={chart.label}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="mb-1.5">
|
||||
{featureMeta ? (
|
||||
<FeatureLabel
|
||||
feature={{ ...featureMeta, name: chart.label }}
|
||||
onShowInfo={setInfoFeature}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
{chart.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<StackedEnumChart
|
||||
components={components}
|
||||
valueOrder={chart.valueOrder}
|
||||
valueColors={chart.valueColors}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,18 +11,37 @@ function downsampleBars(counts: number[], targetBars: number): number[] {
|
|||
return bars;
|
||||
}
|
||||
|
||||
function pickTicks(min: number, max: number, count: number): number[] {
|
||||
if (max <= min) return [min];
|
||||
const range = max - min;
|
||||
const rawStep = range / (count - 1);
|
||||
const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
|
||||
const nice = [1, 2, 2.5, 3, 4, 5, 10].find((n) => n * magnitude >= rawStep) ?? 10;
|
||||
const step = nice * magnitude;
|
||||
const start = Math.ceil(min / step) * step;
|
||||
const ticks: number[] = [];
|
||||
for (let v = start; v <= max + step * 0.01; v += step) {
|
||||
ticks.push(v);
|
||||
}
|
||||
// Ensure at least min and max are represented
|
||||
if (ticks.length === 0) return [min, max];
|
||||
return ticks;
|
||||
}
|
||||
|
||||
export function DualHistogram({
|
||||
localCounts,
|
||||
globalCounts,
|
||||
min,
|
||||
max,
|
||||
p1,
|
||||
p99,
|
||||
globalMean,
|
||||
formatLabel,
|
||||
}: {
|
||||
localCounts: number[];
|
||||
globalCounts: number[];
|
||||
min: number;
|
||||
max: number;
|
||||
p1: number;
|
||||
p99: number;
|
||||
globalMean?: number;
|
||||
formatLabel?: (value: number) => string;
|
||||
}) {
|
||||
const targetBars = 25;
|
||||
const localBars = downsampleBars(localCounts, targetBars);
|
||||
|
|
@ -32,7 +51,37 @@ export function DualHistogram({
|
|||
const localMax = Math.max(...localBars, 1);
|
||||
const globalMax = Math.max(...globalBars, 1);
|
||||
|
||||
const meanFraction = globalMean != null && max > min ? (globalMean - min) / (max - min) : null;
|
||||
const fmt = formatLabel ?? ((v: number) => (Number.isInteger(v) ? v.toLocaleString() : v.toFixed(1)));
|
||||
|
||||
// Compute center value for each bar.
|
||||
// Bar 0 = low outlier, bars 1..n-2 = middle (p1 to p99), bar n-1 = high outlier.
|
||||
const middleBins = Math.max(barCount - 2, 0);
|
||||
const middleWidth = middleBins > 0 && p99 > p1 ? (p99 - p1) / middleBins : 0;
|
||||
const barCenters: number[] = Array.from({ length: barCount }, (_, i) => {
|
||||
if (i === 0) return p1; // outlier bin, label as p1
|
||||
if (i === barCount - 1) return p99; // outlier bin, label as p99
|
||||
return p1 + (i - 1 + 0.5) * middleWidth;
|
||||
});
|
||||
|
||||
// Pick nice tick values and assign each to the nearest bar
|
||||
const ticks = p99 > p1 ? pickTicks(p1, p99, 6) : [];
|
||||
const tickBars = new Map<number, string>(); // bar index → label
|
||||
for (const v of ticks) {
|
||||
let bestBar = 1;
|
||||
let bestDist = Infinity;
|
||||
for (let i = 1; i < barCount - 1; i++) {
|
||||
const dist = Math.abs(barCenters[i] - v);
|
||||
if (dist < bestDist) { bestDist = dist; bestBar = i; }
|
||||
}
|
||||
if (!tickBars.has(bestBar)) tickBars.set(bestBar, fmt(v));
|
||||
}
|
||||
|
||||
// Mean line: position as fraction across the bar area
|
||||
const meanFrac = globalMean != null && p99 > p1 ? (globalMean - p1) / (p99 - p1) : null;
|
||||
// Account for outlier bins: middle region spans bars 1..n-2
|
||||
const meanPct = meanFrac != null
|
||||
? ((1 + meanFrac * middleBins) / barCount) * 100
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="mt-1">
|
||||
|
|
@ -56,13 +105,26 @@ export function DualHistogram({
|
|||
</div>
|
||||
);
|
||||
})}
|
||||
{meanFraction != null && meanFraction >= 0 && meanFraction <= 1 && (
|
||||
{meanPct != null && meanPct >= 0 && meanPct <= 100 && (
|
||||
<div
|
||||
className="absolute bottom-0 top-0 w-px border-l border-dashed border-warm-400 dark:border-warm-500"
|
||||
style={{ left: `${meanFraction * 100}%` }}
|
||||
style={{ left: `${meanPct}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{tickBars.size > 0 && (
|
||||
<div className="flex gap-px mt-0.5">
|
||||
{Array.from({ length: barCount }).map((_, index) => (
|
||||
<div key={index} className="flex-1 min-w-[2px] text-center">
|
||||
{tickBars.has(index) && (
|
||||
<span className="text-[9px] leading-none text-warm-400 dark:text-warm-500">
|
||||
{tickBars.get(index)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { memo, useState, useRef, useCallback, useMemo, useEffect } from 'react';
|
||||
import { memo, useState, useMemo, useEffect } from 'react';
|
||||
import { Slider } from '../ui/Slider';
|
||||
import { Label } from '../ui/Label';
|
||||
import { SearchInput } from '../ui/SearchInput';
|
||||
import { FilterIcon, LightbulbIcon } from '../ui/icons';
|
||||
import { ChevronIcon, FilterIcon, LightbulbIcon } from '../ui/icons';
|
||||
import { EmptyState } from '../ui/EmptyState';
|
||||
import type { FeatureMeta, FeatureFilters } from '../../types';
|
||||
import { formatFilterValue } from '../../lib/format';
|
||||
|
|
@ -56,6 +56,15 @@ function FeatureBrowser({
|
|||
}) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleGroup = (name: string) =>
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
return next;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (openInfoFeature) {
|
||||
|
|
@ -73,50 +82,70 @@ function FeatureBrowser({
|
|||
|
||||
const grouped = useMemo(() => groupFeaturesByCategory(filtered), [filtered]);
|
||||
|
||||
// When searching, expand all groups so results are visible
|
||||
const isSearching = search.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="p-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<SearchInput value={search} onChange={setSearch} placeholder="Search features..." />
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{grouped.map((group) => (
|
||||
<div key={group.name}>
|
||||
<div className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0">
|
||||
{group.name}
|
||||
</div>
|
||||
{group.features.map((f) => {
|
||||
const isPinned = pinnedFeature === f.name;
|
||||
return (
|
||||
<div
|
||||
key={f.name}
|
||||
className="flex items-start justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
|
||||
>
|
||||
<div className="min-w-0 mr-2">
|
||||
<FeatureLabel feature={f} onShowInfo={setInfoFeature} size="sm" />
|
||||
{f.description && (
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500 truncate block">
|
||||
{f.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<FeatureActions
|
||||
feature={f}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
onAdd={onAddFilter}
|
||||
/>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto flex flex-col">
|
||||
{grouped.map((group) => {
|
||||
const isExpanded = isSearching || expandedGroups.has(group.name);
|
||||
return (
|
||||
<div key={group.name} className="shrink-0">
|
||||
<button
|
||||
onClick={() => toggleGroup(group.name)}
|
||||
className="w-full flex items-center justify-between px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span>{group.name}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||
{group.features.length}
|
||||
</span>
|
||||
<ChevronIcon direction={isExpanded ? 'down' : 'right'} className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{grouped.length === 0 && (
|
||||
</button>
|
||||
{isExpanded &&
|
||||
group.features.map((f) => {
|
||||
const isPinned = pinnedFeature === f.name;
|
||||
return (
|
||||
<div
|
||||
key={f.name}
|
||||
className="flex items-start justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
|
||||
>
|
||||
<div className="min-w-0 mr-2">
|
||||
<FeatureLabel feature={f} onShowInfo={setInfoFeature} size="sm" />
|
||||
{f.description && (
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500 truncate block">
|
||||
{f.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<FeatureActions
|
||||
feature={f}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
onAdd={onAddFilter}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{grouped.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
|
||||
title={search ? 'No matching features' : 'All features are active'}
|
||||
description={search ? 'Try a different search term' : 'Remove a filter to see available features'}
|
||||
className="px-3 py-4"
|
||||
/>
|
||||
) : (
|
||||
<p className="flex-1 flex items-center justify-center px-4 py-6 text-xs text-center text-warm-400 dark:text-warm-500">
|
||||
Everyone cares about different things. Pick the filters that matter most to you.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{infoFeature && (
|
||||
|
|
@ -155,38 +184,12 @@ export default memo(function Filters({
|
|||
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
|
||||
const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name));
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const [splitFraction, setSplitFraction] = useState(0.65);
|
||||
const draggingRef = useRef(false);
|
||||
const [showPhilosophy, setShowPhilosophy] = useState(false);
|
||||
const [activeInfoFeature, setActiveInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
|
||||
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 headerHeight = headerRef.current?.offsetHeight ?? 0;
|
||||
const y = e.clientY - rect.top;
|
||||
const fraction = Math.min(0.8, Math.max(0.15, (y - headerHeight) / rect.height));
|
||||
setSplitFraction(fraction);
|
||||
}, []);
|
||||
|
||||
const handleSeparatorPointerUp = useCallback(() => {
|
||||
draggingRef.current = false;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex flex-col bg-white dark:bg-navy-950 overflow-hidden h-full"
|
||||
>
|
||||
<div ref={headerRef} className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<div className="flex flex-col bg-white dark:bg-navy-950 overflow-hidden h-full">
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<button
|
||||
onClick={() => setShowPhilosophy(true)}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md flex items-center justify-center gap-2"
|
||||
|
|
@ -195,7 +198,7 @@ export default memo(function Filters({
|
|||
Finding the Perfect Postcode
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex flex-col" style={{ height: `${splitFraction * 100}%` }}>
|
||||
<div className="min-h-0 flex flex-col max-h-[65%]">
|
||||
<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">
|
||||
|
|
@ -279,13 +282,11 @@ export default memo(function Filters({
|
|||
key={feature.name}
|
||||
className={`space-y-1 p-3 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' : ''}`}
|
||||
>
|
||||
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" />
|
||||
<span className="text-sm text-warm-500 dark:text-warm-400">
|
||||
{formatFilterValue(displayValue[0])} - {formatFilterValue(displayValue[1])}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-warm-500 dark:text-warm-400">
|
||||
{formatFilterValue(displayValue[0])} - {formatFilterValue(displayValue[1])}
|
||||
</span>
|
||||
<FeatureActions
|
||||
feature={feature}
|
||||
isPinned={isPinned}
|
||||
|
|
@ -308,16 +309,7 @@ export default memo(function Filters({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
onPointerMove={handleSeparatorPointerMove}
|
||||
onPointerUp={handleSeparatorPointerUp}
|
||||
>
|
||||
<div className="w-8 h-0.5 rounded bg-warm-300 dark:bg-navy-600" />
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 flex flex-col">
|
||||
<div className="min-h-0 flex-1 flex flex-col border-t border-warm-200 dark:border-warm-700">
|
||||
<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>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import {
|
|||
getBoundsFromViewState,
|
||||
emojiToTwemojiUrl,
|
||||
getMapStyle,
|
||||
DENSITY_GRADIENT,
|
||||
DENSITY_GRADIENT_DARK,
|
||||
} from '../../lib/map-utils';
|
||||
import { INITIAL_VIEW_STATE, MAP_MIN_ZOOM, MAP_BOUNDS } from '../../lib/consts';
|
||||
import PostcodeSearch, { type SearchedPostcode } from './PostcodeSearch';
|
||||
|
|
@ -161,7 +163,7 @@ export default memo(function Map({
|
|||
|
||||
const handleMapLoad = useCallback(
|
||||
(_evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
|
||||
// Hexagons render below roads/buildings/labels so map features show on top
|
||||
// Road opacity is set in getMapStyle
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
|
@ -297,8 +299,15 @@ export default memo(function Map({
|
|||
}
|
||||
}, []);
|
||||
|
||||
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}`;
|
||||
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${selectedPostcode}|${hoveredPostcode}`;
|
||||
const isDark = theme === 'dark';
|
||||
const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
|
||||
const densityGradientRef = useRef(densityGradient);
|
||||
densityGradientRef.current = densityGradient;
|
||||
const isDarkRef = useRef(isDark);
|
||||
isDarkRef.current = isDark;
|
||||
|
||||
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}|${theme}`;
|
||||
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${selectedPostcode}|${hoveredPostcode}|${theme}`;
|
||||
|
||||
const hexLayer = useMemo(
|
||||
() =>
|
||||
|
|
@ -311,14 +320,15 @@ export default memo(function Map({
|
|||
const clr = colorRangeRef.current;
|
||||
const fr = filterRangeRef.current;
|
||||
const cfm = colorFeatureMetaRef.current;
|
||||
const dark = isDarkRef.current;
|
||||
if (vf && clr && cfm) {
|
||||
const val = d[`min_${vf}`];
|
||||
if (val == null) return [128, 128, 128, 80] as [number, number, number, number];
|
||||
if (val == null) return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
|
||||
if (fr) {
|
||||
const minVal = d[`min_${vf}`] as number;
|
||||
const maxVal = d[`max_${vf}`] as number;
|
||||
if (maxVal < fr[0] || minVal > fr[1]) {
|
||||
return [180, 180, 180, 60] as [number, number, number, number];
|
||||
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number];
|
||||
}
|
||||
}
|
||||
const range = clr[1] - clr[0];
|
||||
|
|
@ -330,7 +340,7 @@ export default memo(function Map({
|
|||
const cr = countRangeRef.current;
|
||||
const c = d.count as number;
|
||||
const t = (c - cr.min) / (cr.max - cr.min);
|
||||
return [...countToColor(Math.max(0, Math.min(1, t))), 255] as [
|
||||
return [...countToColor(Math.max(0, Math.min(1, t)), densityGradientRef.current), 255] as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
|
|
@ -378,14 +388,15 @@ export default memo(function Map({
|
|||
const clr = colorRangeRef.current;
|
||||
const fr = filterRangeRef.current;
|
||||
const cfm = colorFeatureMetaRef.current;
|
||||
const dark = isDarkRef.current;
|
||||
if (vf && clr && cfm) {
|
||||
const val = d[`min_${vf}`];
|
||||
if (val == null) return [128, 128, 128, 80] as [number, number, number, number];
|
||||
if (val == null) return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
|
||||
if (fr) {
|
||||
const minVal = d[`min_${vf}`] as number;
|
||||
const maxVal = d[`max_${vf}`] as number;
|
||||
if (maxVal < fr[0] || minVal > fr[1]) {
|
||||
return [180, 180, 180, 60] as [number, number, number, number];
|
||||
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number];
|
||||
}
|
||||
}
|
||||
const range = clr[1] - clr[0];
|
||||
|
|
@ -397,7 +408,7 @@ export default memo(function Map({
|
|||
const cr = postcodeCountRangeRef.current;
|
||||
const c = d.count;
|
||||
const t = (c - cr.min) / (cr.max - cr.min);
|
||||
return [...countToColor(Math.max(0, Math.min(1, t))), 255] as [
|
||||
return [...countToColor(Math.max(0, Math.min(1, t)), densityGradientRef.current), 255] as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
|
|
@ -406,11 +417,12 @@ export default memo(function Map({
|
|||
},
|
||||
getLineColor: (f) => {
|
||||
const pc = f.properties.postcode;
|
||||
const dark = isDarkRef.current;
|
||||
if (pc === selectedPostcodeRef.current)
|
||||
return [255, 255, 255, 255] as [number, number, number, number];
|
||||
if (pc === hoveredPostcodeRef.current)
|
||||
return [29, 228, 195, 200] as [number, number, number, number];
|
||||
return [100, 100, 100, 150] as [number, number, number, number];
|
||||
return (dark ? [180, 170, 160, 100] : [100, 100, 100, 150]) as [number, number, number, number];
|
||||
},
|
||||
getLineWidth: (f) => {
|
||||
const pc = f.properties.postcode;
|
||||
|
|
@ -570,6 +582,7 @@ export default memo(function Map({
|
|||
onCancel={onCancelPin}
|
||||
mode="feature"
|
||||
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
|
||||
theme={theme}
|
||||
/>
|
||||
) : (
|
||||
<MapLegend
|
||||
|
|
@ -578,6 +591,7 @@ export default memo(function Map({
|
|||
showCancel={false}
|
||||
onCancel={onCancelPin}
|
||||
mode="density"
|
||||
theme={theme}
|
||||
/>
|
||||
)}
|
||||
{popupInfo && (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { formatValue } from '../../lib/format';
|
||||
import { FEATURE_GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../../lib/consts';
|
||||
import { gradientToCss } from '../../lib/utils';
|
||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
|
||||
export default function MapLegend({
|
||||
featureLabel,
|
||||
|
|
@ -7,6 +10,7 @@ export default function MapLegend({
|
|||
onCancel,
|
||||
mode,
|
||||
enumValues,
|
||||
theme = 'light',
|
||||
}: {
|
||||
featureLabel: string;
|
||||
range: [number, number];
|
||||
|
|
@ -14,11 +18,10 @@ export default function MapLegend({
|
|||
onCancel: () => void;
|
||||
mode: 'feature' | 'density';
|
||||
enumValues?: string[];
|
||||
theme?: 'light' | 'dark';
|
||||
}) {
|
||||
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))';
|
||||
const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
|
||||
const gradientStyle = mode === 'density' ? gradientToCss(densityGradient) : gradientToCss(FEATURE_GRADIENT);
|
||||
|
||||
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]">
|
||||
|
|
@ -30,15 +33,7 @@ export default function MapLegend({
|
|||
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>
|
||||
<CloseIcon className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState } from '../../types';
|
||||
import type { SearchedPostcode } from './PostcodeSearch';
|
||||
import type { Page } from '../ui/Header';
|
||||
|
|
@ -14,6 +14,13 @@ import { usePOIData } from '../../hooks/usePOIData';
|
|||
import { useFilters } from '../../hooks/useFilters';
|
||||
import { useHexagonSelection } from '../../hooks/useHexagonSelection';
|
||||
import { usePaneResize } from '../../hooks/usePaneResize';
|
||||
import { apiUrl, buildFilterString } from '../../lib/api';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
|
||||
export interface ExportState {
|
||||
onExport: () => void;
|
||||
exporting: boolean;
|
||||
}
|
||||
|
||||
interface MapPageProps {
|
||||
features: FeatureMeta[];
|
||||
|
|
@ -27,6 +34,7 @@ interface MapPageProps {
|
|||
pendingInfoFeature: string | null;
|
||||
onClearPendingInfoFeature: () => void;
|
||||
onNavigateTo: (page: Page, hash?: string, infoFeature?: string) => void;
|
||||
onExportStateChange?: (state: ExportState) => void;
|
||||
screenshotMode?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -42,6 +50,7 @@ export default function MapPage({
|
|||
pendingInfoFeature,
|
||||
onClearPendingInfoFeature,
|
||||
onNavigateTo,
|
||||
onExportStateChange,
|
||||
screenshotMode,
|
||||
}: MapPageProps) {
|
||||
if (screenshotMode) {
|
||||
|
|
@ -142,15 +151,46 @@ export default function MapPage({
|
|||
return { lat: hex.lat as number, lon: hex.lon as number, resolution: mapData.resolution };
|
||||
}, [selection.selectedHexagon?.id, mapData.data, mapData.resolution]);
|
||||
|
||||
// Export to Excel
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const handleExport = useCallback(() => {
|
||||
if (!mapData.bounds || exporting) return;
|
||||
const { south, west, north, east } = mapData.bounds;
|
||||
const params = new URLSearchParams({
|
||||
bounds: `${south},${west},${north},${east}`,
|
||||
});
|
||||
const filterStr = buildFilterString(filters, features);
|
||||
if (filterStr) params.set('filters', filterStr);
|
||||
const url = apiUrl('export', params);
|
||||
|
||||
setExporting(true);
|
||||
fetch(url)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.blob();
|
||||
})
|
||||
.then((blob) => {
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = 'narrowit-export.xlsx';
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
})
|
||||
.catch((err) => console.error('Export failed:', err))
|
||||
.finally(() => setExporting(false));
|
||||
}, [mapData.bounds, filters, features, exporting]);
|
||||
|
||||
// Report export state to parent (Header)
|
||||
useEffect(() => {
|
||||
onExportStateChange?.({ onExport: handleExport, exporting });
|
||||
}, [handleExport, exporting, onExportStateChange]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden relative">
|
||||
{initialLoading && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<svg className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<SpinnerIcon className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" />
|
||||
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">Connecting to server...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useState, useRef, useCallback } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { POICategoryGroup } from '../../types';
|
||||
import { useClickOutside } from '../../hooks/useClickOutside';
|
||||
import InfoPopup from '../ui/InfoPopup';
|
||||
import { SearchInput } from '../ui/SearchInput';
|
||||
import { InfoIcon, ChevronIcon } from '../ui/icons';
|
||||
|
|
@ -21,13 +20,9 @@ export default function POIPane({
|
|||
poiCount,
|
||||
onNavigateToSource,
|
||||
}: POIPaneProps) {
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useClickOutside(dropdownRef, () => setDropdownOpen(false));
|
||||
|
||||
const allCategories = groups.flatMap((g) => g.categories);
|
||||
|
||||
|
|
@ -93,139 +88,129 @@ export default function POIPane({
|
|||
const selectedCount = selectedCategories.size;
|
||||
|
||||
return (
|
||||
<div className="w-72 p-4 bg-white dark:bg-navy-950 shadow-lg space-y-4 overflow-y-auto max-h-screen">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-bold dark:text-warm-100">Points of Interest</h2>
|
||||
<IconButton onClick={() => setShowInfo(true)} title="Data source info">
|
||||
<InfoIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="flex flex-col h-full bg-white dark:bg-navy-950 shadow-lg overflow-hidden">
|
||||
<div className="flex-shrink-0 px-4 pt-4 pb-2 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-bold dark:text-warm-100">Points of Interest</h2>
|
||||
<IconButton onClick={() => setShowInfo(true)} title="Data source info">
|
||||
<InfoIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{showInfo && (
|
||||
<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>
|
||||
)}
|
||||
{showInfo && (
|
||||
<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}>
|
||||
<button
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-warm-300 dark:border-navy-700 rounded hover:border-warm-400 bg-white dark:bg-navy-800 dark:text-warm-200"
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{selectedCount === 0
|
||||
? 'Select categories...'
|
||||
: selectedCount === allCategories.length
|
||||
? 'All categories'
|
||||
: `${selectedCount} selected`}
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder="Search categories..."
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={selectAll}
|
||||
className="px-2 py-0.5 text-xs rounded border border-warm-300 dark:border-warm-700 text-warm-600 dark:text-warm-400 hover:bg-warm-50 dark:hover:bg-warm-700"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={selectNone}
|
||||
className="px-2 py-0.5 text-xs rounded border border-warm-300 dark:border-warm-700 text-warm-600 dark:text-warm-400 hover:bg-warm-50 dark:hover:bg-warm-700"
|
||||
>
|
||||
None
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs text-warm-500 dark:text-warm-400">
|
||||
{selectedCount}/{allCategories.length} selected
|
||||
</span>
|
||||
<ChevronIcon
|
||||
direction={dropdownOpen ? 'up' : 'down'}
|
||||
className="w-4 h-4 ml-2 flex-shrink-0"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{dropdownOpen && (
|
||||
<div className="border border-warm-300 dark:border-navy-700 rounded shadow-lg bg-white dark:bg-navy-800">
|
||||
<div className="px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder="Search categories..."
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto py-1">
|
||||
{filteredGroups.map((group) => {
|
||||
const groupSelected = group.categories.filter((c) =>
|
||||
selectedCategories.has(c)
|
||||
).length;
|
||||
const allInGroupSelected = groupSelected === group.categories.length;
|
||||
const someInGroupSelected = groupSelected > 0 && !allInGroupSelected;
|
||||
const isCollapsed = collapsedGroups.has(group.name) && !searchTerm;
|
||||
|
||||
return (
|
||||
<div key={group.name}>
|
||||
<div className="flex items-center gap-1 px-3 py-1.5 bg-warm-50 dark:bg-navy-950 border-y border-warm-100 dark:border-navy-700">
|
||||
<button
|
||||
onClick={() => toggleCollapse(group.name)}
|
||||
className={`p-0.5 text-warm-400 hover:text-warm-600 transition-transform ${isCollapsed ? '' : 'rotate-90'}`}
|
||||
>
|
||||
<ChevronIcon direction="right" className="w-3 h-3" />
|
||||
</button>
|
||||
<label className="flex items-center gap-2 flex-1 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allInGroupSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someInGroupSelected;
|
||||
}}
|
||||
onChange={() => toggleGroup(group.name)}
|
||||
className="rounded accent-teal-600"
|
||||
/>
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
{!isCollapsed &&
|
||||
group.categories.map((category) => (
|
||||
<label
|
||||
key={category}
|
||||
className="flex items-center gap-2 px-3 pl-8 py-1.5 hover:bg-warm-50 dark:hover:bg-navy-700 cursor-pointer dark:text-warm-300"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCategories.has(category)}
|
||||
onChange={() => toggleCategory(category)}
|
||||
className="rounded accent-teal-600"
|
||||
/>
|
||||
<span className="text-sm flex-1">{category}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{selectedCount > 0 && (
|
||||
<div className="px-3 py-2 bg-teal-50 dark:bg-teal-900/30 rounded text-sm flex items-center justify-between">
|
||||
<span className="font-medium text-teal-900 dark:text-teal-300">
|
||||
{poiCount.toLocaleString()} POI{poiCount !== 1 ? 's' : ''} visible
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedCount > 0 && (
|
||||
<div className="p-3 bg-teal-50 dark:bg-teal-900/30 rounded text-sm">
|
||||
<div className="font-medium text-teal-900 dark:text-teal-300">
|
||||
{poiCount.toLocaleString()} POI{poiCount !== 1 ? 's' : ''} visible
|
||||
</div>
|
||||
<div className="text-xs text-teal-700 dark:text-teal-400 mt-1">
|
||||
{selectedCount} categor{selectedCount !== 1 ? 'ies' : 'y'} selected
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto border-t border-warm-200 dark:border-warm-700">
|
||||
{filteredGroups.map((group) => {
|
||||
const groupSelected = group.categories.filter((c) =>
|
||||
selectedCategories.has(c)
|
||||
).length;
|
||||
const allInGroupSelected = groupSelected === group.categories.length;
|
||||
const someInGroupSelected = groupSelected > 0 && !allInGroupSelected;
|
||||
const isCollapsed = collapsedGroups.has(group.name) && !searchTerm;
|
||||
|
||||
<div className="p-3 bg-warm-100 dark:bg-navy-800 rounded text-xs text-warm-600 dark:text-warm-400">
|
||||
<p>Select categories to display POIs on the map.</p>
|
||||
<p className="mt-2">Zoom in for better visibility of individual locations.</p>
|
||||
return (
|
||||
<div key={group.name}>
|
||||
<div className="flex items-center gap-1 px-3 py-1.5 bg-warm-50 dark:bg-navy-950 border-b border-warm-100 dark:border-navy-700 sticky top-0 z-10">
|
||||
<button
|
||||
onClick={() => toggleCollapse(group.name)}
|
||||
className={`p-0.5 text-warm-400 hover:text-warm-600 transition-transform ${isCollapsed ? '' : 'rotate-90'}`}
|
||||
>
|
||||
<ChevronIcon direction="right" className="w-3 h-3" />
|
||||
</button>
|
||||
<label className="flex items-center gap-2 flex-1 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allInGroupSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someInGroupSelected;
|
||||
}}
|
||||
onChange={() => toggleGroup(group.name)}
|
||||
className="rounded accent-teal-600"
|
||||
/>
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
{!isCollapsed &&
|
||||
group.categories.map((category) => (
|
||||
<label
|
||||
key={category}
|
||||
className="flex items-center gap-2 px-3 pl-8 py-1.5 hover:bg-warm-50 dark:hover:bg-navy-700 cursor-pointer dark:text-warm-300"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCategories.has(category)}
|
||||
onChange={() => toggleCategory(category)}
|
||||
className="rounded accent-teal-600"
|
||||
/>
|
||||
<span className="text-sm flex-1">{category}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import type { PostcodeGeometry } from '../../types';
|
||||
import { authHeaders } from '../../lib/api';
|
||||
|
||||
export interface SearchedPostcode {
|
||||
postcode: string;
|
||||
|
|
@ -26,7 +27,7 @@ export default function PostcodeSearch({
|
|||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/postcode/${encodeURIComponent(trimmed)}`);
|
||||
const res = await fetch(`/api/postcode/${encodeURIComponent(trimmed)}`, authHeaders());
|
||||
if (!res.ok) {
|
||||
setError('Postcode not found');
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useMemo, useRef, useState, useEffect } from 'react';
|
||||
import type { PricePoint } from '../../types';
|
||||
import { formatValue } from '../../lib/format';
|
||||
import { formatValue, FEATURE_FORMATS } from '../../lib/format';
|
||||
|
||||
interface PriceHistoryChartProps {
|
||||
points: PricePoint[];
|
||||
|
|
@ -8,141 +8,159 @@ interface PriceHistoryChartProps {
|
|||
|
||||
const PADDING = { top: 8, right: 8, bottom: 20, left: 42 };
|
||||
const HEIGHT = 120;
|
||||
const priceFmt = FEATURE_FORMATS['Last known price'];
|
||||
|
||||
export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
||||
const { yearMin, yearMax, priceMin, priceMax, averages, priceTicks } = useMemo(() => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const w = entries[0].contentRect.width;
|
||||
if (w > 0) setWidth(w);
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const { yearMin, yearMax, priceMin, priceMax, medians, priceTicks } = useMemo(() => {
|
||||
let yMin = Infinity,
|
||||
yMax = -Infinity,
|
||||
pMin = Infinity,
|
||||
pMax = -Infinity;
|
||||
yMax = -Infinity;
|
||||
for (const p of points) {
|
||||
if (p.year < yMin) yMin = p.year;
|
||||
if (p.year > yMax) yMax = p.year;
|
||||
if (p.price < pMin) pMin = p.price;
|
||||
if (p.price > pMax) pMax = p.price;
|
||||
}
|
||||
// Add 5% padding to price range
|
||||
const pRange = pMax - pMin || 1;
|
||||
pMin = Math.max(0, pMin - pRange * 0.05);
|
||||
pMax = pMax + pRange * 0.05;
|
||||
|
||||
// Yearly averages
|
||||
const byYear = new Map<number, { sum: number; count: number }>();
|
||||
// Use p5/p95 to clip outliers
|
||||
const sorted = points.map((p) => p.price).sort((a, b) => a - b);
|
||||
const p5 = sorted[Math.floor(sorted.length * 0.05)];
|
||||
const p95 = sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95))];
|
||||
const pRange = p95 - p5 || 1;
|
||||
const pMin = Math.max(0, p5 - pRange * 0.1);
|
||||
const pMax = p95 + pRange * 0.1;
|
||||
|
||||
// Yearly medians (robust to outliers)
|
||||
const byYear = new Map<number, number[]>();
|
||||
for (const p of points) {
|
||||
const yr = Math.floor(p.year);
|
||||
const entry = byYear.get(yr);
|
||||
if (entry) {
|
||||
entry.sum += p.price;
|
||||
entry.count += 1;
|
||||
} else {
|
||||
byYear.set(yr, { sum: p.price, count: 1 });
|
||||
}
|
||||
const arr = byYear.get(yr);
|
||||
if (arr) arr.push(p.price);
|
||||
else byYear.set(yr, [p.price]);
|
||||
}
|
||||
const avgs = Array.from(byYear.entries())
|
||||
.map(([yr, { sum, count }]) => ({ year: yr + 0.5, price: sum / count }))
|
||||
const meds = Array.from(byYear.entries())
|
||||
.map(([yr, prices]) => {
|
||||
prices.sort((a, b) => a - b);
|
||||
const mid = Math.floor(prices.length / 2);
|
||||
const median =
|
||||
prices.length % 2 ? prices[mid] : (prices[mid - 1] + prices[mid]) / 2;
|
||||
return { year: yr + 0.5, price: median };
|
||||
})
|
||||
.sort((a, b) => a.year - b.year);
|
||||
|
||||
// Price ticks (3-5 nice round numbers)
|
||||
const ticks = niceTicksForRange(pMin, pMax, 4);
|
||||
|
||||
return { yearMin: yMin, yearMax: yMax, priceMin: pMin, priceMax: pMax, averages: avgs, priceTicks: ticks };
|
||||
return {
|
||||
yearMin: yMin,
|
||||
yearMax: yMax,
|
||||
priceMin: pMin,
|
||||
priceMax: pMax,
|
||||
medians: meds,
|
||||
priceTicks: ticks,
|
||||
};
|
||||
}, [points]);
|
||||
|
||||
const scaleY = (price: number) => {
|
||||
const ratio = (price - priceMin) / (priceMax - priceMin || 1);
|
||||
return PADDING.top + (1 - ratio) * (HEIGHT - PADDING.top - PADDING.bottom);
|
||||
};
|
||||
|
||||
const plotW = width - PADDING.left - PADDING.right;
|
||||
const plotH = HEIGHT - PADDING.top - PADDING.bottom;
|
||||
const yearRange = yearMax - yearMin || 1;
|
||||
|
||||
const scaleX = (year: number) => PADDING.left + ((year - yearMin) / yearRange) * plotW;
|
||||
const scaleY = (price: number) => {
|
||||
const t = (price - priceMin) / (priceMax - priceMin || 1);
|
||||
return PADDING.top + (1 - Math.max(0, Math.min(1, t))) * plotH;
|
||||
};
|
||||
|
||||
// Year labels: every 5 years
|
||||
const yearStart = Math.ceil(yearMin / 5) * 5;
|
||||
const yearLabels: number[] = [];
|
||||
for (let y = yearStart; y <= yearMax; y += 5) yearLabels.push(y);
|
||||
|
||||
const VB_W = 1000;
|
||||
|
||||
const scaleX = (year: number) => {
|
||||
const ratio = (year - yearMin) / yearRange;
|
||||
return PADDING.left + ratio * (VB_W - PADDING.left - PADDING.right);
|
||||
};
|
||||
|
||||
const avgPolyline = averages.map((a) => `${scaleX(a.year)},${scaleY(a.price)}`).join(' ');
|
||||
const medianPolyline = medians
|
||||
.map((a) => `${scaleX(a.year)},${scaleY(a.price)}`)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${VB_W} ${HEIGHT}`}
|
||||
preserveAspectRatio="none"
|
||||
className="w-full"
|
||||
style={{ height: HEIGHT }}
|
||||
>
|
||||
{/* Grid lines */}
|
||||
{priceTicks.map((tick) => (
|
||||
<line
|
||||
key={tick}
|
||||
x1={PADDING.left}
|
||||
y1={scaleY(tick)}
|
||||
x2={VB_W - PADDING.right}
|
||||
y2={scaleY(tick)}
|
||||
className="stroke-warm-200 dark:stroke-warm-700"
|
||||
strokeWidth={1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
))}
|
||||
<div ref={containerRef} style={{ height: HEIGHT }}>
|
||||
{width > 0 && (
|
||||
<svg width={width} height={HEIGHT}>
|
||||
{/* Grid lines */}
|
||||
{priceTicks.map((tick) => (
|
||||
<line
|
||||
key={tick}
|
||||
x1={PADDING.left}
|
||||
y1={scaleY(tick)}
|
||||
x2={width - PADDING.right}
|
||||
y2={scaleY(tick)}
|
||||
className="stroke-warm-200 dark:stroke-warm-700"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Dots */}
|
||||
{points.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={scaleX(p.year)}
|
||||
cy={scaleY(p.price)}
|
||||
r={4}
|
||||
className="fill-teal-500 dark:fill-teal-400"
|
||||
opacity={0.35}
|
||||
/>
|
||||
))}
|
||||
{/* Dots (clamp outliers to visible range) */}
|
||||
{points.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={scaleX(p.year)}
|
||||
cy={scaleY(p.price)}
|
||||
r={3}
|
||||
className="fill-teal-500 dark:fill-teal-400"
|
||||
opacity={0.35}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Average line */}
|
||||
{averages.length > 1 && (
|
||||
<polyline
|
||||
points={avgPolyline}
|
||||
fill="none"
|
||||
className="stroke-teal-600 dark:stroke-teal-400"
|
||||
strokeWidth={3}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Median line */}
|
||||
{medians.length > 1 && (
|
||||
<polyline
|
||||
points={medianPolyline}
|
||||
fill="none"
|
||||
className="stroke-teal-600 dark:stroke-teal-400"
|
||||
strokeWidth={2}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Y-axis labels */}
|
||||
{priceTicks.map((tick) => (
|
||||
<text
|
||||
key={`label-${tick}`}
|
||||
x={PADDING.left - 4}
|
||||
y={scaleY(tick)}
|
||||
textAnchor="end"
|
||||
dominantBaseline="middle"
|
||||
className="fill-warm-500 dark:fill-warm-400"
|
||||
fontSize={10}
|
||||
>
|
||||
{formatValue(tick, priceFmt)}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* X-axis year labels */}
|
||||
{yearLabels.map((yr) => (
|
||||
<text
|
||||
key={yr}
|
||||
x={scaleX(yr)}
|
||||
y={HEIGHT - 2}
|
||||
textAnchor="middle"
|
||||
className="fill-warm-500 dark:fill-warm-400"
|
||||
fontSize={10}
|
||||
>
|
||||
{yr}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* Y-axis labels */}
|
||||
{priceTicks.map((tick) => (
|
||||
<text
|
||||
key={`label-${tick}`}
|
||||
x={PADDING.left - 4}
|
||||
y={scaleY(tick)}
|
||||
textAnchor="end"
|
||||
dominantBaseline="middle"
|
||||
className="fill-warm-500 dark:fill-warm-400"
|
||||
style={{ fontSize: 28 }}
|
||||
>
|
||||
{formatValue(tick)}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* X-axis year labels */}
|
||||
{yearLabels.map((yr) => (
|
||||
<text
|
||||
key={yr}
|
||||
x={scaleX(yr)}
|
||||
y={HEIGHT - 2}
|
||||
textAnchor="middle"
|
||||
className="fill-warm-500 dark:fill-warm-400"
|
||||
style={{ fontSize: 28 }}
|
||||
>
|
||||
{yr}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -151,7 +169,6 @@ function niceTicksForRange(min: number, max: number, count: number): number[] {
|
|||
const range = max - min;
|
||||
if (range <= 0) return [min];
|
||||
const rough = range / count;
|
||||
// Round to a nice step: 1, 2, 5, 10, 20, 50, 100k, 200k, 500k, etc.
|
||||
const magnitude = Math.pow(10, Math.floor(Math.log10(rough)));
|
||||
let step: number;
|
||||
const normalized = rough / magnitude;
|
||||
|
|
|
|||
|
|
@ -17,8 +17,6 @@ interface PropertiesPaneProps {
|
|||
onNavigateToSource?: (slug: string) => void;
|
||||
}
|
||||
|
||||
type SortBy = 'price' | 'size' | 'energy';
|
||||
|
||||
export function PropertiesPane({
|
||||
properties,
|
||||
total,
|
||||
|
|
@ -28,30 +26,19 @@ export function PropertiesPane({
|
|||
onClose,
|
||||
onNavigateToSource,
|
||||
}: PropertiesPaneProps) {
|
||||
const [sortBy, setSortBy] = useState<SortBy>('price');
|
||||
const [search, setSearch] = useState('');
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
|
||||
const filteredAndSorted = useMemo(() => {
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
const filtered = query
|
||||
return 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) {
|
||||
case 'price':
|
||||
return ((b.latest_price as number) || 0) - ((a.latest_price as number) || 0);
|
||||
case 'size':
|
||||
return ((b.total_floor_area as number) || 0) - ((a.total_floor_area as number) || 0);
|
||||
case 'energy':
|
||||
return (a.current_energy_rating || 'Z').localeCompare(b.current_energy_rating || 'Z');
|
||||
}
|
||||
});
|
||||
}, [properties, sortBy, search]);
|
||||
}, [properties, search]);
|
||||
|
||||
if (!hexagonId) {
|
||||
return (
|
||||
|
|
@ -91,22 +78,13 @@ export function PropertiesPane({
|
|||
</InfoPopup>
|
||||
)}
|
||||
|
||||
<div className="p-2 border-b border-warm-200 dark:border-navy-700 space-y-2">
|
||||
<div className="p-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search by address or postcode..."
|
||||
className="p-2"
|
||||
/>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as SortBy)}
|
||||
className="w-full p-2 border border-warm-300 dark:border-navy-700 rounded text-sm bg-white dark:bg-navy-800 dark:text-warm-200"
|
||||
>
|
||||
<option value="price">Price (High to Low)</option>
|
||||
<option value="size">Size (Large to Small)</option>
|
||||
<option value="energy">Energy Rating (Best to Worst)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
|
|
@ -114,7 +92,7 @@ export function PropertiesPane({
|
|||
<div className="p-4 dark:text-warm-400">Loading...</div>
|
||||
) : (
|
||||
<>
|
||||
{filteredAndSorted.map((property, idx) => (
|
||||
{filtered.map((property, idx) => (
|
||||
<PropertyCard key={idx} property={property} />
|
||||
))}
|
||||
{properties.length < total && (
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ interface Segment {
|
|||
interface StackedBarChartProps {
|
||||
segments: Segment[];
|
||||
total: number;
|
||||
/** Optional custom colors keyed by segment name. Falls back to SEGMENT_COLORS. */
|
||||
colorMap?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Strip common suffixes/prefixes to produce short legend labels */
|
||||
|
|
@ -26,7 +28,7 @@ function shortenLabel(name: string): string {
|
|||
.trim();
|
||||
}
|
||||
|
||||
export default function StackedBarChart({ segments, total }: StackedBarChartProps) {
|
||||
export default function StackedBarChart({ segments, total, colorMap }: StackedBarChartProps) {
|
||||
const sortedSegments = useMemo(
|
||||
() => [...segments].sort((a, b) => b.value - a.value),
|
||||
[segments]
|
||||
|
|
@ -51,7 +53,7 @@ export default function StackedBarChart({ segments, total }: StackedBarChartProp
|
|||
className="h-full"
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
backgroundColor: SEGMENT_COLORS[i % SEGMENT_COLORS.length],
|
||||
backgroundColor: colorMap?.[segment.name] ?? SEGMENT_COLORS[i % SEGMENT_COLORS.length],
|
||||
}}
|
||||
title={`${shortenLabel(segment.name)}: ${formatValue(segment.value)} (${pct.toFixed(1)}%)`}
|
||||
/>
|
||||
|
|
@ -66,7 +68,7 @@ export default function StackedBarChart({ segments, total }: StackedBarChartProp
|
|||
<span
|
||||
className="w-2 h-2 rounded-sm shrink-0"
|
||||
style={{
|
||||
backgroundColor: SEGMENT_COLORS[i % SEGMENT_COLORS.length],
|
||||
backgroundColor: colorMap?.[segment.name] ?? SEGMENT_COLORS[i % SEGMENT_COLORS.length],
|
||||
}}
|
||||
/>
|
||||
<span className="text-[10px] text-warm-600 dark:text-warm-400">
|
||||
|
|
|
|||
78
frontend/src/components/map/StackedEnumChart.tsx
Normal file
78
frontend/src/components/map/StackedEnumChart.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import type { EnumFeatureStats } from '../../types';
|
||||
|
||||
interface StackedEnumChartProps {
|
||||
components: { label: string; stats: EnumFeatureStats }[];
|
||||
valueOrder: string[];
|
||||
valueColors: string[];
|
||||
}
|
||||
|
||||
/** Strip common suffixes to produce short row labels */
|
||||
function shortenLabel(name: string): string {
|
||||
return name.replace(/ risk$/, '');
|
||||
}
|
||||
|
||||
export default function StackedEnumChart({
|
||||
components,
|
||||
valueOrder,
|
||||
valueColors,
|
||||
}: StackedEnumChartProps) {
|
||||
const visibleRows = components.filter(({ stats }) => {
|
||||
const total = Object.values(stats.counts).reduce((a, b) => a + b, 0);
|
||||
if (total === 0) return false;
|
||||
const lowCount = stats.counts[valueOrder[0]] ?? 0;
|
||||
return total - lowCount > 0;
|
||||
});
|
||||
|
||||
if (visibleRows.length === 0) {
|
||||
return (
|
||||
<div className="text-xs text-warm-400 dark:text-warm-500 italic mt-1">All low</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{visibleRows.map(({ label, stats }) => {
|
||||
const total = Object.values(stats.counts).reduce((a, b) => a + b, 0);
|
||||
|
||||
return (
|
||||
<div key={label} className="flex items-center gap-2 text-xs">
|
||||
<span className="w-24 truncate text-warm-500 dark:text-warm-400 text-right shrink-0">
|
||||
{shortenLabel(label)}
|
||||
</span>
|
||||
<div className="flex-1 flex h-3.5 rounded overflow-hidden bg-warm-200 dark:bg-warm-700">
|
||||
{valueOrder.map((value, i) => {
|
||||
const count = stats.counts[value] ?? 0;
|
||||
const pct = (count / total) * 100;
|
||||
if (pct < 0.5) return null;
|
||||
return (
|
||||
<div
|
||||
key={value}
|
||||
className="h-full"
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
backgroundColor: valueColors[i],
|
||||
}}
|
||||
title={`${value}: ${count} (${pct.toFixed(0)}%)`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex gap-x-3 gap-y-0.5 justify-center">
|
||||
{valueOrder.map((value, i) => (
|
||||
<div key={value} className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-2 h-2 rounded-sm shrink-0"
|
||||
style={{ backgroundColor: valueColors[i] }}
|
||||
/>
|
||||
<span className="text-[10px] text-warm-600 dark:text-warm-400">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
frontend/src/components/ui/AuthModal.tsx
Normal file
154
frontend/src/components/ui/AuthModal.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { CloseIcon } from './icons/CloseIcon';
|
||||
|
||||
type Tab = 'login' | 'register';
|
||||
|
||||
export default function AuthModal({
|
||||
onClose,
|
||||
onLogin,
|
||||
onRegister,
|
||||
loading,
|
||||
error,
|
||||
onClearError,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onLogin: (email: string, password: string) => Promise<void>;
|
||||
onRegister: (email: string, password: string, name?: string) => Promise<void>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onClearError: () => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<Tab>('login');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
|
||||
const switchTab = useCallback(
|
||||
(newTab: Tab) => {
|
||||
setTab(newTab);
|
||||
onClearError();
|
||||
},
|
||||
[onClearError]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (tab === 'login') {
|
||||
await onLogin(email, password);
|
||||
} else {
|
||||
await onRegister(email, password, name || undefined);
|
||||
}
|
||||
onClose();
|
||||
} catch {
|
||||
// Error is handled by the hook
|
||||
}
|
||||
},
|
||||
[tab, email, password, name, onLogin, onRegister, onClose]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
|
||||
<div className="absolute inset-0 bg-black/50" />
|
||||
<div
|
||||
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 pt-5 pb-3">
|
||||
<h2 className="text-lg font-semibold text-navy-950 dark:text-warm-100">
|
||||
{tab === 'login' ? 'Log in' : 'Create account'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||
>
|
||||
<CloseIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex px-5 gap-4 border-b border-warm-200 dark:border-warm-700">
|
||||
<button
|
||||
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === 'login'
|
||||
? 'border-teal-600 text-teal-600 dark:text-teal-400 dark:border-teal-400'
|
||||
: 'border-transparent text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
|
||||
}`}
|
||||
onClick={() => switchTab('login')}
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
<button
|
||||
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === 'register'
|
||||
? 'border-teal-600 text-teal-600 dark:text-teal-400 dark:border-teal-400'
|
||||
: 'border-transparent text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
|
||||
}`}
|
||||
onClick={() => switchTab('register')}
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
||||
{tab === 'register' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400"
|
||||
placeholder="Your name (optional)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400"
|
||||
placeholder={tab === 'register' ? 'Min 8 characters' : 'Your password'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 rounded bg-teal-600 text-white text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
|
||||
>
|
||||
{loading ? 'Please wait...' : tab === 'login' ? 'Log in' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -17,8 +17,8 @@ export function FeatureLabel({
|
|||
const textClass = size === 'sm' ? 'text-sm' : 'text-xs';
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-1 min-w-0 ${className}`}>
|
||||
<span className={`${textClass} text-warm-700 dark:text-warm-300 truncate`}>
|
||||
<div className={`flex ${size === 'xs' ? 'items-center' : 'items-start'} gap-1 min-w-0 ${className}`}>
|
||||
<span className={`${textClass} text-warm-700 dark:text-warm-300 ${size === 'xs' ? 'truncate' : ''}`}>
|
||||
{feature.name}
|
||||
</span>
|
||||
{feature.detail && onShowInfo && (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
import { DownloadIcon } from './icons/DownloadIcon';
|
||||
import { MapPinIcon } from './icons/MapPinIcon';
|
||||
import { CheckIcon } from './icons/CheckIcon';
|
||||
import { ClipboardIcon } from './icons/ClipboardIcon';
|
||||
import { SunIcon } from './icons/SunIcon';
|
||||
import { MoonIcon } from './icons/MoonIcon';
|
||||
import UserMenu from './UserMenu';
|
||||
|
||||
export type Page = 'home' | 'dashboard' | 'data-sources' | 'faq';
|
||||
|
||||
|
|
@ -7,11 +15,21 @@ export default function Header({
|
|||
onPageChange,
|
||||
theme,
|
||||
onToggleTheme,
|
||||
onExport,
|
||||
exporting,
|
||||
user,
|
||||
onLoginClick,
|
||||
onLogout,
|
||||
}: {
|
||||
activePage: Page;
|
||||
onPageChange: (page: Page) => void;
|
||||
theme: 'light' | 'dark';
|
||||
onToggleTheme: () => void;
|
||||
onExport: (() => void) | null;
|
||||
exporting: boolean;
|
||||
user: AuthUser | null;
|
||||
onLoginClick: () => void;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
|
|
@ -36,25 +54,7 @@ export default function Header({
|
|||
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>
|
||||
<MapPinIcon className="w-5 h-5 text-teal-400" />
|
||||
<span className="font-semibold text-lg">Narrowit</span>
|
||||
</button>
|
||||
<nav className="flex items-center gap-2">
|
||||
|
|
@ -70,73 +70,52 @@ export default function Header({
|
|||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{activePage === 'dashboard' && (
|
||||
<>
|
||||
<button
|
||||
onClick={onExport ?? undefined}
|
||||
disabled={!onExport || exporting}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50 disabled:cursor-wait"
|
||||
title="Export to Excel"
|
||||
>
|
||||
<DownloadIcon className="w-4 h-4" />
|
||||
{exporting ? 'Exporting...' : 'Export'}
|
||||
</button>
|
||||
<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 ? (
|
||||
<>
|
||||
<CheckIcon className="w-4 h-4" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ClipboardIcon className="w-4 h-4" />
|
||||
Share
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{user ? (
|
||||
<UserMenu user={user} onLogout={onLogout} />
|
||||
) : (
|
||||
<button
|
||||
onClick={onLoginClick}
|
||||
className="px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
{theme === 'light' ? <SunIcon className="w-4 h-4" /> : <MoonIcon className="w-4 h-4" />}
|
||||
</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>
|
||||
);
|
||||
|
|
|
|||
57
frontend/src/components/ui/UserMenu.tsx
Normal file
57
frontend/src/components/ui/UserMenu.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
|
||||
export default function UserMenu({ user, onLogout }: { user: AuthUser; onLogout: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [open]);
|
||||
|
||||
const initial = (user.name || user.email)[0].toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className="flex items-center justify-center w-8 h-8 rounded-full bg-teal-600 text-white text-sm font-medium hover:bg-teal-700"
|
||||
title={user.name || user.email}
|
||||
>
|
||||
{initial}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-10 w-56 bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg shadow-lg z-50">
|
||||
<div className="px-4 py-3 border-b border-warm-200 dark:border-warm-700">
|
||||
{user.name && (
|
||||
<p className="text-sm font-medium text-navy-950 dark:text-warm-100 truncate">
|
||||
{user.name}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-warm-500 dark:text-warm-400 truncate">{user.email}</p>
|
||||
</div>
|
||||
<div className="p-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onLogout();
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-sm text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700 rounded"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
frontend/src/components/ui/icons/CheckIcon.tsx
Normal file
11
frontend/src/components/ui/icons/CheckIcon.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CheckIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
15
frontend/src/components/ui/icons/ClipboardIcon.tsx
Normal file
15
frontend/src/components/ui/icons/ClipboardIcon.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ClipboardIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
12
frontend/src/components/ui/icons/DownloadIcon.tsx
Normal file
12
frontend/src/components/ui/icons/DownloadIcon.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DownloadIcon({ className = 'w-3.5 h-3.5' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m0 0l-6-6m6 6l6-6" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 21h14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/ui/icons/MapPinIcon.tsx
Normal file
20
frontend/src/components/ui/icons/MapPinIcon.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MapPinIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
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"
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
15
frontend/src/components/ui/icons/MoonIcon.tsx
Normal file
15
frontend/src/components/ui/icons/MoonIcon.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MoonIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" 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>
|
||||
);
|
||||
}
|
||||
12
frontend/src/components/ui/icons/SpinnerIcon.tsx
Normal file
12
frontend/src/components/ui/icons/SpinnerIcon.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SpinnerIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
15
frontend/src/components/ui/icons/SunIcon.tsx
Normal file
15
frontend/src/components/ui/icons/SunIcon.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SunIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" 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>
|
||||
);
|
||||
}
|
||||
108
frontend/src/hooks/useAuth.ts
Normal file
108
frontend/src/hooks/useAuth.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import pb from '../lib/pocketbase';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
// PocketBase RecordModel stores user fields as dynamic properties
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function recordToUser(record: any): AuthUser {
|
||||
return {
|
||||
id: record.id || '',
|
||||
email: record.email || '',
|
||||
name: record.name || '',
|
||||
avatar: record.avatar || '',
|
||||
verified: record.verified || false,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const [user, setUser] = useState<AuthUser | null>(() => {
|
||||
if (pb.authStore.isValid && pb.authStore.record) {
|
||||
return recordToUser(pb.authStore.record);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Sync with authStore changes (cross-tab, external updates)
|
||||
useEffect(() => {
|
||||
const unsubscribe = pb.authStore.onChange(() => {
|
||||
if (pb.authStore.isValid && pb.authStore.record) {
|
||||
setUser(recordToUser(pb.authStore.record));
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await pb.collection('users').authWithPassword(email, password);
|
||||
setUser(recordToUser(result.record));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Login failed';
|
||||
setError(msg);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (email: string, password: string, name?: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await pb.collection('users').create({
|
||||
email,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
name: name || '',
|
||||
});
|
||||
// Auto-login after registration
|
||||
const result = await pb.collection('users').authWithPassword(email, password);
|
||||
setUser(recordToUser(result.record));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Registration failed';
|
||||
setError(msg);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loginWithOAuth = useCallback(async (provider: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await pb.collection('users').authWithOAuth2({ provider });
|
||||
setUser(recordToUser(result.record));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'OAuth login failed';
|
||||
setError(msg);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
pb.authStore.clear();
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return { user, loading, error, login, register, loginWithOAuth, logout, clearError };
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ import type {
|
|||
HexagonStatsResponse,
|
||||
NumericFeatureStats,
|
||||
} from '../types';
|
||||
import { buildFilterString, apiUrl, logNonAbortError } from '../lib/api';
|
||||
import { buildFilterString, apiUrl, logNonAbortError, authHeaders } from '../lib/api';
|
||||
|
||||
interface SelectedHexagon {
|
||||
id: string;
|
||||
|
|
@ -50,7 +50,7 @@ export function useHexagonSelection({
|
|||
if (fields) {
|
||||
params.set('fields', fields.join(','));
|
||||
}
|
||||
const response = await fetch(apiUrl('hexagon-stats', params), { signal });
|
||||
const response = await fetch(apiUrl('hexagon-stats', params), authHeaders({ signal }));
|
||||
return (await response.json()) as HexagonStatsResponse;
|
||||
},
|
||||
[filters, features]
|
||||
|
|
@ -96,7 +96,7 @@ export function useHexagonSelection({
|
|||
const filterStr = buildFilterString(filters, features);
|
||||
if (filterStr) params.append('filters', filterStr);
|
||||
|
||||
const response = await fetch(apiUrl('hexagon-properties', params));
|
||||
const response = await fetch(apiUrl('hexagon-properties', params), authHeaders());
|
||||
const data: HexagonPropertiesResponse = await response.json();
|
||||
|
||||
if (offset === 0) {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import type {
|
|||
ViewChangeParams,
|
||||
ApiResponse,
|
||||
} from '../types';
|
||||
import { buildFilterString, apiUrl, logNonAbortError } from '../lib/api';
|
||||
import { buildFilterString, apiUrl, logNonAbortError, authHeaders } from '../lib/api';
|
||||
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/map-utils';
|
||||
|
||||
const DEBOUNCE_MS = 150;
|
||||
|
|
@ -76,9 +76,12 @@ export function useMapData({
|
|||
const params = new URLSearchParams({ bounds: boundsStr });
|
||||
if (filtersStr) params.set('filters', filtersStr);
|
||||
params.set('fields', viewFeature || '');
|
||||
const res = await fetch(apiUrl('postcodes', params), {
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
const res = await fetch(
|
||||
apiUrl('postcodes', params),
|
||||
authHeaders({
|
||||
signal: abortControllerRef.current.signal,
|
||||
})
|
||||
);
|
||||
const json: { features: PostcodeFeature[] } = await res.json();
|
||||
setPostcodeData(json.features || []);
|
||||
setRawData([]);
|
||||
|
|
@ -89,9 +92,12 @@ export function useMapData({
|
|||
});
|
||||
if (filtersStr) params.set('filters', filtersStr);
|
||||
params.set('fields', viewFeature || '');
|
||||
const res = await fetch(apiUrl('hexagons', params), {
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
const res = await fetch(
|
||||
apiUrl('hexagons', params),
|
||||
authHeaders({
|
||||
signal: abortControllerRef.current.signal,
|
||||
})
|
||||
);
|
||||
const json: ApiResponse = await res.json();
|
||||
setRawData(json.features || []);
|
||||
setPostcodeData([]);
|
||||
|
|
@ -162,7 +168,13 @@ export function useMapData({
|
|||
}, [viewFeature, features, dataRange, activeFeature, dragValue]);
|
||||
|
||||
const handleViewChange = useCallback(
|
||||
({ resolution: newRes, bounds: newBounds, zoom: newZoom, latitude, longitude }: ViewChangeParams) => {
|
||||
({
|
||||
resolution: newRes,
|
||||
bounds: newBounds,
|
||||
zoom: newZoom,
|
||||
latitude,
|
||||
longitude,
|
||||
}: ViewChangeParams) => {
|
||||
const boundsKey = `${newBounds.south},${newBounds.west},${newBounds.north},${newBounds.east},${newRes}`;
|
||||
if (boundsKey !== prevBoundsRef.current) {
|
||||
prevBoundsRef.current = boundsKey;
|
||||
|
|
@ -175,10 +187,13 @@ export function useMapData({
|
|||
[]
|
||||
);
|
||||
|
||||
const setInitialView = useCallback((view: { latitude: number; longitude: number; zoom: number }) => {
|
||||
setCurrentView(view);
|
||||
setZoom(view.zoom);
|
||||
}, []);
|
||||
const setInitialView = useCallback(
|
||||
(view: { latitude: number; longitude: number; zoom: number }) => {
|
||||
setCurrentView(view);
|
||||
setZoom(view.zoom);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return {
|
||||
data,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useEffect, useRef } from 'react';
|
||||
import type { Bounds, POI, POIResponse } from '../types';
|
||||
import { apiUrl, logNonAbortError } from '../lib/api';
|
||||
import { apiUrl, logNonAbortError, authHeaders } from '../lib/api';
|
||||
|
||||
const DEBOUNCE_MS = 150;
|
||||
|
||||
|
|
@ -32,9 +32,12 @@ export function usePOIData(bounds: Bounds | null, selectedCategories: Set<string
|
|||
categories: categoriesStr,
|
||||
bounds: boundsStr,
|
||||
});
|
||||
const res = await fetch(apiUrl('pois', params), {
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
const res = await fetch(
|
||||
apiUrl('pois', params),
|
||||
authHeaders({
|
||||
signal: abortControllerRef.current.signal,
|
||||
})
|
||||
);
|
||||
const json: POIResponse = await res.json();
|
||||
setPois(json.pois || []);
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ body,
|
|||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
|
|
|
|||
|
|
@ -1,25 +1,29 @@
|
|||
import type { FeatureMeta, FeatureFilters } from '../types';
|
||||
|
||||
const INITIAL_RETRY_MS = 1000;
|
||||
const MAX_RETRY_MS = 10000;
|
||||
|
||||
// Error handling utilities
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === 'AbortError';
|
||||
}
|
||||
import { INITIAL_RETRY_MS, MAX_RETRY_MS } from './consts';
|
||||
import pb from './pocketbase';
|
||||
|
||||
export function logNonAbortError(label: string, error: unknown): void {
|
||||
if (!isAbortError(error)) {
|
||||
console.error(`${label}:`, error);
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`${label}:`, error);
|
||||
}
|
||||
|
||||
export function authHeaders(init?: RequestInit): RequestInit {
|
||||
const headers: Record<string, string> = {};
|
||||
if (pb.authStore.isValid && pb.authStore.token) {
|
||||
headers['Authorization'] = `Bearer ${pb.authStore.token}`;
|
||||
}
|
||||
if (!init) return { headers };
|
||||
const existing = init.headers as Record<string, string> | undefined;
|
||||
return { ...init, headers: { ...existing, ...headers } };
|
||||
}
|
||||
|
||||
// API URL helper
|
||||
export function apiUrl(endpoint: string, params?: URLSearchParams): string {
|
||||
const base = getApiBaseUrl();
|
||||
const path = endpoint.startsWith('/') ? endpoint : `/api/${endpoint}`;
|
||||
const query = params?.toString();
|
||||
return query ? `${base}${path}?${query}` : `${base}${path}`;
|
||||
return query ? `${path}?${query}` : path;
|
||||
}
|
||||
|
||||
export async function fetchWithRetry<T>(
|
||||
|
|
@ -30,7 +34,7 @@ export async function fetchWithRetry<T>(
|
|||
let delay = INITIAL_RETRY_MS;
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
const res = await fetch(url, { signal });
|
||||
const res = await fetch(url, authHeaders({ signal }));
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
onSuccess(json);
|
||||
|
|
@ -44,26 +48,6 @@ export async function fetchWithRetry<T>(
|
|||
}
|
||||
}
|
||||
|
||||
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 '';
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import type { ViewState } from '../types';
|
||||
|
||||
|
||||
export const INITIAL_RETRY_MS = 1000;
|
||||
export const MAX_RETRY_MS = 10000;
|
||||
|
||||
|
||||
export const MAP_BOUNDS: [number, number, number, number] = [-9.5, 49, 5, 57];
|
||||
export const MAP_MIN_ZOOM = 5.5;
|
||||
|
||||
/** Maximum zoom level for tile fetching (map extrapolates beyond this) */
|
||||
export const TILE_MAX_ZOOM = 15;
|
||||
|
||||
/** Initial map view state */
|
||||
export const INITIAL_VIEW_STATE: ViewState = {
|
||||
longitude: -1.5,
|
||||
latitude: 53.5,
|
||||
|
|
@ -27,14 +29,11 @@ export const ZOOM_TO_RESOLUTION_THRESHOLDS = [
|
|||
{ maxZoom: 13, resolution: 9 },
|
||||
{ maxZoom: Infinity, resolution: 10 },
|
||||
] as const;
|
||||
export const POSTCODE_ZOOM_THRESHOLD = 15;
|
||||
|
||||
export const POSTCODE_ZOOM_THRESHOLD = 17.5;
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Color Gradients
|
||||
// =============================================================================
|
||||
|
||||
/** Feature value gradient (green → yellow → red → purple) */
|
||||
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||
{ t: 0, color: [46, 204, 113] },
|
||||
{ t: 0.33, color: [241, 196, 15] },
|
||||
|
|
@ -42,34 +41,37 @@ export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[]
|
|||
{ t: 1, color: [142, 68, 173] },
|
||||
];
|
||||
|
||||
/** Property density gradient (teal → blue → purple) */
|
||||
/** Property density gradient — light mode (cream → orange) */
|
||||
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] },
|
||||
{ t: 0, color: [255, 255, 255] },
|
||||
{ t: 0.1, color: [248, 233, 211] },
|
||||
{ t: 0.5, color: [255, 221, 173] },
|
||||
{ t: 0.8, color: [251, 171, 60] },
|
||||
{ t: 1, color: [255, 162, 31] },
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// External URLs
|
||||
// =============================================================================
|
||||
/** Property density gradient — dark mode (dark warm → bright amber) */
|
||||
export const DENSITY_GRADIENT_DARK: { t: number; color: [number, number, number] }[] = [
|
||||
{ t: 0, color: [55, 45, 35] },
|
||||
{ t: 0.1, color: [85, 65, 40] },
|
||||
{ t: 0.5, color: [170, 115, 50] },
|
||||
{ t: 0.8, color: [230, 155, 45] },
|
||||
{ t: 1, color: [255, 170, 40] },
|
||||
];
|
||||
|
||||
/** Protomaps font glyphs URL */
|
||||
export const GLYPHS_URL = 'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf';
|
||||
/** Protomaps font glyphs URL (served locally from public/assets/) */
|
||||
export const GLYPHS_URL = '/assets/fonts/{fontstack}/{range}.pbf';
|
||||
|
||||
/** Protomaps sprite base URL */
|
||||
export const SPRITE_URL_BASE = 'https://protomaps.github.io/basemaps-assets/sprites/v4';
|
||||
/** Twemoji base URL (served locally from public/assets/) */
|
||||
export const TWEMOJI_BASE = '/assets/twemoji/';
|
||||
|
||||
/** Twemoji CDN base URL */
|
||||
export const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/';
|
||||
|
||||
/** OpenStreetMap attribution HTML */
|
||||
export const OSM_ATTRIBUTION = '© <a href="https://openstreetmap.org">OpenStreetMap</a>';
|
||||
|
||||
// =============================================================================
|
||||
// Stacked Chart Groups
|
||||
// =============================================================================
|
||||
|
||||
export interface StackedChartConfig {
|
||||
/**
|
||||
* Groups whose features should be collapsed into stacked bar charts.
|
||||
* Keyed by feature group name. Each entry defines one stacked chart.
|
||||
*/
|
||||
export const STACKED_GROUPS: Record<string, {
|
||||
/** Display label for the chart */
|
||||
label: string;
|
||||
/** If set, use this feature's stats for the total and info popup. Otherwise sum components. */
|
||||
|
|
@ -78,13 +80,7 @@ export interface StackedChartConfig {
|
|||
unit?: string;
|
||||
/** Feature names that make up the segments */
|
||||
components: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups whose features should be collapsed into stacked bar charts.
|
||||
* Keyed by feature group name. Each entry defines one stacked chart.
|
||||
*/
|
||||
export const STACKED_GROUPS: Record<string, StackedChartConfig[]> = {
|
||||
}[]> = {
|
||||
Crime: [
|
||||
{
|
||||
label: 'Serious crime',
|
||||
|
|
@ -124,6 +120,56 @@ export const STACKED_GROUPS: Record<string, StackedChartConfig[]> = {
|
|||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Groups whose enum features should be collapsed into compact multi-row charts.
|
||||
* Keyed by feature group name. Each entry defines one stacked enum chart.
|
||||
*/
|
||||
export const STACKED_ENUM_GROUPS: Record<string, {
|
||||
/** Display label for the chart */
|
||||
label: string;
|
||||
/** If set, use this feature for the info popup */
|
||||
feature?: string;
|
||||
/** Enum feature names that make up the rows */
|
||||
components: string[];
|
||||
/** Value order for consistent segment ordering */
|
||||
valueOrder: string[];
|
||||
/** Colors for each value (matches valueOrder) */
|
||||
valueColors: string[];
|
||||
}[]> = {
|
||||
Property: [
|
||||
{
|
||||
label: 'Property type',
|
||||
feature: 'Property type',
|
||||
components: ['Property type'],
|
||||
valueOrder: ['Detached', 'Semi-Detached', 'Terraced', 'Flat'],
|
||||
valueColors: ['#8b5cf6', '#3b82f6', '#14b8a6', '#f59e0b'],
|
||||
},
|
||||
{
|
||||
label: 'Leasehold/Freehold',
|
||||
feature: 'Leashold/Freehold',
|
||||
components: ['Leashold/Freehold'],
|
||||
valueOrder: ['Freehold', 'Leasehold'],
|
||||
valueColors: ['#3b82f6', '#f59e0b'],
|
||||
},
|
||||
],
|
||||
Environment: [
|
||||
{
|
||||
label: 'Ground Risk',
|
||||
feature: 'Environmental risk',
|
||||
components: [
|
||||
'Collapsible deposits risk',
|
||||
'Compressible ground risk',
|
||||
'Landslide risk',
|
||||
'Running sand risk',
|
||||
'Shrink-swell risk',
|
||||
'Soluble rocks risk',
|
||||
],
|
||||
valueOrder: ['Low', 'Moderate', 'Significant'],
|
||||
valueColors: ['#22c55e', '#eab308', '#ef4444'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Colors for stacked bar segments */
|
||||
export const SEGMENT_COLORS = [
|
||||
'#ef4444', // red-500
|
||||
|
|
|
|||
|
|
@ -1,10 +1,62 @@
|
|||
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 interface ValueFormat {
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
/** Show full integer (no k/M abbreviation) */
|
||||
raw?: boolean;
|
||||
}
|
||||
|
||||
export function formatValue(value: number, fmt?: ValueFormat): string {
|
||||
const p = fmt?.prefix ?? '';
|
||||
const s = fmt?.suffix ?? '';
|
||||
if (fmt?.raw) return `${p}${Math.round(value)}${s}`;
|
||||
if (Math.abs(value) >= 1_000_000) return `${p}${(value / 1_000_000).toFixed(1)}M${s}`;
|
||||
if (Math.abs(value) >= 1_000) return `${p}${(value / 1_000).toFixed(1)}k${s}`;
|
||||
if (Number.isInteger(value)) return `${p}${value.toLocaleString()}${s}`;
|
||||
return `${p}${value.toFixed(1)}${s}`;
|
||||
}
|
||||
|
||||
/** Lookup table for feature-specific formatting */
|
||||
export const FEATURE_FORMATS: Record<string, ValueFormat> = {
|
||||
// Property
|
||||
'Last known price': { prefix: '£' },
|
||||
'Price per sqm': { prefix: '£' },
|
||||
'Total floor area (sqm)': { suffix: ' sqm' },
|
||||
'Number of bedrooms & living rooms': { suffix: ' rooms' },
|
||||
'Transaction year': { raw: true },
|
||||
'Construction age': { raw: true },
|
||||
// Transport
|
||||
'Public transport to Bank (mins)': { suffix: ' mins' },
|
||||
'Public transport to Fitzrovia (mins)': { suffix: ' mins' },
|
||||
'Cycling to Bank (mins)': { suffix: ' mins' },
|
||||
'Cycling to Fitzrovia (mins)': { suffix: ' mins' },
|
||||
// Crime
|
||||
'Anti-social behaviour (avg/yr)': { suffix: '/yr' },
|
||||
'Violence and sexual offences (avg/yr)': { suffix: '/yr' },
|
||||
'Criminal damage and arson (avg/yr)': { suffix: '/yr' },
|
||||
'Burglary (avg/yr)': { suffix: '/yr' },
|
||||
'Vehicle crime (avg/yr)': { suffix: '/yr' },
|
||||
'Robbery (avg/yr)': { suffix: '/yr' },
|
||||
'Other theft (avg/yr)': { suffix: '/yr' },
|
||||
'Shoplifting (avg/yr)': { suffix: '/yr' },
|
||||
'Drugs (avg/yr)': { suffix: '/yr' },
|
||||
'Possession of weapons (avg/yr)': { suffix: '/yr' },
|
||||
'Public order (avg/yr)': { suffix: '/yr' },
|
||||
'Bicycle theft (avg/yr)': { suffix: '/yr' },
|
||||
'Theft from the person (avg/yr)': { suffix: '/yr' },
|
||||
'Other crime (avg/yr)': { suffix: '/yr' },
|
||||
'Serious crime (avg/yr)': { suffix: '/yr' },
|
||||
'Minor crime (avg/yr)': { suffix: '/yr' },
|
||||
// Demographics
|
||||
'% White': { suffix: '%' },
|
||||
'% Asian': { suffix: '%' },
|
||||
'% Black': { suffix: '%' },
|
||||
'% Mixed': { suffix: '%' },
|
||||
'% Other': { suffix: '%' },
|
||||
// Environment
|
||||
'Noise (dB)': { suffix: ' dB' },
|
||||
'Max available download speed (Mbps)': { suffix: ' Mbps', raw: true },
|
||||
};
|
||||
|
||||
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`;
|
||||
|
|
@ -29,20 +81,31 @@ export function formatNumber(value: number | undefined, decimals = 0): string {
|
|||
return decimals > 0 ? value.toFixed(decimals) : Math.round(value).toLocaleString();
|
||||
}
|
||||
|
||||
// Calculate weighted mean from histogram
|
||||
// Calculate weighted mean from histogram with outlier bins.
|
||||
// Bin 0 = [min, p1), bins 1..n-2 = [p1, p99) evenly, bin n-1 = [p99, max].
|
||||
export function calculateHistogramMean(histogram: {
|
||||
min: number;
|
||||
bin_width: number;
|
||||
max: number;
|
||||
p1: number;
|
||||
p99: number;
|
||||
counts: number[];
|
||||
}): number | undefined {
|
||||
if (!histogram.counts.length) return undefined;
|
||||
const n = histogram.counts.length;
|
||||
if (n === 0) return undefined;
|
||||
const totalCount = histogram.counts.reduce((a, b) => a + b, 0);
|
||||
if (totalCount === 0) return undefined;
|
||||
|
||||
const { min, max, p1, p99 } = histogram;
|
||||
const middleBins = Math.max(n - 2, 0);
|
||||
const middleWidth = middleBins > 0 && p99 > p1 ? (p99 - p1) / middleBins : 0;
|
||||
|
||||
let weightedSum = 0;
|
||||
for (let i = 0; i < histogram.counts.length; i++) {
|
||||
const binCenter = histogram.min + (i + 0.5) * histogram.bin_width;
|
||||
weightedSum += binCenter * histogram.counts[i];
|
||||
for (let i = 0; i < n; i++) {
|
||||
let center: number;
|
||||
if (i === 0) center = (min + p1) / 2;
|
||||
else if (i === n - 1) center = (p99 + max) / 2;
|
||||
else center = p1 + (i - 0.5) * middleWidth;
|
||||
weightedSum += center * histogram.counts[i];
|
||||
}
|
||||
return weightedSum / totalCount;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,40 +3,94 @@ import type { StyleSpecification } from 'maplibre-gl';
|
|||
import { layers, namedFlavor } from '@protomaps/basemaps';
|
||||
import {
|
||||
GLYPHS_URL,
|
||||
SPRITE_URL_BASE,
|
||||
TILE_MAX_ZOOM,
|
||||
OSM_ATTRIBUTION,
|
||||
FEATURE_GRADIENT,
|
||||
DENSITY_GRADIENT,
|
||||
DENSITY_GRADIENT_DARK,
|
||||
ZOOM_TO_RESOLUTION_THRESHOLDS,
|
||||
TWEMOJI_BASE,
|
||||
POSTCODE_ZOOM_THRESHOLD,
|
||||
} from './consts';
|
||||
|
||||
// Re-export constants for backwards compatibility
|
||||
export { FEATURE_GRADIENT as GRADIENT, DENSITY_GRADIENT, POSTCODE_ZOOM_THRESHOLD } from './consts';
|
||||
export { FEATURE_GRADIENT as GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK, POSTCODE_ZOOM_THRESHOLD } from './consts';
|
||||
|
||||
const ROAD_OPACITY = 0.4;
|
||||
|
||||
export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
|
||||
const flavor = namedFlavor(theme);
|
||||
// Use absolute URL for tiles - required by MapLibre
|
||||
const tileUrl = `${window.location.origin}/api/tiles/{z}/{x}/{y}`;
|
||||
const baseLayers = layers('protomaps', flavor, { lang: 'en' });
|
||||
|
||||
// Reduce road layer opacity so hexagons are more visible
|
||||
const modifiedLayers = baseLayers.map((layer) => {
|
||||
if (layer.id.includes('roads_') || layer.id.includes('road_')) {
|
||||
if (layer.type === 'line') {
|
||||
return { ...layer, paint: { ...layer.paint, 'line-opacity': ROAD_OPACITY } };
|
||||
} else if (layer.type === 'fill') {
|
||||
return { ...layer, paint: { ...layer.paint, 'fill-opacity': ROAD_OPACITY } };
|
||||
}
|
||||
}
|
||||
return layer;
|
||||
});
|
||||
|
||||
return {
|
||||
version: 8,
|
||||
glyphs: GLYPHS_URL,
|
||||
sprite: `${SPRITE_URL_BASE}/${theme}`,
|
||||
sources: {
|
||||
protomaps: {
|
||||
type: 'vector',
|
||||
tiles: [tileUrl],
|
||||
maxzoom: TILE_MAX_ZOOM,
|
||||
attribution: OSM_ATTRIBUTION,
|
||||
maxzoom: POSTCODE_ZOOM_THRESHOLD,
|
||||
},
|
||||
},
|
||||
layers: layers('protomaps', flavor, { lang: 'en' }),
|
||||
layers: modifiedLayers,
|
||||
} as StyleSpecification;
|
||||
}
|
||||
|
||||
type GradientStop = { t: number; color: [number, number, number] };
|
||||
|
||||
// Oklab color space for perceptually uniform interpolation
|
||||
function srgbToLinear(c: number): number {
|
||||
const v = c / 255;
|
||||
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
function linearToSrgb(c: number): number {
|
||||
const v = c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
|
||||
return Math.round(Math.max(0, Math.min(255, v * 255)));
|
||||
}
|
||||
|
||||
function rgbToOklab(rgb: [number, number, number]): [number, number, number] {
|
||||
const r = srgbToLinear(rgb[0]);
|
||||
const g = srgbToLinear(rgb[1]);
|
||||
const b = srgbToLinear(rgb[2]);
|
||||
|
||||
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
||||
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
||||
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
||||
|
||||
return [
|
||||
0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
|
||||
1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
|
||||
0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s,
|
||||
];
|
||||
}
|
||||
|
||||
function oklabToRgb(lab: [number, number, number]): [number, number, number] {
|
||||
const L = lab[0], a = lab[1], b = lab[2];
|
||||
|
||||
const l = Math.pow(L + 0.3963377774 * a + 0.2158037573 * b, 3);
|
||||
const m = Math.pow(L - 0.1055613458 * a - 0.0638541728 * b, 3);
|
||||
const s = Math.pow(L - 0.0894841775 * a - 1.2914855480 * b, 3);
|
||||
|
||||
return [
|
||||
linearToSrgb(+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
|
||||
linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
|
||||
linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s),
|
||||
];
|
||||
}
|
||||
|
||||
function interpolateGradient(t: number, gradient: GradientStop[]): [number, number, number] {
|
||||
if (t <= 0) return gradient[0].color;
|
||||
if (t >= 1) return gradient[gradient.length - 1].color;
|
||||
|
|
@ -46,11 +100,14 @@ function interpolateGradient(t: number, gradient: GradientStop[]): [number, numb
|
|||
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),
|
||||
const loLab = rgbToOklab(lo.color);
|
||||
const hiLab = rgbToOklab(hi.color);
|
||||
const interpLab: [number, number, number] = [
|
||||
loLab[0] + (hiLab[0] - loLab[0]) * frac,
|
||||
loLab[1] + (hiLab[1] - loLab[1]) * frac,
|
||||
loLab[2] + (hiLab[2] - loLab[2]) * frac,
|
||||
];
|
||||
return oklabToRgb(interpLab);
|
||||
}
|
||||
}
|
||||
return gradient[gradient.length - 1].color;
|
||||
|
|
@ -60,8 +117,8 @@ export function normalizedToColor(t: number): [number, number, number] {
|
|||
return interpolateGradient(t, FEATURE_GRADIENT);
|
||||
}
|
||||
|
||||
export function countToColor(t: number): [number, number, number] {
|
||||
return interpolateGradient(t, DENSITY_GRADIENT);
|
||||
export function countToColor(t: number, gradient: GradientStop[] = DENSITY_GRADIENT): [number, number, number] {
|
||||
return interpolateGradient(t, gradient);
|
||||
}
|
||||
|
||||
export function zoomToResolution(zoom: number): number {
|
||||
|
|
|
|||
5
frontend/src/lib/pocketbase.ts
Normal file
5
frontend/src/lib/pocketbase.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import PocketBase from 'pocketbase';
|
||||
|
||||
const pb = new PocketBase('/pb');
|
||||
|
||||
export default pb;
|
||||
7
frontend/src/lib/utils.ts
Normal file
7
frontend/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* Converts a gradient definition to CSS linear-gradient string
|
||||
*/
|
||||
export function gradientToCss(gradient: { t: number; color: [number, number, number] }[]): string {
|
||||
const stops = gradient.map(({ t, color }) => `rgb(${color.join(',')}) ${t * 100}%`).join(', ');
|
||||
return `linear-gradient(in oklch to right, ${stops})`;
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ export interface FeatureMeta {
|
|||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
histogram?: { min: number; max: number; bin_width: number; counts: number[] };
|
||||
histogram?: { min: number; max: number; p1: number; p99: number; counts: number[] };
|
||||
// Enum-only fields
|
||||
values?: string[];
|
||||
// Description fields
|
||||
|
|
@ -124,7 +124,7 @@ export interface NumericFeatureStats {
|
|||
min: number;
|
||||
max: number;
|
||||
mean: number;
|
||||
histogram?: { min: number; max: number; bin_width: number; counts: number[] };
|
||||
histogram?: { min: number; max: number; p1: number; p99: number; counts: number[] };
|
||||
}
|
||||
|
||||
export interface EnumFeatureStats {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue