UI improvements
This commit is contained in:
parent
83dd2ca87e
commit
1569d116a9
14 changed files with 222 additions and 92 deletions
|
|
@ -280,11 +280,10 @@ function SavedPropertiesTab({
|
||||||
key={prop.id}
|
key={prop.id}
|
||||||
className="bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg overflow-hidden p-4"
|
className="bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg overflow-hidden p-4"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2 mb-1">
|
<div className="mb-1">
|
||||||
<h3 className="font-medium text-navy-950 dark:text-warm-100 leading-tight">
|
<h3 className="font-medium text-navy-950 dark:text-warm-100 leading-tight">
|
||||||
{prop.address}
|
{prop.address}
|
||||||
</h3>
|
</h3>
|
||||||
<BookmarkIcon className="w-4 h-4 shrink-0 text-teal-600 dark:text-teal-400 mt-0.5" filled />
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-warm-500 dark:text-warm-400 mb-1">{prop.postcode}</p>
|
<p className="text-sm text-warm-500 dark:text-warm-400 mb-1">{prop.postcode}</p>
|
||||||
{price && (
|
{price && (
|
||||||
|
|
@ -304,17 +303,6 @@ function SavedPropertiesTab({
|
||||||
>
|
>
|
||||||
Open postcode
|
Open postcode
|
||||||
</button>
|
</button>
|
||||||
{prop.data.listingUrl && (
|
|
||||||
<a
|
|
||||||
href={prop.data.listingUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
|
||||||
title="View listing"
|
|
||||||
>
|
|
||||||
View listing →
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setDeleteConfirmId(prop.id)}
|
onClick={() => setDeleteConfirmId(prop.id)}
|
||||||
className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||||
|
|
@ -323,6 +311,16 @@ function SavedPropertiesTab({
|
||||||
<TrashIcon className="w-4 h-4" />
|
<TrashIcon className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{prop.data.listingUrl && (
|
||||||
|
<a
|
||||||
|
href={prop.data.listingUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="mt-2 block text-center px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||||
|
>
|
||||||
|
View listing →
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -359,7 +357,9 @@ export function SavedPage({
|
||||||
onDeleteProperty: (id: string) => Promise<void>;
|
onDeleteProperty: (id: string) => Promise<void>;
|
||||||
onOpenProperty: (postcode: string) => void;
|
onOpenProperty: (postcode: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [activeTab, setActiveTab] = useState<'searches' | 'properties'>('searches');
|
const [activeTab, setActiveTab] = useState<'searches' | 'properties'>(
|
||||||
|
window.location.hash === '#properties' ? 'properties' : 'searches'
|
||||||
|
);
|
||||||
|
|
||||||
const tabClass = (tab: string) =>
|
const tabClass = (tab: string) =>
|
||||||
`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
|
@ -448,20 +448,37 @@ function InviteTable({
|
||||||
No invites generated yet
|
No invites generated yet
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-sm">
|
<table className="w-full table-fixed text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-warm-200 dark:border-warm-700 text-left">
|
<tr className="border-b border-warm-200 dark:border-warm-700 text-left">
|
||||||
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium">Link</th>
|
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium">Link</th>
|
||||||
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium">Status</th>
|
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium w-24">Status</th>
|
||||||
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium">Created</th>
|
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium w-24">Created</th>
|
||||||
<th className="px-5 py-2" />
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-warm-100 dark:divide-warm-800">
|
<tbody className="divide-y divide-warm-100 dark:divide-warm-800">
|
||||||
{invites.map((inv) => (
|
{invites.map((inv) => (
|
||||||
<tr key={inv.code}>
|
<tr key={inv.code}>
|
||||||
<td className="px-5 py-2.5 text-navy-950 dark:text-warm-200 font-mono text-xs truncate max-w-[200px]">
|
<td className="px-5 py-2.5">
|
||||||
{inv.code}
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span
|
||||||
|
className="text-navy-950 dark:text-warm-200 font-mono text-xs truncate min-w-0"
|
||||||
|
style={{ direction: 'rtl', textAlign: 'left' }}
|
||||||
|
>
|
||||||
|
{inv.url}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopy(inv.url, inv.code)}
|
||||||
|
className="shrink-0 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||||
|
title="Copy invite link"
|
||||||
|
>
|
||||||
|
{copiedCode === inv.code ? (
|
||||||
|
<CheckIcon className="w-4 h-4 text-teal-600 dark:text-teal-400" />
|
||||||
|
) : (
|
||||||
|
<ClipboardIcon className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-2.5">
|
<td className="px-5 py-2.5">
|
||||||
<span
|
<span
|
||||||
|
|
@ -477,19 +494,6 @@ function InviteTable({
|
||||||
<td className="px-5 py-2.5 text-warm-500 dark:text-warm-400 text-xs">
|
<td className="px-5 py-2.5 text-warm-500 dark:text-warm-400 text-xs">
|
||||||
{formatRelativeTime(inv.created)}
|
{formatRelativeTime(inv.created)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-2.5 text-right">
|
|
||||||
<button
|
|
||||||
onClick={() => handleCopy(inv.url, inv.code)}
|
|
||||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
|
||||||
title="Copy invite link"
|
|
||||||
>
|
|
||||||
{copiedCode === inv.code ? (
|
|
||||||
<CheckIcon className="w-4 h-4 text-teal-600 dark:text-teal-400" />
|
|
||||||
) : (
|
|
||||||
<ClipboardIcon className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ export default memo(function AiFilterInput({ loading, error, notes, onSubmit }:
|
||||||
const hasContent = query.trim().length > 0;
|
const hasContent = query.trim().length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2" data-tutorial="ai-filters">
|
||||||
<form onSubmit={handleSubmit} className="flex items-center gap-1.5">
|
<form onSubmit={handleSubmit} className="flex items-center gap-1.5">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<SparklesIcon className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-teal-500 dark:text-teal-400 pointer-events-none" />
|
<SparklesIcon className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-teal-500 dark:text-teal-400 pointer-events-none" />
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState, useMemo, useEffect } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
|
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
|
||||||
|
import { useTravelModes } from '../../hooks/useTravelModes';
|
||||||
import { SearchInput } from '../ui/SearchInput';
|
import { SearchInput } from '../ui/SearchInput';
|
||||||
import { FilterIcon } from '../ui/icons';
|
import { FilterIcon } from '../ui/icons';
|
||||||
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
||||||
|
|
@ -11,7 +12,6 @@ import { FeatureActions } from '../ui/FeatureIcons';
|
||||||
import { FeatureLabel } from '../ui/FeatureLabel';
|
import { FeatureLabel } from '../ui/FeatureLabel';
|
||||||
import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon, PlusIcon } from '../ui/icons';
|
import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon, PlusIcon } from '../ui/icons';
|
||||||
import type { ComponentType } from 'react';
|
import type { ComponentType } from 'react';
|
||||||
import { IconButton } from '../ui/IconButton';
|
|
||||||
import { TRANSPORT_MODES, MODE_LABELS, MODE_DESCRIPTIONS, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
import { TRANSPORT_MODES, MODE_LABELS, MODE_DESCRIPTIONS, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||||
|
|
||||||
const MODE_ICONS: Record<TransportMode, ComponentType<{ className?: string }>> = {
|
const MODE_ICONS: Record<TransportMode, ComponentType<{ className?: string }>> = {
|
||||||
|
|
@ -53,6 +53,7 @@ export default function FeatureBrowser({
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
||||||
const [expandedGroups, toggleGroup] = useCollapsibleGroups();
|
const [expandedGroups, toggleGroup] = useCollapsibleGroups();
|
||||||
|
const availableTravelModes = useTravelModes();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (openInfoFeature) {
|
if (openInfoFeature) {
|
||||||
|
|
@ -73,9 +74,15 @@ export default function FeatureBrowser({
|
||||||
// When searching, expand all groups so results are visible
|
// When searching, expand all groups so results are visible
|
||||||
const isSearching = search.length > 0;
|
const isSearching = search.length > 0;
|
||||||
|
|
||||||
// All modes are always available (can add multiple entries per mode)
|
// Only show modes that have precomputed travel time data
|
||||||
|
const visibleModes = useMemo(
|
||||||
|
() => (availableTravelModes ? TRANSPORT_MODES.filter((m) => availableTravelModes.has(m)) : []),
|
||||||
|
[availableTravelModes],
|
||||||
|
);
|
||||||
|
|
||||||
const showTravelModes =
|
const showTravelModes =
|
||||||
!search || 'travel time journey commute car bicycle walking transit'.includes(search.toLowerCase());
|
visibleModes.length > 0 &&
|
||||||
|
(!search || 'travel time journey commute car bicycle walking transit'.includes(search.toLowerCase()));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -92,15 +99,15 @@ export default function FeatureBrowser({
|
||||||
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
|
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||||
>
|
>
|
||||||
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
|
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
|
||||||
{TRANSPORT_MODES.length}
|
{visibleModes.length}
|
||||||
</span>
|
</span>
|
||||||
</CollapsibleGroupHeader>
|
</CollapsibleGroupHeader>
|
||||||
{(isSearching || expandedGroups.has('Travel Time')) && TRANSPORT_MODES.map((mode) => {
|
{(isSearching || expandedGroups.has('Travel Time')) && visibleModes.map((mode) => {
|
||||||
const ModeIcon = MODE_ICONS[mode];
|
const ModeIcon = MODE_ICONS[mode];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={mode}
|
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"
|
className="flex items-center 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)}>
|
<div className="flex items-center gap-2 min-w-0" onClick={() => onAddTravelTimeEntry(mode)}>
|
||||||
<ModeIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0" />
|
<ModeIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0" />
|
||||||
|
|
@ -114,9 +121,13 @@ export default function FeatureBrowser({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-0.5 shrink-0">
|
<div className="flex items-center gap-0.5 shrink-0">
|
||||||
<IconButton onClick={() => onAddTravelTimeEntry(mode)} title={`Add ${MODE_LABELS[mode]} travel time`} size="md">
|
<button
|
||||||
<PlusIcon className="w-7 h-7 md:w-3.5 md:h-3.5" />
|
onClick={() => onAddTravelTimeEntry(mode)}
|
||||||
</IconButton>
|
title={`Add ${MODE_LABELS[mode]} travel time`}
|
||||||
|
className="p-1 rounded-md text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30 hover:bg-teal-100 dark:hover:bg-teal-800/40"
|
||||||
|
>
|
||||||
|
<PlusIcon className="w-7 h-7 md:w-5 md:h-5" strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -143,7 +154,7 @@ export default function FeatureBrowser({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={f.name}
|
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"
|
className="flex items-center 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">
|
<div className="min-w-0 mr-2">
|
||||||
<FeatureLabel feature={f} onShowInfo={setInfoFeature} size="sm" />
|
<FeatureLabel feature={f} onShowInfo={setInfoFeature} size="sm" />
|
||||||
|
|
|
||||||
|
|
@ -212,9 +212,6 @@ export default function JourneyInstructions({ postcode, entries, label }: Journe
|
||||||
const displayLegs = j.legs ? invertLegs(j.legs) : null;
|
const displayLegs = j.legs ? invertLegs(j.legs) : null;
|
||||||
const legSum = j.legs ? j.legs.reduce((sum, l) => sum + l.minutes, 0) : 0;
|
const legSum = j.legs ? j.legs.reduce((sum, l) => sum + l.minutes, 0) : 0;
|
||||||
const totalMin = j.minutes ?? legSum;
|
const totalMin = j.minutes ?? legSum;
|
||||||
const waitingMin = j.minutes != null ? Math.max(0, j.minutes - legSum) : null;
|
|
||||||
const bestWaitingMin =
|
|
||||||
j.bestMinutes != null ? Math.max(0, j.bestMinutes - legSum) : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={j.slug} className="bg-warm-50 dark:bg-warm-800 rounded-lg p-2.5">
|
<div key={j.slug} className="bg-warm-50 dark:bg-warm-800 rounded-lg p-2.5">
|
||||||
|
|
@ -238,22 +235,6 @@ export default function JourneyInstructions({ postcode, entries, label }: Journe
|
||||||
{displayLegs.map((leg, i) => (
|
{displayLegs.map((leg, i) => (
|
||||||
<TimelineLeg key={i} leg={leg} isLast={i === displayLegs.length - 1} />
|
<TimelineLeg key={i} leg={leg} isLast={i === displayLegs.length - 1} />
|
||||||
))}
|
))}
|
||||||
{waitingMin != null && waitingMin > 0 && (
|
|
||||||
<div className="mt-1.5 pt-1.5 border-t border-warm-200 dark:border-warm-700 flex items-baseline justify-between">
|
|
||||||
<span className="text-[11px] text-warm-500 dark:text-warm-400">
|
|
||||||
Waiting & transfers
|
|
||||||
</span>
|
|
||||||
<span className="text-[11px] text-warm-600 dark:text-warm-300">
|
|
||||||
{waitingMin} min
|
|
||||||
{bestWaitingMin != null && (
|
|
||||||
<span className="text-warm-400 dark:text-warm-500">
|
|
||||||
{' '}
|
|
||||||
(best: {bestWaitingMin === 0 ? '~0' : bestWaitingMin} min)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-warm-500 dark:text-warm-400">
|
<span className="text-xs text-warm-500 dark:text-warm-400">
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import { useLicense } from '../../hooks/useLicense';
|
||||||
import UpgradeModal from '../ui/UpgradeModal';
|
import UpgradeModal from '../ui/UpgradeModal';
|
||||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||||
import { MapPinIcon } from '../ui/icons/MapPinIcon';
|
import { MapPinIcon } from '../ui/icons/MapPinIcon';
|
||||||
|
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
|
||||||
|
|
||||||
export interface ExportState {
|
export interface ExportState {
|
||||||
onExport: () => void;
|
onExport: () => void;
|
||||||
|
|
@ -101,6 +102,22 @@ export default function MapPage({
|
||||||
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
||||||
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
|
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
|
||||||
|
|
||||||
|
const [showBookmarkToast, setShowBookmarkToast] = useState(false);
|
||||||
|
const bookmarkToastDismissed = useRef(
|
||||||
|
localStorage.getItem('bookmark_toast_dismissed') === '1'
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSavePropertyWithToast = useCallback(
|
||||||
|
(property: Property) => {
|
||||||
|
onSaveProperty?.(property);
|
||||||
|
if (!bookmarkToastDismissed.current) {
|
||||||
|
setShowBookmarkToast(true);
|
||||||
|
bookmarkToastDismissed.current = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSaveProperty]
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
filters,
|
filters,
|
||||||
activeFeature,
|
activeFeature,
|
||||||
|
|
@ -358,6 +375,31 @@ export default function MapPage({
|
||||||
}
|
}
|
||||||
}, [screenshotMode, mapData.loading, mapData.data.length, mapData.postcodeData.length, mapData.usePostcodeView]);
|
}, [screenshotMode, mapData.loading, mapData.data.length, mapData.postcodeData.length, mapData.usePostcodeView]);
|
||||||
|
|
||||||
|
const bookmarkToast = showBookmarkToast && (
|
||||||
|
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[60] flex items-center gap-3 px-4 py-3 rounded-lg bg-navy-900 text-white text-sm shadow-lg animate-fade-in">
|
||||||
|
<BookmarkIcon className="w-4 h-4 text-teal-400 shrink-0" filled />
|
||||||
|
<span>Property saved!</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowBookmarkToast(false);
|
||||||
|
onNavigateTo('saved', 'properties');
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 rounded bg-teal-600 hover:bg-teal-500 text-white text-xs font-medium whitespace-nowrap"
|
||||||
|
>
|
||||||
|
View saved
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowBookmarkToast(false);
|
||||||
|
localStorage.setItem('bookmark_toast_dismissed', '1');
|
||||||
|
}}
|
||||||
|
className="text-warm-400 hover:text-warm-200 text-xs whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Don't show again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
if (screenshotMode) {
|
if (screenshotMode) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
|
|
@ -416,7 +458,7 @@ export default function MapPage({
|
||||||
loading={selection.loadingProperties}
|
loading={selection.loadingProperties}
|
||||||
hexagonId={selection.selectedHexagon?.id || null}
|
hexagonId={selection.selectedHexagon?.id || null}
|
||||||
onLoadMore={selection.handleLoadMoreProperties}
|
onLoadMore={selection.handleLoadMoreProperties}
|
||||||
onSaveProperty={onSaveProperty}
|
onSaveProperty={onSaveProperty ? handleSavePropertyWithToast : undefined}
|
||||||
onUnsaveProperty={onUnsaveProperty}
|
onUnsaveProperty={onUnsaveProperty}
|
||||||
isPropertySaved={isPropertySaved}
|
isPropertySaved={isPropertySaved}
|
||||||
getSavedPropertyId={getSavedPropertyId}
|
getSavedPropertyId={getSavedPropertyId}
|
||||||
|
|
@ -592,6 +634,8 @@ export default function MapPage({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{bookmarkToast}
|
||||||
|
|
||||||
{mapData.licenseRequired && (
|
{mapData.licenseRequired && (
|
||||||
<UpgradeModal
|
<UpgradeModal
|
||||||
isLoggedIn={!!user}
|
isLoggedIn={!!user}
|
||||||
|
|
@ -730,6 +774,8 @@ export default function MapPage({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{bookmarkToast}
|
||||||
|
|
||||||
{mapData.licenseRequired && (
|
{mapData.licenseRequired && (
|
||||||
<UpgradeModal
|
<UpgradeModal
|
||||||
isLoggedIn={!!user}
|
isLoggedIn={!!user}
|
||||||
|
|
|
||||||
|
|
@ -76,11 +76,15 @@ export function TravelTimeCard({
|
||||||
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
|
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
|
||||||
Travel Time ({MODE_LABELS[mode]})
|
Travel Time ({MODE_LABELS[mode]})
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowInfo(true)}
|
||||||
|
className="p-1 -m-0.5 rounded text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 hover:bg-warm-100 dark:hover:bg-warm-700 shrink-0"
|
||||||
|
title="Feature info"
|
||||||
|
>
|
||||||
|
<InfoIcon className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
<IconButton onClick={() => setShowInfo(true)} title="Feature info">
|
|
||||||
<InfoIcon className="w-3.5 h-3.5" />
|
|
||||||
</IconButton>
|
|
||||||
{slug && (
|
{slug && (
|
||||||
<IconButton onClick={onTogglePin} active={isPinned} title={isPinned ? 'Stop previewing' : 'Preview on map'}>
|
<IconButton onClick={onTogglePin} active={isPinned} title={isPinned ? 'Stop previewing' : 'Preview on map'}>
|
||||||
<EyeIcon className="w-3.5 h-3.5" filled={isPinned} />
|
<EyeIcon className="w-3.5 h-3.5" filled={isPinned} />
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,13 @@ export function FeatureActions({
|
||||||
<EyeIcon filled={isPinned} className="w-7 h-7 md:w-3.5 md:h-3.5" />
|
<EyeIcon filled={isPinned} className="w-7 h-7 md:w-3.5 md:h-3.5" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
{onAdd && (
|
{onAdd && (
|
||||||
<IconButton onClick={() => onAdd(feature.name)} title="Add filter" size="md">
|
<button
|
||||||
<PlusIcon className="w-7 h-7 md:w-3.5 md:h-3.5" />
|
onClick={() => onAdd(feature.name)}
|
||||||
</IconButton>
|
title="Add filter"
|
||||||
|
className="p-1 rounded-md text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30 hover:bg-teal-100 dark:hover:bg-teal-800/40"
|
||||||
|
>
|
||||||
|
<PlusIcon className="w-7 h-7 md:w-5 md:h-5" strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
{onRemove && (
|
{onRemove && (
|
||||||
<IconButton onClick={() => onRemove(feature.name)} title="Remove filter">
|
<IconButton onClick={() => onRemove(feature.name)} title="Remove filter">
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
interface IconProps {
|
interface PlusIconProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
strokeWidth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlusIcon({ className = 'w-7 h-7' }: IconProps) {
|
export function PlusIcon({ className = 'w-7 h-7', strokeWidth = 2 }: PlusIconProps) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
className={className}
|
className={className}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth={2}
|
strokeWidth={strokeWidth}
|
||||||
>
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m-7-7h14" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m-7-7h14" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
|
|
@ -414,27 +414,63 @@ export function useDeckLayers({
|
||||||
data: postcodeData as PostcodeFeature[],
|
data: postcodeData as PostcodeFeature[],
|
||||||
getFillColor: (f) => {
|
getFillColor: (f) => {
|
||||||
const d = f.properties;
|
const d = f.properties;
|
||||||
|
const dark = isDarkRef.current;
|
||||||
|
const entries = travelTimeEntriesRef.current;
|
||||||
|
|
||||||
|
// Dim-filter: all travel entries with timeRange dim postcodes outside range
|
||||||
|
for (let i = 0; i < entries.length; i++) {
|
||||||
|
const entry = entries[i];
|
||||||
|
if (!entry.timeRange || !entry.slug) continue;
|
||||||
|
const fk = travelFieldKey(entry);
|
||||||
|
const modeVal = d[`avg_${fk}`];
|
||||||
|
if (modeVal == null || (modeVal as number) < entry.timeRange[0] || (modeVal as number) > entry.timeRange[1]) {
|
||||||
|
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const vf = viewFeatureRef.current;
|
const vf = viewFeatureRef.current;
|
||||||
const clr = colorRangeRef.current;
|
const clr = colorRangeRef.current;
|
||||||
const fr = filterRangeRef.current;
|
const fr = filterRangeRef.current;
|
||||||
const cfm = colorFeatureMetaRef.current;
|
const cfm = colorFeatureMetaRef.current;
|
||||||
const dark = isDarkRef.current;
|
|
||||||
if (vf && clr && cfm) {
|
if (vf && clr) {
|
||||||
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
|
// Travel time feature: dim postcodes with no data
|
||||||
const minVal = d[`min_${vf}`] as number | undefined;
|
if (vf.startsWith('tt_')) {
|
||||||
const maxVal = d[`max_${vf}`] as number | undefined;
|
const ttVal = d[`avg_${vf}`];
|
||||||
return getFeatureFillColor(
|
if (ttVal == null) {
|
||||||
val as number | null | undefined,
|
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
|
||||||
minVal,
|
}
|
||||||
maxVal,
|
return getFeatureFillColor(
|
||||||
clr,
|
ttVal as number,
|
||||||
fr,
|
ttVal as number,
|
||||||
0,
|
ttVal as number,
|
||||||
densityGradientRef.current,
|
clr,
|
||||||
dark,
|
null,
|
||||||
180,
|
0,
|
||||||
enumCountRef.current
|
densityGradientRef.current,
|
||||||
);
|
dark,
|
||||||
|
180
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular feature
|
||||||
|
if (cfm) {
|
||||||
|
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
|
||||||
|
const minVal = d[`min_${vf}`] as number | undefined;
|
||||||
|
const maxVal = d[`max_${vf}`] as number | undefined;
|
||||||
|
return getFeatureFillColor(
|
||||||
|
val as number | null | undefined,
|
||||||
|
minVal,
|
||||||
|
maxVal,
|
||||||
|
clr,
|
||||||
|
fr,
|
||||||
|
0,
|
||||||
|
densityGradientRef.current,
|
||||||
|
dark,
|
||||||
|
180,
|
||||||
|
enumCountRef.current
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const cr = postcodeCountRangeRef.current;
|
const cr = postcodeCountRangeRef.current;
|
||||||
const c = d.count;
|
const c = d.count;
|
||||||
|
|
|
||||||
34
frontend/src/hooks/useTravelModes.ts
Normal file
34
frontend/src/hooks/useTravelModes.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { logNonAbortError } from '../lib/api';
|
||||||
|
import type { TransportMode } from './useTravelTime';
|
||||||
|
|
||||||
|
interface TravelModeInfo {
|
||||||
|
mode: TransportMode;
|
||||||
|
destinations: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches which transport modes have precomputed travel time data. */
|
||||||
|
export function useTravelModes() {
|
||||||
|
const [availableModes, setAvailableModes] = useState<Set<TransportMode> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
fetch('/api/travel-modes', { signal: controller.signal })
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((data: { modes: TravelModeInfo[] }) => {
|
||||||
|
const modes = new Set<TransportMode>(
|
||||||
|
data.modes.filter((m) => m.destinations > 0).map((m) => m.mode),
|
||||||
|
);
|
||||||
|
setAvailableModes(modes);
|
||||||
|
})
|
||||||
|
.catch((err) => logNonAbortError('travel modes', err));
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return availableModes;
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,14 @@ const STEPS: Step[] = [
|
||||||
placement: 'right',
|
placement: 'right',
|
||||||
disableBeacon: true,
|
disableBeacon: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
target: '[data-tutorial="ai-filters"]',
|
||||||
|
title: 'AI-Powered Filters',
|
||||||
|
content:
|
||||||
|
'Describe your ideal area in plain English — like "quiet neighbourhood with good schools" — and AI will set up the right filters for you automatically.',
|
||||||
|
placement: 'right',
|
||||||
|
disableBeacon: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
target: '[data-tutorial="map"]',
|
target: '[data-tutorial="map"]',
|
||||||
title: 'Explore the Map',
|
title: 'Explore the Map',
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ export const ZOOM_TO_RESOLUTION_THRESHOLDS = [
|
||||||
{ maxZoom: 13, resolution: 9 },
|
{ maxZoom: 13, resolution: 9 },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const POSTCODE_ZOOM_THRESHOLD = 15;
|
export const POSTCODE_ZOOM_THRESHOLD = 16;
|
||||||
|
|
||||||
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||||
{ t: 0, color: [46, 204, 113] },
|
{ t: 0, color: [46, 204, 113] },
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version: 8,
|
version: 8,
|
||||||
|
sprite: `${window.location.origin}/assets/sprites/${theme}`,
|
||||||
glyphs: GLYPHS_URL,
|
glyphs: GLYPHS_URL,
|
||||||
sources: {
|
sources: {
|
||||||
protomaps: {
|
protomaps: {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Uses pyosmium's FileProcessor with area assembly to convert OSM ways/relations
|
Uses pyosmium's FileProcessor with area assembly to convert OSM ways/relations
|
||||||
into Shapely polygons, reprojects to BNG (EPSG:27700), and saves as parquet.
|
into Shapely polygons, reprojects to BNG (EPSG:27700), and saves as parquet.
|
||||||
|
|
||||||
Reuses the same great-britain-latest.osm.pbf as pois.py.
|
Reuses the same england-latest.osm.pbf as pois.py.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue