changes
This commit is contained in:
parent
524580eb25
commit
ffe080adef
82 changed files with 2652 additions and 2956 deletions
|
|
@ -1,58 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -22,7 +22,6 @@ 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';
|
||||
|
||||
|
|
@ -38,9 +37,6 @@ interface AreaPaneProps {
|
|||
hexagonLocation: HexagonLocation | null;
|
||||
filters: FeatureFilters;
|
||||
onNavigateToSource?: (slug: string, featureName: string) => void;
|
||||
aiSummary?: string;
|
||||
aiSummaryLoading?: boolean;
|
||||
aiSummaryError?: string | null;
|
||||
}
|
||||
|
||||
export default function AreaPane({
|
||||
|
|
@ -55,16 +51,11 @@ export default function AreaPane({
|
|||
hexagonLocation,
|
||||
filters,
|
||||
onNavigateToSource,
|
||||
aiSummary,
|
||||
aiSummaryLoading,
|
||||
aiSummaryError,
|
||||
}: AreaPaneProps) {
|
||||
// For postcodes, use local data for count
|
||||
const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count;
|
||||
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
|
||||
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
const [collapsedGroups, toggleGroup] = useCollapsibleGroups();
|
||||
const [aiSummaryExpanded, setAiSummaryExpanded] = useState(true);
|
||||
|
||||
const numericByName = useMemo(() => {
|
||||
if (!stats) return new Map();
|
||||
|
|
@ -94,7 +85,7 @@ export default function AreaPane({
|
|||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
|
||||
<div className="p-3 border-b border-warm-200 dark:border-warm-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
|
|
@ -133,13 +124,6 @@ export default function AreaPane({
|
|||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<AISummaryCard
|
||||
summary={aiSummary}
|
||||
loading={aiSummaryLoading}
|
||||
error={aiSummaryError}
|
||||
expanded={aiSummaryExpanded}
|
||||
onToggleExpanded={() => setAiSummaryExpanded(!aiSummaryExpanded)}
|
||||
/>
|
||||
{loading && !stats ? (
|
||||
<LoadingSkeleton />
|
||||
) : stats ? (
|
||||
|
|
@ -154,7 +138,6 @@ export default function AreaPane({
|
|||
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<string>(
|
||||
stackedEnumCharts?.flatMap((c) =>
|
||||
[c.feature, ...c.components].filter((s): s is string => Boolean(s))
|
||||
|
|
@ -173,11 +156,9 @@ export default function AreaPane({
|
|||
/>
|
||||
{isExpanded && (
|
||||
<div className="px-3 py-2 space-y-3">
|
||||
{/* Price History in Property group */}
|
||||
{group.name === 'Property' &&
|
||||
stats.price_history &&
|
||||
(() => {
|
||||
// Only show chart if there are at least 2 unique years
|
||||
const uniqueYears = new Set(
|
||||
stats.price_history.map((p) => Math.floor(p.year))
|
||||
);
|
||||
|
|
@ -191,8 +172,7 @@ export default function AreaPane({
|
|||
</div>
|
||||
)}
|
||||
{stackedCharts
|
||||
? // Render stacked charts for this group
|
||||
stackedCharts.map((chart) => {
|
||||
? stackedCharts.map((chart) => {
|
||||
const segments = chart.components
|
||||
.map((name) => ({
|
||||
name,
|
||||
|
|
@ -200,7 +180,6 @@ export default function AreaPane({
|
|||
}))
|
||||
.filter((s) => s.value > 0);
|
||||
|
||||
// Use aggregate feature stats if available, otherwise sum components
|
||||
const aggregateStats = chart.feature
|
||||
? numericByName.get(chart.feature)
|
||||
: undefined;
|
||||
|
|
@ -240,8 +219,7 @@ export default function AreaPane({
|
|||
</div>
|
||||
);
|
||||
})
|
||||
: // Default: render each feature individually (skip stacked enum features)
|
||||
group.features
|
||||
: group.features
|
||||
.filter((f) => !stackedEnumFeatureNames.has(f.name))
|
||||
.map((feature) => {
|
||||
const numericStats = numericByName.get(feature.name);
|
||||
|
|
@ -306,13 +284,11 @@ export default function AreaPane({
|
|||
|
||||
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;
|
||||
|
|
@ -355,7 +331,6 @@ export default function AreaPane({
|
|||
);
|
||||
}
|
||||
|
||||
// Multi-component: render as compact multi-row chart (like risk features)
|
||||
const components = chart.components
|
||||
.map((name) => {
|
||||
const stats = enumByName.get(name);
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ export function DualHistogram({
|
|||
);
|
||||
}
|
||||
|
||||
export function SkeletonHistogram() {
|
||||
function SkeletonHistogram() {
|
||||
return (
|
||||
<div className="bg-warm-50 dark:bg-warm-800 rounded p-2 animate-pulse">
|
||||
<div className="flex justify-between items-baseline">
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ import { groupFeaturesByCategory } from '../../lib/features';
|
|||
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
||||
import { FeatureActions } from '../ui/FeatureIcons';
|
||||
import { FeatureLabel } from '../ui/FeatureLabel';
|
||||
import { RouteIcon, PlusIcon } from '../ui/icons';
|
||||
import { RouteIcon, PlusIcon, EyeIcon } from '../ui/icons';
|
||||
import { IconButton } from '../ui/IconButton';
|
||||
import { TRANSPORT_MODES, MODE_LABELS, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
import { TRANSPORT_MODES, MODE_LABELS, travelFieldKey, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
|
||||
interface FeatureBrowserProps {
|
||||
availableFeatures: FeatureMeta[];
|
||||
|
|
@ -71,26 +71,58 @@ export default function FeatureBrowser({
|
|||
<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">
|
||||
{showTravelModes && TRANSPORT_MODES.map((mode) => (
|
||||
<div key={mode} className="shrink-0 border-b border-warm-200 dark:border-warm-700">
|
||||
<div className="flex items-start justify-between px-3 py-2 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer">
|
||||
<div className="flex items-center gap-2 min-w-0" onClick={() => onAddTravelTimeEntry(mode)}>
|
||||
<RouteIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
|
||||
Travel Time ({MODE_LABELS[mode]})
|
||||
</span>
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500 block">
|
||||
Filter by journey time to a destination
|
||||
</span>
|
||||
{showTravelModes && (
|
||||
<div className="shrink-0">
|
||||
<CollapsibleGroupHeader
|
||||
name="Travel Time"
|
||||
expanded={isSearching || expandedGroups.has('Travel Time')}
|
||||
onToggle={() => toggleGroup('Travel Time')}
|
||||
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">
|
||||
{TRANSPORT_MODES.length}
|
||||
</span>
|
||||
</CollapsibleGroupHeader>
|
||||
{(isSearching || expandedGroups.has('Travel Time')) && TRANSPORT_MODES.map((mode) => {
|
||||
const activeEntry = travelTimeEntries.find((e) => e.mode === mode && e.slug);
|
||||
const fieldKey = activeEntry ? travelFieldKey(activeEntry) : null;
|
||||
const isPinned = fieldKey !== null && pinnedFeature === fieldKey;
|
||||
return (
|
||||
<div
|
||||
key={mode}
|
||||
className="flex items-start justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0" onClick={() => onAddTravelTimeEntry(mode)}>
|
||||
<RouteIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
|
||||
{MODE_LABELS[mode]}
|
||||
</span>
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500 block">
|
||||
Filter by journey time to a destination
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
{fieldKey && (
|
||||
<IconButton
|
||||
onClick={() => onTogglePin(fieldKey)}
|
||||
active={isPinned}
|
||||
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
|
||||
size="md"
|
||||
>
|
||||
<EyeIcon filled={isPinned} className="w-7 h-7 md:w-3.5 md:h-3.5" />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton onClick={() => onAddTravelTimeEntry(mode)} title={`Add ${MODE_LABELS[mode]} travel time`} size="md">
|
||||
<PlusIcon className="w-7 h-7 md:w-3.5 md:h-3.5" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<IconButton onClick={() => onAddTravelTimeEntry(mode)} title={`Add ${MODE_LABELS[mode]} travel time`}>
|
||||
<PlusIcon className="w-3.5 h-3.5" />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
{grouped.map((group) => {
|
||||
const isExpanded = isSearching || expandedGroups.has(group.name);
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -20,8 +20,20 @@ import { TravelTimeCard } from './TravelTimeCard';
|
|||
import {
|
||||
type TransportMode,
|
||||
type TravelTimeEntry,
|
||||
travelFieldKey,
|
||||
} from '../../hooks/useTravelTime';
|
||||
|
||||
type ListingType = 'historical' | 'buy' | 'rent';
|
||||
|
||||
const MODE_RESTRICTED_FEATURES: Record<string, Set<ListingType>> = {
|
||||
'Bathrooms': new Set(['buy', 'rent']),
|
||||
};
|
||||
|
||||
function isFeatureAllowedInMode(featureName: string, mode: ListingType): boolean {
|
||||
const allowed = MODE_RESTRICTED_FEATURES[featureName];
|
||||
return !allowed || allowed.has(mode);
|
||||
}
|
||||
|
||||
function SliderLabels({
|
||||
min,
|
||||
max,
|
||||
|
|
@ -33,7 +45,6 @@ function SliderLabels({
|
|||
max: number;
|
||||
value: [number, number];
|
||||
displayValues?: [number, number];
|
||||
/** When true and slider is at max, append "+" to indicate unrestricted upper bound */
|
||||
absoluteMax?: boolean;
|
||||
}) {
|
||||
const range = max - min || 1;
|
||||
|
|
@ -72,7 +83,6 @@ interface FiltersProps {
|
|||
onDragEnd: () => void;
|
||||
pinnedFeature: string | null;
|
||||
onTogglePin: (name: string) => void;
|
||||
onCancelPin: () => void;
|
||||
onNavigateToSource?: (slug: string, featureName: string) => void;
|
||||
openInfoFeature?: string | null;
|
||||
onClearOpenInfoFeature?: () => void;
|
||||
|
|
@ -102,7 +112,6 @@ export default memo(function Filters({
|
|||
onDragEnd,
|
||||
pinnedFeature,
|
||||
onTogglePin,
|
||||
onCancelPin: _onCancelPin,
|
||||
onNavigateToSource,
|
||||
openInfoFeature,
|
||||
onClearOpenInfoFeature,
|
||||
|
|
@ -117,37 +126,43 @@ export default memo(function Filters({
|
|||
aiFilterNotes,
|
||||
onAiFilterSubmit,
|
||||
}: FiltersProps) {
|
||||
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
|
||||
const enabledFeatureList = features.filter(
|
||||
(f) => enabledFeatures.has(f.name) && f.name !== 'Listing status'
|
||||
);
|
||||
|
||||
const listingToggles = useMemo(() => {
|
||||
const activeListingType = useMemo((): ListingType => {
|
||||
const val = filters['Listing status'] as string[] | undefined;
|
||||
if (!val) return { historical: true, buy: true, rent: true };
|
||||
return {
|
||||
historical: val.includes('Historical sale'),
|
||||
buy: val.includes('For sale'),
|
||||
rent: val.includes('For rent'),
|
||||
};
|
||||
if (!val || val.length === 0) return 'historical';
|
||||
if (val.includes('For sale')) return 'buy';
|
||||
if (val.includes('For rent')) return 'rent';
|
||||
return 'historical';
|
||||
}, [filters]);
|
||||
|
||||
const handleListingToggle = useCallback(
|
||||
(key: 'historical' | 'buy' | 'rent') => {
|
||||
const next = { ...listingToggles, [key]: !listingToggles[key] };
|
||||
const allOn = next.historical && next.buy && next.rent;
|
||||
const allOff = !next.historical && !next.buy && !next.rent;
|
||||
if (allOn || allOff) {
|
||||
onRemoveFilter('Listing status');
|
||||
const availableFeatures = useMemo(
|
||||
() => features.filter((f) => !enabledFeatures.has(f.name) && isFeatureAllowedInMode(f.name, activeListingType)),
|
||||
[features, enabledFeatures, activeListingType]
|
||||
);
|
||||
const enabledFeatureList = useMemo(
|
||||
() => features.filter((f) => enabledFeatures.has(f.name) && f.name !== 'Listing status'),
|
||||
[features, enabledFeatures]
|
||||
);
|
||||
|
||||
const handleListingSelect = useCallback(
|
||||
(type: ListingType) => {
|
||||
if (type === activeListingType && !filters['Listing status']) return;
|
||||
for (const name of Object.keys(filters)) {
|
||||
if (name !== 'Listing status' && !isFeatureAllowedInMode(name, type)) {
|
||||
onRemoveFilter(name);
|
||||
}
|
||||
}
|
||||
if (type === 'historical' && !filters['Listing status']) {
|
||||
onFilterChange('Listing status', ['Historical sale']);
|
||||
return;
|
||||
}
|
||||
const values: string[] = [];
|
||||
if (next.historical) values.push('Historical sale');
|
||||
if (next.buy) values.push('For sale');
|
||||
if (next.rent) values.push('For rent');
|
||||
onFilterChange('Listing status', values);
|
||||
const valueMap: Record<string, string> = {
|
||||
historical: 'Historical sale',
|
||||
buy: 'For sale',
|
||||
rent: 'For rent',
|
||||
};
|
||||
onFilterChange('Listing status', [valueMap[type]]);
|
||||
},
|
||||
[listingToggles, onFilterChange, onRemoveFilter]
|
||||
[activeListingType, filters, onFilterChange, onRemoveFilter]
|
||||
);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -181,8 +196,7 @@ export default memo(function Filters({
|
|||
return scales;
|
||||
}, [features]);
|
||||
|
||||
const hasListingFilter = !listingToggles.historical || !listingToggles.buy || !listingToggles.rent;
|
||||
const badgeCount = enabledFeatureList.length + activeEntryCount + (hasListingFilter ? 1 : 0);
|
||||
const badgeCount = enabledFeatureList.length + activeEntryCount;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full">
|
||||
|
|
@ -198,16 +212,26 @@ export default memo(function Filters({
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<span className="text-xs font-medium text-warm-500 dark:text-warm-400">Show</span>
|
||||
<PillGroup>
|
||||
<PillToggle label="Historical" active={listingToggles.historical}
|
||||
onClick={() => handleListingToggle('historical')} size="xs" />
|
||||
<PillToggle label="Buy" active={listingToggles.buy}
|
||||
onClick={() => handleListingToggle('buy')} size="xs" />
|
||||
<PillToggle label="Rent" active={listingToggles.rent}
|
||||
onClick={() => handleListingToggle('rent')} size="xs" />
|
||||
</PillGroup>
|
||||
<div className="shrink-0 px-3 py-2.5 border-b border-warm-200 dark:border-navy-700">
|
||||
<div className="flex rounded-lg bg-warm-100 dark:bg-warm-800 p-0.5">
|
||||
{(['historical', 'buy', 'rent'] as const).map((type) => {
|
||||
const labels = { historical: 'Historical', buy: 'Buy', rent: 'Rent' };
|
||||
const isActive = activeListingType === type;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => handleListingSelect(type)}
|
||||
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md cursor-pointer ${
|
||||
isActive
|
||||
? 'bg-white dark:bg-warm-700 text-teal-600 dark:text-teal-400 ring-2 ring-teal-400 shadow-sm'
|
||||
: 'text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
|
||||
}`}
|
||||
>
|
||||
{labels[type]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 md:shrink md:min-h-0 flex flex-col md:basis-[40%]">
|
||||
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
|
|
@ -224,20 +248,39 @@ export default memo(function Filters({
|
|||
</div>
|
||||
|
||||
<div className="md:flex-1 md:overflow-y-auto">
|
||||
{travelTimeEntries.map((entry, index) => (
|
||||
<div key={index} className="px-2 py-1">
|
||||
<TravelTimeCard
|
||||
mode={entry.mode}
|
||||
slug={entry.slug}
|
||||
label={entry.label}
|
||||
timeRange={entry.timeRange}
|
||||
dataRange={travelTimeDataRanges.get(index) ?? null}
|
||||
onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)}
|
||||
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
|
||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{travelTimeEntries.length > 0 && (
|
||||
<div>
|
||||
<CollapsibleGroupHeader
|
||||
name="Travel Time"
|
||||
expanded={!collapsedGroups.has('Travel Time')}
|
||||
onToggle={() => toggleGroup('Travel Time')}
|
||||
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">
|
||||
{travelTimeEntries.length}
|
||||
</span>
|
||||
</CollapsibleGroupHeader>
|
||||
{!collapsedGroups.has('Travel Time') && (
|
||||
<div className="px-2 py-1 space-y-1">
|
||||
{travelTimeEntries.map((entry, index) => (
|
||||
<TravelTimeCard
|
||||
key={index}
|
||||
mode={entry.mode}
|
||||
slug={entry.slug}
|
||||
label={entry.label}
|
||||
timeRange={entry.timeRange}
|
||||
dataRange={travelTimeDataRanges.get(index) ?? null}
|
||||
isPinned={pinnedFeature === travelFieldKey(entry)}
|
||||
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
|
||||
onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)}
|
||||
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
|
||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enabledFeatureList.length === 0 && activeEntryCount === 0 && (
|
||||
<p className="px-3 py-1.5 text-xs text-warm-400 dark:text-warm-500">
|
||||
|
|
|
|||
|
|
@ -29,9 +29,11 @@ const ZOOM_FOR_TYPE: Record<string, number> = {
|
|||
export default function LocationSearch({
|
||||
onFlyTo,
|
||||
onLocationSearched,
|
||||
onMouseEnter,
|
||||
}: {
|
||||
onFlyTo: (lat: number, lng: number, zoom: number) => void;
|
||||
onLocationSearched?: (postcode: SearchedLocation | null) => void;
|
||||
onMouseEnter?: () => void;
|
||||
}) {
|
||||
const search = useLocationSearch();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -118,8 +120,8 @@ export default function LocationSearch({
|
|||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} data-tutorial="search" className="absolute top-3 left-3 z-10 flex flex-col">
|
||||
<div className="flex items-center shadow-lg rounded overflow-hidden bg-white dark:bg-warm-800">
|
||||
<div ref={containerRef} data-tutorial="search" className="absolute top-3 left-3 z-10 flex flex-col" onMouseEnter={onMouseEnter}>
|
||||
<div className="flex items-center shadow-lg rounded bg-white dark:bg-warm-800">
|
||||
<SearchIcon className="w-4 h-4 text-warm-400 dark:text-warm-500 ml-3 shrink-0" />
|
||||
<PlaceSearchInput
|
||||
search={search}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
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 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import type {
|
||||
|
|
@ -21,7 +20,7 @@ import MapLegend from './MapLegend';
|
|||
import HoverCard from './HoverCard';
|
||||
import type { FeatureFilters } from '../../types';
|
||||
import { useDeckLayers, osmIdToUrl } from '../../hooks/useDeckLayers';
|
||||
import { MODE_LABELS, type TravelTimeEntry, travelFieldKey } from '../../hooks/useTravelTime';
|
||||
import { MODE_LABELS, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
|
||||
interface MapProps {
|
||||
data: HexagonData[];
|
||||
|
|
@ -40,6 +39,7 @@ interface MapProps {
|
|||
onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void;
|
||||
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
|
||||
initialViewState?: ViewState;
|
||||
flyToRef?: React.MutableRefObject<((lat: number, lng: number, zoom: number) => void) | null>;
|
||||
theme?: 'light' | 'dark';
|
||||
screenshotMode?: boolean;
|
||||
ogMode?: boolean;
|
||||
|
|
@ -49,11 +49,9 @@ interface MapProps {
|
|||
bounds?: Bounds | null;
|
||||
hideLegend?: boolean;
|
||||
travelTimeEntries?: TravelTimeEntry[];
|
||||
travelTimeColorRanges?: Map<number, [number, number]>;
|
||||
}
|
||||
|
||||
const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = [];
|
||||
const EMPTY_TRAVEL_RANGES = new globalThis.Map<number, [number, number]>();
|
||||
|
||||
interface Dimensions {
|
||||
width: number;
|
||||
|
|
@ -97,6 +95,7 @@ export default memo(function Map({
|
|||
onHexagonClick,
|
||||
onHexagonHover,
|
||||
initialViewState,
|
||||
flyToRef,
|
||||
theme = 'light',
|
||||
screenshotMode = false,
|
||||
ogMode = false,
|
||||
|
|
@ -106,12 +105,17 @@ export default memo(function Map({
|
|||
bounds: viewportBounds,
|
||||
hideLegend = false,
|
||||
travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
|
||||
travelTimeColorRanges = EMPTY_TRAVEL_RANGES,
|
||||
}: MapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW_STATE);
|
||||
const [internalViewState, setInternalViewState] = useState<ViewState>(
|
||||
initialViewState || INITIAL_VIEW_STATE
|
||||
);
|
||||
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
|
||||
|
||||
// In screenshot mode, use the prop directly for instant updates (no async lag)
|
||||
const viewState =
|
||||
screenshotMode && initialViewState ? initialViewState : internalViewState;
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
|
@ -130,8 +134,6 @@ export default memo(function Map({
|
|||
useEffect(() => {
|
||||
if (dimensions.width === 0 || dimensions.height === 0) return;
|
||||
|
||||
// Send exact viewport bounds - server will filter to only return
|
||||
// hexagons/postcodes that intersect this precise AABB
|
||||
const bounds = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
|
||||
const resolution = zoomToResolution(viewState.zoom);
|
||||
|
||||
|
|
@ -145,19 +147,14 @@ export default memo(function Map({
|
|||
}, [viewState, dimensions, onViewChange]);
|
||||
|
||||
const handleMove = useCallback((evt: { viewState: ViewState }) => {
|
||||
setViewState(evt.viewState);
|
||||
setInternalViewState(evt.viewState);
|
||||
}, []);
|
||||
|
||||
const handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => {
|
||||
setViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom }));
|
||||
setInternalViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom }));
|
||||
}, []);
|
||||
|
||||
const handleMapLoad = useCallback(
|
||||
(_evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
|
||||
// Road opacity is set in getMapStyle
|
||||
},
|
||||
[]
|
||||
);
|
||||
if (flyToRef) flyToRef.current = handleFlyTo;
|
||||
|
||||
const mapStyle = useMemo(() => getMapStyle(theme), [theme]);
|
||||
|
||||
|
|
@ -169,7 +166,6 @@ export default memo(function Map({
|
|||
postcodeCountRange,
|
||||
colorFeatureMeta,
|
||||
handleMouseLeave,
|
||||
primaryTravelIndex,
|
||||
} = useDeckLayers({
|
||||
data,
|
||||
postcodeData,
|
||||
|
|
@ -187,7 +183,6 @@ export default memo(function Map({
|
|||
selectedPostcodeGeometry,
|
||||
bounds: viewportBounds,
|
||||
travelTimeEntries,
|
||||
travelTimeColorRanges,
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
@ -195,7 +190,7 @@ export default memo(function Map({
|
|||
<MapGL
|
||||
{...viewState}
|
||||
onMove={handleMove}
|
||||
onLoad={handleMapLoad as never}
|
||||
onLoad={undefined}
|
||||
mapStyle={mapStyle}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
attributionControl={false}
|
||||
|
|
@ -222,35 +217,37 @@ export default memo(function Map({
|
|||
) : null
|
||||
) : (
|
||||
<>
|
||||
<LocationSearch onFlyTo={handleFlyTo} onLocationSearched={onLocationSearched} />
|
||||
<LocationSearch onFlyTo={handleFlyTo} onLocationSearched={onLocationSearched} onMouseEnter={handleMouseLeave} />
|
||||
{!hideLegend &&
|
||||
(primaryTravelIndex >= 0 && travelTimeColorRanges.get(primaryTravelIndex) ? (
|
||||
<MapLegend
|
||||
featureLabel={`Travel time (${MODE_LABELS[travelTimeEntries[primaryTravelIndex].mode]})`}
|
||||
range={travelTimeColorRanges.get(primaryTravelIndex)!}
|
||||
showCancel={false}
|
||||
onCancel={onCancelPin}
|
||||
mode="feature"
|
||||
theme={theme}
|
||||
suffix=" min"
|
||||
/>
|
||||
) : viewFeature && colorRange && colorFeatureMeta ? (
|
||||
<MapLegend
|
||||
featureLabel={
|
||||
viewSource === 'eye'
|
||||
? `Previewing \u201c${colorFeatureMeta.name}\u201d`
|
||||
: colorFeatureMeta.name
|
||||
}
|
||||
range={colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={onCancelPin}
|
||||
mode="feature"
|
||||
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
|
||||
theme={theme}
|
||||
/>
|
||||
(viewFeature && colorRange ? (
|
||||
viewFeature.startsWith('tt_') ? (
|
||||
<MapLegend
|
||||
featureLabel={`Travel time (${MODE_LABELS[viewFeature.split('_')[1] as keyof typeof MODE_LABELS]})`}
|
||||
range={colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={onCancelPin}
|
||||
mode="feature"
|
||||
theme={theme}
|
||||
suffix=" min"
|
||||
/>
|
||||
) : colorFeatureMeta ? (
|
||||
<MapLegend
|
||||
featureLabel={
|
||||
viewSource === 'eye'
|
||||
? `Previewing \u201c${colorFeatureMeta.name}\u201d`
|
||||
: colorFeatureMeta.name
|
||||
}
|
||||
range={colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={onCancelPin}
|
||||
mode="feature"
|
||||
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
|
||||
theme={theme}
|
||||
/>
|
||||
) : null
|
||||
) : (
|
||||
<MapLegend
|
||||
featureLabel="Property density"
|
||||
featureLabel="Number of properties"
|
||||
range={
|
||||
usePostcodeView
|
||||
? [postcodeCountRange.min, postcodeCountRange.max]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState, PostcodeGeometry } from '../../types';
|
||||
import type { SearchedLocation } from './LocationSearch';
|
||||
import type { Page } from '../ui/Header';
|
||||
|
|
@ -16,7 +16,6 @@ import { useFilters } from '../../hooks/useFilters';
|
|||
import { useHexagonSelection } from '../../hooks/useHexagonSelection';
|
||||
import { usePaneResize } from '../../hooks/usePaneResize';
|
||||
import { useAiFilters } from '../../hooks/useAiFilters';
|
||||
import { useAreaSummary } from '../../hooks/useAreaSummary';
|
||||
import { useUrlSync } from '../../hooks/useUrlSync';
|
||||
import { useTutorial } from '../../hooks/useTutorial';
|
||||
import { getTutorialStyles } from '../../lib/tutorial-styles';
|
||||
|
|
@ -28,6 +27,7 @@ import {
|
|||
type TravelTimeInitial,
|
||||
} from '../../hooks/useTravelTime';
|
||||
import { apiUrl, assertOk, authHeaders, buildFilterString, logNonAbortError } from '../../lib/api';
|
||||
import { INITIAL_VIEW_STATE } from '../../lib/consts';
|
||||
import { useLicense } from '../../hooks/useLicense';
|
||||
import UpgradeModal from '../ui/UpgradeModal';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
|
|
@ -87,13 +87,9 @@ export default function MapPage({
|
|||
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 600, 'left');
|
||||
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(288, 200, 500, 'right');
|
||||
|
||||
// Mobile state
|
||||
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
||||
|
||||
// POI floating panel state
|
||||
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
|
||||
|
||||
// Initialize filters first
|
||||
const {
|
||||
filters,
|
||||
activeFeature,
|
||||
|
|
@ -112,6 +108,7 @@ export default function MapPage({
|
|||
handleDragChange,
|
||||
handleDragEnd,
|
||||
handleTogglePin,
|
||||
handleSetPin,
|
||||
handleCancelPin,
|
||||
updateBoundsInfo,
|
||||
} = useFilters({
|
||||
|
|
@ -119,7 +116,6 @@ export default function MapPage({
|
|||
features,
|
||||
});
|
||||
|
||||
// AI filters hook
|
||||
const aiFilters = useAiFilters();
|
||||
const handleAiFilterSubmit = useCallback(
|
||||
async (query: string) => {
|
||||
|
|
@ -129,13 +125,34 @@ export default function MapPage({
|
|||
[aiFilters.fetchAiFilters, handleSetFilters]
|
||||
);
|
||||
|
||||
// Travel time hook
|
||||
const travelTime = useTravelTime(initialTravelTime);
|
||||
|
||||
// License hook
|
||||
const handleTravelTimeSetDestination = useCallback(
|
||||
(index: number, slug: string, label: string) => {
|
||||
travelTime.handleSetDestination(index, slug, label);
|
||||
const entry = travelTime.entries[index];
|
||||
if (entry) {
|
||||
handleSetPin(`tt_${entry.mode}_${slug}`);
|
||||
}
|
||||
},
|
||||
[travelTime.handleSetDestination, travelTime.entries, handleSetPin]
|
||||
);
|
||||
|
||||
const handleTravelTimeRemoveEntry = useCallback(
|
||||
(index: number) => {
|
||||
const entry = travelTime.entries[index];
|
||||
if (entry?.slug && pinnedFeature === travelFieldKey(entry)) {
|
||||
handleCancelPin();
|
||||
}
|
||||
travelTime.handleRemoveEntry(index);
|
||||
},
|
||||
[travelTime.handleRemoveEntry, travelTime.entries, pinnedFeature, handleCancelPin]
|
||||
);
|
||||
|
||||
const license = useLicense();
|
||||
|
||||
// Map data hook
|
||||
const mapFlyToRef = useRef<((lat: number, lng: number, zoom: number) => void) | null>(null);
|
||||
|
||||
const mapData = useMapData({
|
||||
filters,
|
||||
features,
|
||||
|
|
@ -146,19 +163,16 @@ export default function MapPage({
|
|||
travelTimeEntries: travelTime.entries,
|
||||
});
|
||||
|
||||
// Keep filter bounds in sync with map data
|
||||
useEffect(() => {
|
||||
updateBoundsInfo(mapData.bounds, mapData.resolution);
|
||||
}, [mapData.bounds, mapData.resolution, updateBoundsInfo]);
|
||||
|
||||
// Hexagon selection hook
|
||||
const selection = useHexagonSelection({
|
||||
filters,
|
||||
features,
|
||||
resolution: mapData.resolution,
|
||||
});
|
||||
|
||||
// Location search handler — selects postcode + shows stats
|
||||
const handleLocationSearchResult = useCallback(
|
||||
(result: SearchedLocation | null) => {
|
||||
if (result) {
|
||||
|
|
@ -171,10 +185,16 @@ export default function MapPage({
|
|||
[selection.handleLocationSearch, selection.handleCloseSelection, isMobile]
|
||||
);
|
||||
|
||||
// POI data
|
||||
const handleZoomToFreeZone = useCallback(() => {
|
||||
mapFlyToRef.current?.(
|
||||
INITIAL_VIEW_STATE.latitude,
|
||||
INITIAL_VIEW_STATE.longitude,
|
||||
INITIAL_VIEW_STATE.zoom
|
||||
);
|
||||
}, []);
|
||||
|
||||
const pois = usePOIData(mapData.bounds, selectedPOICategories);
|
||||
|
||||
// Compute data range for travel time slider per entry index (full min/max for slider bounds)
|
||||
const travelTimeDataRanges = useMemo((): globalThis.Map<number, [number, number]> => {
|
||||
const ranges = new globalThis.Map<number, [number, number]>();
|
||||
for (let i = 0; i < travelTime.entries.length; i++) {
|
||||
|
|
@ -193,16 +213,13 @@ export default function MapPage({
|
|||
return ranges;
|
||||
}, [travelTime.entries, mapData.data]);
|
||||
|
||||
// Sync current state to URL
|
||||
useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab, travelTime.entries);
|
||||
|
||||
// Set initial view and tab from URL state
|
||||
useEffect(() => {
|
||||
mapData.setInitialView(initialViewState);
|
||||
selection.setRightPaneTab(initialTab);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// On mobile, open drawer and switch tab when hexagon is clicked
|
||||
const { handleHexagonClick } = selection;
|
||||
const handleMobileHexagonClick = useCallback(
|
||||
(id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => {
|
||||
|
|
@ -214,7 +231,6 @@ export default function MapPage({
|
|||
[handleHexagonClick]
|
||||
);
|
||||
|
||||
// Compute hexagon location for external links
|
||||
const hexagonLocation = useMemo(() => {
|
||||
const hexId = selection.selectedHexagon?.id;
|
||||
const isPostcode = selection.selectedHexagon?.type === 'postcode';
|
||||
|
|
@ -239,19 +255,8 @@ export default function MapPage({
|
|||
mapData.resolution,
|
||||
]);
|
||||
|
||||
// Tutorial
|
||||
const tutorial = useTutorial(initialLoading, isMobile);
|
||||
|
||||
// AI area summary
|
||||
const aiSummary = useAreaSummary({
|
||||
stats: selection.areaStats,
|
||||
hexagonId: selection.selectedHexagon?.id || null,
|
||||
isPostcode: selection.selectedHexagon?.type === 'postcode',
|
||||
filters,
|
||||
features,
|
||||
});
|
||||
|
||||
// Export to Excel
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const handleExport = useCallback(() => {
|
||||
if (!mapData.bounds || exporting) return;
|
||||
|
|
@ -280,12 +285,10 @@ export default function MapPage({
|
|||
.finally(() => setExporting(false));
|
||||
}, [mapData.bounds, filters, features, exporting]);
|
||||
|
||||
// Report export state to parent (Header)
|
||||
useEffect(() => {
|
||||
onExportStateChange?.({ onExport: handleExport, exporting });
|
||||
}, [handleExport, exporting, onExportStateChange]);
|
||||
|
||||
// Mobile legend data (computed from API-fetched data, which is already viewport-scoped)
|
||||
const mobileLegendMeta = useMemo(
|
||||
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
|
||||
[viewFeature, features]
|
||||
|
|
@ -305,7 +308,6 @@ export default function MapPage({
|
|||
return [min, max];
|
||||
}, [mapData.data, mapData.postcodeData, mapData.usePostcodeView]);
|
||||
|
||||
// Signal screenshot readiness once map data has loaded
|
||||
useEffect(() => {
|
||||
if (screenshotMode && !mapData.loading && mapData.data.length > 0) {
|
||||
window.__screenshot_ready = true;
|
||||
|
|
@ -337,13 +339,11 @@ export default function MapPage({
|
|||
ogMode={ogMode}
|
||||
bounds={mapData.bounds}
|
||||
travelTimeEntries={travelTime.entries}
|
||||
travelTimeColorRanges={mapData.travelTimeColorRanges}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Shared pane content renderers
|
||||
const renderAreaPane = () => (
|
||||
<AreaPane
|
||||
stats={selection.areaStats}
|
||||
|
|
@ -362,9 +362,6 @@ export default function MapPage({
|
|||
onClose={selection.handleCloseSelection}
|
||||
hexagonLocation={hexagonLocation}
|
||||
filters={filters}
|
||||
aiSummary={aiSummary.summary}
|
||||
aiSummaryLoading={aiSummary.loading}
|
||||
aiSummaryError={aiSummary.error}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -375,7 +372,6 @@ export default function MapPage({
|
|||
loading={selection.loadingProperties}
|
||||
hexagonId={selection.selectedHexagon?.id || null}
|
||||
onLoadMore={selection.handleLoadMoreProperties}
|
||||
onClose={selection.handleCloseSelection}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -403,14 +399,13 @@ export default function MapPage({
|
|||
onDragEnd={handleDragEnd}
|
||||
pinnedFeature={pinnedFeature}
|
||||
onTogglePin={handleTogglePin}
|
||||
onCancelPin={handleCancelPin}
|
||||
openInfoFeature={pendingInfoFeature}
|
||||
onClearOpenInfoFeature={onClearPendingInfoFeature}
|
||||
travelTimeEntries={travelTime.entries}
|
||||
travelTimeDataRanges={travelTimeDataRanges}
|
||||
onTravelTimeAddEntry={travelTime.handleAddEntry}
|
||||
onTravelTimeRemoveEntry={travelTime.handleRemoveEntry}
|
||||
onTravelTimeSetDestination={travelTime.handleSetDestination}
|
||||
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
|
||||
onTravelTimeSetDestination={handleTravelTimeSetDestination}
|
||||
onTravelTimeRangeChange={travelTime.handleTimeRangeChange}
|
||||
aiFilterLoading={aiFilters.loading}
|
||||
aiFilterError={aiFilters.error}
|
||||
|
|
@ -419,7 +414,6 @@ export default function MapPage({
|
|||
/>
|
||||
);
|
||||
|
||||
// Mobile layout
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden relative">
|
||||
|
|
@ -434,7 +428,6 @@ export default function MapPage({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Map — 45% */}
|
||||
<div className="relative overflow-hidden" style={{ flex: '45 0 0' }}>
|
||||
<Map
|
||||
data={mapData.data}
|
||||
|
|
@ -453,6 +446,7 @@ export default function MapPage({
|
|||
onHexagonClick={handleMobileHexagonClick}
|
||||
onHexagonHover={selection.handleHexagonHover}
|
||||
initialViewState={initialViewState}
|
||||
flyToRef={mapFlyToRef}
|
||||
theme={theme}
|
||||
filters={filters}
|
||||
selectedPostcodeGeometry={selection.selectedPostcodeGeometry}
|
||||
|
|
@ -460,21 +454,21 @@ export default function MapPage({
|
|||
bounds={mapData.bounds}
|
||||
hideLegend
|
||||
travelTimeEntries={travelTime.entries}
|
||||
travelTimeColorRanges={mapData.travelTimeColorRanges}
|
||||
/>
|
||||
{mapData.loading && (
|
||||
<div className="absolute bottom-2 left-2 bg-white dark:bg-navy-800 dark:text-warm-200 px-2 py-1 rounded shadow text-xs">
|
||||
Loading...
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
|
||||
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
|
||||
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Floating POI button */}
|
||||
<button
|
||||
onClick={() => setPoiPaneOpen((p) => !p)}
|
||||
className={`absolute bottom-2 right-2 z-10 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
|
||||
>
|
||||
<MapPinIcon className="w-5 h-5" />
|
||||
</button>
|
||||
{/* Floating POI panel */}
|
||||
{poiPaneOpen && (
|
||||
<div className="absolute bottom-12 right-2 z-10 w-[calc(100%-1rem)] max-h-[60%] overflow-hidden flex flex-col rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900">
|
||||
{renderPOIPane()}
|
||||
|
|
@ -482,67 +476,54 @@ export default function MapPage({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom panel — 55% */}
|
||||
<div
|
||||
className="bg-white dark:bg-warm-900 border-t border-warm-200 dark:border-warm-700 overflow-hidden flex flex-col"
|
||||
style={{ flex: '55 0 0' }}
|
||||
>
|
||||
{/* Legend */}
|
||||
{(() => {
|
||||
const primaryIdx = travelTime.entries.findIndex(
|
||||
(e, i) => e.slug && mapData.travelTimeColorRanges.get(i)
|
||||
);
|
||||
if (primaryIdx >= 0) {
|
||||
return (
|
||||
<MapLegend
|
||||
featureLabel={`Travel time (${MODE_LABELS[travelTime.entries[primaryIdx].mode]})`}
|
||||
range={mapData.travelTimeColorRanges.get(primaryIdx)!}
|
||||
showCancel={false}
|
||||
onCancel={handleCancelPin}
|
||||
mode="feature"
|
||||
theme={theme}
|
||||
inline
|
||||
suffix=" min"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (viewFeature && mapData.colorRange && mobileLegendMeta) {
|
||||
return (
|
||||
<MapLegend
|
||||
featureLabel={
|
||||
viewSource === 'eye'
|
||||
? `Previewing \u201c${mobileLegendMeta.name}\u201d`
|
||||
: mobileLegendMeta.name
|
||||
}
|
||||
range={mapData.colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={handleCancelPin}
|
||||
mode="feature"
|
||||
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
|
||||
theme={theme}
|
||||
inline
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
{viewFeature && mapData.colorRange ? (
|
||||
viewFeature.startsWith('tt_') ? (
|
||||
<MapLegend
|
||||
featureLabel="Property density"
|
||||
range={mobileDensityRange}
|
||||
showCancel={false}
|
||||
featureLabel={`Travel time (${MODE_LABELS[viewFeature.split('_')[1] as keyof typeof MODE_LABELS]})`}
|
||||
range={mapData.colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={handleCancelPin}
|
||||
mode="density"
|
||||
mode="feature"
|
||||
theme={theme}
|
||||
inline
|
||||
suffix=" min"
|
||||
/>
|
||||
) : mobileLegendMeta ? (
|
||||
<MapLegend
|
||||
featureLabel={
|
||||
viewSource === 'eye'
|
||||
? `Previewing \u201c${mobileLegendMeta.name}\u201d`
|
||||
: mobileLegendMeta.name
|
||||
}
|
||||
range={mapData.colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={handleCancelPin}
|
||||
mode="feature"
|
||||
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
|
||||
theme={theme}
|
||||
inline
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{/* Filters content */}
|
||||
) : null
|
||||
) : (
|
||||
<MapLegend
|
||||
featureLabel="Number of properties"
|
||||
range={mobileDensityRange}
|
||||
showCancel={false}
|
||||
onCancel={handleCancelPin}
|
||||
mode="density"
|
||||
theme={theme}
|
||||
inline
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
{renderFilters()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile drawer for full-screen hexagon details */}
|
||||
{mobileDrawerOpen && selection.selectedHexagon && (
|
||||
<MobileDrawer
|
||||
onClose={() => setMobileDrawerOpen(false)}
|
||||
|
|
@ -557,14 +538,13 @@ export default function MapPage({
|
|||
onLoginClick={onLoginClick ?? (() => {})}
|
||||
onRegisterClick={onRegisterClick ?? (() => {})}
|
||||
onStartCheckout={() => license.startCheckout()}
|
||||
onDismiss={() => {}}
|
||||
onZoomToFreeZone={handleZoomToFreeZone}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop layout (unchanged)
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden relative">
|
||||
{initialLoading && (
|
||||
|
|
@ -589,7 +569,6 @@ export default function MapPage({
|
|||
disableScrolling
|
||||
/>
|
||||
|
||||
{/* Left Pane */}
|
||||
<div
|
||||
data-tutorial="filters"
|
||||
className="flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden"
|
||||
|
|
@ -604,7 +583,6 @@ export default function MapPage({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map */}
|
||||
<div data-tutorial="map" className="flex-1 relative">
|
||||
<Map
|
||||
data={mapData.data}
|
||||
|
|
@ -623,17 +601,20 @@ export default function MapPage({
|
|||
onHexagonClick={selection.handleHexagonClick}
|
||||
onHexagonHover={selection.handleHexagonHover}
|
||||
initialViewState={initialViewState}
|
||||
flyToRef={mapFlyToRef}
|
||||
theme={theme}
|
||||
filters={filters}
|
||||
selectedPostcodeGeometry={selection.selectedPostcodeGeometry}
|
||||
onLocationSearched={handleLocationSearchResult}
|
||||
bounds={mapData.bounds}
|
||||
travelTimeEntries={travelTime.entries}
|
||||
travelTimeColorRanges={mapData.travelTimeColorRanges}
|
||||
/>
|
||||
{mapData.loading && (
|
||||
<div className="absolute bottom-4 left-4 bg-white dark:bg-navy-800 dark:text-warm-200 px-3 py-1 rounded shadow">
|
||||
Loading...
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
|
||||
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
|
||||
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Floating POI button */}
|
||||
|
|
@ -652,7 +633,6 @@ export default function MapPage({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Pane */}
|
||||
<div
|
||||
data-tutorial="right-pane"
|
||||
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
|
||||
|
|
@ -692,7 +672,7 @@ export default function MapPage({
|
|||
onLoginClick={onLoginClick ?? (() => {})}
|
||||
onRegisterClick={onRegisterClick ?? (() => {})}
|
||||
onStartCheckout={() => license.startCheckout()}
|
||||
onDismiss={() => {}}
|
||||
onZoomToFreeZone={handleZoomToFreeZone}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -49,15 +49,30 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
if (arr) arr.push(p.price);
|
||||
else byYear.set(yr, [p.price]);
|
||||
}
|
||||
const meds = Array.from(byYear.entries())
|
||||
const yearlyMedians = Array.from(byYear.entries())
|
||||
.map(([yr, prices]) => {
|
||||
prices.sort((a, b) => a - b);
|
||||
const mid = Math.floor(prices.length / 2);
|
||||
const median = prices.length % 2 ? prices[mid] : (prices[mid - 1] + prices[mid]) / 2;
|
||||
return { year: yr + 0.5, price: median };
|
||||
return { year: yr, price: median };
|
||||
})
|
||||
.sort((a, b) => a.year - b.year);
|
||||
|
||||
// 3-year rolling average
|
||||
const meds = yearlyMedians.map((pt, i) => {
|
||||
let sum = pt.price;
|
||||
let count = 1;
|
||||
for (let j = i - 1; j >= 0 && pt.year - yearlyMedians[j].year <= 1; j--) {
|
||||
sum += yearlyMedians[j].price;
|
||||
count++;
|
||||
}
|
||||
for (let j = i + 1; j < yearlyMedians.length && yearlyMedians[j].year - pt.year <= 1; j++) {
|
||||
sum += yearlyMedians[j].price;
|
||||
count++;
|
||||
}
|
||||
return { year: pt.year + 0.5, price: sum / count };
|
||||
});
|
||||
|
||||
const ticks = niceTicksForRange(pMin, pMax, 4);
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Property } from '../../types';
|
||||
import { formatDuration, formatAge, formatNumber, formatTransactionDate } from '../../lib/format';
|
||||
import { getNum } from '../../lib/property-fields';
|
||||
|
|
@ -13,7 +13,6 @@ interface PropertiesPaneProps {
|
|||
loading: boolean;
|
||||
hexagonId: string | null;
|
||||
onLoadMore: () => void;
|
||||
onClose: () => void;
|
||||
onNavigateToSource?: (slug: string) => void;
|
||||
}
|
||||
|
||||
|
|
@ -23,7 +22,6 @@ export function PropertiesPane({
|
|||
loading,
|
||||
hexagonId,
|
||||
onLoadMore,
|
||||
onClose: _onClose,
|
||||
onNavigateToSource,
|
||||
}: PropertiesPaneProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
|
|
@ -123,13 +121,9 @@ function PropertyLoadingSkeleton() {
|
|||
<div className="space-y-0">
|
||||
{Array.from({ length: 5 }).map((_, idx) => (
|
||||
<div key={idx} className="p-4 border-b border-warm-100 dark:border-navy-800 animate-pulse">
|
||||
{/* Address */}
|
||||
<div className="h-5 w-3/4 bg-warm-200 dark:bg-warm-700 rounded mb-2" />
|
||||
{/* Postcode */}
|
||||
<div className="h-4 w-24 bg-warm-200 dark:bg-warm-700 rounded mb-3" />
|
||||
{/* Price */}
|
||||
<div className="h-6 w-32 bg-warm-200 dark:bg-warm-700 rounded mb-3" />
|
||||
{/* Property details grid */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-4 bg-warm-200 dark:bg-warm-700 rounded" />
|
||||
|
|
@ -141,39 +135,93 @@ function PropertyLoadingSkeleton() {
|
|||
);
|
||||
}
|
||||
|
||||
const LISTING_STATUS_STYLES: Record<string, string> = {
|
||||
'For sale': 'bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300',
|
||||
'For rent': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
'Historical sale': 'bg-warm-100 text-warm-600 dark:bg-warm-700 dark:text-warm-300',
|
||||
};
|
||||
|
||||
function ListingStatusBadge({ status }: { status: string }) {
|
||||
const style = LISTING_STATUS_STYLES[status] ?? LISTING_STATUS_STYLES['Historical sale'];
|
||||
return <span className={`text-xs font-medium px-1.5 py-0.5 rounded ${style}`}>{status}</span>;
|
||||
}
|
||||
|
||||
function PropertyCard({ property }: { property: Property }) {
|
||||
const price = getNum(property, 'Last known price');
|
||||
const estimatedPrice = getNum(property, 'Estimated current price');
|
||||
const pricePerSqm = getNum(property, 'Price per sqm');
|
||||
const estPricePerSqm = getNum(property, 'Est. price per sqm');
|
||||
const floorArea = getNum(property, 'Total floor area (sqm)');
|
||||
const rooms = getNum(property, 'Rooms (including bedrooms & bathrooms)');
|
||||
const age = getNum(property, 'Approximate construction age');
|
||||
const rooms = getNum(property, 'Number of bedrooms & living rooms');
|
||||
const age = getNum(property, 'Construction age');
|
||||
const transactionDate = getNum(property, 'Date of last transaction');
|
||||
const councilTax = getNum(property, 'Council tax (£/yr)');
|
||||
const councilTaxD = getNum(property, 'Council tax Band D (£/yr)');
|
||||
const askingPrice = getNum(property, 'Asking price');
|
||||
const askingRent = getNum(property, 'Asking rent (monthly)');
|
||||
const bedrooms = getNum(property, 'Bedrooms');
|
||||
const bathrooms = getNum(property, 'Bathrooms');
|
||||
const listingDate = getNum(property, 'Listing date');
|
||||
|
||||
const listingStatus = property.listing_status;
|
||||
|
||||
return (
|
||||
<div className="p-4 border-b border-warm-100 dark:border-navy-800 hover:bg-warm-50 dark:hover:bg-navy-800">
|
||||
<div className="font-semibold dark:text-warm-100">
|
||||
{property.address || 'Unknown Address'}
|
||||
<div className="p-4 border-b border-warm-100 dark:border-warm-800 hover:bg-warm-50 dark:hover:bg-warm-800">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="font-semibold dark:text-warm-100">
|
||||
{property.address || 'Unknown Address'}
|
||||
</div>
|
||||
<div className="text-sm text-warm-600 dark:text-warm-400">{property.postcode}</div>
|
||||
</div>
|
||||
{listingStatus && <ListingStatusBadge status={listingStatus} />}
|
||||
</div>
|
||||
<div className="text-sm text-warm-600 dark:text-warm-400">{property.postcode}</div>
|
||||
|
||||
{price !== undefined && (
|
||||
{property.property_sub_type && (
|
||||
<div className="text-sm text-warm-600 dark:text-warm-400 mt-1">
|
||||
{property.property_sub_type}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{askingPrice !== undefined && (
|
||||
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
|
||||
£{formatNumber(price)}
|
||||
{transactionDate !== undefined && (
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
||||
{' '}
|
||||
({formatTransactionDate(transactionDate)})
|
||||
{property.price_qualifier && (
|
||||
<span className="text-sm font-normal text-warm-500 dark:text-warm-400">
|
||||
{property.price_qualifier}{' '}
|
||||
</span>
|
||||
)}
|
||||
{pricePerSqm !== undefined && (
|
||||
£{formatNumber(askingPrice)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{askingRent !== undefined && (
|
||||
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
|
||||
£{formatNumber(askingRent)}
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">/mo</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{price !== undefined && (
|
||||
<div className={`${askingPrice !== undefined || askingRent !== undefined ? '' : 'mt-2 '}text-lg font-bold text-teal-700 dark:text-teal-400`}>
|
||||
{askingPrice !== undefined || askingRent !== undefined ? (
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
||||
{' '}
|
||||
£{formatNumber(pricePerSqm)}/m²
|
||||
Last sold: £{formatNumber(price)}
|
||||
{transactionDate !== undefined && ` (${formatTransactionDate(transactionDate)})`}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
£{formatNumber(price)}
|
||||
{transactionDate !== undefined && (
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
||||
{' '}
|
||||
({formatTransactionDate(transactionDate)})
|
||||
</span>
|
||||
)}
|
||||
{pricePerSqm !== undefined && (
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
||||
{' '}
|
||||
£{formatNumber(pricePerSqm)}/m²
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -213,6 +261,18 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
{formatNumber(floorArea)}m²
|
||||
</div>
|
||||
)}
|
||||
{bedrooms !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Bedrooms:</span>{' '}
|
||||
{formatNumber(bedrooms)}
|
||||
</div>
|
||||
)}
|
||||
{bathrooms !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Bathrooms:</span>{' '}
|
||||
{formatNumber(bathrooms)}
|
||||
</div>
|
||||
)}
|
||||
{rooms !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Rooms:</span> {formatNumber(rooms)}
|
||||
|
|
@ -236,19 +296,30 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
{property.potential_energy_rating}
|
||||
</div>
|
||||
)}
|
||||
{councilTax !== undefined ? (
|
||||
{listingDate !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Council tax:</span> £
|
||||
{formatNumber(councilTax)}/yr
|
||||
<span className="text-warm-500 dark:text-warm-400">Listed:</span>{' '}
|
||||
{formatTransactionDate(listingDate)}
|
||||
</div>
|
||||
) : councilTaxD !== undefined ? (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Council tax (D):</span> £
|
||||
{formatNumber(councilTaxD)}/yr
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{property.listing_features && property.listing_features.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">Key features</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{property.listing_features.map((feature, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="text-xs bg-warm-100 dark:bg-warm-700 text-warm-700 dark:text-warm-300 rounded px-1.5 py-0.5"
|
||||
>
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{property.renovation_history && property.renovation_history.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">Renovations</div>
|
||||
|
|
@ -265,6 +336,19 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{property.listing_url && (
|
||||
<div className="mt-2">
|
||||
<a
|
||||
href={property.listing_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
>
|
||||
View external listing →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Slider } from '../ui/Slider';
|
|||
import { IconButton } from '../ui/IconButton';
|
||||
import { PlaceSearchInput } from '../ui/PlaceSearchInput';
|
||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import { EyeIcon } from '../ui/icons/EyeIcon';
|
||||
import { MapPinIcon } from '../ui/icons/MapPinIcon';
|
||||
import { RouteIcon } from '../ui/icons/RouteIcon';
|
||||
import { formatFilterValue } from '../../lib/format';
|
||||
|
|
@ -15,6 +16,8 @@ interface TravelTimeCardProps {
|
|||
label: string;
|
||||
timeRange: [number, number] | null;
|
||||
dataRange: [number, number] | null;
|
||||
isPinned: boolean;
|
||||
onTogglePin: () => void;
|
||||
onSetDestination: (slug: string, label: string) => void;
|
||||
onTimeRangeChange: (range: [number, number]) => void;
|
||||
onRemove: () => void;
|
||||
|
|
@ -26,6 +29,8 @@ export function TravelTimeCard({
|
|||
label,
|
||||
timeRange,
|
||||
dataRange,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
onSetDestination,
|
||||
onTimeRangeChange,
|
||||
onRemove,
|
||||
|
|
@ -59,7 +64,7 @@ export function TravelTimeCard({
|
|||
const displayRange = timeRange ?? [sliderMin, sliderMax];
|
||||
|
||||
return (
|
||||
<div className="space-y-2 px-2 py-2 rounded ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20">
|
||||
<div className={`space-y-2 px-2 py-2 rounded ${isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
|
|
@ -68,9 +73,16 @@ export function TravelTimeCard({
|
|||
Travel Time ({MODE_LABELS[mode]})
|
||||
</span>
|
||||
</div>
|
||||
<IconButton onClick={() => onRemove()} title="Remove travel time">
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
</IconButton>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{slug && (
|
||||
<IconButton onClick={onTogglePin} active={isPinned} title={isPinned ? 'Stop previewing' : 'Preview on map'}>
|
||||
<EyeIcon className="w-3.5 h-3.5" filled={isPinned} />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton onClick={() => onRemove()} title="Remove travel time">
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Destination search */}
|
||||
|
|
@ -81,6 +93,7 @@ export function TravelTimeCard({
|
|||
placeholder={slug ? 'Change destination...' : 'Search destination...'}
|
||||
size="xs"
|
||||
inputClassName="w-full px-2 py-1 text-xs rounded border border-warm-200 dark:border-warm-600 bg-white dark:bg-warm-800 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-1 focus:ring-teal-400"
|
||||
portal
|
||||
/>
|
||||
|
||||
{slug && label && (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue