Codex changes

This commit is contained in:
Andras Schmelczer 2026-05-04 16:19:09 +01:00
parent 0bae902e08
commit d4dde21ad2
46 changed files with 4953 additions and 966 deletions

View file

@ -9,16 +9,21 @@ import type {
} from '../../types';
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
import type { HexagonLocation } from '../../lib/external-search';
import { formatValue, formatFilterValue, calculateHistogramMean } from '../../lib/format';
import {
formatValue,
formatFilterValue,
calculateHistogramMean,
roundedPercentages,
} from '../../lib/format';
import { groupFeaturesByCategory } from '../../lib/features';
import { STACKED_GROUPS, STACKED_ENUM_GROUPS } from '../../lib/consts';
import { PARTY_FEATURE_COLORS, STACKED_GROUPS, STACKED_ENUM_GROUPS } from '../../lib/consts';
import { DualHistogram, LoadingSkeleton } from './DualHistogram';
import EnumBarChart from './EnumBarChart';
import StackedBarChart from './StackedBarChart';
import StackedEnumChart from './StackedEnumChart';
import PriceHistoryChart from './PriceHistoryChart';
import ExternalSearchLinks from './ExternalSearchLinks';
import { InfoIcon } from '../ui/icons';
import { FilterIcon, InfoIcon } from '../ui/icons';
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { EmptyState } from '../ui/EmptyState';
@ -35,14 +40,26 @@ interface AreaPaneProps {
isPostcode?: boolean;
postcodeData?: PostcodeFeature | null;
onViewProperties: () => void;
onClearFilters?: () => void;
hexagonLocation: HexagonLocation | null;
filters: FeatureFilters;
unfilteredCount?: number | null;
onNavigateToSource?: (slug: string, featureName: string) => void;
travelTimeEntries?: TravelTimeEntry[];
isGroupExpanded: (name: string) => boolean;
onToggleGroup: (name: string) => void;
}
function normalizePercentageSegments<T extends { value: number }>(segments: T[]): T[] {
const total = segments.reduce((sum, segment) => sum + segment.value, 0);
const normalizedValues = roundedPercentages(
segments.map((segment) => segment.value),
total,
1
);
return segments.map((segment, index) => ({ ...segment, value: normalizedValues[index] }));
}
export default function AreaPane({
stats,
globalFeatures,
@ -51,8 +68,10 @@ export default function AreaPane({
isPostcode = false,
postcodeData,
onViewProperties,
onClearFilters,
hexagonLocation,
filters,
unfilteredCount,
onNavigateToSource,
travelTimeEntries,
isGroupExpanded,
@ -60,6 +79,8 @@ export default function AreaPane({
}: AreaPaneProps) {
const { t } = useTranslation();
const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count;
const activeFilterCount = Object.keys(filters).length + (travelTimeEntries?.length ?? 0);
const hasFilteredOutArea = activeFilterCount > 0 && stats?.count === 0;
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
@ -119,8 +140,36 @@ export default function AreaPane({
? t('common.postcode').toLowerCase()
: t('common.area').toLowerCase(),
})}
{Object.keys(filters).length > 0 ? t('areaPane.matchingFilters') : ''}
</p>
<div className="mt-2 flex gap-2 rounded border border-teal-200 bg-teal-50 px-2.5 py-2 text-xs leading-snug text-teal-800 dark:border-teal-800/70 dark:bg-teal-950/40 dark:text-teal-200">
<FilterIcon className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<p>
{activeFilterCount > 0
? t('areaPane.filtersAffectStats', { count: activeFilterCount })
: t('areaPane.noFiltersAffectStats')}
</p>
</div>
{hasFilteredOutArea && (
<div className="mt-2 rounded border border-amber-200 bg-amber-50 px-2.5 py-2 text-xs leading-snug text-amber-900 dark:border-amber-800/70 dark:bg-amber-950/40 dark:text-amber-100">
<p className="font-semibold">{t('areaPane.noFilteredMatches')}</p>
<p className="mt-1">
{unfilteredCount != null && unfilteredCount > 0
? t('areaPane.unfilteredAreaCount', { count: unfilteredCount })
: unfilteredCount === 0
? t('areaPane.noUnfilteredAreaProperties')
: t('areaPane.relaxFiltersHint')}
</p>
{onClearFilters && (
<button
type="button"
onClick={onClearFilters}
className="mt-2 rounded bg-amber-600 px-2 py-1 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
>
{t('filters.clearAll')}
</button>
)}
</div>
)}
{stats && stats.count > 0 && (
<button
onClick={onViewProperties}
@ -149,7 +198,7 @@ export default function AreaPane({
) : stats ? (
<div>
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
<HistogramLegend />
{stats.count > 0 && <HistogramLegend />}
{stats.price_history &&
(() => {
const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year)));
@ -190,148 +239,170 @@ export default function AreaPane({
{expanded && (
<div className="px-3 py-2 space-y-3">
{stackedCharts?.map((chart) => {
const segments = chart.components
.map((name) => ({
name,
value: numericByName.get(name)?.mean ?? 0,
}))
.filter((s) => s.value > 0);
const segments = chart.components
.map((name) => ({
name,
value: numericByName.get(name)?.mean ?? 0,
}))
.filter((s) => s.value > 0);
const aggregateStats = chart.feature
? numericByName.get(chart.feature)
: undefined;
const total = aggregateStats
? aggregateStats.mean
: segments.reduce((sum, s) => sum + s.value, 0);
const isPercentageComposition = chart.unit === '%' && !chart.feature;
const displaySegments = isPercentageComposition
? normalizePercentageSegments(segments)
: segments;
// Use rateFeature (e.g. per-1k) for display if available
const rateStats = chart.rateFeature
? numericByName.get(chart.rateFeature)
: undefined;
const displayValue = rateStats ? rateStats.mean : total;
const aggregateStats = chart.feature
? numericByName.get(chart.feature)
: undefined;
const total = aggregateStats
? aggregateStats.mean
: displaySegments.reduce((sum, s) => sum + s.value, 0);
// Use rateFeature for info popup and national average when available
const infoFeatureName = chart.rateFeature ?? chart.feature;
const featureMeta = infoFeatureName
? globalFeatureByName.get(infoFeatureName)
: undefined;
// Use rateFeature (e.g. per-1k) for display if available
const rateStats = chart.rateFeature
? numericByName.get(chart.rateFeature)
: undefined;
const displayValue = isPercentageComposition
? 100
: rateStats
? rateStats.mean
: total;
const globalMean =
featureMeta?.histogram
? calculateHistogramMean(featureMeta.histogram)
: undefined;
// Use rateFeature for info popup and national average when available
const infoFeatureName = chart.rateFeature ?? chart.feature;
const featureMeta = infoFeatureName
? globalFeatureByName.get(infoFeatureName)
: undefined;
if (total === 0) return null;
const globalMean = featureMeta?.histogram
? calculateHistogramMean(featureMeta.histogram)
: undefined;
return (
<div
key={ts(chart.label)}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline mb-1.5">
{featureMeta ? (
<FeatureLabel
feature={{ ...featureMeta, name: ts(chart.label) }}
onShowInfo={setInfoFeature}
className="mr-2"
/>
) : (
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
{ts(chart.label)}
</span>
)}
<div className="text-right shrink-0">
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(displayValue)}
{chart.unit ? ` ${chart.unit}` : ''}
</span>
{globalMean != null && (
<div className="text-[10px] text-warm-400 dark:text-warm-500 whitespace-nowrap">
{t('areaPane.nationalAvg')}: {formatValue(globalMean)}
</div>
)}
if (total === 0) return null;
return (
<div
key={ts(chart.label)}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline mb-1.5">
{featureMeta ? (
<FeatureLabel
feature={{ ...featureMeta, name: ts(chart.label) }}
onShowInfo={setInfoFeature}
className="mr-2"
/>
) : (
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
{ts(chart.label)}
</span>
)}
<div className="text-right shrink-0">
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(displayValue)}
{chart.unit ? ` ${chart.unit}` : ''}
</span>
{globalMean != null && (
<div className="text-[10px] text-warm-400 dark:text-warm-500 whitespace-nowrap">
{t('areaPane.nationalAvg')}: {formatValue(globalMean)}
</div>
</div>
<StackedBarChart segments={segments} total={total} />
)}
</div>
);
})}
</div>
<StackedBarChart
segments={displaySegments}
total={total}
colorMap={
chart.label === 'Political vote share'
? PARTY_FEATURE_COLORS
: undefined
}
/>
</div>
);
})}
{(() => {
const stackedFeatureNames = new Set<string>(
stackedCharts?.flatMap((c) =>
[c.feature, c.rateFeature, ...c.components].filter((s): s is string => Boolean(s))
[c.feature, c.rateFeature, ...c.components].filter((s): s is string =>
Boolean(s)
)
) ?? []
);
return group.features
.filter((f) => !stackedFeatureNames.has(f.name) && !stackedEnumFeatureNames.has(f.name))
.map((feature) => {
const numericStats = numericByName.get(feature.name);
const enumStats = enumByName.get(feature.name);
.filter(
(f) =>
!stackedFeatureNames.has(f.name) &&
!stackedEnumFeatureNames.has(f.name)
)
.map((feature) => {
const numericStats = numericByName.get(feature.name);
const enumStats = enumByName.get(feature.name);
if (numericStats) {
const globalFeature = globalFeatureByName.get(feature.name);
const globalHistogram = globalFeature?.histogram;
const globalMean = globalHistogram
? calculateHistogramMean(globalHistogram)
: undefined;
if (numericStats) {
const globalFeature = globalFeatureByName.get(feature.name);
const globalHistogram = globalFeature?.histogram;
const globalMean = globalHistogram
? calculateHistogramMean(globalHistogram)
: undefined;
return (
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline">
<FeatureLabel
feature={feature}
onShowInfo={setInfoFeature}
className="mr-2"
/>
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(numericStats.mean, feature)}
</span>
</div>
{numericStats.histogram &&
(globalHistogram ? (
<DualHistogram
localCounts={numericStats.histogram.counts}
globalCounts={globalHistogram.counts}
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
globalMean={globalMean}
formatLabel={(v) => formatFilterValue(v, feature.raw)}
/>
) : (
<DualHistogram
localCounts={numericStats.histogram.counts}
globalCounts={numericStats.histogram.counts}
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
formatLabel={(v) => formatFilterValue(v, feature.raw)}
/>
))}
</div>
);
}
if (enumStats) {
const globalFeature = globalFeatureByName.get(feature.name);
return (
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
<EnumBarChart
counts={enumStats.counts}
globalCounts={globalFeature?.counts}
featureName={feature.name}
return (
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline">
<FeatureLabel
feature={feature}
onShowInfo={setInfoFeature}
className="mr-2"
/>
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(numericStats.mean, feature)}
</span>
</div>
);
}
{numericStats.histogram &&
(globalHistogram ? (
<DualHistogram
localCounts={numericStats.histogram.counts}
globalCounts={globalHistogram.counts}
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
globalMean={globalMean}
formatLabel={(v) => formatFilterValue(v, feature.raw)}
/>
) : (
<DualHistogram
localCounts={numericStats.histogram.counts}
globalCounts={numericStats.histogram.counts}
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
formatLabel={(v) => formatFilterValue(v, feature.raw)}
/>
))}
</div>
);
}
return null;
});
if (enumStats) {
const globalFeature = globalFeatureByName.get(feature.name);
return (
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
<EnumBarChart
counts={enumStats.counts}
globalCounts={globalFeature?.counts}
featureName={feature.name}
/>
</div>
);
}
return null;
});
})()}
{stackedEnumCharts?.map((chart) => {
const featureMeta = chart.feature

View file

@ -604,6 +604,7 @@ export default memo(function Filters({
<FeatureActions
feature={feature}
isPinned={isPinned}
isPreviewing={isActive}
onTogglePin={onTogglePin}
onShowInfo={setActiveInfoFeature}
onRemove={onRemoveFilter}

View file

@ -34,10 +34,12 @@ const ZOOM_FOR_TYPE: Record<string, number> = {
export default function LocationSearch({
onFlyTo,
onLocationSearched,
onCurrentLocationFound,
onMouseEnter,
}: {
onFlyTo: (lat: number, lng: number, zoom: number) => void;
onLocationSearched?: (postcode: SearchedLocation | null) => void;
onCurrentLocationFound?: (lat: number, lng: number) => void;
onMouseEnter?: () => void;
}) {
const { t } = useTranslation();
@ -131,27 +133,8 @@ export default function LocationSearch({
});
});
const { latitude, longitude } = position.coords;
const res = await fetch(
`/api/nearest-postcode?lat=${latitude}&lng=${longitude}`,
authHeaders()
);
if (!res.ok) {
setError(t('locationSearch.lookupFailed'));
return;
}
const json: {
postcode: string;
latitude: number;
longitude: number;
geometry: PostcodeGeometry;
} = await res.json();
onFlyTo(json.latitude, json.longitude, 16);
onLocationSearched?.({
postcode: json.postcode,
geometry: json.geometry,
latitude: json.latitude,
longitude: json.longitude,
});
onFlyTo(latitude, longitude, 17);
onCurrentLocationFound?.(latitude, longitude);
search.clear();
if (isMobile) setExpanded(false);
} catch {
@ -159,7 +142,7 @@ export default function LocationSearch({
} finally {
setLocating(false);
}
}, [onFlyTo, onLocationSearched, isMobile, search, t]);
}, [onFlyTo, onCurrentLocationFound, isMobile, search, t]);
// Mobile collapsed state: search icon + locate button
if (isMobile && !expanded) {

View file

@ -56,6 +56,8 @@ interface MapProps {
filters?: FeatureFilters;
selectedPostcodeGeometry?: PostcodeGeometry | null;
onLocationSearched?: (location: SearchedLocation | null) => void;
onCurrentLocationFound?: (lat: number, lng: number) => void;
currentLocation?: { lat: number; lng: number } | null;
bounds?: Bounds | null;
hideLegend?: boolean;
travelTimeEntries?: TravelTimeEntry[];
@ -114,6 +116,8 @@ export default memo(function Map({
filters = {},
selectedPostcodeGeometry,
onLocationSearched,
onCurrentLocationFound,
currentLocation,
bounds: viewportBounds,
hideLegend = false,
travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
@ -225,6 +229,7 @@ export default memo(function Map({
onHexagonHover,
theme,
selectedPostcodeGeometry,
currentLocation,
bounds: viewportBounds,
travelTimeEntries,
});
@ -307,6 +312,7 @@ export default memo(function Map({
<LocationSearch
onFlyTo={handleFlyTo}
onLocationSearched={onLocationSearched}
onCurrentLocationFound={onCurrentLocationFound}
onMouseEnter={handleMouseLeave}
/>
{!hideLegend &&

View file

@ -2,10 +2,10 @@ import { useTranslation } from 'react-i18next';
import { formatValue } from '../../lib/format';
import { ts } from '../../i18n/server';
import {
FEATURE_GRADIENT,
DENSITY_GRADIENT,
DENSITY_GRADIENT_DARK,
getEnumPaletteForFeature,
getFeatureGradient,
} from '../../lib/consts';
import { gradientToCss } from '../../lib/utils';
import { CloseIcon } from '../ui/icons/CloseIcon';
@ -95,7 +95,9 @@ export default function MapLegend({
const enumPalette = getEnumPaletteForFeature(featureName ?? null, enumValues);
const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
const gradientStyle =
mode === 'density' ? gradientToCss(densityGradient) : gradientToCss(FEATURE_GRADIENT);
mode === 'density'
? gradientToCss(densityGradient)
: gradientToCss(getFeatureGradient(featureName));
const fmt = raw ? { raw: true } : undefined;

View file

@ -1,5 +1,6 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { cellToLatLng } from 'h3-js';
import type {
FeatureMeta,
FeatureFilters,
@ -17,7 +18,7 @@ import { PropertiesPane } from './PropertiesPane';
import AreaPane from './AreaPane';
import MobileDrawer from './MobileDrawer';
import MapLegend from './MapLegend';
import { TabButton } from '../ui/TabButton';
import { MapPageSelectionPane } from './MapPageSelectionPane';
import { useMapData } from '../../hooks/useMapData';
import { usePOIData } from '../../hooks/usePOIData';
import { useFilters } from '../../hooks/useFilters';
@ -42,7 +43,6 @@ import { trackEvent } from '../../lib/analytics';
import { INITIAL_VIEW_STATE } from '../../lib/consts';
import { useLicense } from '../../hooks/useLicense';
import UpgradeModal from '../ui/UpgradeModal';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { MapPinIcon } from '../ui/icons/MapPinIcon';
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
@ -125,6 +125,7 @@ export default function MapPage({
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null);
const [showBookmarkToast, setShowBookmarkToast] = useState(false);
const bookmarkToastDismissed = useRef(localStorage.getItem('bookmark_toast_dismissed') === '1');
@ -249,7 +250,14 @@ export default function MapPage({
}
}
},
[fetchAiFilters, handleSetFilters, handleSetEntries, activeEntries, filters, mapData.currentView?.zoom]
[
fetchAiFilters,
handleSetFilters,
handleSetEntries,
activeEntries,
filters,
mapData.currentView?.zoom,
]
);
const handleClearAll = useCallback(() => {
@ -304,6 +312,7 @@ export default function MapPage({
loadingProperties,
areaStats,
loadingAreaStats,
unfilteredAreaCount,
hoveredHexagon,
rightPaneTab,
setRightPaneTab,
@ -315,25 +324,38 @@ export default function MapPage({
handleCloseSelection,
selectedPostcodeGeometry,
handleLocationSearch,
handleCurrentLocationSearch,
} = useHexagonSelection({
filters,
features,
resolution: mapData.resolution,
usePostcodeView: mapData.usePostcodeView,
journeyDest,
});
const handleLocationSearchResult = useCallback(
(result: SearchedLocation | null) => {
if (result) {
setCurrentLocation(null);
handleLocationSearch(result.postcode, result.geometry, result.latitude, result.longitude);
if (isMobile) setMobileDrawerOpen(true);
} else {
setCurrentLocation(null);
handleCloseSelection();
}
},
[handleLocationSearch, handleCloseSelection, isMobile]
);
const handleCurrentLocationFound = useCallback(
(lat: number, lng: number) => {
setCurrentLocation({ lat, lng });
handleCurrentLocationSearch(lat, lng);
if (isMobile) setMobileDrawerOpen(true);
},
[handleCurrentLocationSearch, isMobile]
);
const handleZoomToFreeZone = useCallback(() => {
mapFlyToRef.current?.(
INITIAL_VIEW_STATE.latitude,
@ -428,20 +450,19 @@ export default function MapPage({
const [lon, lat] = postcodeFeature.properties.centroid;
return { lat, lon, resolution: mapData.resolution, postcode: hexId, isPostcode: true };
} else {
// For hexagons, get lat/lon from hexagon data; central postcode comes from stats
const hex = hexId ? mapData.data.find((d) => d.h3 === hexId) : null;
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null;
if (!hexId) return null;
const [lat, lon] = cellToLatLng(hexId);
return {
lat: hex.lat as number,
lon: hex.lon as number,
resolution: mapData.resolution,
lat,
lon,
resolution: selectedHexagon?.resolution ?? mapData.resolution,
postcode: areaStats?.central_postcode,
};
}
}, [
selectedHexagon?.id,
selectedHexagon?.resolution,
selectedHexagon?.type,
mapData.data,
mapData.postcodeData,
mapData.resolution,
areaStats?.central_postcode,
@ -487,6 +508,7 @@ export default function MapPage({
}, [mapData.licenseRequired]);
const densityLabel = t('mapLegend.historicalMatches');
const hasActiveFilters = Object.keys(filters).length > 0 || activeEntries.length > 0;
const mobileLegendMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
@ -607,8 +629,10 @@ export default function MapPage({
: null
}
onViewProperties={handleViewPropertiesFromArea}
onClearFilters={hasActiveFilters ? handleClearAll : undefined}
hexagonLocation={hexagonLocation}
filters={filters}
unfilteredCount={unfilteredAreaCount}
travelTimeEntries={activeEntries}
isGroupExpanded={isAreaGroupExpanded}
onToggleGroup={toggleAreaGroup}
@ -695,10 +719,7 @@ export default function MapPage({
</div>
)}
<div
ref={mobileMapRef}
className="relative overflow-hidden"
>
<div ref={mobileMapRef} className="relative overflow-hidden">
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
@ -721,6 +742,8 @@ export default function MapPage({
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={handleLocationSearchResult}
onCurrentLocationFound={handleCurrentLocationFound}
currentLocation={currentLocation}
bounds={mapData.bounds}
hideLegend
travelTimeEntries={entries}
@ -907,10 +930,12 @@ export default function MapPage({
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={handleLocationSearchResult}
onCurrentLocationFound={handleCurrentLocationFound}
currentLocation={currentLocation}
bounds={mapData.bounds}
travelTimeEntries={entries}
densityLabel={densityLabel}
totalCount={filterCounts.total || undefined}
totalCount={hasActiveFilters ? filterCounts.total : undefined}
/>
{mapData.loading && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
@ -940,47 +965,16 @@ export default function MapPage({
</div>
{selectedHexagon && (
<div
data-tutorial="right-pane"
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
style={{ width: rightPaneWidth }}
>
<div
className="w-3 cursor-col-resize flex items-center justify-center group bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
{...rightPaneHandlers}
>
<div className="flex flex-col gap-1.5">
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
</div>
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
<TabButton
label="Area"
isActive={rightPaneTab === 'area'}
onClick={() => setRightPaneTab('area')}
/>
<TabButton
label="Properties"
isActive={rightPaneTab === 'properties'}
onClick={handlePropertiesTabClick}
/>
<button
onClick={handleCloseSelection}
className="px-2 flex items-center text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
title="Close pane"
>
<CloseIcon className="w-4 h-4" />
</button>
</div>
<div className="flex-1 overflow-hidden">
{rightPaneTab === 'properties' ? renderPropertiesPane() : renderAreaPane()}
</div>
</div>
</div>
<MapPageSelectionPane
width={rightPaneWidth}
resizeHandlers={rightPaneHandlers}
tab={rightPaneTab}
onAreaTabClick={() => setRightPaneTab('area')}
onPropertiesTabClick={handlePropertiesTabClick}
onClose={handleCloseSelection}
renderAreaPane={renderAreaPane}
renderPropertiesPane={renderPropertiesPane}
/>
)}
{bookmarkToast}

View file

@ -90,10 +90,10 @@ export function TravelTimeCard({
{slug && (
<IconButton
onClick={onTogglePin}
active={isPinned}
active={isPinned || isActive}
title={isPinned ? t('travel.stopPreviewing') : t('travel.previewOnMap')}
>
<EyeIcon className="w-3.5 h-3.5" filled={isPinned} />
<EyeIcon className="w-3.5 h-3.5" filled={isPinned || isActive} />
</IconButton>
)}
<IconButton onClick={() => onRemove()} title={t('travel.removeTravelTime')}>

View file

@ -5,6 +5,7 @@ import { IconButton } from './IconButton';
interface FeatureActionsProps {
feature: FeatureMeta;
isPinned: boolean;
isPreviewing?: boolean;
onTogglePin: (name: string) => void;
onShowInfo?: (feature: FeatureMeta) => void;
onRemove?: (name: string) => void;
@ -14,11 +15,14 @@ interface FeatureActionsProps {
export function FeatureActions({
feature,
isPinned,
isPreviewing = false,
onTogglePin,
onShowInfo,
onRemove,
onAdd,
}: FeatureActionsProps) {
const isEyeActive = isPinned || isPreviewing;
return (
<div className="flex items-center gap-0.5 shrink-0">
{feature.detail && onShowInfo && (
@ -29,10 +33,10 @@ export function FeatureActions({
<IconButton
onClick={() => onTogglePin(feature.name)}
title={isPinned ? 'Unpin colour view' : 'Colour map by this feature'}
active={isPinned}
active={isEyeActive}
size="md"
>
<EyeIcon filled={isPinned} className="w-5 h-5 md:w-3.5 md:h-3.5" />
<EyeIcon filled={isEyeActive} className="w-5 h-5 md:w-3.5 md:h-3.5" />
</IconButton>
{onAdd && (
<button