Fun changes
This commit is contained in:
parent
cd778dd088
commit
349a6c1d53
60 changed files with 1260 additions and 2600 deletions
|
|
@ -189,8 +189,7 @@ export default function AreaPane({
|
|||
/>
|
||||
{expanded && (
|
||||
<div className="px-3 py-2 space-y-3">
|
||||
{stackedCharts
|
||||
? stackedCharts.map((chart) => {
|
||||
{stackedCharts?.map((chart) => {
|
||||
const segments = chart.components
|
||||
.map((name) => ({
|
||||
name,
|
||||
|
|
@ -205,9 +204,22 @@ export default function AreaPane({
|
|||
? aggregateStats.mean
|
||||
: segments.reduce((sum, s) => sum + s.value, 0);
|
||||
|
||||
const featureMeta = chart.feature
|
||||
? globalFeatureByName.get(chart.feature)
|
||||
// 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;
|
||||
|
||||
// Use rateFeature for info popup and national average when available
|
||||
const infoFeatureName = chart.rateFeature ?? chart.feature;
|
||||
const featureMeta = infoFeatureName
|
||||
? globalFeatureByName.get(infoFeatureName)
|
||||
: undefined;
|
||||
|
||||
const globalMean =
|
||||
featureMeta?.histogram
|
||||
? calculateHistogramMean(featureMeta.histogram)
|
||||
: undefined;
|
||||
|
||||
if (total === 0) return null;
|
||||
|
||||
|
|
@ -228,17 +240,30 @@ export default function AreaPane({
|
|||
{ts(chart.label)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(total)}
|
||||
{chart.unit ? ` ${chart.unit}` : ''}
|
||||
</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>
|
||||
</div>
|
||||
<StackedBarChart segments={segments} total={total} />
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: group.features
|
||||
.filter((f) => !stackedEnumFeatureNames.has(f.name))
|
||||
})}
|
||||
{(() => {
|
||||
const stackedFeatureNames = new Set<string>(
|
||||
stackedCharts?.flatMap((c) =>
|
||||
[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);
|
||||
|
|
@ -289,19 +314,25 @@ export default function AreaPane({
|
|||
}
|
||||
|
||||
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} />
|
||||
<EnumBarChart
|
||||
counts={enumStats.counts}
|
||||
globalCounts={globalFeature?.counts}
|
||||
featureName={feature.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
});
|
||||
})()}
|
||||
{stackedEnumCharts?.map((chart) => {
|
||||
const featureMeta = chart.feature
|
||||
? globalFeatureByName.get(chart.feature)
|
||||
|
|
|
|||
|
|
@ -1,23 +1,77 @@
|
|||
export default function EnumBarChart({ counts }: { counts: Record<string, number> }) {
|
||||
import { getEnumValueColor } from '../../lib/consts';
|
||||
|
||||
export default function EnumBarChart({
|
||||
counts,
|
||||
globalCounts,
|
||||
featureName,
|
||||
}: {
|
||||
counts: Record<string, number>;
|
||||
globalCounts?: Record<string, number>;
|
||||
featureName?: string;
|
||||
}) {
|
||||
const entries = Object.entries(counts).sort(([, countA], [, countB]) => countB - countA);
|
||||
const localTotal = entries.reduce((sum, [, c]) => sum + c, 0);
|
||||
|
||||
// When global counts are available, normalize both to percentages for comparison
|
||||
const globalTotal = globalCounts
|
||||
? Object.values(globalCounts).reduce((sum, c) => sum + c, 0)
|
||||
: 0;
|
||||
|
||||
const hasGlobal = globalCounts && globalTotal > 0;
|
||||
|
||||
// Compute max percentage across both datasets for consistent bar scaling
|
||||
const maxPct = entries.reduce((max, [label, count]) => {
|
||||
const localPct = localTotal > 0 ? count / localTotal : 0;
|
||||
const globalPct = hasGlobal ? (globalCounts[label] ?? 0) / globalTotal : 0;
|
||||
return Math.max(max, localPct, globalPct);
|
||||
}, 0);
|
||||
|
||||
// Fallback to raw count scaling when no global data
|
||||
const maxCount = Math.max(...entries.map(([, count]) => count), 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-1 mt-1">
|
||||
{entries.map(([label, count]) => (
|
||||
<div key={label} className="flex items-center gap-2 text-xs">
|
||||
<span className="w-16 truncate text-warm-500 dark:text-warm-400 text-right shrink-0">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex-1 h-3 bg-warm-100 dark:bg-navy-700 rounded overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-teal-500 dark:bg-teal-400 rounded"
|
||||
style={{ width: `${(count / maxCount) * 100}%` }}
|
||||
/>
|
||||
{entries.map(([label, count]) => {
|
||||
const localPct = localTotal > 0 ? count / localTotal : 0;
|
||||
const globalPct = hasGlobal ? (globalCounts[label] ?? 0) / globalTotal : 0;
|
||||
|
||||
const localWidth = hasGlobal
|
||||
? maxPct > 0
|
||||
? (localPct / maxPct) * 100
|
||||
: 0
|
||||
: (count / maxCount) * 100;
|
||||
const globalWidth = hasGlobal && maxPct > 0 ? (globalPct / maxPct) * 100 : 0;
|
||||
|
||||
const overrideColor = featureName ? getEnumValueColor(featureName, label) : null;
|
||||
const barStyle = overrideColor
|
||||
? `rgb(${overrideColor[0]},${overrideColor[1]},${overrideColor[2]})`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div key={label} className="flex items-center gap-2 text-xs">
|
||||
<span className="w-16 truncate text-warm-500 dark:text-warm-400 text-right shrink-0">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex-1 h-3 bg-warm-100 dark:bg-navy-700 rounded overflow-hidden relative">
|
||||
{hasGlobal && (
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-warm-300/60 dark:bg-warm-600/60 rounded"
|
||||
style={{ width: `${globalWidth}%` }}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={
|
||||
barStyle ? 'h-full rounded relative' : 'h-full bg-teal-500 dark:bg-teal-400 rounded relative'
|
||||
}
|
||||
style={{ width: `${localWidth}%`, ...(barStyle ? { backgroundColor: barStyle } : {}) }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 text-warm-500 dark:text-warm-400 text-right shrink-0">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
<span className="w-8 text-warm-500 dark:text-warm-400 text-right shrink-0">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ export default function ExternalSearchLinks({
|
|||
() => buildPropertySearchUrls({ location, filters, rightmoveLocationId }),
|
||||
[location, filters, rightmoveLocationId]
|
||||
);
|
||||
const radiusMiles = location.isPostcode ? 0.25 : (H3_RADIUS_MILES[location.resolution] ?? 1);
|
||||
const label = `${radiusMiles}mi radius`;
|
||||
const radiusMiles = location.isPostcode ? 0 : (H3_RADIUS_MILES[location.resolution] ?? 1);
|
||||
const label = radiusMiles === 0 ? t('externalSearch.exact') : `${radiusMiles}mi radius`;
|
||||
|
||||
if (!urls) return null;
|
||||
|
||||
|
|
@ -61,11 +61,6 @@ export default function ExternalSearchLinks({
|
|||
<a href={urls.zoopla} target="_blank" rel="noopener noreferrer" className={linkClass}>
|
||||
Zoopla
|
||||
</a>
|
||||
{urls.openrent && (
|
||||
<a href={urls.openrent} target="_blank" rel="noopener noreferrer" className={linkClass}>
|
||||
OpenRent
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -30,8 +30,6 @@ import {
|
|||
travelFieldKey,
|
||||
} from '../../hooks/useTravelTime';
|
||||
|
||||
type ListingType = 'historical' | 'buy' | 'rent';
|
||||
|
||||
function EditableLabel({
|
||||
value,
|
||||
formatted,
|
||||
|
|
@ -210,7 +208,6 @@ interface FiltersProps {
|
|||
isLoggedIn: boolean;
|
||||
onLoginRequired: () => void;
|
||||
isLicensed: boolean;
|
||||
isAdmin: boolean;
|
||||
onUpgradeClick?: () => void;
|
||||
onResetTutorial?: () => void;
|
||||
filterImpacts?: Record<string, number>;
|
||||
|
|
@ -252,7 +249,6 @@ export default memo(function Filters({
|
|||
isLoggedIn,
|
||||
onLoginRequired,
|
||||
isLicensed,
|
||||
isAdmin,
|
||||
onUpgradeClick,
|
||||
onResetTutorial,
|
||||
filterImpacts,
|
||||
|
|
@ -261,119 +257,14 @@ export default memo(function Filters({
|
|||
savingSearch,
|
||||
}: FiltersProps) {
|
||||
const { t } = useTranslation();
|
||||
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';
|
||||
if (val.includes('For sale')) return 'buy';
|
||||
if (val.includes('For rent')) return 'rent';
|
||||
return 'historical';
|
||||
}, [filters]);
|
||||
|
||||
const availableFeatures = useMemo(
|
||||
() =>
|
||||
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'),
|
||||
() => features.filter((f) => !enabledFeatures.has(f.name)),
|
||||
[features, enabledFeatures]
|
||||
);
|
||||
|
||||
const parkedFiltersRef = useRef<FeatureFilters>({});
|
||||
|
||||
const handleListingSelect = useCallback(
|
||||
(type: ListingType) => {
|
||||
// Track what will be active after swaps (to avoid conflicts with restoration)
|
||||
const activeAfterSwaps = new Set<string>();
|
||||
|
||||
for (const name of Object.keys(filters)) {
|
||||
if (name === 'Listing status') continue;
|
||||
if (isAllowed(name, type)) {
|
||||
activeAfterSwaps.add(name);
|
||||
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);
|
||||
activeAfterSwaps.add(counterpart);
|
||||
swapped = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!swapped) {
|
||||
parkedFiltersRef.current[name] = filters[name];
|
||||
onRemoveFilter(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore parked filters that are now allowed in the new mode
|
||||
const restored: string[] = [];
|
||||
for (const [name, value] of Object.entries(parkedFiltersRef.current)) {
|
||||
if (isAllowed(name, type) && !activeAfterSwaps.has(name)) {
|
||||
onFilterChange(name, value);
|
||||
activeAfterSwaps.add(name);
|
||||
restored.push(name);
|
||||
} else if (!isAllowed(name, type)) {
|
||||
// Try restoring as linked counterpart
|
||||
for (const [a, b] of linkedFeatures) {
|
||||
const counterpart = name === a ? b : name === b ? a : null;
|
||||
if (counterpart && isAllowed(counterpart, type) && !activeAfterSwaps.has(counterpart)) {
|
||||
onFilterChange(counterpart, value);
|
||||
activeAfterSwaps.add(counterpart);
|
||||
restored.push(name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const name of restored) {
|
||||
delete parkedFiltersRef.current[name];
|
||||
}
|
||||
|
||||
const valueMap: Record<string, string> = {
|
||||
historical: 'Historical sale',
|
||||
buy: 'For sale',
|
||||
rent: 'For rent',
|
||||
};
|
||||
onFilterChange('Listing status', [valueMap[type]]);
|
||||
},
|
||||
[filters, onFilterChange, onRemoveFilter, isAllowed, linkedFeatures]
|
||||
const enabledFeatureList = useMemo(
|
||||
() => features.filter((f) => enabledFeatures.has(f.name)),
|
||||
[features, enabledFeatures]
|
||||
);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -548,31 +439,6 @@ export default memo(function Filters({
|
|||
onLoginRequired={onLoginRequired}
|
||||
/>
|
||||
<div className="px-3 pb-2 space-y-2">
|
||||
{isAdmin && (
|
||||
<div className="flex rounded-lg bg-warm-100 dark:bg-warm-800 p-0.5">
|
||||
{(['historical', 'buy', 'rent'] as const).map((type) => {
|
||||
const labels = {
|
||||
historical: t('filters.historical'),
|
||||
buy: t('filters.buy'),
|
||||
rent: t('filters.rent'),
|
||||
};
|
||||
const isActive = activeListingType === type;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => handleListingSelect(type)}
|
||||
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md cursor-pointer ${
|
||||
isActive
|
||||
? 'bg-white dark:bg-warm-700 text-teal-600 dark:text-teal-400 ring-2 ring-teal-400 shadow-sm'
|
||||
: 'text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
|
||||
}`}
|
||||
>
|
||||
{labels[type]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowPhilosophy(true)}
|
||||
className="w-full px-3 py-1.5 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:bg-warm-50 dark:hover:bg-warm-700 text-teal-600 dark:text-teal-400 font-medium text-sm flex items-center justify-center gap-2"
|
||||
|
|
|
|||
|
|
@ -39,8 +39,8 @@ export default memo(function HoverCard({
|
|||
|
||||
const results: { name: string; value: string }[] = [];
|
||||
|
||||
// Show stats for active filters (up to 4), excluding Listing status
|
||||
for (const name of activeFilterNames.filter((n) => n !== 'Listing status').slice(0, 4)) {
|
||||
// Show stats for active filters (up to 4)
|
||||
for (const name of activeFilterNames.slice(0, 4)) {
|
||||
const val = data[`avg_${name}`] ?? data[`min_${name}`];
|
||||
if (val == null || typeof val !== 'number') continue;
|
||||
const meta = featureMap.get(name);
|
||||
|
|
|
|||
|
|
@ -174,7 +174,22 @@ export default memo(function Map({
|
|||
}, [viewState, dimensions, onViewChange]);
|
||||
|
||||
const handleMove = useCallback((evt: { viewState: ViewState }) => {
|
||||
setInternalViewState(evt.viewState);
|
||||
setInternalViewState((prev) => {
|
||||
const next = evt.viewState;
|
||||
// Skip re-render when viewport values haven't changed (e.g. container resize
|
||||
// fires move events with identical lat/lng/zoom). Returning the same reference
|
||||
// tells React to bail out.
|
||||
if (
|
||||
prev.latitude === next.latitude &&
|
||||
prev.longitude === next.longitude &&
|
||||
prev.zoom === next.zoom &&
|
||||
prev.pitch === next.pitch &&
|
||||
prev.bearing === next.bearing
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => {
|
||||
|
|
@ -324,6 +339,7 @@ export default memo(function Map({
|
|||
enumValues={
|
||||
colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined
|
||||
}
|
||||
featureName={colorFeatureMeta.name}
|
||||
theme={theme}
|
||||
raw={colorFeatureMeta.raw}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -5,17 +5,23 @@ import {
|
|||
FEATURE_GRADIENT,
|
||||
DENSITY_GRADIENT,
|
||||
DENSITY_GRADIENT_DARK,
|
||||
ENUM_PALETTE,
|
||||
getEnumPaletteForFeature,
|
||||
} from '../../lib/consts';
|
||||
import { gradientToCss } from '../../lib/utils';
|
||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import { TickerValue } from '../ui/TickerValue';
|
||||
|
||||
function EnumSwatches({ values }: { values: string[] }) {
|
||||
function EnumSwatches({
|
||||
values,
|
||||
palette,
|
||||
}: {
|
||||
values: string[];
|
||||
palette: [number, number, number][];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{values.map((label, i) => {
|
||||
const color = ENUM_PALETTE[i % ENUM_PALETTE.length];
|
||||
const color = palette[i % palette.length];
|
||||
return (
|
||||
<div key={label} className="flex items-center gap-1.5">
|
||||
<div
|
||||
|
|
@ -30,11 +36,17 @@ function EnumSwatches({ values }: { values: string[] }) {
|
|||
);
|
||||
}
|
||||
|
||||
function InlineEnumSwatches({ values }: { values: string[] }) {
|
||||
function InlineEnumSwatches({
|
||||
values,
|
||||
palette,
|
||||
}: {
|
||||
values: string[];
|
||||
palette: [number, number, number][];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-1 min-w-[40%] flex-wrap">
|
||||
{values.map((label, i) => {
|
||||
const color = ENUM_PALETTE[i % ENUM_PALETTE.length];
|
||||
const color = palette[i % palette.length];
|
||||
return (
|
||||
<div key={label} className="flex items-center gap-1">
|
||||
<div
|
||||
|
|
@ -58,6 +70,7 @@ export default function MapLegend({
|
|||
onCancel,
|
||||
mode,
|
||||
enumValues,
|
||||
featureName,
|
||||
theme = 'light',
|
||||
inline = false,
|
||||
suffix,
|
||||
|
|
@ -70,6 +83,7 @@ export default function MapLegend({
|
|||
onCancel: () => void;
|
||||
mode: 'feature' | 'density';
|
||||
enumValues?: string[];
|
||||
featureName?: string;
|
||||
theme?: 'light' | 'dark';
|
||||
inline?: boolean;
|
||||
suffix?: string;
|
||||
|
|
@ -78,6 +92,7 @@ export default function MapLegend({
|
|||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isEnum = enumValues && enumValues.length > 0;
|
||||
const enumPalette = getEnumPaletteForFeature(featureName ?? null, enumValues);
|
||||
const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
|
||||
const gradientStyle =
|
||||
mode === 'density' ? gradientToCss(densityGradient) : gradientToCss(FEATURE_GRADIENT);
|
||||
|
|
@ -114,7 +129,7 @@ export default function MapLegend({
|
|||
</button>
|
||||
)}
|
||||
{isEnum ? (
|
||||
<InlineEnumSwatches values={enumValues} />
|
||||
<InlineEnumSwatches values={enumValues} palette={enumPalette} />
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-[40%] text-warm-500 dark:text-warm-400">
|
||||
{rangeMin}
|
||||
|
|
@ -144,7 +159,7 @@ export default function MapLegend({
|
|||
)}
|
||||
</div>
|
||||
{isEnum ? (
|
||||
<EnumSwatches values={enumValues} />
|
||||
<EnumSwatches values={enumValues} palette={enumPalette} />
|
||||
) : (
|
||||
<>
|
||||
<div className="h-3 rounded" style={{ background: gradientStyle }} />
|
||||
|
|
|
|||
|
|
@ -187,6 +187,16 @@ export default function MapPage({
|
|||
handleToggleBest,
|
||||
} = useTravelTime(initialTravelTime);
|
||||
|
||||
const mapFlyToRef = useRef<((lat: number, lng: number, zoom: number) => void) | null>(null);
|
||||
|
||||
const mapData = useMapData({
|
||||
filters,
|
||||
features,
|
||||
viewFeature,
|
||||
activeFeature,
|
||||
travelTimeEntries: entries,
|
||||
});
|
||||
|
||||
const handleAiFilterSubmit = useCallback(
|
||||
async (query: string) => {
|
||||
// Build context from current filters for conversational refinement
|
||||
|
|
@ -213,8 +223,33 @@ export default function MapPage({
|
|||
useBest: false,
|
||||
}));
|
||||
handleSetEntries(newEntries);
|
||||
|
||||
// Pan to the first travel time destination (mirroring handleTravelTimeSetDestination)
|
||||
const firstTT = result.travelTimeFilters[0];
|
||||
if (firstTT?.slug) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
apiUrl('travel-destinations', new URLSearchParams({ mode: firstTT.mode })),
|
||||
authHeaders({})
|
||||
);
|
||||
if (res.ok) {
|
||||
const data: { destinations: { slug: string; lat: number; lon: number }[] } =
|
||||
await res.json();
|
||||
const dest = data.destinations.find((d) => d.slug === firstTT.slug);
|
||||
if (dest) {
|
||||
mapFlyToRef.current?.(
|
||||
dest.lat,
|
||||
dest.lon,
|
||||
mapData.currentView?.zoom ?? INITIAL_VIEW_STATE.zoom
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — filters are already applied, just skip the pan
|
||||
}
|
||||
}
|
||||
},
|
||||
[fetchAiFilters, handleSetFilters, handleSetEntries, activeEntries, filters]
|
||||
[fetchAiFilters, handleSetFilters, handleSetEntries, activeEntries, filters, mapData.currentView?.zoom]
|
||||
);
|
||||
|
||||
const handleClearAll = useCallback(() => {
|
||||
|
|
@ -244,16 +279,6 @@ export default function MapPage({
|
|||
|
||||
const license = useLicense();
|
||||
|
||||
const mapFlyToRef = useRef<((lat: number, lng: number, zoom: number) => void) | null>(null);
|
||||
|
||||
const mapData = useMapData({
|
||||
filters,
|
||||
features,
|
||||
viewFeature,
|
||||
activeFeature,
|
||||
travelTimeEntries: entries,
|
||||
});
|
||||
|
||||
const filterCounts = useFilterCounts(filters, features, mapData.bounds, entries);
|
||||
|
||||
const handleTravelTimeSetDestination = useCallback(
|
||||
|
|
@ -461,12 +486,7 @@ export default function MapPage({
|
|||
if (mapData.licenseRequired) trackEvent('Upgrade Modal Shown');
|
||||
}, [mapData.licenseRequired]);
|
||||
|
||||
const densityLabel = useMemo(() => {
|
||||
const listingVal = filters['Listing status'] as string[] | undefined;
|
||||
if (listingVal?.includes('For sale')) return t('mapLegend.propertiesForSale');
|
||||
if (listingVal?.includes('For rent')) return t('mapLegend.propertiesForRent');
|
||||
return t('mapLegend.historicalMatches');
|
||||
}, [filters, t]);
|
||||
const densityLabel = t('mapLegend.historicalMatches');
|
||||
|
||||
const mobileLegendMeta = useMemo(
|
||||
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
|
||||
|
|
@ -652,7 +672,6 @@ export default function MapPage({
|
|||
isLoggedIn={!!user}
|
||||
onLoginRequired={onRegisterClick ?? (() => {})}
|
||||
isLicensed={user?.subscription === 'licensed'}
|
||||
isAdmin={user?.isAdmin === true}
|
||||
onUpgradeClick={() => onNavigateTo('pricing')}
|
||||
onResetTutorial={tutorial.resetTutorial}
|
||||
filterImpacts={filterCounts.impacts}
|
||||
|
|
@ -771,6 +790,7 @@ export default function MapPage({
|
|||
onCancel={handleCancelPin}
|
||||
mode="feature"
|
||||
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
|
||||
featureName={mobileLegendMeta.name}
|
||||
theme={theme}
|
||||
inline
|
||||
raw={mobileLegendMeta.raw}
|
||||
|
|
|
|||
|
|
@ -180,11 +180,6 @@ function PropertyCard({
|
|||
const rooms = getNum(property, 'Number of bedrooms & living rooms');
|
||||
const age = getNum(property, 'Construction year');
|
||||
const transactionDate = getNum(property, 'Date of last transaction');
|
||||
const askingPrice = getNum(property, 'Asking price');
|
||||
const askingRent = getNum(property, 'Asking rent (monthly)');
|
||||
const bedrooms = getNum(property, 'Bedrooms');
|
||||
const bathrooms = getNum(property, 'Bathrooms');
|
||||
const listingDate = getNum(property, 'Listing date');
|
||||
|
||||
return (
|
||||
<div className="p-4 border-b border-warm-100 dark:border-warm-800 hover:bg-warm-50 dark:hover:bg-warm-800">
|
||||
|
|
@ -193,7 +188,14 @@ function PropertyCard({
|
|||
<div className="font-semibold dark:text-warm-100">
|
||||
{property.address || t('propertyCard.unknownAddress')}
|
||||
</div>
|
||||
<div className="text-sm text-warm-600 dark:text-warm-400">{property.postcode}</div>
|
||||
<div className="text-sm text-warm-600 dark:text-warm-400 flex items-center gap-1.5">
|
||||
{property.postcode}
|
||||
{property.former_council_house === 'Yes' && (
|
||||
<span className="text-xs bg-teal-50 dark:bg-teal-900/30 text-teal-700 dark:text-teal-400 rounded-full px-1.5 py-0.5 font-medium leading-none">
|
||||
{t('propertyCard.exCouncilBadge')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onSave && (
|
||||
<button
|
||||
|
|
@ -216,51 +218,20 @@ function PropertyCard({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{askingPrice !== undefined && (
|
||||
{price !== undefined && (
|
||||
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
|
||||
{property.price_qualifier && (
|
||||
<span className="text-sm font-normal text-warm-500 dark:text-warm-400">
|
||||
{property.price_qualifier}{' '}
|
||||
£{formatNumber(price)}
|
||||
{transactionDate !== undefined && (
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
||||
{' '}
|
||||
({formatTransactionDate(transactionDate)})
|
||||
</span>
|
||||
)}
|
||||
£{formatNumber(askingPrice)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{askingRent !== undefined && (
|
||||
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
|
||||
£{formatNumber(askingRent)}
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
||||
{t('propertyCard.perMonth')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{price !== undefined && (
|
||||
<div
|
||||
className={`${askingPrice !== undefined || askingRent !== undefined ? '' : 'mt-2 '}text-lg font-bold text-teal-700 dark:text-teal-400`}
|
||||
>
|
||||
{askingPrice !== undefined || askingRent !== undefined ? (
|
||||
{pricePerSqm !== undefined && (
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
||||
{t('propertyCard.lastSold', { price: formatNumber(price) })}
|
||||
{transactionDate !== undefined && ` (${formatTransactionDate(transactionDate)})`}
|
||||
{' '}
|
||||
£{formatNumber(pricePerSqm)}/m²
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
£{formatNumber(price)}
|
||||
{transactionDate !== undefined && (
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
||||
{' '}
|
||||
({formatTransactionDate(transactionDate)})
|
||||
</span>
|
||||
)}
|
||||
{pricePerSqm !== undefined && (
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
||||
{' '}
|
||||
£{formatNumber(pricePerSqm)}/m²
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -299,18 +270,6 @@ function PropertyCard({
|
|||
{formatNumber(floorArea)}m²
|
||||
</div>
|
||||
)}
|
||||
{bedrooms !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.bedrooms')}</span>{' '}
|
||||
{formatNumber(bedrooms)}
|
||||
</div>
|
||||
)}
|
||||
{bathrooms !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.bathrooms')}</span>{' '}
|
||||
{formatNumber(bathrooms)}
|
||||
</div>
|
||||
)}
|
||||
{rooms !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.rooms')}</span>{' '}
|
||||
|
|
@ -323,14 +282,6 @@ function PropertyCard({
|
|||
{formatAge(age, property.is_construction_date_approximate)}
|
||||
</div>
|
||||
)}
|
||||
{property.former_council_house === 'Yes' && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">
|
||||
{t('propertyCard.formerCouncil')}
|
||||
</span>{' '}
|
||||
{ts(property.former_council_house)}
|
||||
</div>
|
||||
)}
|
||||
{property.current_energy_rating && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.epcRating')}</span>{' '}
|
||||
|
|
@ -345,32 +296,8 @@ function PropertyCard({
|
|||
{ts(property.potential_energy_rating)}
|
||||
</div>
|
||||
)}
|
||||
{listingDate !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.listed')}</span>{' '}
|
||||
{formatTransactionDate(listingDate)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{property.listing_features && property.listing_features.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">
|
||||
{t('propertyCard.keyFeatures')}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{property.listing_features.map((feature, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="text-xs bg-warm-100 dark:bg-warm-700 text-warm-700 dark:text-warm-300 rounded px-1.5 py-0.5"
|
||||
>
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{property.renovation_history && property.renovation_history.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">
|
||||
|
|
@ -390,18 +317,6 @@ function PropertyCard({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{property.listing_url && (
|
||||
<div className="mt-2">
|
||||
<a
|
||||
href={property.listing_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
>
|
||||
{t('propertyCard.viewExternalListing')} →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue