Codex changes
This commit is contained in:
parent
0bae902e08
commit
d4dde21ad2
46 changed files with 4953 additions and 966 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -604,6 +604,7 @@ export default memo(function Filters({
|
|||
<FeatureActions
|
||||
feature={feature}
|
||||
isPinned={isPinned}
|
||||
isPreviewing={isActive}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={setActiveInfoFeature}
|
||||
onRemove={onRemoveFilter}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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')}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue