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 { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { trackPageview } from './hooks/usePlausible';
|
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 DataSourcesPage from './components/data-sources/DataSourcesPage';
|
||||||
import FAQPage from './components/faq/FAQPage';
|
import FAQPage from './components/faq/FAQPage';
|
||||||
import HomePage from './components/home/HomePage';
|
import HomePage from './components/home/HomePage';
|
||||||
import Header, { type Page } from './components/ui/Header';
|
import Header, { type Page } from './components/ui/Header';
|
||||||
|
import AuthModal from './components/ui/AuthModal';
|
||||||
import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
|
import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
|
||||||
import { fetchWithRetry, apiUrl } from './lib/api';
|
import { fetchWithRetry, apiUrl } from './lib/api';
|
||||||
import { parseUrlState } from './lib/url-state';
|
import { parseUrlState } from './lib/url-state';
|
||||||
import { INITIAL_VIEW_STATE } from './lib/consts';
|
import { INITIAL_VIEW_STATE } from './lib/consts';
|
||||||
import { useTheme } from './hooks/useTheme';
|
import { useTheme } from './hooks/useTheme';
|
||||||
|
import { useAuth } from './hooks/useAuth';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|
@ -43,6 +45,16 @@ export default function App() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const { theme, toggleTheme } = useTheme();
|
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
|
// Load features and POI categories on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -116,6 +128,8 @@ export default function App() {
|
||||||
return () => window.removeEventListener('popstate', handlePopState);
|
return () => window.removeEventListener('popstate', handlePopState);
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const [exportState, setExportState] = useState<ExportState | null>(null);
|
||||||
|
|
||||||
if (isScreenshotMode) {
|
if (isScreenshotMode) {
|
||||||
return (
|
return (
|
||||||
<MapPage
|
<MapPage
|
||||||
|
|
@ -142,6 +156,11 @@ export default function App() {
|
||||||
onPageChange={navigateTo}
|
onPageChange={navigateTo}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onToggleTheme={toggleTheme}
|
onToggleTheme={toggleTheme}
|
||||||
|
onExport={exportState?.onExport ?? null}
|
||||||
|
exporting={exportState?.exporting ?? false}
|
||||||
|
user={user}
|
||||||
|
onLoginClick={() => setShowAuthModal(true)}
|
||||||
|
onLogout={logout}
|
||||||
/>
|
/>
|
||||||
{activePage === 'home' ? (
|
{activePage === 'home' ? (
|
||||||
<HomePage onOpenDashboard={() => navigateTo('dashboard')} theme={theme} />
|
<HomePage onOpenDashboard={() => navigateTo('dashboard')} theme={theme} />
|
||||||
|
|
@ -162,6 +181,17 @@ export default function App() {
|
||||||
pendingInfoFeature={pendingInfoFeature}
|
pendingInfoFeature={pendingInfoFeature}
|
||||||
onClearPendingInfoFeature={() => setPendingInfoFeature(null)}
|
onClearPendingInfoFeature={() => setPendingInfoFeature(null)}
|
||||||
onNavigateTo={navigateTo}
|
onNavigateTo={navigateTo}
|
||||||
|
onExportStateChange={setExportState}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showAuthModal && (
|
||||||
|
<AuthModal
|
||||||
|
onClose={() => setShowAuthModal(false)}
|
||||||
|
onLogin={login}
|
||||||
|
onRegister={register}
|
||||||
|
loading={authLoading}
|
||||||
|
error={authError}
|
||||||
|
onClearError={clearError}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ export default function DataSources({ onNavigate }: { onNavigate: () => void })
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onNavigate}
|
onClick={onNavigate}
|
||||||
className="absolute bottom-2 right-2 bg-white/90 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
|
Data Sources
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { ChevronIcon } from '../ui/icons/ChevronIcon';
|
||||||
|
|
||||||
interface FAQItem {
|
interface FAQItem {
|
||||||
question: string;
|
question: string;
|
||||||
|
|
@ -78,15 +79,10 @@ function FAQItemCard({ item }: { item: FAQItem }) {
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
>
|
>
|
||||||
<span className="font-medium text-warm-900 dark:text-warm-100">{item.question}</span>
|
<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' : ''}`}
|
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>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="px-5 pb-4">
|
<div className="px-5 pb-4">
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import type { FeatureFilters, FeatureMeta, HexagonStatsResponse, PostcodeFeature } from '../../types';
|
import type { FeatureFilters, FeatureMeta, HexagonStatsResponse, PostcodeFeature } from '../../types';
|
||||||
import type { HexagonLocation } from '../../lib/external-search';
|
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 { 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 { DualHistogram, LoadingSkeleton } from './DualHistogram';
|
||||||
import EnumBarChart from './EnumBarChart';
|
import EnumBarChart from './EnumBarChart';
|
||||||
import StackedBarChart from './StackedBarChart';
|
import StackedBarChart from './StackedBarChart';
|
||||||
|
import StackedEnumChart from './StackedEnumChart';
|
||||||
import PriceHistoryChart from './PriceHistoryChart';
|
import PriceHistoryChart from './PriceHistoryChart';
|
||||||
import ExternalSearchLinks from './ExternalSearchLinks';
|
import ExternalSearchLinks from './ExternalSearchLinks';
|
||||||
import { InfoIcon, CloseIcon } from '../ui/icons';
|
import { InfoIcon, CloseIcon } from '../ui/icons';
|
||||||
|
|
@ -126,6 +127,14 @@ export default function AreaPane({
|
||||||
if (!hasData) return null;
|
if (!hasData) return null;
|
||||||
|
|
||||||
const stackedCharts = STACKED_GROUPS[group.name];
|
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 (
|
return (
|
||||||
<div key={group.name}>
|
<div key={group.name}>
|
||||||
|
|
@ -183,75 +192,157 @@ export default function AreaPane({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
: // Default: render each feature individually
|
: // Default: render each feature individually (skip stacked enum features)
|
||||||
group.features.map((feature) => {
|
group.features
|
||||||
const numericStats = numericByName.get(feature.name);
|
.filter((f) => !stackedEnumFeatureNames.has(f.name))
|
||||||
const enumStats = enumByName.get(feature.name);
|
.map((feature) => {
|
||||||
|
const numericStats = numericByName.get(feature.name);
|
||||||
|
const enumStats = enumByName.get(feature.name);
|
||||||
|
|
||||||
if (numericStats) {
|
if (numericStats) {
|
||||||
const globalFeature = globalFeatureByName.get(feature.name);
|
const globalFeature = globalFeatureByName.get(feature.name);
|
||||||
const globalHistogram = globalFeature?.histogram;
|
const globalHistogram = globalFeature?.histogram;
|
||||||
const globalMean = globalHistogram
|
const globalMean = globalHistogram
|
||||||
? calculateHistogramMean(globalHistogram)
|
? calculateHistogramMean(globalHistogram)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={feature.name}
|
key={feature.name}
|
||||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-baseline">
|
<div className="flex justify-between items-baseline">
|
||||||
<FeatureLabel
|
<FeatureLabel
|
||||||
feature={feature}
|
feature={feature}
|
||||||
onShowInfo={setInfoFeature}
|
onShowInfo={setInfoFeature}
|
||||||
className="mr-2"
|
className="mr-2"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||||
{formatValue(numericStats.mean)}
|
{formatValue(numericStats.mean, FEATURE_FORMATS[feature.name])}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{numericStats.histogram && (
|
{numericStats.histogram && (
|
||||||
<>
|
globalHistogram ? (
|
||||||
<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 ? (
|
|
||||||
<DualHistogram
|
<DualHistogram
|
||||||
localCounts={numericStats.histogram.counts}
|
localCounts={numericStats.histogram.counts}
|
||||||
globalCounts={globalHistogram.counts}
|
globalCounts={globalHistogram.counts}
|
||||||
min={numericStats.histogram.min}
|
p1={numericStats.histogram.p1}
|
||||||
max={numericStats.histogram.max}
|
p99={numericStats.histogram.p99}
|
||||||
globalMean={globalMean}
|
globalMean={globalMean}
|
||||||
|
formatLabel={formatFilterValue}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<DualHistogram
|
<DualHistogram
|
||||||
localCounts={numericStats.histogram.counts}
|
localCounts={numericStats.histogram.counts}
|
||||||
globalCounts={numericStats.histogram.counts}
|
globalCounts={numericStats.histogram.counts}
|
||||||
min={numericStats.histogram.min}
|
p1={numericStats.histogram.p1}
|
||||||
max={numericStats.histogram.max}
|
p99={numericStats.histogram.p99}
|
||||||
|
formatLabel={formatFilterValue}
|
||||||
/>
|
/>
|
||||||
)}
|
)
|
||||||
</>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (enumStats) {
|
if (enumStats) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={feature.name}
|
key={feature.name}
|
||||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||||
>
|
>
|
||||||
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
|
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
|
||||||
<EnumBarChart counts={enumStats.counts} />
|
<EnumBarChart counts={enumStats.counts} />
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -11,18 +11,37 @@ function downsampleBars(counts: number[], targetBars: number): number[] {
|
||||||
return bars;
|
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({
|
export function DualHistogram({
|
||||||
localCounts,
|
localCounts,
|
||||||
globalCounts,
|
globalCounts,
|
||||||
min,
|
p1,
|
||||||
max,
|
p99,
|
||||||
globalMean,
|
globalMean,
|
||||||
|
formatLabel,
|
||||||
}: {
|
}: {
|
||||||
localCounts: number[];
|
localCounts: number[];
|
||||||
globalCounts: number[];
|
globalCounts: number[];
|
||||||
min: number;
|
p1: number;
|
||||||
max: number;
|
p99: number;
|
||||||
globalMean?: number;
|
globalMean?: number;
|
||||||
|
formatLabel?: (value: number) => string;
|
||||||
}) {
|
}) {
|
||||||
const targetBars = 25;
|
const targetBars = 25;
|
||||||
const localBars = downsampleBars(localCounts, targetBars);
|
const localBars = downsampleBars(localCounts, targetBars);
|
||||||
|
|
@ -32,7 +51,37 @@ export function DualHistogram({
|
||||||
const localMax = Math.max(...localBars, 1);
|
const localMax = Math.max(...localBars, 1);
|
||||||
const globalMax = Math.max(...globalBars, 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 (
|
return (
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
|
|
@ -56,13 +105,26 @@ export function DualHistogram({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{meanFraction != null && meanFraction >= 0 && meanFraction <= 1 && (
|
{meanPct != null && meanPct >= 0 && meanPct <= 100 && (
|
||||||
<div
|
<div
|
||||||
className="absolute bottom-0 top-0 w-px border-l border-dashed border-warm-400 dark:border-warm-500"
|
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>
|
</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>
|
</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 { Slider } from '../ui/Slider';
|
||||||
import { Label } from '../ui/Label';
|
import { Label } from '../ui/Label';
|
||||||
import { SearchInput } from '../ui/SearchInput';
|
import { SearchInput } from '../ui/SearchInput';
|
||||||
import { FilterIcon, LightbulbIcon } from '../ui/icons';
|
import { ChevronIcon, FilterIcon, LightbulbIcon } from '../ui/icons';
|
||||||
import { EmptyState } from '../ui/EmptyState';
|
import { EmptyState } from '../ui/EmptyState';
|
||||||
import type { FeatureMeta, FeatureFilters } from '../../types';
|
import type { FeatureMeta, FeatureFilters } from '../../types';
|
||||||
import { formatFilterValue } from '../../lib/format';
|
import { formatFilterValue } from '../../lib/format';
|
||||||
|
|
@ -56,6 +56,15 @@ function FeatureBrowser({
|
||||||
}) {
|
}) {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (openInfoFeature) {
|
if (openInfoFeature) {
|
||||||
|
|
@ -73,50 +82,70 @@ function FeatureBrowser({
|
||||||
|
|
||||||
const grouped = useMemo(() => groupFeaturesByCategory(filtered), [filtered]);
|
const grouped = useMemo(() => groupFeaturesByCategory(filtered), [filtered]);
|
||||||
|
|
||||||
|
// When searching, expand all groups so results are visible
|
||||||
|
const isSearching = search.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="p-2 border-b border-warm-200 dark:border-navy-700">
|
<div className="p-2 border-b border-warm-200 dark:border-navy-700">
|
||||||
<SearchInput value={search} onChange={setSearch} placeholder="Search features..." />
|
<SearchInput value={search} onChange={setSearch} placeholder="Search features..." />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-y-auto flex flex-col">
|
||||||
{grouped.map((group) => (
|
{grouped.map((group) => {
|
||||||
<div key={group.name}>
|
const isExpanded = isSearching || expandedGroups.has(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">
|
return (
|
||||||
{group.name}
|
<div key={group.name} className="shrink-0">
|
||||||
</div>
|
<button
|
||||||
{group.features.map((f) => {
|
onClick={() => toggleGroup(group.name)}
|
||||||
const isPinned = pinnedFeature === f.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"
|
||||||
return (
|
>
|
||||||
<div
|
<span>{group.name}</span>
|
||||||
key={f.name}
|
<div className="flex items-center gap-1">
|
||||||
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"
|
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||||
>
|
{group.features.length}
|
||||||
<div className="min-w-0 mr-2">
|
</span>
|
||||||
<FeatureLabel feature={f} onShowInfo={setInfoFeature} size="sm" />
|
<ChevronIcon direction={isExpanded ? 'down' : 'right'} className="w-3.5 h-3.5" />
|
||||||
{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>
|
||||||
);
|
</button>
|
||||||
})}
|
{isExpanded &&
|
||||||
</div>
|
group.features.map((f) => {
|
||||||
))}
|
const isPinned = pinnedFeature === f.name;
|
||||||
{grouped.length === 0 && (
|
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
|
<EmptyState
|
||||||
icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
|
icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
|
||||||
title={search ? 'No matching features' : 'All features are active'}
|
title={search ? 'No matching features' : 'All features are active'}
|
||||||
description={search ? 'Try a different search term' : 'Remove a filter to see available features'}
|
description={search ? 'Try a different search term' : 'Remove a filter to see available features'}
|
||||||
className="px-3 py-4"
|
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>
|
</div>
|
||||||
{infoFeature && (
|
{infoFeature && (
|
||||||
|
|
@ -155,38 +184,12 @@ export default memo(function Filters({
|
||||||
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
|
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
|
||||||
const enabledFeatureList = 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 [showPhilosophy, setShowPhilosophy] = useState(false);
|
||||||
const [activeInfoFeature, setActiveInfoFeature] = useState<FeatureMeta | null>(null);
|
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 (
|
return (
|
||||||
<div
|
<div className="flex flex-col bg-white dark:bg-navy-950 overflow-hidden h-full">
|
||||||
ref={containerRef}
|
<div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||||
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">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowPhilosophy(true)}
|
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"
|
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
|
Finding the Perfect Postcode
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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="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">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">
|
<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}
|
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' : ''}`}
|
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 justify-between">
|
||||||
<div className="flex items-center gap-1 min-w-0">
|
<span className="text-sm text-warm-500 dark:text-warm-400">
|
||||||
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" />
|
{formatFilterValue(displayValue[0])} - {formatFilterValue(displayValue[1])}
|
||||||
<span className="text-sm text-warm-500 dark:text-warm-400">
|
</span>
|
||||||
{formatFilterValue(displayValue[0])} - {formatFilterValue(displayValue[1])}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<FeatureActions
|
<FeatureActions
|
||||||
feature={feature}
|
feature={feature}
|
||||||
isPinned={isPinned}
|
isPinned={isPinned}
|
||||||
|
|
@ -308,16 +309,7 @@ export default memo(function Filters({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="min-h-0 flex-1 flex flex-col border-t border-warm-200 dark:border-warm-700">
|
||||||
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="shrink-0 px-3 py-2 border-b border-warm-200 dark:border-navy-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>
|
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">Add Filter</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ import {
|
||||||
getBoundsFromViewState,
|
getBoundsFromViewState,
|
||||||
emojiToTwemojiUrl,
|
emojiToTwemojiUrl,
|
||||||
getMapStyle,
|
getMapStyle,
|
||||||
|
DENSITY_GRADIENT,
|
||||||
|
DENSITY_GRADIENT_DARK,
|
||||||
} from '../../lib/map-utils';
|
} from '../../lib/map-utils';
|
||||||
import { INITIAL_VIEW_STATE, MAP_MIN_ZOOM, MAP_BOUNDS } from '../../lib/consts';
|
import { INITIAL_VIEW_STATE, MAP_MIN_ZOOM, MAP_BOUNDS } from '../../lib/consts';
|
||||||
import PostcodeSearch, { type SearchedPostcode } from './PostcodeSearch';
|
import PostcodeSearch, { type SearchedPostcode } from './PostcodeSearch';
|
||||||
|
|
@ -161,7 +163,7 @@ export default memo(function Map({
|
||||||
|
|
||||||
const handleMapLoad = useCallback(
|
const handleMapLoad = useCallback(
|
||||||
(_evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
|
(_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 isDark = theme === 'dark';
|
||||||
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${selectedPostcode}|${hoveredPostcode}`;
|
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(
|
const hexLayer = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -311,14 +320,15 @@ export default memo(function Map({
|
||||||
const clr = colorRangeRef.current;
|
const clr = colorRangeRef.current;
|
||||||
const fr = filterRangeRef.current;
|
const fr = filterRangeRef.current;
|
||||||
const cfm = colorFeatureMetaRef.current;
|
const cfm = colorFeatureMetaRef.current;
|
||||||
|
const dark = isDarkRef.current;
|
||||||
if (vf && clr && cfm) {
|
if (vf && clr && cfm) {
|
||||||
const val = d[`min_${vf}`];
|
const val = d[`min_${vf}`];
|
||||||
if (val == null) return [128, 128, 128, 80] as [number, number, number, number];
|
if (val == null) return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
|
||||||
if (fr) {
|
if (fr) {
|
||||||
const minVal = d[`min_${vf}`] as number;
|
const minVal = d[`min_${vf}`] as number;
|
||||||
const maxVal = d[`max_${vf}`] as number;
|
const maxVal = d[`max_${vf}`] as number;
|
||||||
if (maxVal < fr[0] || minVal > fr[1]) {
|
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];
|
const range = clr[1] - clr[0];
|
||||||
|
|
@ -330,7 +340,7 @@ export default memo(function Map({
|
||||||
const cr = countRangeRef.current;
|
const cr = countRangeRef.current;
|
||||||
const c = d.count as number;
|
const c = d.count as number;
|
||||||
const t = (c - cr.min) / (cr.max - cr.min);
|
const t = (c - cr.min) / (cr.max - cr.min);
|
||||||
return [...countToColor(Math.max(0, Math.min(1, t))), 255] as [
|
return [...countToColor(Math.max(0, Math.min(1, t)), densityGradientRef.current), 255] as [
|
||||||
number,
|
number,
|
||||||
number,
|
number,
|
||||||
number,
|
number,
|
||||||
|
|
@ -378,14 +388,15 @@ export default memo(function Map({
|
||||||
const clr = colorRangeRef.current;
|
const clr = colorRangeRef.current;
|
||||||
const fr = filterRangeRef.current;
|
const fr = filterRangeRef.current;
|
||||||
const cfm = colorFeatureMetaRef.current;
|
const cfm = colorFeatureMetaRef.current;
|
||||||
|
const dark = isDarkRef.current;
|
||||||
if (vf && clr && cfm) {
|
if (vf && clr && cfm) {
|
||||||
const val = d[`min_${vf}`];
|
const val = d[`min_${vf}`];
|
||||||
if (val == null) return [128, 128, 128, 80] as [number, number, number, number];
|
if (val == null) return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
|
||||||
if (fr) {
|
if (fr) {
|
||||||
const minVal = d[`min_${vf}`] as number;
|
const minVal = d[`min_${vf}`] as number;
|
||||||
const maxVal = d[`max_${vf}`] as number;
|
const maxVal = d[`max_${vf}`] as number;
|
||||||
if (maxVal < fr[0] || minVal > fr[1]) {
|
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];
|
const range = clr[1] - clr[0];
|
||||||
|
|
@ -397,7 +408,7 @@ export default memo(function Map({
|
||||||
const cr = postcodeCountRangeRef.current;
|
const cr = postcodeCountRangeRef.current;
|
||||||
const c = d.count;
|
const c = d.count;
|
||||||
const t = (c - cr.min) / (cr.max - cr.min);
|
const t = (c - cr.min) / (cr.max - cr.min);
|
||||||
return [...countToColor(Math.max(0, Math.min(1, t))), 255] as [
|
return [...countToColor(Math.max(0, Math.min(1, t)), densityGradientRef.current), 255] as [
|
||||||
number,
|
number,
|
||||||
number,
|
number,
|
||||||
number,
|
number,
|
||||||
|
|
@ -406,11 +417,12 @@ export default memo(function Map({
|
||||||
},
|
},
|
||||||
getLineColor: (f) => {
|
getLineColor: (f) => {
|
||||||
const pc = f.properties.postcode;
|
const pc = f.properties.postcode;
|
||||||
|
const dark = isDarkRef.current;
|
||||||
if (pc === selectedPostcodeRef.current)
|
if (pc === selectedPostcodeRef.current)
|
||||||
return [255, 255, 255, 255] as [number, number, number, number];
|
return [255, 255, 255, 255] as [number, number, number, number];
|
||||||
if (pc === hoveredPostcodeRef.current)
|
if (pc === hoveredPostcodeRef.current)
|
||||||
return [29, 228, 195, 200] as [number, number, number, number];
|
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) => {
|
getLineWidth: (f) => {
|
||||||
const pc = f.properties.postcode;
|
const pc = f.properties.postcode;
|
||||||
|
|
@ -570,6 +582,7 @@ export default memo(function Map({
|
||||||
onCancel={onCancelPin}
|
onCancel={onCancelPin}
|
||||||
mode="feature"
|
mode="feature"
|
||||||
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
|
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
|
||||||
|
theme={theme}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<MapLegend
|
<MapLegend
|
||||||
|
|
@ -578,6 +591,7 @@ export default memo(function Map({
|
||||||
showCancel={false}
|
showCancel={false}
|
||||||
onCancel={onCancelPin}
|
onCancel={onCancelPin}
|
||||||
mode="density"
|
mode="density"
|
||||||
|
theme={theme}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{popupInfo && (
|
{popupInfo && (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
import { formatValue } from '../../lib/format';
|
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({
|
export default function MapLegend({
|
||||||
featureLabel,
|
featureLabel,
|
||||||
|
|
@ -7,6 +10,7 @@ export default function MapLegend({
|
||||||
onCancel,
|
onCancel,
|
||||||
mode,
|
mode,
|
||||||
enumValues,
|
enumValues,
|
||||||
|
theme = 'light',
|
||||||
}: {
|
}: {
|
||||||
featureLabel: string;
|
featureLabel: string;
|
||||||
range: [number, number];
|
range: [number, number];
|
||||||
|
|
@ -14,11 +18,10 @@ export default function MapLegend({
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
mode: 'feature' | 'density';
|
mode: 'feature' | 'density';
|
||||||
enumValues?: string[];
|
enumValues?: string[];
|
||||||
|
theme?: 'light' | 'dark';
|
||||||
}) {
|
}) {
|
||||||
const gradientStyle =
|
const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
|
||||||
mode === 'density'
|
const gradientStyle = mode === 'density' ? gradientToCss(densityGradient) : gradientToCss(FEATURE_GRADIENT);
|
||||||
? 'linear-gradient(to right, rgb(130, 234, 220), rgb(20, 140, 180), rgb(88, 28, 140))'
|
|
||||||
: 'linear-gradient(to right, rgb(46, 204, 113), rgb(241, 196, 15), rgb(231, 76, 60), rgb(142, 68, 173))';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute top-3 right-3 z-10 bg-white dark:bg-navy-800 dark:text-warm-200 rounded shadow-lg p-3 text-xs min-w-[160px]">
|
<div className="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"
|
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 ml-2"
|
||||||
title="Clear color view"
|
title="Clear color view"
|
||||||
>
|
>
|
||||||
<svg
|
<CloseIcon className="w-4 h-4" />
|
||||||
className="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState } from '../../types';
|
||||||
import type { SearchedPostcode } from './PostcodeSearch';
|
import type { SearchedPostcode } from './PostcodeSearch';
|
||||||
import type { Page } from '../ui/Header';
|
import type { Page } from '../ui/Header';
|
||||||
|
|
@ -14,6 +14,13 @@ import { usePOIData } from '../../hooks/usePOIData';
|
||||||
import { useFilters } from '../../hooks/useFilters';
|
import { useFilters } from '../../hooks/useFilters';
|
||||||
import { useHexagonSelection } from '../../hooks/useHexagonSelection';
|
import { useHexagonSelection } from '../../hooks/useHexagonSelection';
|
||||||
import { usePaneResize } from '../../hooks/usePaneResize';
|
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 {
|
interface MapPageProps {
|
||||||
features: FeatureMeta[];
|
features: FeatureMeta[];
|
||||||
|
|
@ -27,6 +34,7 @@ interface MapPageProps {
|
||||||
pendingInfoFeature: string | null;
|
pendingInfoFeature: string | null;
|
||||||
onClearPendingInfoFeature: () => void;
|
onClearPendingInfoFeature: () => void;
|
||||||
onNavigateTo: (page: Page, hash?: string, infoFeature?: string) => void;
|
onNavigateTo: (page: Page, hash?: string, infoFeature?: string) => void;
|
||||||
|
onExportStateChange?: (state: ExportState) => void;
|
||||||
screenshotMode?: boolean;
|
screenshotMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,6 +50,7 @@ export default function MapPage({
|
||||||
pendingInfoFeature,
|
pendingInfoFeature,
|
||||||
onClearPendingInfoFeature,
|
onClearPendingInfoFeature,
|
||||||
onNavigateTo,
|
onNavigateTo,
|
||||||
|
onExportStateChange,
|
||||||
screenshotMode,
|
screenshotMode,
|
||||||
}: MapPageProps) {
|
}: MapPageProps) {
|
||||||
if (screenshotMode) {
|
if (screenshotMode) {
|
||||||
|
|
@ -142,15 +151,46 @@ export default function MapPage({
|
||||||
return { lat: hex.lat as number, lon: hex.lon as number, resolution: mapData.resolution };
|
return { lat: hex.lat as number, lon: hex.lon as number, resolution: mapData.resolution };
|
||||||
}, [selection.selectedHexagon?.id, mapData.data, 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 (
|
return (
|
||||||
<div className="flex-1 flex overflow-hidden relative">
|
<div className="flex-1 flex overflow-hidden relative">
|
||||||
{initialLoading && (
|
{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="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">
|
<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">
|
<SpinnerIcon className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" />
|
||||||
<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>
|
|
||||||
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">Connecting to server...</p>
|
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">Connecting to server...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { useState, useRef, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import type { POICategoryGroup } from '../../types';
|
import type { POICategoryGroup } from '../../types';
|
||||||
import { useClickOutside } from '../../hooks/useClickOutside';
|
|
||||||
import InfoPopup from '../ui/InfoPopup';
|
import InfoPopup from '../ui/InfoPopup';
|
||||||
import { SearchInput } from '../ui/SearchInput';
|
import { SearchInput } from '../ui/SearchInput';
|
||||||
import { InfoIcon, ChevronIcon } from '../ui/icons';
|
import { InfoIcon, ChevronIcon } from '../ui/icons';
|
||||||
|
|
@ -21,13 +20,9 @@ export default function POIPane({
|
||||||
poiCount,
|
poiCount,
|
||||||
onNavigateToSource,
|
onNavigateToSource,
|
||||||
}: POIPaneProps) {
|
}: POIPaneProps) {
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||||
const [showInfo, setShowInfo] = useState(false);
|
const [showInfo, setShowInfo] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useClickOutside(dropdownRef, () => setDropdownOpen(false));
|
|
||||||
|
|
||||||
const allCategories = groups.flatMap((g) => g.categories);
|
const allCategories = groups.flatMap((g) => g.categories);
|
||||||
|
|
||||||
|
|
@ -93,139 +88,129 @@ export default function POIPane({
|
||||||
const selectedCount = selectedCategories.size;
|
const selectedCount = selectedCategories.size;
|
||||||
|
|
||||||
return (
|
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 flex-col h-full bg-white dark:bg-navy-950 shadow-lg overflow-hidden">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex-shrink-0 px-4 pt-4 pb-2 space-y-3">
|
||||||
<h2 className="text-xl font-bold dark:text-warm-100">Points of Interest</h2>
|
<div className="flex items-center gap-2">
|
||||||
<IconButton onClick={() => setShowInfo(true)} title="Data source info">
|
<h2 className="text-xl font-bold dark:text-warm-100">Points of Interest</h2>
|
||||||
<InfoIcon />
|
<IconButton onClick={() => setShowInfo(true)} title="Data source info">
|
||||||
</IconButton>
|
<InfoIcon />
|
||||||
</div>
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
{showInfo && (
|
{showInfo && (
|
||||||
<InfoPopup
|
<InfoPopup
|
||||||
title="Points of Interest"
|
title="Points of Interest"
|
||||||
onClose={() => setShowInfo(false)}
|
onClose={() => setShowInfo(false)}
|
||||||
sourceLink={
|
sourceLink={
|
||||||
onNavigateToSource
|
onNavigateToSource
|
||||||
? {
|
? {
|
||||||
label: 'View data source',
|
label: 'View data source',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
onNavigateToSource('osm-pois');
|
onNavigateToSource('osm-pois');
|
||||||
setShowInfo(false);
|
setShowInfo(false);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
|
<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
|
Points of interest are sourced from OpenStreetMap via Geofabrik extracts. Categories
|
||||||
include public transport stops, shops, restaurants, healthcare facilities, leisure
|
include public transport stops, shops, restaurants, healthcare facilities, leisure
|
||||||
venues, and more. Data is filtered and mapped to friendly names with exhaustive category
|
venues, and more. Data is filtered and mapped to friendly names with exhaustive
|
||||||
coverage.
|
category coverage.
|
||||||
</p>
|
</p>
|
||||||
</InfoPopup>
|
</InfoPopup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2" ref={dropdownRef}>
|
<SearchInput
|
||||||
<button
|
value={searchTerm}
|
||||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
onChange={setSearchTerm}
|
||||||
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"
|
placeholder="Search categories..."
|
||||||
>
|
/>
|
||||||
<span className="truncate text-left">
|
|
||||||
{selectedCount === 0
|
<div className="flex items-center justify-between">
|
||||||
? 'Select categories...'
|
<div className="flex gap-1">
|
||||||
: selectedCount === allCategories.length
|
<button
|
||||||
? 'All categories'
|
onClick={selectAll}
|
||||||
: `${selectedCount} selected`}
|
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>
|
</span>
|
||||||
<ChevronIcon
|
</div>
|
||||||
direction={dropdownOpen ? 'up' : 'down'}
|
|
||||||
className="w-4 h-4 ml-2 flex-shrink-0"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{dropdownOpen && (
|
{selectedCount > 0 && (
|
||||||
<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 bg-teal-50 dark:bg-teal-900/30 rounded text-sm flex items-center justify-between">
|
||||||
<div className="px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
<span className="font-medium text-teal-900 dark:text-teal-300">
|
||||||
<SearchInput
|
{poiCount.toLocaleString()} POI{poiCount !== 1 ? 's' : ''} visible
|
||||||
value={searchTerm}
|
</span>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedCount > 0 && (
|
<div className="flex-1 overflow-y-auto border-t border-warm-200 dark:border-warm-700">
|
||||||
<div className="p-3 bg-teal-50 dark:bg-teal-900/30 rounded text-sm">
|
{filteredGroups.map((group) => {
|
||||||
<div className="font-medium text-teal-900 dark:text-teal-300">
|
const groupSelected = group.categories.filter((c) =>
|
||||||
{poiCount.toLocaleString()} POI{poiCount !== 1 ? 's' : ''} visible
|
selectedCategories.has(c)
|
||||||
</div>
|
).length;
|
||||||
<div className="text-xs text-teal-700 dark:text-teal-400 mt-1">
|
const allInGroupSelected = groupSelected === group.categories.length;
|
||||||
{selectedCount} categor{selectedCount !== 1 ? 'ies' : 'y'} selected
|
const someInGroupSelected = groupSelected > 0 && !allInGroupSelected;
|
||||||
</div>
|
const isCollapsed = collapsedGroups.has(group.name) && !searchTerm;
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="p-3 bg-warm-100 dark:bg-navy-800 rounded text-xs text-warm-600 dark:text-warm-400">
|
return (
|
||||||
<p>Select categories to display POIs on the map.</p>
|
<div key={group.name}>
|
||||||
<p className="mt-2">Zoom in for better visibility of individual locations.</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import type { PostcodeGeometry } from '../../types';
|
import type { PostcodeGeometry } from '../../types';
|
||||||
|
import { authHeaders } from '../../lib/api';
|
||||||
|
|
||||||
export interface SearchedPostcode {
|
export interface SearchedPostcode {
|
||||||
postcode: string;
|
postcode: string;
|
||||||
|
|
@ -26,7 +27,7 @@ export default function PostcodeSearch({
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/postcode/${encodeURIComponent(trimmed)}`);
|
const res = await fetch(`/api/postcode/${encodeURIComponent(trimmed)}`, authHeaders());
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setError('Postcode not found');
|
setError('Postcode not found');
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo, useRef, useState, useEffect } from 'react';
|
||||||
import type { PricePoint } from '../../types';
|
import type { PricePoint } from '../../types';
|
||||||
import { formatValue } from '../../lib/format';
|
import { formatValue, FEATURE_FORMATS } from '../../lib/format';
|
||||||
|
|
||||||
interface PriceHistoryChartProps {
|
interface PriceHistoryChartProps {
|
||||||
points: PricePoint[];
|
points: PricePoint[];
|
||||||
|
|
@ -8,141 +8,159 @@ interface PriceHistoryChartProps {
|
||||||
|
|
||||||
const PADDING = { top: 8, right: 8, bottom: 20, left: 42 };
|
const PADDING = { top: 8, right: 8, bottom: 20, left: 42 };
|
||||||
const HEIGHT = 120;
|
const HEIGHT = 120;
|
||||||
|
const priceFmt = FEATURE_FORMATS['Last known price'];
|
||||||
|
|
||||||
export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
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,
|
let yMin = Infinity,
|
||||||
yMax = -Infinity,
|
yMax = -Infinity;
|
||||||
pMin = Infinity,
|
|
||||||
pMax = -Infinity;
|
|
||||||
for (const p of points) {
|
for (const p of points) {
|
||||||
if (p.year < yMin) yMin = p.year;
|
if (p.year < yMin) yMin = p.year;
|
||||||
if (p.year > yMax) yMax = 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
|
// Use p5/p95 to clip outliers
|
||||||
const byYear = new Map<number, { sum: number; count: number }>();
|
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) {
|
for (const p of points) {
|
||||||
const yr = Math.floor(p.year);
|
const yr = Math.floor(p.year);
|
||||||
const entry = byYear.get(yr);
|
const arr = byYear.get(yr);
|
||||||
if (entry) {
|
if (arr) arr.push(p.price);
|
||||||
entry.sum += p.price;
|
else byYear.set(yr, [p.price]);
|
||||||
entry.count += 1;
|
|
||||||
} else {
|
|
||||||
byYear.set(yr, { sum: p.price, count: 1 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const avgs = Array.from(byYear.entries())
|
const meds = Array.from(byYear.entries())
|
||||||
.map(([yr, { sum, count }]) => ({ year: yr + 0.5, price: sum / count }))
|
.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);
|
.sort((a, b) => a.year - b.year);
|
||||||
|
|
||||||
// Price ticks (3-5 nice round numbers)
|
|
||||||
const ticks = niceTicksForRange(pMin, pMax, 4);
|
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]);
|
}, [points]);
|
||||||
|
|
||||||
const scaleY = (price: number) => {
|
const plotW = width - PADDING.left - PADDING.right;
|
||||||
const ratio = (price - priceMin) / (priceMax - priceMin || 1);
|
const plotH = HEIGHT - PADDING.top - PADDING.bottom;
|
||||||
return PADDING.top + (1 - ratio) * (HEIGHT - PADDING.top - PADDING.bottom);
|
|
||||||
};
|
|
||||||
|
|
||||||
const yearRange = yearMax - yearMin || 1;
|
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
|
// Year labels: every 5 years
|
||||||
const yearStart = Math.ceil(yearMin / 5) * 5;
|
const yearStart = Math.ceil(yearMin / 5) * 5;
|
||||||
const yearLabels: number[] = [];
|
const yearLabels: number[] = [];
|
||||||
for (let y = yearStart; y <= yearMax; y += 5) yearLabels.push(y);
|
for (let y = yearStart; y <= yearMax; y += 5) yearLabels.push(y);
|
||||||
|
|
||||||
const VB_W = 1000;
|
const medianPolyline = medians
|
||||||
|
.map((a) => `${scaleX(a.year)},${scaleY(a.price)}`)
|
||||||
const scaleX = (year: number) => {
|
.join(' ');
|
||||||
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(' ');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<div ref={containerRef} style={{ height: HEIGHT }}>
|
||||||
viewBox={`0 0 ${VB_W} ${HEIGHT}`}
|
{width > 0 && (
|
||||||
preserveAspectRatio="none"
|
<svg width={width} height={HEIGHT}>
|
||||||
className="w-full"
|
{/* Grid lines */}
|
||||||
style={{ height: HEIGHT }}
|
{priceTicks.map((tick) => (
|
||||||
>
|
<line
|
||||||
{/* Grid lines */}
|
key={tick}
|
||||||
{priceTicks.map((tick) => (
|
x1={PADDING.left}
|
||||||
<line
|
y1={scaleY(tick)}
|
||||||
key={tick}
|
x2={width - PADDING.right}
|
||||||
x1={PADDING.left}
|
y2={scaleY(tick)}
|
||||||
y1={scaleY(tick)}
|
className="stroke-warm-200 dark:stroke-warm-700"
|
||||||
x2={VB_W - PADDING.right}
|
strokeWidth={1}
|
||||||
y2={scaleY(tick)}
|
/>
|
||||||
className="stroke-warm-200 dark:stroke-warm-700"
|
))}
|
||||||
strokeWidth={1}
|
|
||||||
vectorEffect="non-scaling-stroke"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Dots */}
|
{/* Dots (clamp outliers to visible range) */}
|
||||||
{points.map((p, i) => (
|
{points.map((p, i) => (
|
||||||
<circle
|
<circle
|
||||||
key={i}
|
key={i}
|
||||||
cx={scaleX(p.year)}
|
cx={scaleX(p.year)}
|
||||||
cy={scaleY(p.price)}
|
cy={scaleY(p.price)}
|
||||||
r={4}
|
r={3}
|
||||||
className="fill-teal-500 dark:fill-teal-400"
|
className="fill-teal-500 dark:fill-teal-400"
|
||||||
opacity={0.35}
|
opacity={0.35}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Average line */}
|
{/* Median line */}
|
||||||
{averages.length > 1 && (
|
{medians.length > 1 && (
|
||||||
<polyline
|
<polyline
|
||||||
points={avgPolyline}
|
points={medianPolyline}
|
||||||
fill="none"
|
fill="none"
|
||||||
className="stroke-teal-600 dark:stroke-teal-400"
|
className="stroke-teal-600 dark:stroke-teal-400"
|
||||||
strokeWidth={3}
|
strokeWidth={2}
|
||||||
vectorEffect="non-scaling-stroke"
|
strokeLinejoin="round"
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
{/* 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,7 +169,6 @@ function niceTicksForRange(min: number, max: number, count: number): number[] {
|
||||||
const range = max - min;
|
const range = max - min;
|
||||||
if (range <= 0) return [min];
|
if (range <= 0) return [min];
|
||||||
const rough = range / count;
|
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)));
|
const magnitude = Math.pow(10, Math.floor(Math.log10(rough)));
|
||||||
let step: number;
|
let step: number;
|
||||||
const normalized = rough / magnitude;
|
const normalized = rough / magnitude;
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,6 @@ interface PropertiesPaneProps {
|
||||||
onNavigateToSource?: (slug: string) => void;
|
onNavigateToSource?: (slug: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortBy = 'price' | 'size' | 'energy';
|
|
||||||
|
|
||||||
export function PropertiesPane({
|
export function PropertiesPane({
|
||||||
properties,
|
properties,
|
||||||
total,
|
total,
|
||||||
|
|
@ -28,30 +26,19 @@ export function PropertiesPane({
|
||||||
onClose,
|
onClose,
|
||||||
onNavigateToSource,
|
onNavigateToSource,
|
||||||
}: PropertiesPaneProps) {
|
}: PropertiesPaneProps) {
|
||||||
const [sortBy, setSortBy] = useState<SortBy>('price');
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [showInfo, setShowInfo] = useState(false);
|
const [showInfo, setShowInfo] = useState(false);
|
||||||
|
|
||||||
const filteredAndSorted = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const query = search.trim().toLowerCase();
|
const query = search.trim().toLowerCase();
|
||||||
const filtered = query
|
return query
|
||||||
? properties.filter((p) => {
|
? properties.filter((p) => {
|
||||||
const addr = (p.address || '').toLowerCase();
|
const addr = (p.address || '').toLowerCase();
|
||||||
const pc = (p.postcode || '').toLowerCase();
|
const pc = (p.postcode || '').toLowerCase();
|
||||||
return addr.includes(query) || pc.includes(query);
|
return addr.includes(query) || pc.includes(query);
|
||||||
})
|
})
|
||||||
: properties;
|
: properties;
|
||||||
return [...filtered].sort((a, b) => {
|
}, [properties, search]);
|
||||||
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]);
|
|
||||||
|
|
||||||
if (!hexagonId) {
|
if (!hexagonId) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -91,22 +78,13 @@ export function PropertiesPane({
|
||||||
</InfoPopup>
|
</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
|
<SearchInput
|
||||||
value={search}
|
value={search}
|
||||||
onChange={setSearch}
|
onChange={setSearch}
|
||||||
placeholder="Search by address or postcode..."
|
placeholder="Search by address or postcode..."
|
||||||
className="p-2"
|
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>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
|
@ -114,7 +92,7 @@ export function PropertiesPane({
|
||||||
<div className="p-4 dark:text-warm-400">Loading...</div>
|
<div className="p-4 dark:text-warm-400">Loading...</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{filteredAndSorted.map((property, idx) => (
|
{filtered.map((property, idx) => (
|
||||||
<PropertyCard key={idx} property={property} />
|
<PropertyCard key={idx} property={property} />
|
||||||
))}
|
))}
|
||||||
{properties.length < total && (
|
{properties.length < total && (
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ interface Segment {
|
||||||
interface StackedBarChartProps {
|
interface StackedBarChartProps {
|
||||||
segments: Segment[];
|
segments: Segment[];
|
||||||
total: number;
|
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 */
|
/** Strip common suffixes/prefixes to produce short legend labels */
|
||||||
|
|
@ -26,7 +28,7 @@ function shortenLabel(name: string): string {
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StackedBarChart({ segments, total }: StackedBarChartProps) {
|
export default function StackedBarChart({ segments, total, colorMap }: StackedBarChartProps) {
|
||||||
const sortedSegments = useMemo(
|
const sortedSegments = useMemo(
|
||||||
() => [...segments].sort((a, b) => b.value - a.value),
|
() => [...segments].sort((a, b) => b.value - a.value),
|
||||||
[segments]
|
[segments]
|
||||||
|
|
@ -51,7 +53,7 @@ export default function StackedBarChart({ segments, total }: StackedBarChartProp
|
||||||
className="h-full"
|
className="h-full"
|
||||||
style={{
|
style={{
|
||||||
width: `${pct}%`,
|
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)}%)`}
|
title={`${shortenLabel(segment.name)}: ${formatValue(segment.value)} (${pct.toFixed(1)}%)`}
|
||||||
/>
|
/>
|
||||||
|
|
@ -66,7 +68,7 @@ export default function StackedBarChart({ segments, total }: StackedBarChartProp
|
||||||
<span
|
<span
|
||||||
className="w-2 h-2 rounded-sm shrink-0"
|
className="w-2 h-2 rounded-sm shrink-0"
|
||||||
style={{
|
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">
|
<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';
|
const textClass = size === 'sm' ? 'text-sm' : 'text-xs';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex items-center gap-1 min-w-0 ${className}`}>
|
<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 truncate`}>
|
<span className={`${textClass} text-warm-700 dark:text-warm-300 ${size === 'xs' ? 'truncate' : ''}`}>
|
||||||
{feature.name}
|
{feature.name}
|
||||||
</span>
|
</span>
|
||||||
{feature.detail && onShowInfo && (
|
{feature.detail && onShowInfo && (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,12 @@
|
||||||
import { useState, useCallback } from 'react';
|
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';
|
export type Page = 'home' | 'dashboard' | 'data-sources' | 'faq';
|
||||||
|
|
||||||
|
|
@ -7,11 +15,21 @@ export default function Header({
|
||||||
onPageChange,
|
onPageChange,
|
||||||
theme,
|
theme,
|
||||||
onToggleTheme,
|
onToggleTheme,
|
||||||
|
onExport,
|
||||||
|
exporting,
|
||||||
|
user,
|
||||||
|
onLoginClick,
|
||||||
|
onLogout,
|
||||||
}: {
|
}: {
|
||||||
activePage: Page;
|
activePage: Page;
|
||||||
onPageChange: (page: Page) => void;
|
onPageChange: (page: Page) => void;
|
||||||
theme: 'light' | 'dark';
|
theme: 'light' | 'dark';
|
||||||
onToggleTheme: () => void;
|
onToggleTheme: () => void;
|
||||||
|
onExport: (() => void) | null;
|
||||||
|
exporting: boolean;
|
||||||
|
user: AuthUser | null;
|
||||||
|
onLoginClick: () => void;
|
||||||
|
onLogout: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
|
@ -36,25 +54,7 @@ export default function Header({
|
||||||
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
|
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
|
||||||
onClick={() => onPageChange('home')}
|
onClick={() => onPageChange('home')}
|
||||||
>
|
>
|
||||||
<svg
|
<MapPinIcon className="w-5 h-5 text-teal-400" />
|
||||||
className="w-5 h-5 text-teal-400"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span className="font-semibold text-lg">Narrowit</span>
|
<span className="font-semibold text-lg">Narrowit</span>
|
||||||
</button>
|
</button>
|
||||||
<nav className="flex items-center gap-2">
|
<nav className="flex items-center gap-2">
|
||||||
|
|
@ -70,73 +70,52 @@ export default function Header({
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<button
|
||||||
onClick={onToggleTheme}
|
onClick={onToggleTheme}
|
||||||
className="flex items-center justify-center w-8 h-8 rounded bg-navy-800 hover:bg-navy-700 transition-colors"
|
className="flex items-center justify-center w-8 h-8 rounded bg-navy-800 hover:bg-navy-700 transition-colors"
|
||||||
title={`Theme: ${theme}`}
|
title={`Theme: ${theme}`}
|
||||||
>
|
>
|
||||||
{theme === 'light' ? (
|
{theme === 'light' ? <SunIcon className="w-4 h-4" /> : <MoonIcon className="w-4 h-4" />}
|
||||||
<svg
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</header>
|
</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,
|
HexagonStatsResponse,
|
||||||
NumericFeatureStats,
|
NumericFeatureStats,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { buildFilterString, apiUrl, logNonAbortError } from '../lib/api';
|
import { buildFilterString, apiUrl, logNonAbortError, authHeaders } from '../lib/api';
|
||||||
|
|
||||||
interface SelectedHexagon {
|
interface SelectedHexagon {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -50,7 +50,7 @@ export function useHexagonSelection({
|
||||||
if (fields) {
|
if (fields) {
|
||||||
params.set('fields', fields.join(','));
|
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;
|
return (await response.json()) as HexagonStatsResponse;
|
||||||
},
|
},
|
||||||
[filters, features]
|
[filters, features]
|
||||||
|
|
@ -96,7 +96,7 @@ export function useHexagonSelection({
|
||||||
const filterStr = buildFilterString(filters, features);
|
const filterStr = buildFilterString(filters, features);
|
||||||
if (filterStr) params.append('filters', filterStr);
|
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();
|
const data: HexagonPropertiesResponse = await response.json();
|
||||||
|
|
||||||
if (offset === 0) {
|
if (offset === 0) {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import type {
|
||||||
ViewChangeParams,
|
ViewChangeParams,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
} from '../types';
|
} 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';
|
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/map-utils';
|
||||||
|
|
||||||
const DEBOUNCE_MS = 150;
|
const DEBOUNCE_MS = 150;
|
||||||
|
|
@ -76,9 +76,12 @@ export function useMapData({
|
||||||
const params = new URLSearchParams({ bounds: boundsStr });
|
const params = new URLSearchParams({ bounds: boundsStr });
|
||||||
if (filtersStr) params.set('filters', filtersStr);
|
if (filtersStr) params.set('filters', filtersStr);
|
||||||
params.set('fields', viewFeature || '');
|
params.set('fields', viewFeature || '');
|
||||||
const res = await fetch(apiUrl('postcodes', params), {
|
const res = await fetch(
|
||||||
signal: abortControllerRef.current.signal,
|
apiUrl('postcodes', params),
|
||||||
});
|
authHeaders({
|
||||||
|
signal: abortControllerRef.current.signal,
|
||||||
|
})
|
||||||
|
);
|
||||||
const json: { features: PostcodeFeature[] } = await res.json();
|
const json: { features: PostcodeFeature[] } = await res.json();
|
||||||
setPostcodeData(json.features || []);
|
setPostcodeData(json.features || []);
|
||||||
setRawData([]);
|
setRawData([]);
|
||||||
|
|
@ -89,9 +92,12 @@ export function useMapData({
|
||||||
});
|
});
|
||||||
if (filtersStr) params.set('filters', filtersStr);
|
if (filtersStr) params.set('filters', filtersStr);
|
||||||
params.set('fields', viewFeature || '');
|
params.set('fields', viewFeature || '');
|
||||||
const res = await fetch(apiUrl('hexagons', params), {
|
const res = await fetch(
|
||||||
signal: abortControllerRef.current.signal,
|
apiUrl('hexagons', params),
|
||||||
});
|
authHeaders({
|
||||||
|
signal: abortControllerRef.current.signal,
|
||||||
|
})
|
||||||
|
);
|
||||||
const json: ApiResponse = await res.json();
|
const json: ApiResponse = await res.json();
|
||||||
setRawData(json.features || []);
|
setRawData(json.features || []);
|
||||||
setPostcodeData([]);
|
setPostcodeData([]);
|
||||||
|
|
@ -162,7 +168,13 @@ export function useMapData({
|
||||||
}, [viewFeature, features, dataRange, activeFeature, dragValue]);
|
}, [viewFeature, features, dataRange, activeFeature, dragValue]);
|
||||||
|
|
||||||
const handleViewChange = useCallback(
|
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}`;
|
const boundsKey = `${newBounds.south},${newBounds.west},${newBounds.north},${newBounds.east},${newRes}`;
|
||||||
if (boundsKey !== prevBoundsRef.current) {
|
if (boundsKey !== prevBoundsRef.current) {
|
||||||
prevBoundsRef.current = boundsKey;
|
prevBoundsRef.current = boundsKey;
|
||||||
|
|
@ -175,10 +187,13 @@ export function useMapData({
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const setInitialView = useCallback((view: { latitude: number; longitude: number; zoom: number }) => {
|
const setInitialView = useCallback(
|
||||||
setCurrentView(view);
|
(view: { latitude: number; longitude: number; zoom: number }) => {
|
||||||
setZoom(view.zoom);
|
setCurrentView(view);
|
||||||
}, []);
|
setZoom(view.zoom);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import type { Bounds, POI, POIResponse } from '../types';
|
import type { Bounds, POI, POIResponse } from '../types';
|
||||||
import { apiUrl, logNonAbortError } from '../lib/api';
|
import { apiUrl, logNonAbortError, authHeaders } from '../lib/api';
|
||||||
|
|
||||||
const DEBOUNCE_MS = 150;
|
const DEBOUNCE_MS = 150;
|
||||||
|
|
||||||
|
|
@ -32,9 +32,12 @@ export function usePOIData(bounds: Bounds | null, selectedCategories: Set<string
|
||||||
categories: categoriesStr,
|
categories: categoriesStr,
|
||||||
bounds: boundsStr,
|
bounds: boundsStr,
|
||||||
});
|
});
|
||||||
const res = await fetch(apiUrl('pois', params), {
|
const res = await fetch(
|
||||||
signal: abortControllerRef.current.signal,
|
apiUrl('pois', params),
|
||||||
});
|
authHeaders({
|
||||||
|
signal: abortControllerRef.current.signal,
|
||||||
|
})
|
||||||
|
);
|
||||||
const json: POIResponse = await res.json();
|
const json: POIResponse = await res.json();
|
||||||
setPois(json.pois || []);
|
setPois(json.pois || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ body,
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
overscroll-behavior: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark {
|
html.dark {
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,29 @@
|
||||||
import type { FeatureMeta, FeatureFilters } from '../types';
|
import type { FeatureMeta, FeatureFilters } from '../types';
|
||||||
|
import { INITIAL_RETRY_MS, MAX_RETRY_MS } from './consts';
|
||||||
const INITIAL_RETRY_MS = 1000;
|
import pb from './pocketbase';
|
||||||
const MAX_RETRY_MS = 10000;
|
|
||||||
|
|
||||||
// Error handling utilities
|
|
||||||
function isAbortError(error: unknown): boolean {
|
|
||||||
return error instanceof Error && error.name === 'AbortError';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logNonAbortError(label: string, error: unknown): void {
|
export function logNonAbortError(label: string, error: unknown): void {
|
||||||
if (!isAbortError(error)) {
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
console.error(`${label}:`, error);
|
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 {
|
export function apiUrl(endpoint: string, params?: URLSearchParams): string {
|
||||||
const base = getApiBaseUrl();
|
|
||||||
const path = endpoint.startsWith('/') ? endpoint : `/api/${endpoint}`;
|
const path = endpoint.startsWith('/') ? endpoint : `/api/${endpoint}`;
|
||||||
const query = params?.toString();
|
const query = params?.toString();
|
||||||
return query ? `${base}${path}?${query}` : `${base}${path}`;
|
return query ? `${path}?${query}` : path;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchWithRetry<T>(
|
export async function fetchWithRetry<T>(
|
||||||
|
|
@ -30,7 +34,7 @@ export async function fetchWithRetry<T>(
|
||||||
let delay = INITIAL_RETRY_MS;
|
let delay = INITIAL_RETRY_MS;
|
||||||
while (!signal.aborted) {
|
while (!signal.aborted) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { signal });
|
const res = await fetch(url, authHeaders({ signal }));
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
onSuccess(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 {
|
export function buildFilterString(filters: FeatureFilters, features: FeatureMeta[]): string {
|
||||||
const entries = Object.entries(filters);
|
const entries = Object.entries(filters);
|
||||||
if (entries.length === 0) return '';
|
if (entries.length === 0) return '';
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import type { ViewState } from '../types';
|
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_BOUNDS: [number, number, number, number] = [-9.5, 49, 5, 57];
|
||||||
export const MAP_MIN_ZOOM = 5.5;
|
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 = {
|
export const INITIAL_VIEW_STATE: ViewState = {
|
||||||
longitude: -1.5,
|
longitude: -1.5,
|
||||||
latitude: 53.5,
|
latitude: 53.5,
|
||||||
|
|
@ -27,14 +29,11 @@ export const ZOOM_TO_RESOLUTION_THRESHOLDS = [
|
||||||
{ maxZoom: 13, resolution: 9 },
|
{ maxZoom: 13, resolution: 9 },
|
||||||
{ maxZoom: Infinity, resolution: 10 },
|
{ maxZoom: Infinity, resolution: 10 },
|
||||||
] as const;
|
] 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] }[] = [
|
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||||
{ t: 0, color: [46, 204, 113] },
|
{ t: 0, color: [46, 204, 113] },
|
||||||
{ t: 0.33, color: [241, 196, 15] },
|
{ 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] },
|
{ 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] }[] = [
|
export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||||
{ t: 0, color: [130, 234, 220] },
|
{ t: 0, color: [255, 255, 255] },
|
||||||
{ t: 0.5, color: [20, 140, 180] },
|
{ t: 0.1, color: [248, 233, 211] },
|
||||||
{ t: 1, color: [88, 28, 140] },
|
{ t: 0.5, color: [255, 221, 173] },
|
||||||
|
{ t: 0.8, color: [251, 171, 60] },
|
||||||
|
{ t: 1, color: [255, 162, 31] },
|
||||||
];
|
];
|
||||||
|
|
||||||
// =============================================================================
|
/** Property density gradient — dark mode (dark warm → bright amber) */
|
||||||
// External URLs
|
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 */
|
/** Protomaps font glyphs URL (served locally from public/assets/) */
|
||||||
export const GLYPHS_URL = 'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf';
|
export const GLYPHS_URL = '/assets/fonts/{fontstack}/{range}.pbf';
|
||||||
|
|
||||||
/** Protomaps sprite base URL */
|
/** Twemoji base URL (served locally from public/assets/) */
|
||||||
export const SPRITE_URL_BASE = 'https://protomaps.github.io/basemaps-assets/sprites/v4';
|
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
|
* Groups whose features should be collapsed into stacked bar charts.
|
||||||
// =============================================================================
|
* Keyed by feature group name. Each entry defines one stacked chart.
|
||||||
|
*/
|
||||||
export interface StackedChartConfig {
|
export const STACKED_GROUPS: Record<string, {
|
||||||
/** Display label for the chart */
|
/** Display label for the chart */
|
||||||
label: string;
|
label: string;
|
||||||
/** If set, use this feature's stats for the total and info popup. Otherwise sum components. */
|
/** 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;
|
unit?: string;
|
||||||
/** Feature names that make up the segments */
|
/** Feature names that make up the segments */
|
||||||
components: string[];
|
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: [
|
Crime: [
|
||||||
{
|
{
|
||||||
label: 'Serious 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 */
|
/** Colors for stacked bar segments */
|
||||||
export const SEGMENT_COLORS = [
|
export const SEGMENT_COLORS = [
|
||||||
'#ef4444', // red-500
|
'#ef4444', // red-500
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,62 @@
|
||||||
export function formatValue(value: number): string {
|
export interface ValueFormat {
|
||||||
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
prefix?: string;
|
||||||
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
|
suffix?: string;
|
||||||
if (Number.isInteger(value)) return value.toLocaleString();
|
/** Show full integer (no k/M abbreviation) */
|
||||||
return value.toFixed(1);
|
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 {
|
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_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||||
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
|
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
|
||||||
|
|
@ -29,20 +81,31 @@ export function formatNumber(value: number | undefined, decimals = 0): string {
|
||||||
return decimals > 0 ? value.toFixed(decimals) : Math.round(value).toLocaleString();
|
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: {
|
export function calculateHistogramMean(histogram: {
|
||||||
min: number;
|
min: number;
|
||||||
bin_width: number;
|
max: number;
|
||||||
|
p1: number;
|
||||||
|
p99: number;
|
||||||
counts: number[];
|
counts: number[];
|
||||||
}): number | undefined {
|
}): 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);
|
const totalCount = histogram.counts.reduce((a, b) => a + b, 0);
|
||||||
if (totalCount === 0) return undefined;
|
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;
|
let weightedSum = 0;
|
||||||
for (let i = 0; i < histogram.counts.length; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
const binCenter = histogram.min + (i + 0.5) * histogram.bin_width;
|
let center: number;
|
||||||
weightedSum += binCenter * histogram.counts[i];
|
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;
|
return weightedSum / totalCount;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,40 +3,94 @@ import type { StyleSpecification } from 'maplibre-gl';
|
||||||
import { layers, namedFlavor } from '@protomaps/basemaps';
|
import { layers, namedFlavor } from '@protomaps/basemaps';
|
||||||
import {
|
import {
|
||||||
GLYPHS_URL,
|
GLYPHS_URL,
|
||||||
SPRITE_URL_BASE,
|
|
||||||
TILE_MAX_ZOOM,
|
|
||||||
OSM_ATTRIBUTION,
|
|
||||||
FEATURE_GRADIENT,
|
FEATURE_GRADIENT,
|
||||||
DENSITY_GRADIENT,
|
DENSITY_GRADIENT,
|
||||||
|
DENSITY_GRADIENT_DARK,
|
||||||
ZOOM_TO_RESOLUTION_THRESHOLDS,
|
ZOOM_TO_RESOLUTION_THRESHOLDS,
|
||||||
TWEMOJI_BASE,
|
TWEMOJI_BASE,
|
||||||
|
POSTCODE_ZOOM_THRESHOLD,
|
||||||
} from './consts';
|
} from './consts';
|
||||||
|
|
||||||
// Re-export constants for backwards compatibility
|
// 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 {
|
export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
|
||||||
const flavor = namedFlavor(theme);
|
const flavor = namedFlavor(theme);
|
||||||
// Use absolute URL for tiles - required by MapLibre
|
// Use absolute URL for tiles - required by MapLibre
|
||||||
const tileUrl = `${window.location.origin}/api/tiles/{z}/{x}/{y}`;
|
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 {
|
return {
|
||||||
version: 8,
|
version: 8,
|
||||||
glyphs: GLYPHS_URL,
|
glyphs: GLYPHS_URL,
|
||||||
sprite: `${SPRITE_URL_BASE}/${theme}`,
|
|
||||||
sources: {
|
sources: {
|
||||||
protomaps: {
|
protomaps: {
|
||||||
type: 'vector',
|
type: 'vector',
|
||||||
tiles: [tileUrl],
|
tiles: [tileUrl],
|
||||||
maxzoom: TILE_MAX_ZOOM,
|
maxzoom: POSTCODE_ZOOM_THRESHOLD,
|
||||||
attribution: OSM_ATTRIBUTION,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
layers: layers('protomaps', flavor, { lang: 'en' }),
|
layers: modifiedLayers,
|
||||||
} as StyleSpecification;
|
} as StyleSpecification;
|
||||||
}
|
}
|
||||||
|
|
||||||
type GradientStop = { t: number; color: [number, number, number] };
|
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] {
|
function interpolateGradient(t: number, gradient: GradientStop[]): [number, number, number] {
|
||||||
if (t <= 0) return gradient[0].color;
|
if (t <= 0) return gradient[0].color;
|
||||||
if (t >= 1) return gradient[gradient.length - 1].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];
|
const hi = gradient[i + 1];
|
||||||
if (t >= lo.t && t <= hi.t) {
|
if (t >= lo.t && t <= hi.t) {
|
||||||
const frac = (t - lo.t) / (hi.t - lo.t);
|
const frac = (t - lo.t) / (hi.t - lo.t);
|
||||||
return [
|
const loLab = rgbToOklab(lo.color);
|
||||||
Math.round(lo.color[0] + (hi.color[0] - lo.color[0]) * frac),
|
const hiLab = rgbToOklab(hi.color);
|
||||||
Math.round(lo.color[1] + (hi.color[1] - lo.color[1]) * frac),
|
const interpLab: [number, number, number] = [
|
||||||
Math.round(lo.color[2] + (hi.color[2] - lo.color[2]) * frac),
|
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;
|
return gradient[gradient.length - 1].color;
|
||||||
|
|
@ -60,8 +117,8 @@ export function normalizedToColor(t: number): [number, number, number] {
|
||||||
return interpolateGradient(t, FEATURE_GRADIENT);
|
return interpolateGradient(t, FEATURE_GRADIENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function countToColor(t: number): [number, number, number] {
|
export function countToColor(t: number, gradient: GradientStop[] = DENSITY_GRADIENT): [number, number, number] {
|
||||||
return interpolateGradient(t, DENSITY_GRADIENT);
|
return interpolateGradient(t, gradient);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function zoomToResolution(zoom: number): number {
|
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;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
step?: 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
|
// Enum-only fields
|
||||||
values?: string[];
|
values?: string[];
|
||||||
// Description fields
|
// Description fields
|
||||||
|
|
@ -124,7 +124,7 @@ export interface NumericFeatureStats {
|
||||||
min: number;
|
min: number;
|
||||||
max: number;
|
max: number;
|
||||||
mean: 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 {
|
export interface EnumFeatureStats {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue