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}
|
||||
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">
|
||||
{prop.address}
|
||||
</h3>
|
||||
<BookmarkIcon className="w-4 h-4 shrink-0 text-teal-600 dark:text-teal-400 mt-0.5" filled />
|
||||
</div>
|
||||
<p className="text-sm text-warm-500 dark:text-warm-400 mb-1">{prop.postcode}</p>
|
||||
{price && (
|
||||
|
|
@ -304,17 +303,6 @@ function SavedPropertiesTab({
|
|||
>
|
||||
Open postcode
|
||||
</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
|
||||
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"
|
||||
|
|
@ -323,6 +311,16 @@ function SavedPropertiesTab({
|
|||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
|
@ -359,7 +357,9 @@ export function SavedPage({
|
|||
onDeleteProperty: (id: string) => Promise<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) =>
|
||||
`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
|
|
@ -448,20 +448,37 @@ function InviteTable({
|
|||
No invites generated yet
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<table className="w-full table-fixed text-sm">
|
||||
<thead>
|
||||
<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">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" />
|
||||
<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 w-24">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-warm-100 dark:divide-warm-800">
|
||||
{invites.map((inv) => (
|
||||
<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]">
|
||||
{inv.code}
|
||||
<td className="px-5 py-2.5">
|
||||
<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 className="px-5 py-2.5">
|
||||
<span
|
||||
|
|
@ -477,19 +494,6 @@ function InviteTable({
|
|||
<td className="px-5 py-2.5 text-warm-500 dark:text-warm-400 text-xs">
|
||||
{formatRelativeTime(inv.created)}
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export default memo(function AiFilterInput({ loading, error, notes, onSubmit }:
|
|||
const hasContent = query.trim().length > 0;
|
||||
|
||||
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">
|
||||
<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" />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
|
||||
import { useTravelModes } from '../../hooks/useTravelModes';
|
||||
import { SearchInput } from '../ui/SearchInput';
|
||||
import { FilterIcon } from '../ui/icons';
|
||||
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
||||
|
|
@ -11,7 +12,6 @@ import { FeatureActions } from '../ui/FeatureIcons';
|
|||
import { FeatureLabel } from '../ui/FeatureLabel';
|
||||
import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon, PlusIcon } from '../ui/icons';
|
||||
import type { ComponentType } from 'react';
|
||||
import { IconButton } from '../ui/IconButton';
|
||||
import { TRANSPORT_MODES, MODE_LABELS, MODE_DESCRIPTIONS, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
|
||||
const MODE_ICONS: Record<TransportMode, ComponentType<{ className?: string }>> = {
|
||||
|
|
@ -53,6 +53,7 @@ export default function FeatureBrowser({
|
|||
const [search, setSearch] = useState('');
|
||||
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
const [expandedGroups, toggleGroup] = useCollapsibleGroups();
|
||||
const availableTravelModes = useTravelModes();
|
||||
|
||||
useEffect(() => {
|
||||
if (openInfoFeature) {
|
||||
|
|
@ -73,9 +74,15 @@ export default function FeatureBrowser({
|
|||
// When searching, expand all groups so results are visible
|
||||
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 =
|
||||
!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 (
|
||||
<>
|
||||
|
|
@ -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"
|
||||
>
|
||||
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
|
||||
{TRANSPORT_MODES.length}
|
||||
{visibleModes.length}
|
||||
</span>
|
||||
</CollapsibleGroupHeader>
|
||||
{(isSearching || expandedGroups.has('Travel Time')) && TRANSPORT_MODES.map((mode) => {
|
||||
{(isSearching || expandedGroups.has('Travel Time')) && visibleModes.map((mode) => {
|
||||
const ModeIcon = MODE_ICONS[mode];
|
||||
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"
|
||||
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)}>
|
||||
<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 className="flex items-center gap-0.5 shrink-0">
|
||||
<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>
|
||||
<button
|
||||
onClick={() => onAddTravelTimeEntry(mode)}
|
||||
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>
|
||||
);
|
||||
|
|
@ -143,7 +154,7 @@ export default function FeatureBrowser({
|
|||
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"
|
||||
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">
|
||||
<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 legSum = j.legs ? j.legs.reduce((sum, l) => sum + l.minutes, 0) : 0;
|
||||
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 (
|
||||
<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) => (
|
||||
<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>
|
||||
) : (
|
||||
<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 { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
import { MapPinIcon } from '../ui/icons/MapPinIcon';
|
||||
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
|
||||
|
||||
export interface ExportState {
|
||||
onExport: () => void;
|
||||
|
|
@ -101,6 +102,22 @@ export default function MapPage({
|
|||
const [mobileDrawerOpen, setMobileDrawerOpen] = 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 {
|
||||
filters,
|
||||
activeFeature,
|
||||
|
|
@ -358,6 +375,31 @@ export default function MapPage({
|
|||
}
|
||||
}, [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) {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
|
|
@ -416,7 +458,7 @@ export default function MapPage({
|
|||
loading={selection.loadingProperties}
|
||||
hexagonId={selection.selectedHexagon?.id || null}
|
||||
onLoadMore={selection.handleLoadMoreProperties}
|
||||
onSaveProperty={onSaveProperty}
|
||||
onSaveProperty={onSaveProperty ? handleSavePropertyWithToast : undefined}
|
||||
onUnsaveProperty={onUnsaveProperty}
|
||||
isPropertySaved={isPropertySaved}
|
||||
getSavedPropertyId={getSavedPropertyId}
|
||||
|
|
@ -592,6 +634,8 @@ export default function MapPage({
|
|||
/>
|
||||
)}
|
||||
|
||||
{bookmarkToast}
|
||||
|
||||
{mapData.licenseRequired && (
|
||||
<UpgradeModal
|
||||
isLoggedIn={!!user}
|
||||
|
|
@ -730,6 +774,8 @@ export default function MapPage({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{bookmarkToast}
|
||||
|
||||
{mapData.licenseRequired && (
|
||||
<UpgradeModal
|
||||
isLoggedIn={!!user}
|
||||
|
|
|
|||
|
|
@ -76,11 +76,15 @@ export function TravelTimeCard({
|
|||
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
|
||||
Travel Time ({MODE_LABELS[mode]})
|
||||
</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 className="flex items-center gap-0.5">
|
||||
<IconButton onClick={() => setShowInfo(true)} title="Feature info">
|
||||
<InfoIcon className="w-3.5 h-3.5" />
|
||||
</IconButton>
|
||||
{slug && (
|
||||
<IconButton onClick={onTogglePin} active={isPinned} title={isPinned ? 'Stop previewing' : 'Preview on map'}>
|
||||
<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" />
|
||||
</IconButton>
|
||||
{onAdd && (
|
||||
<IconButton onClick={() => onAdd(feature.name)} title="Add filter" size="md">
|
||||
<PlusIcon className="w-7 h-7 md:w-3.5 md:h-3.5" />
|
||||
</IconButton>
|
||||
<button
|
||||
onClick={() => onAdd(feature.name)}
|
||||
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 && (
|
||||
<IconButton onClick={() => onRemove(feature.name)} title="Remove filter">
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
interface IconProps {
|
||||
interface PlusIconProps {
|
||||
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 (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeWidth={strokeWidth}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m-7-7h14" />
|
||||
</svg>
|
||||
|
|
|
|||
|
|
@ -414,27 +414,63 @@ export function useDeckLayers({
|
|||
data: postcodeData as PostcodeFeature[],
|
||||
getFillColor: (f) => {
|
||||
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 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}`];
|
||||
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
|
||||
);
|
||||
|
||||
if (vf && clr) {
|
||||
// Travel time feature: dim postcodes with no data
|
||||
if (vf.startsWith('tt_')) {
|
||||
const ttVal = d[`avg_${vf}`];
|
||||
if (ttVal == null) {
|
||||
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
|
||||
}
|
||||
return getFeatureFillColor(
|
||||
ttVal as number,
|
||||
ttVal as number,
|
||||
ttVal as number,
|
||||
clr,
|
||||
null,
|
||||
0,
|
||||
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 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',
|
||||
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"]',
|
||||
title: 'Explore the Map',
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export const ZOOM_TO_RESOLUTION_THRESHOLDS = [
|
|||
{ maxZoom: 13, resolution: 9 },
|
||||
] as const;
|
||||
|
||||
export const POSTCODE_ZOOM_THRESHOLD = 15;
|
||||
export const POSTCODE_ZOOM_THRESHOLD = 16;
|
||||
|
||||
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||
{ t: 0, color: [46, 204, 113] },
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
|
|||
|
||||
return {
|
||||
version: 8,
|
||||
sprite: `${window.location.origin}/assets/sprites/${theme}`,
|
||||
glyphs: GLYPHS_URL,
|
||||
sources: {
|
||||
protomaps: {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Uses pyosmium's FileProcessor with area assembly to convert OSM ways/relations
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue