Good stuff

This commit is contained in:
Andras Schmelczer 2026-02-22 22:36:40 +00:00
parent 9da2db707f
commit 8032011708
32 changed files with 1052 additions and 374 deletions

View file

@ -133,6 +133,7 @@ export default function AreaPane({
<LoadingSkeleton />
) : stats ? (
<div>
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
<HistogramLegend />
{featureGroups.map((group) => {
const hasData = group.features.some(
@ -375,7 +376,6 @@ export default function AreaPane({
</div>
);
})}
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
</div>
) : null}
</div>

View file

@ -9,10 +9,10 @@ import { groupFeaturesByCategory } from '../../lib/features';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { FeatureActions } from '../ui/FeatureIcons';
import { FeatureLabel } from '../ui/FeatureLabel';
import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon, PlusIcon, EyeIcon } from '../ui/icons';
import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon, PlusIcon } from '../ui/icons';
import type { ComponentType } from 'react';
import { IconButton } from '../ui/IconButton';
import { TRANSPORT_MODES, MODE_LABELS, travelFieldKey, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
import { TRANSPORT_MODES, MODE_LABELS, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
const MODE_ICONS: Record<TransportMode, ComponentType<{ className?: string }>> = {
car: CarIcon,
@ -96,9 +96,6 @@ export default function FeatureBrowser({
</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;
const ModeIcon = MODE_ICONS[mode];
return (
<div
@ -117,16 +114,6 @@ export default function FeatureBrowser({
</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>

View file

@ -25,15 +25,6 @@ import {
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,
@ -89,7 +80,6 @@ interface FiltersProps {
openInfoFeature?: string | null;
onClearOpenInfoFeature?: () => void;
travelTimeEntries: TravelTimeEntry[];
travelTimeDataRanges: Map<number, [number, number]>;
onTravelTimeAddEntry: (mode: TransportMode) => void;
onTravelTimeRemoveEntry: (index: number) => void;
onTravelTimeSetDestination: (index: number, slug: string, label: string) => void;
@ -122,7 +112,6 @@ export default memo(function Filters({
openInfoFeature,
onClearOpenInfoFeature,
travelTimeEntries,
travelTimeDataRanges,
onTravelTimeAddEntry,
onTravelTimeRemoveEntry,
onTravelTimeSetDestination,
@ -136,6 +125,36 @@ export default memo(function Filters({
onUpgradeClick,
onResetTutorial,
}: FiltersProps) {
const modeRestrictions = useMemo(() => {
const map: Record<string, Set<ListingType>> = {};
for (const f of features) {
if (f.modes && f.modes.length > 0) {
map[f.name] = new Set(f.modes as ListingType[]);
}
}
return map;
}, [features]);
const linkedFeatures = useMemo(() => {
const pairs: [string, string][] = [];
const seen = new Set<string>();
for (const f of features) {
if (f.linked && !seen.has(f.name)) {
pairs.push([f.name, f.linked]);
seen.add(f.linked);
}
}
return pairs;
}, [features]);
const isAllowed = useCallback(
(name: string, mode: ListingType) => {
const allowed = modeRestrictions[name];
return !allowed || allowed.has(mode);
},
[modeRestrictions]
);
const activeListingType = useMemo((): ListingType => {
const val = filters['Listing status'] as string[] | undefined;
if (!val || val.length === 0) return 'historical';
@ -145,8 +164,8 @@ export default memo(function Filters({
}, [filters]);
const availableFeatures = useMemo(
() => features.filter((f) => !enabledFeatures.has(f.name) && isFeatureAllowedInMode(f.name, activeListingType)),
[features, enabledFeatures, activeListingType]
() => features.filter((f) => !enabledFeatures.has(f.name) && isAllowed(f.name, activeListingType)),
[features, enabledFeatures, activeListingType, isAllowed]
);
const enabledFeatureList = useMemo(
() => features.filter((f) => enabledFeatures.has(f.name) && f.name !== 'Listing status'),
@ -156,7 +175,22 @@ export default memo(function Filters({
const handleListingSelect = useCallback(
(type: ListingType) => {
for (const name of Object.keys(filters)) {
if (name !== 'Listing status' && !isFeatureAllowedInMode(name, type)) {
if (name === 'Listing status') continue;
if (isAllowed(name, type)) continue;
// Check if this feature has a linked counterpart in the new mode
let swapped = false;
for (const [a, b] of linkedFeatures) {
const counterpart = name === a ? b : name === b ? a : null;
if (counterpart && isAllowed(counterpart, type)) {
onFilterChange(counterpart, filters[name] as [number, number]);
onRemoveFilter(name);
swapped = true;
break;
}
}
if (!swapped) {
onRemoveFilter(name);
}
}
@ -167,7 +201,7 @@ export default memo(function Filters({
};
onFilterChange('Listing status', [valueMap[type]]);
},
[filters, onFilterChange, onRemoveFilter]
[filters, onFilterChange, onRemoveFilter, isAllowed, linkedFeatures]
);
const containerRef = useRef<HTMLDivElement>(null);
@ -275,7 +309,6 @@ export default memo(function Filters({
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
dataRange={travelTimeDataRanges.get(index) ?? null}
isPinned={pinnedFeature === travelFieldKey(entry)}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)}

View file

@ -170,6 +170,7 @@ export default memo(function Map({
data,
postcodeData,
usePostcodeView,
zoom: viewState.zoom,
pois,
viewFeature,
colorRange,

View file

@ -188,24 +188,6 @@ export default function MapPage({
const pois = usePOIData(mapData.bounds, selectedPOICategories);
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++) {
const entry = travelTime.entries[i];
if (!entry.slug) continue;
const fieldName = `avg_${travelFieldKey(entry)}`;
const vals: number[] = [];
for (const item of mapData.data) {
const val = item[fieldName];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
}
if (vals.length === 0) continue;
vals.sort((a, b) => a - b);
ranges.set(i, [vals[0], vals[vals.length - 1]]);
}
return ranges;
}, [travelTime.entries, mapData.data]);
useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab, travelTime.entries);
useEffect(() => {
@ -401,7 +383,6 @@ export default function MapPage({
openInfoFeature={pendingInfoFeature}
onClearOpenInfoFeature={onClearPendingInfoFeature}
travelTimeEntries={travelTime.entries}
travelTimeDataRanges={travelTimeDataRanges}
onTravelTimeAddEntry={travelTime.handleAddEntry}
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
onTravelTimeSetDestination={handleTravelTimeSetDestination}
@ -625,9 +606,10 @@ export default function MapPage({
<button
data-tutorial="poi-button"
onClick={() => setPoiPaneOpen((p) => !p)}
className={`absolute bottom-4 right-4 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'}`}
className={`absolute bottom-4 right-4 z-10 px-3 py-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 flex items-center gap-2 ${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" />
<span className="text-sm font-medium">Points of interest</span>
</button>
{/* Floating POI panel */}
{poiPaneOpen && (

View file

@ -27,7 +27,6 @@ interface TravelTimeCardProps {
label: string;
timeRange: [number, number] | null;
useBest: boolean;
dataRange: [number, number] | null;
isPinned: boolean;
onTogglePin: () => void;
onSetDestination: (slug: string, label: string) => void;
@ -42,7 +41,6 @@ export function TravelTimeCard({
label,
timeRange,
useBest,
dataRange,
isPinned,
onTogglePin,
onSetDestination,
@ -74,8 +72,8 @@ export function TravelTimeCard({
[onSetDestination, search.clear],
);
const sliderMin = dataRange ? Math.floor(dataRange[0]) : 0;
const sliderMax = dataRange ? Math.ceil(dataRange[1]) : 120;
const sliderMin = 0;
const sliderMax = 120;
const displayRange = timeRange ?? [sliderMin, sliderMax];
const ModeIcon = MODE_ICONS[mode];
@ -142,7 +140,7 @@ export function TravelTimeCard({
)}
{/* Time range slider — only show when we have data */}
{slug && dataRange && (
{slug && (
<div>
<span className="text-[10px] font-medium text-warm-500 dark:text-warm-400 uppercase tracking-wide">
Max time