Codex changes
This commit is contained in:
parent
0bae902e08
commit
d4dde21ad2
46 changed files with 4953 additions and 966 deletions
1833
frontend/package-lock.json
generated
1833
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,6 +6,7 @@
|
|||
"build": "webpack --mode production && node scripts/prerender.mjs",
|
||||
"build:no-prerender": "webpack --mode production",
|
||||
"prerender": "node scripts/prerender.mjs",
|
||||
"test": "vitest run --environment jsdom",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"lint:fix": "eslint src --ext .ts,.tsx --fix",
|
||||
|
|
@ -39,6 +40,7 @@
|
|||
"@babel/preset-react": "^7.28.5",
|
||||
"@babel/preset-typescript": "^7.28.5",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
|
|
@ -53,6 +55,7 @@
|
|||
"favicons": "^7.2.0",
|
||||
"favicons-webpack-plugin": "^6.0.1",
|
||||
"html-webpack-plugin": "^5.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"mini-css-extract-plugin": "^2.9.0",
|
||||
"postcss": "^8.4.0",
|
||||
"postcss-loader": "^8.0.0",
|
||||
|
|
@ -63,6 +66,7 @@
|
|||
"tailwindcss": "^3.4.0",
|
||||
"ts-loader": "^9.5.0",
|
||||
"typescript": "^5.4.0",
|
||||
"vitest": "^4.1.5",
|
||||
"webpack": "^5.90.0",
|
||||
"webpack-cli": "^5.1.0",
|
||||
"webpack-dev-server": "^5.0.0"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { useCallback, useRef, useState, useMemo, useEffect } from 'react';
|
||||
import { H3HexagonLayer } from '@deck.gl/geo-layers';
|
||||
import { GeoJsonLayer, IconLayer, TextLayer, ScatterplotLayer } from '@deck.gl/layers';
|
||||
import { GeoJsonLayer, TextLayer, ScatterplotLayer } from '@deck.gl/layers';
|
||||
import { cellToBoundary } from 'h3-js';
|
||||
import Supercluster from 'supercluster';
|
||||
import type { PickingInfo } from '@deck.gl/core';
|
||||
import type {
|
||||
HexagonData,
|
||||
|
|
@ -16,16 +15,12 @@ import type {
|
|||
import {
|
||||
DENSITY_GRADIENT,
|
||||
DENSITY_GRADIENT_DARK,
|
||||
POI_GROUP_COLORS,
|
||||
POI_DEFAULT_COLOR,
|
||||
MINOR_POI_CATEGORIES,
|
||||
MINOR_POI_ZOOM_THRESHOLD,
|
||||
POI_CLUSTER_RADIUS,
|
||||
POI_CLUSTER_MAX_ZOOM,
|
||||
getEnumPaletteForFeature,
|
||||
getFeatureGradient,
|
||||
} from '../lib/consts';
|
||||
import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils';
|
||||
import { getFeatureFillColor } from '../lib/map-utils';
|
||||
import type { TravelTimeEntry } from './useTravelTime';
|
||||
import { usePoiLayers } from './usePoiLayers';
|
||||
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
|
||||
import { PieHexExtension } from '../lib/PieHexExtension';
|
||||
|
||||
|
|
@ -45,29 +40,11 @@ interface UseDeckLayersProps {
|
|||
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
|
||||
theme: 'light' | 'dark';
|
||||
selectedPostcodeGeometry?: PostcodeGeometry | null;
|
||||
currentLocation?: { lat: number; lng: number } | null;
|
||||
bounds?: Bounds | null;
|
||||
travelTimeEntries?: TravelTimeEntry[];
|
||||
}
|
||||
|
||||
interface PopupInfo {
|
||||
x: number;
|
||||
y: number;
|
||||
name: string;
|
||||
category: string;
|
||||
group: string;
|
||||
emoji: string;
|
||||
id: string;
|
||||
isCluster?: boolean;
|
||||
clusterCount?: number;
|
||||
}
|
||||
|
||||
interface ClusterPoint {
|
||||
lng: number;
|
||||
lat: number;
|
||||
count: number;
|
||||
clusterId: number;
|
||||
}
|
||||
|
||||
/** Normalize a distribution count array to [0..1] ratios, padded to 10 values. */
|
||||
function distToRatios(dist: unknown): number[] {
|
||||
if (!Array.isArray(dist) || dist.length === 0) return [1, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
|
|
@ -95,10 +72,10 @@ export function useDeckLayers({
|
|||
onHexagonHover,
|
||||
theme,
|
||||
selectedPostcodeGeometry,
|
||||
currentLocation,
|
||||
bounds: viewportBounds,
|
||||
travelTimeEntries = [],
|
||||
}: UseDeckLayersProps) {
|
||||
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
|
||||
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const [hoveredPostcode, setHoveredPostcode] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -114,6 +91,7 @@ export function useDeckLayers({
|
|||
|
||||
const isDark = theme === 'dark';
|
||||
const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
|
||||
const { poiLayers, popupInfo, clearPopupInfo } = usePoiLayers({ pois, zoom, isDark });
|
||||
|
||||
// --- Refs for deck.gl accessors ---
|
||||
const viewFeatureRef = useRef(viewFeature);
|
||||
|
|
@ -126,6 +104,8 @@ export function useDeckLayers({
|
|||
isDarkRef.current = isDark;
|
||||
const densityGradientRef = useRef(densityGradient);
|
||||
densityGradientRef.current = densityGradient;
|
||||
const featureGradientRef = useRef(getFeatureGradient(viewFeature));
|
||||
featureGradientRef.current = getFeatureGradient(viewFeature);
|
||||
const selectedHexagonIdRef = useRef(selectedHexagonId);
|
||||
selectedHexagonIdRef.current = selectedHexagonId;
|
||||
const hoveredHexagonIdRef = useRef(hoveredHexagonId);
|
||||
|
|
@ -148,9 +128,7 @@ export function useDeckLayers({
|
|||
: 0;
|
||||
|
||||
// Per-feature color palette (uses overrides when defined)
|
||||
const enumPaletteRef = useRef(
|
||||
getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values)
|
||||
);
|
||||
const enumPaletteRef = useRef(getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values));
|
||||
enumPaletteRef.current = getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values);
|
||||
|
||||
const countRange = useMemo(() => {
|
||||
|
|
@ -231,52 +209,6 @@ export function useDeckLayers({
|
|||
}
|
||||
}, []);
|
||||
|
||||
const handlePoiHover = useCallback((info: PickingInfo<POI>) => {
|
||||
if (info.object && info.x !== undefined && info.y !== undefined) {
|
||||
setPopupInfo({
|
||||
x: info.x,
|
||||
y: info.y,
|
||||
name: info.object.name,
|
||||
category: info.object.category,
|
||||
group: info.object.group,
|
||||
emoji: info.object.emoji,
|
||||
id: info.object.id,
|
||||
});
|
||||
} else {
|
||||
setPopupInfo(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePoiHoverRef = useRef(handlePoiHover);
|
||||
handlePoiHoverRef.current = handlePoiHover;
|
||||
const stablePoiHover = useCallback((info: PickingInfo<POI>) => {
|
||||
handlePoiHoverRef.current(info);
|
||||
}, []);
|
||||
|
||||
const handleClusterHover = useCallback((info: PickingInfo<ClusterPoint>) => {
|
||||
if (info.object && info.x !== undefined && info.y !== undefined) {
|
||||
setPopupInfo({
|
||||
x: info.x,
|
||||
y: info.y,
|
||||
name: `${info.object.count} places`,
|
||||
category: 'Zoom in to see details',
|
||||
group: '',
|
||||
emoji: '',
|
||||
id: '',
|
||||
isCluster: true,
|
||||
clusterCount: info.object.count,
|
||||
});
|
||||
} else {
|
||||
setPopupInfo(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClusterHoverRef = useRef(handleClusterHover);
|
||||
handleClusterHoverRef.current = handleClusterHover;
|
||||
const stableClusterHover = useCallback((info: PickingInfo<ClusterPoint>) => {
|
||||
handleClusterHoverRef.current(info);
|
||||
}, []);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handlePostcodeClick = useCallback((info: PickingInfo<any>) => {
|
||||
const pc = info.object?.properties?.postcode;
|
||||
|
|
@ -380,7 +312,10 @@ export function useDeckLayers({
|
|||
0,
|
||||
densityGradientRef.current,
|
||||
dark,
|
||||
255
|
||||
255,
|
||||
0,
|
||||
undefined,
|
||||
featureGradientRef.current
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -399,7 +334,8 @@ export function useDeckLayers({
|
|||
dark,
|
||||
255,
|
||||
enumCountRef.current,
|
||||
enumPaletteRef.current
|
||||
enumPaletteRef.current,
|
||||
featureGradientRef.current
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -481,7 +417,10 @@ export function useDeckLayers({
|
|||
0,
|
||||
densityGradientRef.current,
|
||||
dark,
|
||||
180
|
||||
180,
|
||||
0,
|
||||
undefined,
|
||||
featureGradientRef.current
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -501,7 +440,8 @@ export function useDeckLayers({
|
|||
dark,
|
||||
180,
|
||||
enumCountRef.current,
|
||||
enumPaletteRef.current
|
||||
enumPaletteRef.current,
|
||||
featureGradientRef.current
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -576,148 +516,6 @@ export function useDeckLayers({
|
|||
[postcodeData, theme]
|
||||
);
|
||||
|
||||
// --- POI clustering ---
|
||||
const clusterIndex = useMemo(() => {
|
||||
if (pois.length === 0) return null;
|
||||
const index = new Supercluster<POI>({
|
||||
radius: POI_CLUSTER_RADIUS,
|
||||
maxZoom: POI_CLUSTER_MAX_ZOOM,
|
||||
});
|
||||
const features: Supercluster.PointFeature<POI>[] = pois.map((poi) => ({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [poi.lng, poi.lat] },
|
||||
properties: poi,
|
||||
}));
|
||||
index.load(features);
|
||||
return index;
|
||||
}, [pois]);
|
||||
|
||||
const clusterZoom = Math.floor(zoom);
|
||||
const showMinorPois = zoom >= MINOR_POI_ZOOM_THRESHOLD;
|
||||
|
||||
const { visiblePois, clusters } = useMemo(() => {
|
||||
if (!clusterIndex || pois.length === 0) {
|
||||
return { visiblePois: [] as POI[], clusters: [] as ClusterPoint[] };
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const allFeatures = clusterIndex.getClusters([-180, -85, 180, 85], clusterZoom) as any[];
|
||||
const individual: POI[] = [];
|
||||
const clusterPoints: ClusterPoint[] = [];
|
||||
for (const feature of allFeatures) {
|
||||
if (feature.properties.cluster) {
|
||||
clusterPoints.push({
|
||||
lng: feature.geometry.coordinates[0],
|
||||
lat: feature.geometry.coordinates[1],
|
||||
count: feature.properties.point_count,
|
||||
clusterId: feature.properties.cluster_id,
|
||||
});
|
||||
} else {
|
||||
const poi = feature.properties as POI;
|
||||
if (!showMinorPois && MINOR_POI_CATEGORIES.has(poi.category)) continue;
|
||||
individual.push(poi);
|
||||
}
|
||||
}
|
||||
return { visiblePois: individual, clusters: clusterPoints };
|
||||
}, [clusterIndex, clusterZoom, showMinorPois, pois]);
|
||||
|
||||
// --- Individual POI layers (shadow → background → emoji) ---
|
||||
const poiShadowLayer = useMemo(
|
||||
() =>
|
||||
new ScatterplotLayer<POI>({
|
||||
id: 'poi-shadow',
|
||||
data: visiblePois,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getRadius: 16,
|
||||
radiusUnits: 'pixels',
|
||||
getFillColor: isDark ? [0, 0, 0, 50] : [0, 0, 0, 25],
|
||||
pickable: false,
|
||||
transitions: { getRadius: { duration: 300, enter: () => [0] } },
|
||||
}),
|
||||
[visiblePois, isDark]
|
||||
);
|
||||
|
||||
const poiBackgroundLayer = useMemo(
|
||||
() =>
|
||||
new ScatterplotLayer<POI>({
|
||||
id: 'poi-background',
|
||||
data: visiblePois,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getRadius: 14,
|
||||
radiusUnits: 'pixels',
|
||||
getFillColor: isDark ? [41, 37, 36, 255] : [255, 255, 255, 255],
|
||||
getLineColor: (d) => {
|
||||
const c = POI_GROUP_COLORS[d.group] || POI_DEFAULT_COLOR;
|
||||
return [c[0], c[1], c[2], 255] as [number, number, number, number];
|
||||
},
|
||||
getLineWidth: 2.5,
|
||||
lineWidthUnits: 'pixels',
|
||||
stroked: true,
|
||||
pickable: true,
|
||||
onHover: stablePoiHover,
|
||||
transitions: { getRadius: { duration: 300, enter: () => [0] } },
|
||||
}),
|
||||
[visiblePois, isDark, stablePoiHover]
|
||||
);
|
||||
|
||||
const poiIconLayer = useMemo(
|
||||
() =>
|
||||
new IconLayer<POI>({
|
||||
id: 'poi-icons',
|
||||
data: visiblePois,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: (d) => ({
|
||||
url: emojiToTwemojiUrl(d.emoji),
|
||||
width: 72,
|
||||
height: 72,
|
||||
}),
|
||||
getSize: 18,
|
||||
sizeUnits: 'pixels',
|
||||
pickable: false,
|
||||
transitions: { getSize: { duration: 300, enter: () => [0] } },
|
||||
}),
|
||||
[visiblePois]
|
||||
);
|
||||
|
||||
// --- Cluster layers ---
|
||||
const clusterCircleLayer = useMemo(
|
||||
() =>
|
||||
new ScatterplotLayer<ClusterPoint>({
|
||||
id: 'poi-clusters',
|
||||
data: clusters,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getRadius: (d) => Math.min(30, 14 + Math.sqrt(d.count) * 2),
|
||||
radiusUnits: 'pixels',
|
||||
getFillColor: isDark ? [5, 129, 114, 220] : [20, 184, 166, 220],
|
||||
getLineColor: [255, 255, 255, isDark ? 60 : 120],
|
||||
getLineWidth: 2,
|
||||
lineWidthUnits: 'pixels',
|
||||
stroked: true,
|
||||
pickable: true,
|
||||
onHover: stableClusterHover,
|
||||
transitions: { getRadius: { duration: 300, enter: () => [0] } },
|
||||
}),
|
||||
[clusters, isDark, stableClusterHover]
|
||||
);
|
||||
|
||||
const clusterTextLayer = useMemo(
|
||||
() =>
|
||||
new TextLayer<ClusterPoint>({
|
||||
id: 'poi-cluster-text',
|
||||
data: clusters,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => (d.count >= 1000 ? `${(d.count / 1000).toFixed(1)}k` : String(d.count)),
|
||||
getSize: 12,
|
||||
getColor: [255, 255, 255, 255],
|
||||
fontWeight: 700,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
sizeUnits: 'pixels',
|
||||
pickable: false,
|
||||
}),
|
||||
[clusters]
|
||||
);
|
||||
|
||||
// Marching ants highlight layer for selected hexagon or postcode
|
||||
const marchingAntsLayer = useMemo(() => {
|
||||
let geometry: PostcodeGeometry | null = null;
|
||||
|
|
@ -748,10 +546,25 @@ export function useDeckLayers({
|
|||
});
|
||||
}, [selectedPostcodeGeometry, selectedHexagonId, marchTime]);
|
||||
|
||||
const poiLayers = useMemo(
|
||||
() => [poiShadowLayer, poiBackgroundLayer, poiIconLayer, clusterCircleLayer, clusterTextLayer],
|
||||
[poiShadowLayer, poiBackgroundLayer, poiIconLayer, clusterCircleLayer, clusterTextLayer]
|
||||
);
|
||||
const currentLocationLayer = useMemo(() => {
|
||||
if (!currentLocation) return null;
|
||||
return new ScatterplotLayer<{ lat: number; lng: number; kind: 'ring' | 'dot' }>({
|
||||
id: 'current-location-dot',
|
||||
data: [
|
||||
{ ...currentLocation, kind: 'ring' },
|
||||
{ ...currentLocation, kind: 'dot' },
|
||||
],
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getRadius: (d) => (d.kind === 'ring' ? 16 : 5),
|
||||
radiusUnits: 'pixels',
|
||||
getFillColor: (d) => (d.kind === 'ring' ? [20, 184, 166, 45] : [220, 38, 38, 255]),
|
||||
getLineColor: (d) => (d.kind === 'ring' ? [20, 184, 166, 240] : [255, 255, 255, 240]),
|
||||
getLineWidth: 2,
|
||||
lineWidthUnits: 'pixels',
|
||||
stroked: true,
|
||||
pickable: false,
|
||||
});
|
||||
}, [currentLocation]);
|
||||
|
||||
const layers = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
|
@ -761,6 +574,7 @@ export function useDeckLayers({
|
|||
: [postcodeLayer, ...poiLayers]
|
||||
: [hexLayer, ...poiLayers];
|
||||
if (marchingAntsLayer) baseLayers.push(marchingAntsLayer);
|
||||
if (currentLocationLayer) baseLayers.push(currentLocationLayer);
|
||||
return baseLayers;
|
||||
}, [
|
||||
usePostcodeView,
|
||||
|
|
@ -770,16 +584,15 @@ export function useDeckLayers({
|
|||
postcodeLabelsLayer,
|
||||
poiLayers,
|
||||
marchingAntsLayer,
|
||||
currentLocationLayer,
|
||||
]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setHoverPosition(null);
|
||||
setHoveredPostcode(null);
|
||||
setPopupInfo(null);
|
||||
clearPopupInfo();
|
||||
onHexagonHoverRef.current(null);
|
||||
}, []);
|
||||
|
||||
const clearPopupInfo = useCallback(() => setPopupInfo(null), []);
|
||||
}, [clearPopupInfo]);
|
||||
|
||||
return {
|
||||
layers,
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
const meta = features.find((f) => f.name === name);
|
||||
if (meta?.type === 'enum') return;
|
||||
pendingDragRef.current = name;
|
||||
setActiveFeature(name);
|
||||
},
|
||||
[features]
|
||||
);
|
||||
|
|
@ -112,8 +113,9 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
if (pendingDragRef.current) {
|
||||
// Click without drag — no state was changed, just clear the ref
|
||||
// Click without drag — no filter value was changed, just clear preview state.
|
||||
pendingDragRef.current = null;
|
||||
setActiveFeature(null);
|
||||
return;
|
||||
}
|
||||
const af = dragActiveRef.current;
|
||||
|
|
@ -131,6 +133,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
const handleDragEndNoCommit = useCallback((): [number, number] | null => {
|
||||
if (pendingDragRef.current) {
|
||||
pendingDragRef.current = null;
|
||||
setActiveFeature(null);
|
||||
return null;
|
||||
}
|
||||
const dv = dragValueRef.current;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { latLngToCell } from 'h3-js';
|
||||
import { cellToLatLng, cellToParent, latLngToCell } from 'h3-js';
|
||||
import { trackEvent } from '../lib/analytics';
|
||||
import type {
|
||||
FeatureMeta,
|
||||
|
|
@ -11,10 +11,13 @@ import type {
|
|||
} from '../types';
|
||||
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders } from '../lib/api';
|
||||
|
||||
const CURRENT_LOCATION_HEX_RESOLUTION = 12;
|
||||
|
||||
interface SelectedHexagon {
|
||||
id: string;
|
||||
type: 'hexagon' | 'postcode';
|
||||
resolution: number;
|
||||
lockedResolution?: boolean;
|
||||
}
|
||||
|
||||
interface JourneyDest {
|
||||
|
|
@ -22,10 +25,18 @@ interface JourneyDest {
|
|||
slug: string;
|
||||
}
|
||||
|
||||
interface PostcodeLookupResponse {
|
||||
postcode: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
geometry: PostcodeGeometry;
|
||||
}
|
||||
|
||||
interface UseHexagonSelectionOptions {
|
||||
filters: FeatureFilters;
|
||||
features: FeatureMeta[];
|
||||
resolution: number;
|
||||
usePostcodeView: boolean;
|
||||
/** First transit destination — used to pick the best central_postcode for journey display. */
|
||||
journeyDest?: JourneyDest | null;
|
||||
}
|
||||
|
|
@ -34,6 +45,7 @@ export function useHexagonSelection({
|
|||
filters,
|
||||
features,
|
||||
resolution,
|
||||
usePostcodeView,
|
||||
journeyDest,
|
||||
}: UseHexagonSelectionOptions) {
|
||||
const [selectedHexagon, setSelectedHexagon] = useState<SelectedHexagon | null>(null);
|
||||
|
|
@ -42,6 +54,7 @@ export function useHexagonSelection({
|
|||
const [propertiesOffset, setPropertiesOffset] = useState(0);
|
||||
const [loadingProperties, setLoadingProperties] = useState(false);
|
||||
const [areaStats, setAreaStats] = useState<HexagonStatsResponse | null>(null);
|
||||
const [unfilteredAreaCount, setUnfilteredAreaCount] = useState<number | null>(null);
|
||||
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
|
||||
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
|
||||
const [rightPaneTab, setRightPaneTab] = useState<'properties' | 'area'>('area');
|
||||
|
|
@ -50,12 +63,18 @@ export function useHexagonSelection({
|
|||
);
|
||||
|
||||
const fetchHexagonStats = useCallback(
|
||||
async (h3: string, res: number, signal?: AbortSignal, fields?: string[]) => {
|
||||
async (
|
||||
h3: string,
|
||||
res: number,
|
||||
signal?: AbortSignal,
|
||||
fields?: string[],
|
||||
includeFilters = true
|
||||
) => {
|
||||
const params = new URLSearchParams({
|
||||
h3,
|
||||
resolution: res.toString(),
|
||||
});
|
||||
const filterStr = buildFilterString(filters, features);
|
||||
const filterStr = includeFilters ? buildFilterString(filters, features) : '';
|
||||
if (filterStr) params.append('filters', filterStr);
|
||||
if (fields) {
|
||||
params.set('fields', fields.join(';;'));
|
||||
|
|
@ -72,9 +91,9 @@ export function useHexagonSelection({
|
|||
);
|
||||
|
||||
const fetchPostcodeStats = useCallback(
|
||||
async (postcode: string, signal?: AbortSignal) => {
|
||||
async (postcode: string, signal?: AbortSignal, includeFilters = true) => {
|
||||
const params = new URLSearchParams({ postcode });
|
||||
const filterStr = buildFilterString(filters, features);
|
||||
const filterStr = includeFilters ? buildFilterString(filters, features) : '';
|
||||
if (filterStr) params.append('filters', filterStr);
|
||||
const response = await fetch(apiUrl('postcode-stats', params), authHeaders({ signal }));
|
||||
assertOk(response, 'postcode-stats');
|
||||
|
|
@ -83,6 +102,47 @@ export function useHexagonSelection({
|
|||
[filters, features]
|
||||
);
|
||||
|
||||
const filterStr = useMemo(() => buildFilterString(filters, features), [filters, features]);
|
||||
|
||||
const fetchUnfilteredAreaCount = useCallback(
|
||||
async (selection: SelectedHexagon, signal?: AbortSignal) => {
|
||||
if (!filterStr) {
|
||||
setUnfilteredAreaCount(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const stats =
|
||||
selection.type === 'postcode'
|
||||
? await fetchPostcodeStats(selection.id, signal, false)
|
||||
: await fetchHexagonStats(selection.id, selection.resolution, signal, undefined, false);
|
||||
setUnfilteredAreaCount(stats.count);
|
||||
},
|
||||
[filterStr, fetchHexagonStats, fetchPostcodeStats]
|
||||
);
|
||||
|
||||
const refreshUnfilteredAreaCount = useCallback(
|
||||
(selection: SelectedHexagon, filteredCount: number, signal?: AbortSignal) => {
|
||||
if (!filterStr || filteredCount > 0) {
|
||||
setUnfilteredAreaCount(null);
|
||||
return;
|
||||
}
|
||||
|
||||
fetchUnfilteredAreaCount(selection, signal).catch((error) =>
|
||||
logNonAbortError('Failed to fetch unfiltered area count', error)
|
||||
);
|
||||
},
|
||||
[filterStr, fetchUnfilteredAreaCount]
|
||||
);
|
||||
|
||||
const fetchPostcodeLookup = useCallback(async (postcode: string, signal?: AbortSignal) => {
|
||||
const response = await fetch(
|
||||
`/api/postcode/${encodeURIComponent(postcode)}`,
|
||||
authHeaders({ signal })
|
||||
);
|
||||
assertOk(response, 'postcode lookup');
|
||||
return (await response.json()) as PostcodeLookupResponse;
|
||||
}, []);
|
||||
|
||||
const fetchHexagonProperties = useCallback(
|
||||
async (h3: string, res: number, offset = 0) => {
|
||||
setLoadingProperties(true);
|
||||
|
|
@ -156,33 +216,42 @@ export function useHexagonSelection({
|
|||
setSelectedHexagon(null);
|
||||
setProperties([]);
|
||||
setAreaStats(null);
|
||||
setUnfilteredAreaCount(null);
|
||||
setSelectedPostcodeGeometry(null);
|
||||
} else {
|
||||
const type = isPostcode ? 'postcode' : 'hexagon';
|
||||
const type: SelectedHexagon['type'] = isPostcode ? 'postcode' : 'hexagon';
|
||||
const selection = { id, type, resolution };
|
||||
trackEvent('Hexagon Click', { type });
|
||||
setSelectedHexagon({ id, type, resolution });
|
||||
setSelectedHexagon(selection);
|
||||
setSelectedPostcodeGeometry(isPostcode && geometry ? geometry : null);
|
||||
setProperties([]);
|
||||
setPropertiesTotal(0);
|
||||
setPropertiesOffset(0);
|
||||
setUnfilteredAreaCount(null);
|
||||
setRightPaneTab('area');
|
||||
|
||||
if (isPostcode) {
|
||||
setLoadingAreaStats(true);
|
||||
fetchPostcodeStats(id)
|
||||
.then((stats) => setAreaStats(stats))
|
||||
.then((stats) => {
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count);
|
||||
})
|
||||
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
|
||||
.finally(() => setLoadingAreaStats(false));
|
||||
} else {
|
||||
setLoadingAreaStats(true);
|
||||
fetchHexagonStats(id, resolution)
|
||||
.then((stats) => setAreaStats(stats))
|
||||
.then((stats) => {
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count);
|
||||
})
|
||||
.catch((error) => logNonAbortError('Failed to fetch area stats', error))
|
||||
.finally(() => setLoadingAreaStats(false));
|
||||
}
|
||||
}
|
||||
},
|
||||
[selectedHexagon, resolution, fetchHexagonStats, fetchPostcodeStats]
|
||||
[selectedHexagon, resolution, fetchHexagonStats, fetchPostcodeStats, refreshUnfilteredAreaCount]
|
||||
);
|
||||
|
||||
const handleHexagonHover = useCallback((h3: string | null) => {
|
||||
|
|
@ -232,11 +301,111 @@ export function useHexagonSelection({
|
|||
setSelectedHexagon(null);
|
||||
setProperties([]);
|
||||
setAreaStats(null);
|
||||
setUnfilteredAreaCount(null);
|
||||
setSelectedPostcodeGeometry(null);
|
||||
}, []);
|
||||
|
||||
// Keep the selected area aligned with the active map view as zoom changes.
|
||||
useEffect(() => {
|
||||
if (!selectedHexagon) return;
|
||||
const selection = selectedHexagon;
|
||||
const shouldSync =
|
||||
(usePostcodeView &&
|
||||
selection.type === 'hexagon' &&
|
||||
!selection.lockedResolution &&
|
||||
areaStats?.central_postcode != null) ||
|
||||
(!usePostcodeView && selection.type === 'postcode') ||
|
||||
(!usePostcodeView &&
|
||||
selection.type === 'hexagon' &&
|
||||
!selection.lockedResolution &&
|
||||
selection.resolution !== resolution);
|
||||
if (!shouldSync) return;
|
||||
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
|
||||
const refreshProperties = (selection: SelectedHexagon) => {
|
||||
if (rightPaneTab !== 'properties') return;
|
||||
if (selection.type === 'postcode') {
|
||||
fetchPostcodeProperties(selection.id, 0);
|
||||
} else {
|
||||
fetchHexagonProperties(selection.id, selection.resolution, 0);
|
||||
}
|
||||
};
|
||||
|
||||
async function syncSelection() {
|
||||
let nextSelection: SelectedHexagon | null = null;
|
||||
let nextGeometry: PostcodeGeometry | null = null;
|
||||
let nextStats: HexagonStatsResponse | null = null;
|
||||
|
||||
if (usePostcodeView && selection.type === 'hexagon' && !selection.lockedResolution) {
|
||||
if (!areaStats?.central_postcode) return;
|
||||
const lookup = await fetchPostcodeLookup(areaStats.central_postcode, controller.signal);
|
||||
nextSelection = { id: lookup.postcode, type: 'postcode', resolution };
|
||||
nextGeometry = lookup.geometry;
|
||||
nextStats = await fetchPostcodeStats(lookup.postcode, controller.signal);
|
||||
} else if (!usePostcodeView && selection.type === 'postcode') {
|
||||
const lookup = await fetchPostcodeLookup(selection.id, controller.signal);
|
||||
const nextId = latLngToCell(lookup.latitude, lookup.longitude, resolution);
|
||||
nextSelection = { id: nextId, type: 'hexagon', resolution };
|
||||
nextStats = await fetchHexagonStats(nextId, resolution, controller.signal);
|
||||
} else if (
|
||||
!usePostcodeView &&
|
||||
selection.type === 'hexagon' &&
|
||||
!selection.lockedResolution &&
|
||||
selection.resolution !== resolution
|
||||
) {
|
||||
const nextId =
|
||||
resolution < selection.resolution
|
||||
? cellToParent(selection.id, resolution)
|
||||
: latLngToCell(...cellToLatLng(selection.id), resolution);
|
||||
nextSelection = { id: nextId, type: 'hexagon', resolution };
|
||||
nextStats = await fetchHexagonStats(nextId, resolution, controller.signal);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cancelled || !nextSelection || !nextStats) return;
|
||||
setSelectedHexagon(nextSelection);
|
||||
setSelectedPostcodeGeometry(nextGeometry);
|
||||
setAreaStats(nextStats);
|
||||
refreshUnfilteredAreaCount(nextSelection, nextStats.count, controller.signal);
|
||||
refreshProperties(nextSelection);
|
||||
}
|
||||
|
||||
setProperties([]);
|
||||
setPropertiesTotal(0);
|
||||
setPropertiesOffset(0);
|
||||
setUnfilteredAreaCount(null);
|
||||
setLoadingAreaStats(true);
|
||||
|
||||
syncSelection()
|
||||
.catch((error) => {
|
||||
if (!cancelled) logNonAbortError('Failed to sync selected area with map view', error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingAreaStats(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [
|
||||
selectedHexagon,
|
||||
resolution,
|
||||
usePostcodeView,
|
||||
areaStats?.central_postcode,
|
||||
fetchHexagonStats,
|
||||
fetchPostcodeStats,
|
||||
fetchPostcodeLookup,
|
||||
fetchHexagonProperties,
|
||||
fetchPostcodeProperties,
|
||||
refreshUnfilteredAreaCount,
|
||||
rightPaneTab,
|
||||
]);
|
||||
|
||||
// Re-fetch stats when filters change while a hexagon is selected
|
||||
const filterStr = useMemo(() => buildFilterString(filters, features), [filters, features]);
|
||||
const prevFilterStr = useRef(filterStr);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -261,19 +430,14 @@ export function useHexagonSelection({
|
|||
fetchStats
|
||||
.then((stats) => {
|
||||
if (cancelled) return;
|
||||
if (stats.count === 0) {
|
||||
setSelectedHexagon(null);
|
||||
setAreaStats(null);
|
||||
setSelectedPostcodeGeometry(null);
|
||||
} else {
|
||||
setAreaStats(stats);
|
||||
// Re-fetch properties if the properties tab is active
|
||||
if (rightPaneTab === 'properties') {
|
||||
if (selectedHexagon.type === 'postcode') {
|
||||
fetchPostcodeProperties(selectedHexagon.id, 0);
|
||||
} else {
|
||||
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
|
||||
}
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selectedHexagon, stats.count);
|
||||
// Re-fetch properties if the properties tab is active and the filtered area still has matches.
|
||||
if (rightPaneTab === 'properties' && stats.count > 0) {
|
||||
if (selectedHexagon.type === 'postcode') {
|
||||
fetchPostcodeProperties(selectedHexagon.id, 0);
|
||||
} else {
|
||||
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -296,6 +460,7 @@ export function useHexagonSelection({
|
|||
rightPaneTab,
|
||||
fetchHexagonProperties,
|
||||
fetchPostcodeProperties,
|
||||
refreshUnfilteredAreaCount,
|
||||
]);
|
||||
|
||||
const handleLocationSearch = useCallback(
|
||||
|
|
@ -304,6 +469,7 @@ export function useHexagonSelection({
|
|||
setProperties([]);
|
||||
setPropertiesTotal(0);
|
||||
setPropertiesOffset(0);
|
||||
setUnfilteredAreaCount(null);
|
||||
setRightPaneTab('area');
|
||||
setLoadingAreaStats(true);
|
||||
|
||||
|
|
@ -311,18 +477,22 @@ export function useHexagonSelection({
|
|||
fetchPostcodeStats(postcode)
|
||||
.then(async (stats) => {
|
||||
if (stats.count > 0) {
|
||||
setSelectedHexagon({ id: postcode, type: 'postcode', resolution });
|
||||
const selection = { id: postcode, type: 'postcode' as const, resolution };
|
||||
setSelectedHexagon(selection);
|
||||
setSelectedPostcodeGeometry(geometry);
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count);
|
||||
return;
|
||||
}
|
||||
|
||||
// No properties in this postcode — fall back to hexagons
|
||||
if (lat == null || lng == null) {
|
||||
// No coordinates available, show empty postcode anyway
|
||||
setSelectedHexagon({ id: postcode, type: 'postcode', resolution });
|
||||
const selection = { id: postcode, type: 'postcode' as const, resolution };
|
||||
setSelectedHexagon(selection);
|
||||
setSelectedPostcodeGeometry(geometry);
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -332,9 +502,11 @@ export function useHexagonSelection({
|
|||
const h3 = latLngToCell(lat, lng, res);
|
||||
const hexStats = await fetchHexagonStats(h3, res);
|
||||
if (hexStats.count > 1) {
|
||||
setSelectedHexagon({ id: h3, type: 'hexagon', resolution: res });
|
||||
const selection = { id: h3, type: 'hexagon' as const, resolution: res };
|
||||
setSelectedHexagon(selection);
|
||||
setSelectedPostcodeGeometry(null);
|
||||
setAreaStats(hexStats);
|
||||
refreshUnfilteredAreaCount(selection, hexStats.count);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -342,14 +514,47 @@ export function useHexagonSelection({
|
|||
// Even the coarsest hexagon has ≤1 property — show whatever the finest has
|
||||
const h3 = latLngToCell(lat, lng, 9);
|
||||
const fallbackStats = await fetchHexagonStats(h3, 9);
|
||||
setSelectedHexagon({ id: h3, type: 'hexagon', resolution: 9 });
|
||||
const selection = { id: h3, type: 'hexagon' as const, resolution: 9 };
|
||||
setSelectedHexagon(selection);
|
||||
setSelectedPostcodeGeometry(null);
|
||||
setAreaStats(fallbackStats);
|
||||
refreshUnfilteredAreaCount(selection, fallbackStats.count);
|
||||
})
|
||||
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
|
||||
.finally(() => setLoadingAreaStats(false));
|
||||
},
|
||||
[resolution, fetchPostcodeStats, fetchHexagonStats]
|
||||
[resolution, fetchPostcodeStats, fetchHexagonStats, refreshUnfilteredAreaCount]
|
||||
);
|
||||
|
||||
const handleCurrentLocationSearch = useCallback(
|
||||
(lat: number, lng: number) => {
|
||||
const h3 = latLngToCell(lat, lng, CURRENT_LOCATION_HEX_RESOLUTION);
|
||||
const selection = {
|
||||
id: h3,
|
||||
type: 'hexagon' as const,
|
||||
resolution: CURRENT_LOCATION_HEX_RESOLUTION,
|
||||
lockedResolution: true,
|
||||
};
|
||||
|
||||
trackEvent('Current Location Search');
|
||||
setSelectedHexagon(selection);
|
||||
setSelectedPostcodeGeometry(null);
|
||||
setProperties([]);
|
||||
setPropertiesTotal(0);
|
||||
setPropertiesOffset(0);
|
||||
setUnfilteredAreaCount(null);
|
||||
setRightPaneTab('area');
|
||||
setLoadingAreaStats(true);
|
||||
|
||||
fetchHexagonStats(h3, CURRENT_LOCATION_HEX_RESOLUTION)
|
||||
.then((stats) => {
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count);
|
||||
})
|
||||
.catch((error) => logNonAbortError('Failed to fetch current location hex stats', error))
|
||||
.finally(() => setLoadingAreaStats(false));
|
||||
},
|
||||
[fetchHexagonStats, refreshUnfilteredAreaCount]
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
@ -359,6 +564,7 @@ export function useHexagonSelection({
|
|||
loadingProperties,
|
||||
areaStats,
|
||||
loadingAreaStats,
|
||||
unfilteredAreaCount,
|
||||
hoveredHexagon,
|
||||
rightPaneTab,
|
||||
setRightPaneTab,
|
||||
|
|
@ -370,5 +576,6 @@ export function useHexagonSelection({
|
|||
handleCloseSelection,
|
||||
selectedPostcodeGeometry,
|
||||
handleLocationSearch,
|
||||
handleCurrentLocationSearch,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,14 @@ const descriptions: Record<string, Record<string, string>> = {
|
|||
'Écoles primaires notées Bien ou Excellent par Ofsted dans un rayon de 5 km',
|
||||
'Good+ secondary schools within 5km':
|
||||
'Collèges/lycées notés Bien ou Excellent par Ofsted dans un rayon de 5 km',
|
||||
'Outstanding primary schools within 2km':
|
||||
'Écoles primaires notées Excellent par Ofsted dans un rayon de 2 km',
|
||||
'Outstanding secondary schools within 2km':
|
||||
'Collèges/lycées notés Excellent par Ofsted dans un rayon de 2 km',
|
||||
'Outstanding primary schools within 5km':
|
||||
'Écoles primaires notées Excellent par Ofsted dans un rayon de 5 km',
|
||||
'Outstanding secondary schools within 5km':
|
||||
'Collèges/lycées notés Excellent par Ofsted dans un rayon de 5 km',
|
||||
'Education, Skills and Training Score':
|
||||
'Score de qualité éducative du secteur (plus élevé = meilleur)',
|
||||
'Income Score (rate)': 'Taux de précarité de revenu, inversé (plus élevé = moins précaire)',
|
||||
|
|
@ -121,6 +129,14 @@ const descriptions: Record<string, Record<string, string>> = {
|
|||
'Von Ofsted mit Gut oder Hervorragend bewertete Grundschulen im Umkreis von 5 km',
|
||||
'Good+ secondary schools within 5km':
|
||||
'Von Ofsted mit Gut oder Hervorragend bewertete weiterführende Schulen im Umkreis von 5 km',
|
||||
'Outstanding primary schools within 2km':
|
||||
'Von Ofsted mit Hervorragend bewertete Grundschulen im Umkreis von 2 km',
|
||||
'Outstanding secondary schools within 2km':
|
||||
'Von Ofsted mit Hervorragend bewertete weiterführende Schulen im Umkreis von 2 km',
|
||||
'Outstanding primary schools within 5km':
|
||||
'Von Ofsted mit Hervorragend bewertete Grundschulen im Umkreis von 5 km',
|
||||
'Outstanding secondary schools within 5km':
|
||||
'Von Ofsted mit Hervorragend bewertete weiterführende Schulen im Umkreis von 5 km',
|
||||
'Education, Skills and Training Score': 'Bildungsqualitätsscore der Gegend (höher = besser)',
|
||||
'Income Score (rate)':
|
||||
'Einkommensbenachteiligungsrate, invertiert (höher = weniger benachteiligt)',
|
||||
|
|
@ -202,6 +218,10 @@ const descriptions: Record<string, Record<string, string>> = {
|
|||
'Good+ secondary schools within 2km': 'Ofsted评为良好或优秀的2公里内中学',
|
||||
'Good+ primary schools within 5km': 'Ofsted评为良好或优秀的5公里内小学',
|
||||
'Good+ secondary schools within 5km': 'Ofsted评为良好或优秀的5公里内中学',
|
||||
'Outstanding primary schools within 2km': 'Ofsted评为优秀的2公里内小学',
|
||||
'Outstanding secondary schools within 2km': 'Ofsted评为优秀的2公里内中学',
|
||||
'Outstanding primary schools within 5km': 'Ofsted评为优秀的5公里内小学',
|
||||
'Outstanding secondary schools within 5km': 'Ofsted评为优秀的5公里内中学',
|
||||
'Education, Skills and Training Score': '当地教育质量得分(越高越好)',
|
||||
'Income Score (rate)': '收入贫困率,反向指标(越高越不贫困)',
|
||||
'Employment Score (rate)': '就业贫困率,反向指标(越高越不贫困)',
|
||||
|
|
@ -275,6 +295,14 @@ const descriptions: Record<string, Record<string, string>> = {
|
|||
'Ofsted által Jó vagy Kiváló minősítésű általános iskolák 5 km-en belül',
|
||||
'Good+ secondary schools within 5km':
|
||||
'Ofsted által Jó vagy Kiváló minősítésű középiskolák 5 km-en belül',
|
||||
'Outstanding primary schools within 2km':
|
||||
'Ofsted által Kiváló minősítésű általános iskolák 2 km-en belül',
|
||||
'Outstanding secondary schools within 2km':
|
||||
'Ofsted által Kiváló minősítésű középiskolák 2 km-en belül',
|
||||
'Outstanding primary schools within 5km':
|
||||
'Ofsted által Kiváló minősítésű általános iskolák 5 km-en belül',
|
||||
'Outstanding secondary schools within 5km':
|
||||
'Ofsted által Kiváló minősítésű középiskolák 5 km-en belül',
|
||||
'Education, Skills and Training Score':
|
||||
'A környék oktatási minőségi pontszáma (magasabb = jobb)',
|
||||
'Income Score (rate)': 'Jövedelmi deprivációs ráta, invertálva (magasabb = kevésbé hátrányos)',
|
||||
|
|
|
|||
|
|
@ -45,6 +45,14 @@ export const details: Record<string, Record<string, string>> = {
|
|||
"Écoles primaires financées par l'État dans un rayon de 5km ayant une note Ofsted actuelle de Bon ou Exceptionnel. Les écoles n'ayant pas encore été inspectées sont exclues.",
|
||||
'Good+ secondary schools within 5km':
|
||||
"Lycées et collèges financés par l'État dans un rayon de 5km ayant une note Ofsted actuelle de Bon ou Exceptionnel. Les établissements n'ayant pas encore été inspectés sont exclus.",
|
||||
'Outstanding primary schools within 2km':
|
||||
"Écoles primaires financées par l'État dans un rayon de 2km ayant une note Ofsted actuelle d'Exceptionnel. Les écoles n'ayant pas encore été inspectées sont exclues.",
|
||||
'Outstanding secondary schools within 2km':
|
||||
"Lycées et collèges financés par l'État dans un rayon de 2km ayant une note Ofsted actuelle d'Exceptionnel. Les établissements n'ayant pas encore été inspectés sont exclus.",
|
||||
'Outstanding primary schools within 5km':
|
||||
"Écoles primaires financées par l'État dans un rayon de 5km ayant une note Ofsted actuelle d'Exceptionnel. Les écoles n'ayant pas encore été inspectées sont exclues.",
|
||||
'Outstanding secondary schools within 5km':
|
||||
"Lycées et collèges financés par l'État dans un rayon de 5km ayant une note Ofsted actuelle d'Exceptionnel. Les établissements n'ayant pas encore été inspectés sont exclus.",
|
||||
'Education, Skills and Training Score':
|
||||
"Provient des Indices de Déprivation anglais (inversé afin que plus le score est élevé, meilleur est le résultat). Couvre les résultats scolaires, l'accès à l'enseignement supérieur, les qualifications des adultes et la maîtrise de la langue anglaise. Des scores plus élevés indiquent moins de déprivation.",
|
||||
'Income Score (rate)':
|
||||
|
|
@ -177,6 +185,14 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'Staatlich geförderte Grundschulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von „Good" oder „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
|
||||
'Good+ secondary schools within 5km':
|
||||
'Staatlich geförderte weiterführende Schulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von „Good" oder „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
|
||||
'Outstanding primary schools within 2km':
|
||||
'Staatlich geförderte Grundschulen innerhalb von 2 km mit einer aktuellen Ofsted-Bewertung von „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
|
||||
'Outstanding secondary schools within 2km':
|
||||
'Staatlich geförderte weiterführende Schulen innerhalb von 2 km mit einer aktuellen Ofsted-Bewertung von „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
|
||||
'Outstanding primary schools within 5km':
|
||||
'Staatlich geförderte Grundschulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
|
||||
'Outstanding secondary schools within 5km':
|
||||
'Staatlich geförderte weiterführende Schulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
|
||||
'Education, Skills and Training Score':
|
||||
'Aus den englischen Deprivationsindizes (invertiert, sodass höher = besser bedeutet). Umfasst Schulleistungen, Hochschulzugang, Qualifikationen Erwachsener und Englischsprachkenntnisse. Höhere Werte weisen auf geringere Benachteiligung hin.',
|
||||
'Income Score (rate)':
|
||||
|
|
@ -309,6 +325,14 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'5km范围内Ofsted评级为"Good"或"Outstanding"的公立小学数量。尚未接受评估的学校不计入。',
|
||||
'Good+ secondary schools within 5km':
|
||||
'5km范围内Ofsted评级为"Good"或"Outstanding"的公立中学数量。尚未接受评估的学校不计入。',
|
||||
'Outstanding primary schools within 2km':
|
||||
'2km范围内Ofsted评级为"Outstanding"的公立小学数量。尚未接受评估的学校不计入。',
|
||||
'Outstanding secondary schools within 2km':
|
||||
'2km范围内Ofsted评级为"Outstanding"的公立中学数量。尚未接受评估的学校不计入。',
|
||||
'Outstanding primary schools within 5km':
|
||||
'5km范围内Ofsted评级为"Outstanding"的公立小学数量。尚未接受评估的学校不计入。',
|
||||
'Outstanding secondary schools within 5km':
|
||||
'5km范围内Ofsted评级为"Outstanding"的公立中学数量。尚未接受评估的学校不计入。',
|
||||
'Education, Skills and Training Score':
|
||||
'来自英格兰剥夺指数(取反后越高越好)。涵盖学校成绩、高等教育入学率、成人学历和英语水平。分数越高表示剥夺程度越低。',
|
||||
'Income Score (rate)':
|
||||
|
|
@ -439,6 +463,14 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'5 km-en belüli állami fenntartású általános iskolák, amelyek aktuális Ofsted besorolása Jó vagy Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
|
||||
'Good+ secondary schools within 5km':
|
||||
'5 km-en belüli állami fenntartású középiskolák, amelyek aktuális Ofsted besorolása Jó vagy Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
|
||||
'Outstanding primary schools within 2km':
|
||||
'2 km-en belüli állami fenntartású általános iskolák, amelyek aktuális Ofsted besorolása Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
|
||||
'Outstanding secondary schools within 2km':
|
||||
'2 km-en belüli állami fenntartású középiskolák, amelyek aktuális Ofsted besorolása Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
|
||||
'Outstanding primary schools within 5km':
|
||||
'5 km-en belüli állami fenntartású általános iskolák, amelyek aktuális Ofsted besorolása Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
|
||||
'Outstanding secondary schools within 5km':
|
||||
'5 km-en belüli állami fenntartású középiskolák, amelyek aktuális Ofsted besorolása Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
|
||||
'Education, Skills and Training Score':
|
||||
'Az Angol Nélkülözési Indexekből (megfordítva, így magasabb = jobb). Az iskolai teljesítményt, a felsőoktatásba való bejutást, a felnőttkori képesítéseket és az angol nyelvi jártasságot foglalja magában. A magasabb pontszámok kisebb mértékű nélkülözést jeleznek.',
|
||||
'Income Score (rate)':
|
||||
|
|
|
|||
|
|
@ -259,6 +259,16 @@ const de: Translations = {
|
|||
areaStatistics: 'Gebietsstatistiken',
|
||||
statsFor: 'Statistiken für alle Immobilien in diesem {{type}}',
|
||||
matchingFilters: ', die allen aktiven Filtern entsprechen',
|
||||
filtersAffectStats:
|
||||
'Filter im linken Bereich werden hier angewendet: Werte, Diagramme und Immobilienzahlen nutzen die {{count}} aktiven Filter.',
|
||||
noFiltersAffectStats:
|
||||
'Filter im linken Bereich aktualisieren diesen Bereich: Fügen Sie Filter hinzu, um diese Werte für passende Immobilien neu zu berechnen.',
|
||||
noFilteredMatches: 'Keine Immobilien in diesem Gebiet entsprechen Ihren Filtern.',
|
||||
unfilteredAreaCount:
|
||||
'{{count}} Immobilien gibt es hier vor den Filtern; der Ort ist gültig, wird aber herausgefiltert.',
|
||||
noUnfilteredAreaProperties:
|
||||
'In diesem ausgewählten Gebiet wurden auch vor den Filtern keine Immobilien gefunden.',
|
||||
relaxFiltersHint: 'Lockern oder löschen Sie Filter, um Immobilien in diesem Gebiet zu sehen.',
|
||||
viewProperties: '{{count}} Immobilien ansehen',
|
||||
priceHistory: 'Preisentwicklung',
|
||||
journeysFrom: 'Verbindungen ab {{label}}',
|
||||
|
|
@ -742,6 +752,12 @@ const de: Translations = {
|
|||
'Good+ secondary schools within 2km': 'Gute+ weiterführende Schulen im Umkreis von 2 km',
|
||||
'Good+ primary schools within 5km': 'Gute+ Grundschulen im Umkreis von 5 km',
|
||||
'Good+ secondary schools within 5km': 'Gute+ weiterführende Schulen im Umkreis von 5 km',
|
||||
'Outstanding primary schools within 2km': 'Hervorragende Grundschulen im Umkreis von 2 km',
|
||||
'Outstanding secondary schools within 2km':
|
||||
'Hervorragende weiterführende Schulen im Umkreis von 2 km',
|
||||
'Outstanding primary schools within 5km': 'Hervorragende Grundschulen im Umkreis von 5 km',
|
||||
'Outstanding secondary schools within 5km':
|
||||
'Hervorragende weiterführende Schulen im Umkreis von 5 km',
|
||||
'Education, Skills and Training Score': 'Score für Bildung, Kompetenzen und Ausbildung',
|
||||
|
||||
// ─ Feature names (Deprivation) ─
|
||||
|
|
|
|||
|
|
@ -256,6 +256,15 @@ const en = {
|
|||
areaStatistics: 'Area Statistics',
|
||||
statsFor: 'Stats for all properties in this {{type}}',
|
||||
matchingFilters: ' matching all active filters',
|
||||
filtersAffectStats:
|
||||
'Left-pane filters are applied here: values, charts, and property counts use the {{count}} active filters.',
|
||||
noFiltersAffectStats:
|
||||
'Left-pane filters update this pane: add filters to recalculate these values for matching properties.',
|
||||
noFilteredMatches: 'No properties match your filters in this area.',
|
||||
unfilteredAreaCount:
|
||||
'{{count}} properties exist here before filters, so the location is valid but filtered out.',
|
||||
noUnfilteredAreaProperties: 'No properties were found in this selected area before filters.',
|
||||
relaxFiltersHint: 'Relax or clear filters to see properties in this area.',
|
||||
viewProperties: 'View {{count}} Properties',
|
||||
priceHistory: 'Price History',
|
||||
journeysFrom: 'Journeys from {{label}}',
|
||||
|
|
@ -729,6 +738,10 @@ const en = {
|
|||
'Good+ secondary schools within 2km': 'Good+ secondary schools within 2km',
|
||||
'Good+ primary schools within 5km': 'Good+ primary schools within 5km',
|
||||
'Good+ secondary schools within 5km': 'Good+ secondary schools within 5km',
|
||||
'Outstanding primary schools within 2km': 'Outstanding primary schools within 2km',
|
||||
'Outstanding secondary schools within 2km': 'Outstanding secondary schools within 2km',
|
||||
'Outstanding primary schools within 5km': 'Outstanding primary schools within 5km',
|
||||
'Outstanding secondary schools within 5km': 'Outstanding secondary schools within 5km',
|
||||
'Education, Skills and Training Score': 'Education, Skills and Training Score',
|
||||
|
||||
// ─ Feature names (Deprivation) ─
|
||||
|
|
|
|||
|
|
@ -263,6 +263,16 @@ const fr: Translations = {
|
|||
areaStatistics: 'Statistiques de la zone',
|
||||
statsFor: 'Statistiques pour toutes les propriétés de ce/cette {{type}}',
|
||||
matchingFilters: ' correspondant à tous les filtres actifs',
|
||||
filtersAffectStats:
|
||||
'Les filtres du panneau de gauche sont appliqués ici : valeurs, graphiques et nombres de propriétés utilisent les {{count}} filtres actifs.',
|
||||
noFiltersAffectStats:
|
||||
'Les filtres du panneau de gauche mettent ce panneau à jour : ajoutez des filtres pour recalculer ces valeurs pour les propriétés correspondantes.',
|
||||
noFilteredMatches: 'Aucune propriété de cette zone ne correspond à vos filtres.',
|
||||
unfilteredAreaCount:
|
||||
'{{count}} propriétés existent ici avant les filtres ; le lieu est valide, mais filtré.',
|
||||
noUnfilteredAreaProperties:
|
||||
'Aucune propriété n’a été trouvée dans cette zone sélectionnée avant les filtres.',
|
||||
relaxFiltersHint: 'Assouplissez ou effacez les filtres pour voir les propriétés de cette zone.',
|
||||
viewProperties: 'Voir {{count}} propriétés',
|
||||
priceHistory: 'Historique des prix',
|
||||
journeysFrom: 'Trajets depuis {{label}}',
|
||||
|
|
@ -745,6 +755,10 @@ const fr: Translations = {
|
|||
'Good+ secondary schools within 2km': 'Collèges/lycées Bien+ dans un rayon de 2 km',
|
||||
'Good+ primary schools within 5km': 'Écoles primaires Bien+ dans un rayon de 5 km',
|
||||
'Good+ secondary schools within 5km': 'Collèges/lycées Bien+ dans un rayon de 5 km',
|
||||
'Outstanding primary schools within 2km': 'Écoles primaires Excellent dans un rayon de 2 km',
|
||||
'Outstanding secondary schools within 2km': 'Collèges/lycées Excellent dans un rayon de 2 km',
|
||||
'Outstanding primary schools within 5km': 'Écoles primaires Excellent dans un rayon de 5 km',
|
||||
'Outstanding secondary schools within 5km': 'Collèges/lycées Excellent dans un rayon de 5 km',
|
||||
'Education, Skills and Training Score': 'Score éducation, compétences et formation',
|
||||
|
||||
// ─ Feature names (Deprivation) ─
|
||||
|
|
|
|||
|
|
@ -257,6 +257,16 @@ const hu: Translations = {
|
|||
areaStatistics: 'Területi statisztikák',
|
||||
statsFor: 'Statisztikák a(z) {{type}} összes ingatlanáról',
|
||||
matchingFilters: ' az összes aktív szűrőnek megfelelően',
|
||||
filtersAffectStats:
|
||||
'A bal oldali panel szűrői itt is érvényesek: az értékek, diagramok és ingatlanszámok a(z) {{count}} aktív szűrőt használják.',
|
||||
noFiltersAffectStats:
|
||||
'A bal oldali panel szűrői frissítik ezt a panelt: adjon hozzá szűrőket, hogy ezek az értékek az illeszkedő ingatlanokra számolódjanak újra.',
|
||||
noFilteredMatches: 'Ezen a területen egyetlen ingatlan sem felel meg a szűrőknek.',
|
||||
unfilteredAreaCount:
|
||||
'{{count}} ingatlan található itt szűrők nélkül, tehát a hely érvényes, csak a szűrők kizárják.',
|
||||
noUnfilteredAreaProperties:
|
||||
'A kiválasztott területen szűrők nélkül sem található ingatlan.',
|
||||
relaxFiltersHint: 'Lazítson vagy törölje a szűrőket, hogy lássa a terület ingatlanjait.',
|
||||
viewProperties: '{{count}} ingatlan megtekintése',
|
||||
priceHistory: 'Ártörténet',
|
||||
journeysFrom: 'Utazások innen: {{label}}',
|
||||
|
|
@ -737,6 +747,10 @@ const hu: Translations = {
|
|||
'Good+ secondary schools within 2km': 'Jó+ középiskolák 2 km-en belül',
|
||||
'Good+ primary schools within 5km': 'Jó+ általános iskolák 5 km-en belül',
|
||||
'Good+ secondary schools within 5km': 'Jó+ középiskolák 5 km-en belül',
|
||||
'Outstanding primary schools within 2km': 'Kiemelkedő általános iskolák 2 km-en belül',
|
||||
'Outstanding secondary schools within 2km': 'Kiemelkedő középiskolák 2 km-en belül',
|
||||
'Outstanding primary schools within 5km': 'Kiemelkedő általános iskolák 5 km-en belül',
|
||||
'Outstanding secondary schools within 5km': 'Kiemelkedő középiskolák 5 km-en belül',
|
||||
'Education, Skills and Training Score': 'Oktatás, készségek és képzés pontszám',
|
||||
|
||||
// ─ Feature names (Deprivation) ─
|
||||
|
|
|
|||
|
|
@ -254,6 +254,14 @@ const zh: Translations = {
|
|||
areaStatistics: '区域统计',
|
||||
statsFor: '该{{type}}内所有房产的统计数据',
|
||||
matchingFilters: ',满足所有当前筛选条件',
|
||||
filtersAffectStats:
|
||||
'左侧面板的筛选条件会应用到这里:数值、图表和房产数量都会使用 {{count}} 个当前筛选条件。',
|
||||
noFiltersAffectStats:
|
||||
'左侧面板的筛选条件会更新此面板:添加筛选条件后,这些值会按匹配的房产重新计算。',
|
||||
noFilteredMatches: '该区域没有房产符合当前筛选条件。',
|
||||
unfilteredAreaCount: '筛选前这里有 {{count}} 处房产;位置有效,但被筛选条件排除了。',
|
||||
noUnfilteredAreaProperties: '筛选前该选定区域内也没有找到房产。',
|
||||
relaxFiltersHint: '放宽或清除筛选条件即可查看该区域的房产。',
|
||||
viewProperties: '查看 {{count}} 处房产',
|
||||
priceHistory: '价格历史',
|
||||
journeysFrom: '从 {{label}} 出发的路线',
|
||||
|
|
@ -661,7 +669,7 @@ const zh: Translations = {
|
|||
'设置预算、通勤上限、学校质量、犯罪门槛。您关心的一切。只有符合条件的区域会保持高亮。使用眼睛图标按任意特征着色。',
|
||||
step2Title: '或者直接描述',
|
||||
step2Content:
|
||||
'用中文输入您的需求,例如“安静的地区,靠近好学校,£400k 以下”,我们会为您设置筛选。',
|
||||
'用中文输入您的需求,例如“安静的地区,靠近好学校,£40万 以下”,我们会为您设置筛选。',
|
||||
step3Title: '探索现有住宅',
|
||||
step3Content:
|
||||
'在英格兰各地平移和缩放。点击任何彩色区域查看犯罪、学校、价格、宽带、噪音等信息。',
|
||||
|
|
@ -712,6 +720,10 @@ const zh: Translations = {
|
|||
'Good+ secondary schools within 2km': '2公里内良好+中学数量',
|
||||
'Good+ primary schools within 5km': '5公里内良好+小学数量',
|
||||
'Good+ secondary schools within 5km': '5公里内良好+中学数量',
|
||||
'Outstanding primary schools within 2km': '2公里内优秀小学数量',
|
||||
'Outstanding secondary schools within 2km': '2公里内优秀中学数量',
|
||||
'Outstanding primary schools within 5km': '5公里内优秀小学数量',
|
||||
'Outstanding secondary schools within 5km': '5公里内优秀中学数量',
|
||||
'Education, Skills and Training Score': '教育、技能和培训得分',
|
||||
|
||||
// ─ Feature names (Deprivation) ─
|
||||
|
|
|
|||
|
|
@ -44,6 +44,46 @@ export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[]
|
|||
{ t: 1, color: [142, 68, 173] },
|
||||
];
|
||||
|
||||
export type GradientStop = { t: number; color: [number, number, number] };
|
||||
|
||||
function partyGradient(color: [number, number, number]): GradientStop[] {
|
||||
return [
|
||||
{ t: 0, color: [255, 255, 255] },
|
||||
{
|
||||
t: 0.5,
|
||||
color: [
|
||||
Math.round(255 + (color[0] - 255) * 0.45),
|
||||
Math.round(255 + (color[1] - 255) * 0.45),
|
||||
Math.round(255 + (color[2] - 255) * 0.45),
|
||||
],
|
||||
},
|
||||
{ t: 1, color },
|
||||
];
|
||||
}
|
||||
|
||||
/** UK party colours for the 2024 General Election vote-share map layers. */
|
||||
export const PARTY_FEATURE_GRADIENTS: Record<string, GradientStop[]> = {
|
||||
'% Labour': partyGradient([228, 0, 59]), // Labour red
|
||||
'% Conservative': partyGradient([0, 135, 220]), // Conservative blue
|
||||
'% Liberal Democrat': partyGradient([255, 100, 0]), // Liberal Democrat orange
|
||||
'% Reform UK': partyGradient([18, 182, 207]), // Reform UK cyan
|
||||
'% Green': partyGradient([106, 176, 35]), // Green Party green
|
||||
'% Other parties': partyGradient([107, 114, 128]), // neutral fallback for grouped parties
|
||||
};
|
||||
|
||||
export const PARTY_FEATURE_COLORS: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(PARTY_FEATURE_GRADIENTS).map(([featureName, gradient]) => {
|
||||
const color = gradient[gradient.length - 1].color;
|
||||
return [featureName, `rgb(${color[0]}, ${color[1]}, ${color[2]})`];
|
||||
})
|
||||
);
|
||||
|
||||
export function getFeatureGradient(featureName: string | null | undefined): GradientStop[] {
|
||||
return featureName
|
||||
? (PARTY_FEATURE_GRADIENTS[featureName] ?? FEATURE_GRADIENT)
|
||||
: FEATURE_GRADIENT;
|
||||
}
|
||||
|
||||
/** Number of properties gradient — light mode (cream → orange) */
|
||||
export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||
{ t: 0, color: [255, 255, 255] },
|
||||
|
|
|
|||
|
|
@ -122,6 +122,16 @@ export function buildPropertySearchUrls({
|
|||
? (tenureFilter as string[])
|
||||
: [];
|
||||
|
||||
const habitableRoomsFilter = filters['Number of bedrooms & living rooms'];
|
||||
const minBedrooms =
|
||||
Array.isArray(habitableRoomsFilter) && typeof habitableRoomsFilter[0] === 'number'
|
||||
? Math.max(0, habitableRoomsFilter[0] - 1)
|
||||
: undefined;
|
||||
const maxBedrooms =
|
||||
Array.isArray(habitableRoomsFilter) && typeof habitableRoomsFilter[1] === 'number'
|
||||
? Math.max(0, habitableRoomsFilter[1] - 1)
|
||||
: undefined;
|
||||
|
||||
// Rightmove — requires locationIdentifier from typeahead API
|
||||
let rightmove: string | null = null;
|
||||
if (rightmoveLocationId) {
|
||||
|
|
@ -134,6 +144,8 @@ export function buildPropertySearchUrls({
|
|||
rmParams.set('minPrice', String(snapToAllowed(minPrice, RIGHTMOVE_PRICES, 'floor')));
|
||||
if (maxPrice !== undefined)
|
||||
rmParams.set('maxPrice', String(snapToAllowed(maxPrice, RIGHTMOVE_PRICES, 'ceil')));
|
||||
if (minBedrooms !== undefined) rmParams.set('minBedrooms', String(minBedrooms));
|
||||
if (maxBedrooms !== undefined) rmParams.set('maxBedrooms', String(maxBedrooms));
|
||||
if (selectedTypes.length > 0) {
|
||||
const rmTypes = [
|
||||
...new Set(
|
||||
|
|
@ -161,6 +173,8 @@ export function buildPropertySearchUrls({
|
|||
otmParams.set('min-price', String(snapToAllowed(minPrice, OTM_PRICES, 'floor')));
|
||||
if (maxPrice !== undefined)
|
||||
otmParams.set('max-price', String(snapToAllowed(maxPrice, OTM_PRICES, 'ceil')));
|
||||
if (minBedrooms !== undefined) otmParams.set('min-bedrooms', String(minBedrooms));
|
||||
if (maxBedrooms !== undefined) otmParams.set('max-bedrooms', String(maxBedrooms));
|
||||
if (selectedTypes.length > 0) {
|
||||
const otmTypes = [
|
||||
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean)),
|
||||
|
|
@ -181,6 +195,8 @@ export function buildPropertySearchUrls({
|
|||
zParams.set('price_min', String(snapToAllowed(minPrice, ZOOPLA_PRICES, 'floor')));
|
||||
if (maxPrice !== undefined)
|
||||
zParams.set('price_max', String(snapToAllowed(maxPrice, ZOOPLA_PRICES, 'ceil')));
|
||||
if (minBedrooms !== undefined) zParams.set('beds_min', String(minBedrooms));
|
||||
if (maxBedrooms !== undefined) zParams.set('beds_max', String(maxBedrooms));
|
||||
if (selectedTypes.length > 0) {
|
||||
const zTypes = [
|
||||
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.zoopla).filter(Boolean)),
|
||||
|
|
|
|||
|
|
@ -129,6 +129,19 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
|
|||
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
|
||||
</>
|
||||
),
|
||||
'Outstanding primary schools within 5km': (
|
||||
<>
|
||||
<path d="M4 19V9l8-6 8 6v10" />
|
||||
<path d="M9 19v-6h6v6" />
|
||||
<line x1="4" y1="19" x2="20" y2="19" />
|
||||
</>
|
||||
),
|
||||
'Outstanding secondary schools within 5km': (
|
||||
<>
|
||||
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
|
||||
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
|
||||
</>
|
||||
),
|
||||
'Good+ primary schools within 2km': (
|
||||
<>
|
||||
<path d="M4 19V9l8-6 8 6v10" />
|
||||
|
|
@ -142,6 +155,19 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
|
|||
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
|
||||
</>
|
||||
),
|
||||
'Outstanding primary schools within 2km': (
|
||||
<>
|
||||
<path d="M4 19V9l8-6 8 6v10" />
|
||||
<path d="M9 19v-6h6v6" />
|
||||
<line x1="4" y1="19" x2="20" y2="19" />
|
||||
</>
|
||||
),
|
||||
'Outstanding secondary schools within 2km': (
|
||||
<>
|
||||
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
|
||||
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
|
||||
</>
|
||||
),
|
||||
|
||||
// ── Deprivation ──────────────────────────────
|
||||
'Income Score (rate)': (
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import i18n from 'i18next';
|
||||
|
||||
interface ValueFormat {
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
|
|
@ -5,10 +7,31 @@ interface ValueFormat {
|
|||
raw?: boolean;
|
||||
}
|
||||
|
||||
function usesChineseNumberUnits(): boolean {
|
||||
return i18n.language?.toLowerCase().startsWith('zh') ?? false;
|
||||
}
|
||||
|
||||
function formatChineseCompactNumber(value: number): string | null {
|
||||
const abs = Math.abs(value);
|
||||
if (abs >= 100_000_000) return `${trimFixed(value / 100_000_000)}亿`;
|
||||
if (abs >= 10_000) return `${trimFixed(value / 10_000)}万`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function trimFixed(value: number): string {
|
||||
return value.toFixed(1).replace(/\.0$/, '');
|
||||
}
|
||||
|
||||
export function formatValue(value: number, fmt?: ValueFormat): string {
|
||||
const p = fmt?.prefix ?? '';
|
||||
const s = fmt?.suffix ?? '';
|
||||
if (fmt?.raw) return `${p}${Math.round(value)}${s}`;
|
||||
if (usesChineseNumberUnits()) {
|
||||
const chineseCompactValue = formatChineseCompactNumber(value);
|
||||
if (chineseCompactValue) return `${p}${chineseCompactValue}${s}`;
|
||||
if (Number.isInteger(value)) return `${p}${value.toLocaleString()}${s}`;
|
||||
return `${p}${value.toFixed(1)}${s}`;
|
||||
}
|
||||
if (Math.abs(value) >= 1_000_000) return `${p}${(value / 1_000_000).toFixed(1)}M${s}`;
|
||||
if (Math.abs(value) >= 1_000) return `${p}${(value / 1_000).toFixed(1)}k${s}`;
|
||||
if (Number.isInteger(value)) return `${p}${value.toLocaleString()}${s}`;
|
||||
|
|
@ -17,6 +40,12 @@ export function formatValue(value: number, fmt?: ValueFormat): string {
|
|||
|
||||
export function formatFilterValue(value: number, raw?: boolean): string {
|
||||
if (raw) return Math.round(value).toString();
|
||||
if (usesChineseNumberUnits()) {
|
||||
const chineseCompactValue = formatChineseCompactNumber(value);
|
||||
if (chineseCompactValue) return chineseCompactValue;
|
||||
if (Number.isInteger(value)) return value.toString();
|
||||
return value.toFixed(2);
|
||||
}
|
||||
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
|
||||
if (Number.isInteger(value)) return value.toString();
|
||||
|
|
@ -31,14 +60,17 @@ export function parseInputValue(
|
|||
let s = text.trim();
|
||||
if (opts?.prefix) s = s.replace(new RegExp(`^\\${opts.prefix}`), '');
|
||||
if (opts?.suffix) s = s.replace(new RegExp(`${opts.suffix.trim()}$`), '');
|
||||
s = s.trim().replace(/,/g, '');
|
||||
const m = s.match(/^(-?\d+\.?\d*)\s*([kKmM]?)$/);
|
||||
s = s.trim().replace(/[,,]/g, '');
|
||||
const m = s.match(/^(-?\d+\.?\d*)\s*([kKmM万亿億]?)$/);
|
||||
if (!m) return null;
|
||||
let val = parseFloat(m[1]);
|
||||
if (isNaN(val)) return null;
|
||||
const unit = m[2].toLowerCase();
|
||||
const unit = m[2];
|
||||
if (unit === 'k') val *= 1_000;
|
||||
else if (unit === 'm') val *= 1_000_000;
|
||||
else if (unit === 'K') val *= 1_000;
|
||||
else if (unit === 'm' || unit === 'M') val *= 1_000_000;
|
||||
else if (unit === '万') val *= 10_000;
|
||||
else if (unit === '亿' || unit === '億') val *= 100_000_000;
|
||||
if (opts?.step) val = Math.round(val / opts.step) * opts.step;
|
||||
return val;
|
||||
}
|
||||
|
|
@ -102,9 +134,7 @@ export function roundedPercentages(values: number[], total: number, decimals = 0
|
|||
const floors = raw.map((r) => Math.floor(r));
|
||||
const result = floors.slice();
|
||||
let diff = targetSum - floors.reduce((a, b) => a + b, 0);
|
||||
const order = raw
|
||||
.map((r, i) => ({ i, frac: r - floors[i] }))
|
||||
.sort((a, b) => b.frac - a.frac);
|
||||
const order = raw.map((r, i) => ({ i, frac: r - floors[i] })).sort((a, b) => b.frac - a.frac);
|
||||
for (let k = 0; k < order.length && diff > 0; k++) {
|
||||
result[order[k].i] += 1;
|
||||
diff -= 1;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
TWEMOJI_BASE,
|
||||
BUFFER_MULTIPLIER,
|
||||
ENUM_PALETTE,
|
||||
type GradientStop,
|
||||
} from './consts';
|
||||
const ROAD_OPACITY = 0.4;
|
||||
|
||||
|
|
@ -64,8 +65,6 @@ export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
|
|||
} as StyleSpecification;
|
||||
}
|
||||
|
||||
type GradientStop = { t: number; color: [number, number, number] };
|
||||
|
||||
// Oklab color space for perceptually uniform interpolation
|
||||
function srgbToLinear(c: number): number {
|
||||
const v = c / 255;
|
||||
|
|
@ -131,8 +130,11 @@ function interpolateGradient(t: number, gradient: GradientStop[]): [number, numb
|
|||
return gradient[gradient.length - 1].color;
|
||||
}
|
||||
|
||||
function normalizedToColor(t: number): [number, number, number] {
|
||||
return interpolateGradient(t, FEATURE_GRADIENT);
|
||||
function normalizedToColor(
|
||||
t: number,
|
||||
gradient: GradientStop[] = FEATURE_GRADIENT
|
||||
): [number, number, number] {
|
||||
return interpolateGradient(t, gradient);
|
||||
}
|
||||
|
||||
function countToColor(
|
||||
|
|
@ -220,7 +222,8 @@ export function getFeatureFillColor(
|
|||
isDark: boolean,
|
||||
alpha: number,
|
||||
enumCount: number = 0,
|
||||
enumPalette?: [number, number, number][]
|
||||
enumPalette?: [number, number, number][],
|
||||
featureGradient: GradientStop[] = FEATURE_GRADIENT
|
||||
): [number, number, number, number] {
|
||||
if (colorRange) {
|
||||
if (value == null)
|
||||
|
|
@ -244,9 +247,9 @@ export function getFeatureFillColor(
|
|||
|
||||
const range = colorRange[1] - colorRange[0];
|
||||
if (range === 0)
|
||||
return [...FEATURE_GRADIENT[0].color, alpha] as [number, number, number, number];
|
||||
return [...featureGradient[0].color, alpha] as [number, number, number, number];
|
||||
const t = ((value as number) - colorRange[0]) / range;
|
||||
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)));
|
||||
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)), featureGradient);
|
||||
return [...rgb, alpha] as [number, number, number, number];
|
||||
}
|
||||
return [...countToColor(Math.max(0, Math.min(1, countNormalized)), densityGradient), alpha] as [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue