Refactor and other improvements
This commit is contained in:
parent
04a78e7bfe
commit
6c90cf3c0f
47 changed files with 2705 additions and 1568 deletions
58
frontend/src/components/map/AISummaryCard.tsx
Normal file
58
frontend/src/components/map/AISummaryCard.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { ChevronIcon } from '../ui/icons';
|
||||
import { LightbulbIcon } from '../ui/icons/LightbulbIcon';
|
||||
|
||||
interface AISummaryCardProps {
|
||||
summary?: string;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
expanded: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
}
|
||||
|
||||
export default function AISummaryCard({
|
||||
summary,
|
||||
loading,
|
||||
error,
|
||||
expanded,
|
||||
onToggleExpanded,
|
||||
}: AISummaryCardProps) {
|
||||
if (!summary && !loading && !error) return null;
|
||||
|
||||
return (
|
||||
<div className="px-3 pt-3 pb-1">
|
||||
<div className="bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-800/50 rounded p-2.5">
|
||||
<button
|
||||
onClick={onToggleExpanded}
|
||||
className="w-full flex items-center justify-between gap-1.5 mb-1.5"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<LightbulbIcon className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400" />
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">
|
||||
AI Summary
|
||||
</span>
|
||||
</div>
|
||||
<ChevronIcon
|
||||
direction={expanded ? 'down' : 'right'}
|
||||
className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400"
|
||||
/>
|
||||
</button>
|
||||
{expanded && (
|
||||
<>
|
||||
{error ? (
|
||||
<div className="text-xs text-warm-600 dark:text-warm-400">
|
||||
Failed to generate summary.
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-3 bg-teal-200/60 dark:bg-teal-800/40 rounded animate-pulse w-full" />
|
||||
<div className="h-3 bg-teal-200/60 dark:bg-teal-800/40 rounded animate-pulse w-4/5" />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-warm-700 dark:text-warm-300 leading-relaxed">{summary}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
|
||||
import type {
|
||||
FeatureFilters,
|
||||
FeatureMeta,
|
||||
|
|
@ -15,13 +16,15 @@ import StackedBarChart from './StackedBarChart';
|
|||
import StackedEnumChart from './StackedEnumChart';
|
||||
import PriceHistoryChart from './PriceHistoryChart';
|
||||
import ExternalSearchLinks from './ExternalSearchLinks';
|
||||
import { InfoIcon, CloseIcon, ChevronIcon } from '../ui/icons';
|
||||
import { InfoIcon, CloseIcon } from '../ui/icons';
|
||||
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
||||
import { LightbulbIcon } from '../ui/icons/LightbulbIcon';
|
||||
import { IconButton } from '../ui/IconButton';
|
||||
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
||||
import { EmptyState } from '../ui/EmptyState';
|
||||
import { FeatureLabel } from '../ui/FeatureLabel';
|
||||
import AISummaryCard from './AISummaryCard';
|
||||
import StreetViewEmbed from './StreetViewEmbed';
|
||||
import HistogramLegend from './HistogramLegend';
|
||||
|
||||
interface AreaPaneProps {
|
||||
stats: HexagonStatsResponse | null;
|
||||
|
|
@ -60,17 +63,9 @@ export default function AreaPane({
|
|||
const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count;
|
||||
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
|
||||
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||
const [collapsedGroups, toggleGroup] = useCollapsibleGroups();
|
||||
const [aiSummaryExpanded, setAiSummaryExpanded] = useState(true);
|
||||
|
||||
const toggleGroup = (name: string) =>
|
||||
setCollapsedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
return next;
|
||||
});
|
||||
|
||||
const numericByName = useMemo(() => {
|
||||
if (!stats) return new Map();
|
||||
return new Map(stats.numeric_features.map((feature) => [feature.name, feature]));
|
||||
|
|
@ -138,78 +133,18 @@ export default function AreaPane({
|
|||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* AI Summary Card */}
|
||||
{(aiSummary || aiSummaryLoading || aiSummaryError) && (
|
||||
<div className="px-3 pt-3 pb-1">
|
||||
<div className="bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-800/50 rounded p-2.5">
|
||||
<button
|
||||
onClick={() => setAiSummaryExpanded(!aiSummaryExpanded)}
|
||||
className="w-full flex items-center justify-between gap-1.5 mb-1.5"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<LightbulbIcon className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400" />
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">
|
||||
AI Summary
|
||||
</span>
|
||||
</div>
|
||||
<ChevronIcon
|
||||
direction={aiSummaryExpanded ? 'down' : 'right'}
|
||||
className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400"
|
||||
/>
|
||||
</button>
|
||||
{aiSummaryExpanded && (
|
||||
<>
|
||||
{aiSummaryError ? (
|
||||
<div className="text-xs text-warm-600 dark:text-warm-400">
|
||||
Failed to generate summary.
|
||||
</div>
|
||||
) : aiSummaryLoading ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-3 bg-teal-200/60 dark:bg-teal-800/40 rounded animate-pulse w-full" />
|
||||
<div className="h-3 bg-teal-200/60 dark:bg-teal-800/40 rounded animate-pulse w-4/5" />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-warm-700 dark:text-warm-300 leading-relaxed">
|
||||
{aiSummary}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<AISummaryCard
|
||||
summary={aiSummary}
|
||||
loading={aiSummaryLoading}
|
||||
error={aiSummaryError}
|
||||
expanded={aiSummaryExpanded}
|
||||
onToggleExpanded={() => setAiSummaryExpanded(!aiSummaryExpanded)}
|
||||
/>
|
||||
{loading && !stats ? (
|
||||
<LoadingSkeleton />
|
||||
) : stats ? (
|
||||
<div>
|
||||
{/* Histogram color legend */}
|
||||
<div className="mx-3 mt-3 bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-800/50 rounded p-2.5 text-xs">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-teal-500 dark:bg-teal-400 rounded" />
|
||||
<span className="text-warm-700 dark:text-warm-300">
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">Teal bars</span>{' '}
|
||||
show the distribution in this selected area
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-warm-300/60 dark:bg-warm-600/60 rounded" />
|
||||
<span className="text-warm-700 dark:text-warm-300">
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">Gray bars</span>{' '}
|
||||
show the overall distribution across all areas
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-px border-t border-dashed border-warm-500 dark:border-warm-400" />
|
||||
<span className="text-warm-700 dark:text-warm-300">
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">
|
||||
Dashed line
|
||||
</span>{' '}
|
||||
indicates the global average
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<HistogramLegend />
|
||||
{featureGroups.map((group) => {
|
||||
const hasData = group.features.some(
|
||||
(feature) => numericByName.has(feature.name) || enumByName.has(feature.name)
|
||||
|
|
@ -460,25 +395,7 @@ export default function AreaPane({
|
|||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Google Street View */}
|
||||
{hexagonLocation && (
|
||||
<div>
|
||||
<div className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0">
|
||||
Street View
|
||||
</div>
|
||||
<div className="px-3 py-2">
|
||||
<div className="rounded overflow-hidden border border-warm-200 dark:border-warm-700">
|
||||
<iframe
|
||||
className="w-full"
|
||||
style={{ height: 240, border: 0 }}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
src={`https://maps.google.com/maps?layer=c&cbll=${hexagonLocation.lat},${hexagonLocation.lon}&cbp=11,0,0,0,0&output=svembed`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
129
frontend/src/components/map/FeatureBrowser.tsx
Normal file
129
frontend/src/components/map/FeatureBrowser.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
|
||||
import { SearchInput } from '../ui/SearchInput';
|
||||
import { FilterIcon } from '../ui/icons';
|
||||
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
||||
import { EmptyState } from '../ui/EmptyState';
|
||||
import type { FeatureMeta } from '../../types';
|
||||
import { groupFeaturesByCategory } from '../../lib/features';
|
||||
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
||||
import { FeatureActions } from '../ui/FeatureIcons';
|
||||
import { FeatureLabel } from '../ui/FeatureLabel';
|
||||
|
||||
interface FeatureBrowserProps {
|
||||
availableFeatures: FeatureMeta[];
|
||||
allFeatures: FeatureMeta[];
|
||||
pinnedFeature: string | null;
|
||||
onAddFilter: (name: string) => void;
|
||||
onTogglePin: (name: string) => void;
|
||||
onNavigateToSource?: (slug: string, featureName: string) => void;
|
||||
openInfoFeature?: string | null;
|
||||
onClearOpenInfoFeature?: () => void;
|
||||
}
|
||||
|
||||
export default function FeatureBrowser({
|
||||
availableFeatures,
|
||||
allFeatures,
|
||||
pinnedFeature,
|
||||
onAddFilter,
|
||||
onTogglePin,
|
||||
onNavigateToSource,
|
||||
openInfoFeature,
|
||||
onClearOpenInfoFeature,
|
||||
}: FeatureBrowserProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
const [expandedGroups, toggleGroup] = useCollapsibleGroups();
|
||||
|
||||
useEffect(() => {
|
||||
if (openInfoFeature) {
|
||||
const feat = allFeatures.find((f) => f.name === openInfoFeature);
|
||||
if (feat) setInfoFeature(feat);
|
||||
onClearOpenInfoFeature?.();
|
||||
}
|
||||
}, [openInfoFeature, allFeatures, onClearOpenInfoFeature]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return availableFeatures;
|
||||
const lower = search.toLowerCase();
|
||||
return availableFeatures.filter((f) => f.name.toLowerCase().includes(lower));
|
||||
}, [availableFeatures, search]);
|
||||
|
||||
const grouped = useMemo(() => groupFeaturesByCategory(filtered), [filtered]);
|
||||
|
||||
// When searching, expand all groups so results are visible
|
||||
const isSearching = search.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="shrink-0 p-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<SearchInput value={search} onChange={setSearch} placeholder="Search features..." />
|
||||
</div>
|
||||
<div className="md:min-h-0 md:flex-1 md:overflow-y-auto flex flex-col">
|
||||
{grouped.map((group) => {
|
||||
const isExpanded = isSearching || expandedGroups.has(group.name);
|
||||
return (
|
||||
<div key={group.name} className="shrink-0">
|
||||
<CollapsibleGroupHeader
|
||||
name={group.name}
|
||||
expanded={isExpanded}
|
||||
onToggle={() => toggleGroup(group.name)}
|
||||
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 hover:bg-warm-100 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||
{group.features.length}
|
||||
</span>
|
||||
</CollapsibleGroupHeader>
|
||||
{isExpanded &&
|
||||
group.features.map((f) => {
|
||||
const isPinned = pinnedFeature === f.name;
|
||||
return (
|
||||
<div
|
||||
key={f.name}
|
||||
className="flex items-start justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
|
||||
>
|
||||
<div className="min-w-0 mr-2">
|
||||
<FeatureLabel feature={f} onShowInfo={setInfoFeature} size="sm" />
|
||||
{f.description && (
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500 truncate block">
|
||||
{f.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<FeatureActions
|
||||
feature={f}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
onAdd={onAddFilter}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{grouped.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
|
||||
title={search ? 'No matching features' : 'All features are active'}
|
||||
description={
|
||||
search ? 'Try a different search term' : 'Remove a filter to see available features'
|
||||
}
|
||||
className="px-3 py-4"
|
||||
/>
|
||||
) : (
|
||||
<p className="flex-1 flex items-center justify-center px-4 py-6 text-xs text-center text-warm-400 dark:text-warm-500">
|
||||
Everyone cares about different things. Pick the filters that matter most to you.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{infoFeature && (
|
||||
<FeatureInfoPopup
|
||||
feature={infoFeature}
|
||||
onClose={() => setInfoFeature(null)}
|
||||
onNavigateToSource={onNavigateToSource}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,16 +1,14 @@
|
|||
import { memo, useState, useMemo, useEffect } from 'react';
|
||||
import { memo, useState } from 'react';
|
||||
import { Slider } from '../ui/Slider';
|
||||
import { SearchInput } from '../ui/SearchInput';
|
||||
import { FilterIcon, LightbulbIcon } from '../ui/icons';
|
||||
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
||||
import { EmptyState } from '../ui/EmptyState';
|
||||
import type { FeatureMeta, FeatureFilters } from '../../types';
|
||||
import { formatFilterValue } from '../../lib/format';
|
||||
import { groupFeaturesByCategory } from '../../lib/features';
|
||||
import InfoPopup from '../ui/InfoPopup';
|
||||
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
||||
import { FeatureActions } from '../ui/FeatureIcons';
|
||||
import { FeatureLabel } from '../ui/FeatureLabel';
|
||||
import FeatureBrowser from './FeatureBrowser';
|
||||
|
||||
interface FiltersProps {
|
||||
features: FeatureMeta[];
|
||||
|
|
@ -35,130 +33,6 @@ interface FiltersProps {
|
|||
onClearOpenInfoFeature?: () => void;
|
||||
}
|
||||
|
||||
function FeatureBrowser({
|
||||
availableFeatures,
|
||||
allFeatures,
|
||||
pinnedFeature,
|
||||
onAddFilter,
|
||||
onTogglePin,
|
||||
onNavigateToSource,
|
||||
openInfoFeature,
|
||||
onClearOpenInfoFeature,
|
||||
}: {
|
||||
availableFeatures: FeatureMeta[];
|
||||
allFeatures: FeatureMeta[];
|
||||
pinnedFeature: string | null;
|
||||
onAddFilter: (name: string) => void;
|
||||
onTogglePin: (name: string) => void;
|
||||
onNavigateToSource?: (slug: string, featureName: string) => void;
|
||||
openInfoFeature?: string | null;
|
||||
onClearOpenInfoFeature?: () => void;
|
||||
}) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleGroup = (name: string) =>
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
return next;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (openInfoFeature) {
|
||||
const feat = allFeatures.find((f) => f.name === openInfoFeature);
|
||||
if (feat) setInfoFeature(feat);
|
||||
onClearOpenInfoFeature?.();
|
||||
}
|
||||
}, [openInfoFeature, allFeatures, onClearOpenInfoFeature]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return availableFeatures;
|
||||
const lower = search.toLowerCase();
|
||||
return availableFeatures.filter((f) => f.name.toLowerCase().includes(lower));
|
||||
}, [availableFeatures, search]);
|
||||
|
||||
const grouped = useMemo(() => groupFeaturesByCategory(filtered), [filtered]);
|
||||
|
||||
// When searching, expand all groups so results are visible
|
||||
const isSearching = search.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="shrink-0 p-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<SearchInput value={search} onChange={setSearch} placeholder="Search features..." />
|
||||
</div>
|
||||
<div className="md:min-h-0 md:flex-1 md:overflow-y-auto flex flex-col">
|
||||
{grouped.map((group) => {
|
||||
const isExpanded = isSearching || expandedGroups.has(group.name);
|
||||
return (
|
||||
<div key={group.name} className="shrink-0">
|
||||
<CollapsibleGroupHeader
|
||||
name={group.name}
|
||||
expanded={isExpanded}
|
||||
onToggle={() => toggleGroup(group.name)}
|
||||
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 hover:bg-warm-100 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||
{group.features.length}
|
||||
</span>
|
||||
</CollapsibleGroupHeader>
|
||||
{isExpanded &&
|
||||
group.features.map((f) => {
|
||||
const isPinned = pinnedFeature === f.name;
|
||||
return (
|
||||
<div
|
||||
key={f.name}
|
||||
className="flex items-start justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
|
||||
>
|
||||
<div className="min-w-0 mr-2">
|
||||
<FeatureLabel feature={f} onShowInfo={setInfoFeature} size="sm" />
|
||||
{f.description && (
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500 truncate block">
|
||||
{f.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<FeatureActions
|
||||
feature={f}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
onAdd={onAddFilter}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{grouped.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
|
||||
title={search ? 'No matching features' : 'All features are active'}
|
||||
description={
|
||||
search ? 'Try a different search term' : 'Remove a filter to see available features'
|
||||
}
|
||||
className="px-3 py-4"
|
||||
/>
|
||||
) : (
|
||||
<p className="flex-1 flex items-center justify-center px-4 py-6 text-xs text-center text-warm-400 dark:text-warm-500">
|
||||
Everyone cares about different things. Pick the filters that matter most to you.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{infoFeature && (
|
||||
<FeatureInfoPopup
|
||||
feature={infoFeature}
|
||||
onClose={() => setInfoFeature(null)}
|
||||
onNavigateToSource={onNavigateToSource}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(function Filters({
|
||||
features,
|
||||
filters,
|
||||
|
|
|
|||
29
frontend/src/components/map/HistogramLegend.tsx
Normal file
29
frontend/src/components/map/HistogramLegend.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
export default function HistogramLegend() {
|
||||
return (
|
||||
<div className="mx-3 mt-3 bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-800/50 rounded p-2.5 text-xs">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-teal-500 dark:bg-teal-400 rounded" />
|
||||
<span className="text-warm-700 dark:text-warm-300">
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">Teal bars</span> show the
|
||||
distribution in this selected area
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-warm-300/60 dark:bg-warm-600/60 rounded" />
|
||||
<span className="text-warm-700 dark:text-warm-300">
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">Gray bars</span> show the
|
||||
overall distribution across all areas
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-px border-t border-dashed border-warm-500 dark:border-warm-400" />
|
||||
<span className="text-warm-700 dark:text-warm-300">
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">Dashed line</span>{' '}
|
||||
indicates the global average
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,14 +2,10 @@ import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
|
|||
import { Map as MapGL, useControl } from 'react-map-gl/maplibre';
|
||||
import type { MapRef } from 'react-map-gl/maplibre';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import { H3HexagonLayer } from '@deck.gl/geo-layers';
|
||||
import { GeoJsonLayer, IconLayer, TextLayer } from '@deck.gl/layers';
|
||||
import type { PickingInfo } from '@deck.gl/core';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import type {
|
||||
HexagonData,
|
||||
PostcodeFeature,
|
||||
PostcodeProperties,
|
||||
ViewState,
|
||||
ViewChangeParams,
|
||||
POI,
|
||||
|
|
@ -17,30 +13,13 @@ import type {
|
|||
Bounds,
|
||||
} from '../../types';
|
||||
|
||||
import {
|
||||
GRADIENT,
|
||||
normalizedToColor,
|
||||
countToColor,
|
||||
zoomToResolution,
|
||||
getBoundsFromViewState,
|
||||
emojiToTwemojiUrl,
|
||||
getMapStyle,
|
||||
DENSITY_GRADIENT,
|
||||
DENSITY_GRADIENT_DARK,
|
||||
} from '../../lib/map-utils';
|
||||
import { zoomToResolution, getBoundsFromViewState, getMapStyle } from '../../lib/map-utils';
|
||||
import { INITIAL_VIEW_STATE, MAP_MIN_ZOOM, MAP_BOUNDS } from '../../lib/consts';
|
||||
import PostcodeSearch, { type SearchedPostcode } from './PostcodeSearch';
|
||||
import MapLegend from './MapLegend';
|
||||
import HoverCard from './HoverCard';
|
||||
import type { FeatureFilters } from '../../types';
|
||||
|
||||
/** Convert POI id (e.g. "n12345") to OpenStreetMap URL */
|
||||
function osmIdToUrl(id: string): string | null {
|
||||
const match = id.match(/^([nwr])(\d+)$/);
|
||||
if (!match) return null;
|
||||
const typeMap: Record<string, string> = { n: 'node', w: 'way', r: 'relation' };
|
||||
return `https://www.openstreetmap.org/${typeMap[match[1]]}/${match[2]}`;
|
||||
}
|
||||
import { useDeckLayers, osmIdToUrl } from '../../hooks/useDeckLayers';
|
||||
|
||||
interface MapProps {
|
||||
data: HexagonData[];
|
||||
|
|
@ -123,7 +102,6 @@ export default memo(function Map({
|
|||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW_STATE);
|
||||
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
|
||||
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
|
|
@ -165,9 +143,6 @@ export default memo(function Map({
|
|||
setViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom }));
|
||||
}, []);
|
||||
|
||||
const themeRef = useRef(theme);
|
||||
themeRef.current = theme;
|
||||
|
||||
const handleMapLoad = useCallback(
|
||||
(_evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
|
||||
// Road opacity is set in getMapStyle
|
||||
|
|
@ -177,425 +152,31 @@ export default memo(function Map({
|
|||
|
||||
const mapStyle = useMemo(() => getMapStyle(theme), [theme]);
|
||||
|
||||
const [popupInfo, setPopupInfo] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
name: string;
|
||||
category: string;
|
||||
id: string;
|
||||
} | null>(null);
|
||||
|
||||
const handlePoiHover = useCallback((info: PickingInfo<POI>) => {
|
||||
if (info.object && info.x !== undefined && info.y !== undefined) {
|
||||
setPopupInfo({
|
||||
x: info.x,
|
||||
y: info.y,
|
||||
name: info.object.name,
|
||||
category: info.object.category,
|
||||
id: info.object.id,
|
||||
});
|
||||
} else {
|
||||
setPopupInfo(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const countRange = useMemo(() => {
|
||||
if (data.length === 0) return { min: 0, max: 1 };
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const d of data) {
|
||||
if (viewportBounds) {
|
||||
if (
|
||||
d.lat < viewportBounds.south ||
|
||||
d.lat > viewportBounds.north ||
|
||||
d.lon < viewportBounds.west ||
|
||||
d.lon > viewportBounds.east
|
||||
)
|
||||
continue;
|
||||
}
|
||||
const c = d.count as number;
|
||||
if (c < min) min = c;
|
||||
if (c > max) max = c;
|
||||
}
|
||||
if (min === Infinity) return { min: 0, max: 1 };
|
||||
if (min === max) return { min, max: min + 1 };
|
||||
return { min, max };
|
||||
}, [data, viewportBounds]);
|
||||
|
||||
const colorFeatureMeta = useMemo(
|
||||
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
|
||||
[viewFeature, features]
|
||||
);
|
||||
|
||||
const viewFeatureRef = useRef(viewFeature);
|
||||
viewFeatureRef.current = viewFeature;
|
||||
const colorRangeRef = useRef(colorRange);
|
||||
colorRangeRef.current = colorRange;
|
||||
const filterRangeRef = useRef(filterRange);
|
||||
filterRangeRef.current = filterRange;
|
||||
const colorFeatureMetaRef = useRef(colorFeatureMeta);
|
||||
colorFeatureMetaRef.current = colorFeatureMeta;
|
||||
const countRangeRef = useRef(countRange);
|
||||
countRangeRef.current = countRange;
|
||||
const selectedHexagonIdRef = useRef(selectedHexagonId);
|
||||
selectedHexagonIdRef.current = selectedHexagonId;
|
||||
const hoveredHexagonIdRef = useRef(hoveredHexagonId);
|
||||
hoveredHexagonIdRef.current = hoveredHexagonId;
|
||||
|
||||
const onHexagonClickRef = useRef(onHexagonClick);
|
||||
onHexagonClickRef.current = onHexagonClick;
|
||||
const handleHexagonClick = useCallback((info: PickingInfo<HexagonData>) => {
|
||||
if (info.object && 'h3' in info.object) {
|
||||
onHexagonClickRef.current(info.object.h3);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onHexagonHoverRef = useRef(onHexagonHover);
|
||||
onHexagonHoverRef.current = onHexagonHover;
|
||||
const handleHexagonHover = useCallback((info: PickingInfo<HexagonData>) => {
|
||||
if (info.object && 'h3' in info.object && info.x !== undefined && info.y !== undefined) {
|
||||
setHoverPosition({ x: info.x, y: info.y });
|
||||
onHexagonHoverRef.current(info.object.h3, info.x, info.y);
|
||||
} else {
|
||||
setHoverPosition(null);
|
||||
onHexagonHoverRef.current(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePoiHoverRef = useRef(handlePoiHover);
|
||||
handlePoiHoverRef.current = handlePoiHover;
|
||||
const stablePoiHover = useCallback((info: PickingInfo<POI>) => {
|
||||
handlePoiHoverRef.current(info);
|
||||
}, []);
|
||||
|
||||
// Compute count range for postcodes (similar to hexagons)
|
||||
const postcodeCountRange = useMemo(() => {
|
||||
if (postcodeData.length === 0) return { min: 0, max: 1 };
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const d of postcodeData) {
|
||||
if (viewportBounds) {
|
||||
const [lng, lat] = d.properties.centroid as [number, number];
|
||||
if (
|
||||
lat < viewportBounds.south ||
|
||||
lat > viewportBounds.north ||
|
||||
lng < viewportBounds.west ||
|
||||
lng > viewportBounds.east
|
||||
)
|
||||
continue;
|
||||
}
|
||||
const c = d.properties.count;
|
||||
if (c < min) min = c;
|
||||
if (c > max) max = c;
|
||||
}
|
||||
if (min === Infinity) return { min: 0, max: 1 };
|
||||
if (min === max) return { min, max: min + 1 };
|
||||
return { min, max };
|
||||
}, [postcodeData, viewportBounds]);
|
||||
|
||||
const postcodeCountRangeRef = useRef(postcodeCountRange);
|
||||
postcodeCountRangeRef.current = postcodeCountRange;
|
||||
|
||||
// Track selected/hovered postcode for styling
|
||||
const [selectedPostcode, setSelectedPostcode] = useState<string | null>(null);
|
||||
const [hoveredPostcode, setHoveredPostcode] = useState<string | null>(null);
|
||||
const selectedPostcodeRef = useRef(selectedPostcode);
|
||||
selectedPostcodeRef.current = selectedPostcode;
|
||||
const hoveredPostcodeRef = useRef(hoveredPostcode);
|
||||
hoveredPostcodeRef.current = hoveredPostcode;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handlePostcodeClick = useCallback((info: PickingInfo<any>) => {
|
||||
const pc = info.object?.properties?.postcode;
|
||||
if (pc) {
|
||||
setSelectedPostcode((prev) => (prev === pc ? null : pc));
|
||||
onHexagonClickRef.current(pc, true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handlePostcodeHoverCallback = useCallback((info: PickingInfo<any>) => {
|
||||
const pc = info.object?.properties?.postcode;
|
||||
if (pc && info.x !== undefined && info.y !== undefined) {
|
||||
setHoveredPostcode(pc);
|
||||
setHoverPosition({ x: info.x, y: info.y });
|
||||
onHexagonHoverRef.current(pc, info.x, info.y);
|
||||
} else {
|
||||
setHoveredPostcode(null);
|
||||
setHoverPosition(null);
|
||||
onHexagonHoverRef.current(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isDark = theme === 'dark';
|
||||
const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
|
||||
const densityGradientRef = useRef(densityGradient);
|
||||
densityGradientRef.current = densityGradient;
|
||||
const isDarkRef = useRef(isDark);
|
||||
isDarkRef.current = isDark;
|
||||
|
||||
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}|${theme}`;
|
||||
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${selectedPostcode}|${hoveredPostcode}|${theme}`;
|
||||
|
||||
const hexLayer = useMemo(
|
||||
() =>
|
||||
new H3HexagonLayer<HexagonData>({
|
||||
id: 'h3-hexagons',
|
||||
data,
|
||||
getHexagon: (d) => d.h3,
|
||||
getFillColor: (d) => {
|
||||
const vf = viewFeatureRef.current;
|
||||
const clr = colorRangeRef.current;
|
||||
const fr = filterRangeRef.current;
|
||||
const cfm = colorFeatureMetaRef.current;
|
||||
const dark = isDarkRef.current;
|
||||
if (vf && clr && cfm) {
|
||||
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
|
||||
if (val == null)
|
||||
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
if (fr) {
|
||||
const minVal = d[`min_${vf}`] as number;
|
||||
const maxVal = d[`max_${vf}`] as number;
|
||||
if (maxVal < fr[0] || minVal > fr[1]) {
|
||||
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
}
|
||||
}
|
||||
const range = clr[1] - clr[0];
|
||||
if (range === 0) return [...GRADIENT[0].color, 255] as [number, number, number, number];
|
||||
const t = ((val as number) - clr[0]) / range;
|
||||
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)));
|
||||
return [...rgb, 255] as [number, number, number, number];
|
||||
}
|
||||
const cr = countRangeRef.current;
|
||||
const c = d.count as number;
|
||||
const t = (c - cr.min) / (cr.max - cr.min);
|
||||
return [
|
||||
...countToColor(Math.max(0, Math.min(1, t)), densityGradientRef.current),
|
||||
255,
|
||||
] as [number, number, number, number];
|
||||
},
|
||||
getLineColor: (d) => {
|
||||
if (d.h3 === selectedHexagonIdRef.current)
|
||||
return [255, 255, 255, 255] as [number, number, number, number];
|
||||
if (d.h3 === hoveredHexagonIdRef.current)
|
||||
return [29, 228, 195, 200] as [number, number, number, number];
|
||||
return [0, 0, 0, 0] as [number, number, number, number];
|
||||
},
|
||||
getLineWidth: (d) => {
|
||||
if (d.h3 === selectedHexagonIdRef.current) return 3;
|
||||
if (d.h3 === hoveredHexagonIdRef.current) return 2;
|
||||
return 0;
|
||||
},
|
||||
lineWidthUnits: 'pixels',
|
||||
updateTriggers: {
|
||||
getFillColor: [colorTrigger],
|
||||
getLineColor: [colorTrigger],
|
||||
getLineWidth: [colorTrigger],
|
||||
},
|
||||
extruded: false,
|
||||
pickable: true,
|
||||
opacity: 1,
|
||||
highPrecision: true,
|
||||
onClick: handleHexagonClick,
|
||||
onHover: handleHexagonHover,
|
||||
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
|
||||
beforeId: 'landuse_park',
|
||||
}),
|
||||
[data, colorTrigger, handleHexagonClick, handleHexagonHover]
|
||||
);
|
||||
|
||||
const postcodeLayer = useMemo(
|
||||
() =>
|
||||
new GeoJsonLayer<PostcodeProperties>({
|
||||
id: 'postcode-polygons',
|
||||
data: postcodeData as PostcodeFeature[],
|
||||
getFillColor: (f) => {
|
||||
const d = f.properties;
|
||||
const vf = viewFeatureRef.current;
|
||||
const clr = colorRangeRef.current;
|
||||
const fr = filterRangeRef.current;
|
||||
const cfm = colorFeatureMetaRef.current;
|
||||
const dark = isDarkRef.current;
|
||||
if (vf && clr && cfm) {
|
||||
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
|
||||
if (val == null)
|
||||
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
if (fr) {
|
||||
const minVal = d[`min_${vf}`] as number;
|
||||
const maxVal = d[`max_${vf}`] as number;
|
||||
if (maxVal < fr[0] || minVal > fr[1]) {
|
||||
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
}
|
||||
}
|
||||
const range = clr[1] - clr[0];
|
||||
if (range === 0) return [...GRADIENT[0].color, 180] as [number, number, number, number];
|
||||
const t = ((val as number) - clr[0]) / range;
|
||||
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)));
|
||||
return [...rgb, 180] as [number, number, number, number];
|
||||
}
|
||||
const cr = postcodeCountRangeRef.current;
|
||||
const c = d.count;
|
||||
const t = (c - cr.min) / (cr.max - cr.min);
|
||||
return [
|
||||
...countToColor(Math.max(0, Math.min(1, t)), densityGradientRef.current),
|
||||
180,
|
||||
] as [number, number, number, number];
|
||||
},
|
||||
getLineColor: (f) => {
|
||||
const pc = f.properties.postcode;
|
||||
const dark = isDarkRef.current;
|
||||
if (pc === selectedPostcodeRef.current)
|
||||
return [255, 255, 255, 255] as [number, number, number, number];
|
||||
if (pc === hoveredPostcodeRef.current)
|
||||
return [29, 228, 195, 200] as [number, number, number, number];
|
||||
return (dark ? [180, 170, 160, 100] : [100, 100, 100, 150]) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
},
|
||||
getLineWidth: (f) => {
|
||||
const pc = f.properties.postcode;
|
||||
if (pc === selectedPostcodeRef.current) return 3;
|
||||
if (pc === hoveredPostcodeRef.current) return 2;
|
||||
return 1;
|
||||
},
|
||||
lineWidthUnits: 'pixels',
|
||||
updateTriggers: {
|
||||
getFillColor: [postcodeColorTrigger],
|
||||
getLineColor: [postcodeColorTrigger],
|
||||
getLineWidth: [postcodeColorTrigger],
|
||||
},
|
||||
extruded: false,
|
||||
pickable: true,
|
||||
onClick: handlePostcodeClick,
|
||||
onHover: handlePostcodeHoverCallback,
|
||||
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
|
||||
beforeId: 'landuse_park',
|
||||
}),
|
||||
[postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback]
|
||||
);
|
||||
|
||||
const postcodeLabelsLayer = useMemo(
|
||||
() =>
|
||||
new TextLayer<PostcodeFeature>({
|
||||
id: 'postcode-labels',
|
||||
data: postcodeData,
|
||||
getPosition: (f) => f.properties.centroid,
|
||||
getText: (f) => f.properties.postcode,
|
||||
getSize: 12,
|
||||
getColor: theme === 'dark' ? [255, 255, 255, 240] : [40, 40, 40, 220],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 600,
|
||||
outlineWidth: 2,
|
||||
outlineColor: theme === 'dark' ? [30, 30, 30, 200] : [255, 255, 255, 200],
|
||||
sizeUnits: 'pixels',
|
||||
sizeMinPixels: 10,
|
||||
sizeMaxPixels: 14,
|
||||
billboard: false,
|
||||
pickable: false,
|
||||
}),
|
||||
[postcodeData, theme]
|
||||
);
|
||||
|
||||
const poiLayer = useMemo(
|
||||
() =>
|
||||
new IconLayer<POI>({
|
||||
id: 'poi-icons',
|
||||
data: pois,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: (d) => ({
|
||||
url: emojiToTwemojiUrl(d.emoji),
|
||||
width: 72,
|
||||
height: 72,
|
||||
}),
|
||||
getSize: 24,
|
||||
sizeMinPixels: 20,
|
||||
sizeMaxPixels: 40,
|
||||
pickable: true,
|
||||
onHover: stablePoiHover,
|
||||
}),
|
||||
[pois, stablePoiHover]
|
||||
);
|
||||
|
||||
// Check if the searched postcode has data (passes current filters)
|
||||
const searchedPostcodeHasData = useMemo(() => {
|
||||
if (!searchedPostcode) return false;
|
||||
return postcodeData.some((f) => f.properties.postcode === searchedPostcode.postcode);
|
||||
}, [searchedPostcode, postcodeData]);
|
||||
|
||||
// Highlight layer for searched postcode
|
||||
const searchedPostcodeHighlightLayer = useMemo(() => {
|
||||
if (!searchedPostcode) return null;
|
||||
const hasData = searchedPostcodeHasData;
|
||||
const feature = {
|
||||
type: 'Feature' as const,
|
||||
geometry: searchedPostcode.geometry,
|
||||
properties: {},
|
||||
};
|
||||
return new GeoJsonLayer({
|
||||
id: 'searched-postcode-highlight',
|
||||
data: [feature],
|
||||
getFillColor: hasData
|
||||
? [29, 228, 195, 40] // teal tint when has data
|
||||
: [255, 180, 0, 30], // orange tint when filtered out
|
||||
getLineColor: hasData
|
||||
? [29, 228, 195, 255] // solid teal when has data
|
||||
: [255, 180, 0, 200], // orange when filtered out (no matching properties)
|
||||
getLineWidth: hasData ? 4 : 3,
|
||||
lineWidthUnits: 'pixels',
|
||||
stroked: true,
|
||||
filled: true,
|
||||
pickable: false,
|
||||
});
|
||||
}, [searchedPostcode, searchedPostcodeHasData]);
|
||||
|
||||
const layers = useMemo(() => {
|
||||
const baseLayers = usePostcodeView
|
||||
? [postcodeLayer, postcodeLabelsLayer, poiLayer]
|
||||
: [hexLayer, poiLayer];
|
||||
if (searchedPostcodeHighlightLayer) {
|
||||
return [...baseLayers, searchedPostcodeHighlightLayer];
|
||||
}
|
||||
return baseLayers;
|
||||
}, [
|
||||
const {
|
||||
layers,
|
||||
popupInfo,
|
||||
hoverPosition,
|
||||
countRange,
|
||||
postcodeCountRange,
|
||||
colorFeatureMeta,
|
||||
handleMouseLeave,
|
||||
} = useDeckLayers({
|
||||
data,
|
||||
postcodeData,
|
||||
usePostcodeView,
|
||||
hexLayer,
|
||||
postcodeLayer,
|
||||
postcodeLabelsLayer,
|
||||
poiLayer,
|
||||
searchedPostcodeHighlightLayer,
|
||||
]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setHoverPosition(null);
|
||||
setHoveredPostcode(null);
|
||||
setPopupInfo(null);
|
||||
onHexagonHoverRef.current(null);
|
||||
}, []);
|
||||
pois,
|
||||
viewFeature,
|
||||
colorRange,
|
||||
filterRange,
|
||||
features,
|
||||
selectedHexagonId,
|
||||
hoveredHexagonId,
|
||||
onHexagonClick,
|
||||
onHexagonHover,
|
||||
theme,
|
||||
searchedPostcode,
|
||||
bounds: viewportBounds,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex-1 h-full relative" ref={containerRef} onMouseLeave={handleMouseLeave}>
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ export default function MapPage({
|
|||
.then((blob) => {
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = 'narrowit-export.xlsx';
|
||||
link.download = 'perfect-postcodes-export.xlsx';
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
|
||||
import type { POICategoryGroup } from '../../types';
|
||||
import InfoPopup from '../ui/InfoPopup';
|
||||
import { SearchInput } from '../ui/SearchInput';
|
||||
|
|
@ -21,7 +22,7 @@ export default function POIPane({
|
|||
onNavigateToSource,
|
||||
}: POIPaneProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||
const [collapsedGroups, toggleCollapse] = useCollapsibleGroups();
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
|
||||
const allCategories = groups.flatMap((g) => g.categories);
|
||||
|
|
@ -60,18 +61,6 @@ export default function POIPane({
|
|||
[groups, selectedCategories, onCategoriesChange]
|
||||
);
|
||||
|
||||
const toggleCollapse = (groupName: string) => {
|
||||
setCollapsedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(groupName)) {
|
||||
next.delete(groupName);
|
||||
} else {
|
||||
next.add(groupName);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const lowerSearch = searchTerm.toLowerCase();
|
||||
|
||||
const filteredGroups = groups
|
||||
|
|
|
|||
26
frontend/src/components/map/StreetViewEmbed.tsx
Normal file
26
frontend/src/components/map/StreetViewEmbed.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { HexagonLocation } from '../../lib/external-search';
|
||||
|
||||
interface StreetViewEmbedProps {
|
||||
location: HexagonLocation;
|
||||
}
|
||||
|
||||
export default function StreetViewEmbed({ location }: StreetViewEmbedProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0">
|
||||
Street View
|
||||
</div>
|
||||
<div className="px-3 py-2">
|
||||
<div className="rounded overflow-hidden border border-warm-200 dark:border-warm-700">
|
||||
<iframe
|
||||
className="w-full"
|
||||
style={{ height: 240, border: 0 }}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
src={`https://maps.google.com/maps?layer=c&cbll=${location.lat},${location.lon}&cbp=11,0,0,0,0&output=svembed`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue