@@ -617,6 +760,11 @@ export default function AreaPane({
: STACKED_SEGMENT_COLORS
}
/>
+ {numberLinePoints.length >= 2 && (
+
+ )}
{crimeSeries && crimeSeries.points.length > 1 && (
);
}
diff --git a/frontend/src/components/map/DevelopmentPopup.tsx b/frontend/src/components/map/DevelopmentPopup.tsx
new file mode 100644
index 0000000..8174469
--- /dev/null
+++ b/frontend/src/components/map/DevelopmentPopup.tsx
@@ -0,0 +1,72 @@
+import { memo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import type { Development } from '../../types';
+
+/** Turn a raw server status like "full-planning-permission" into "Full planning permission". */
+function prettifyStatus(value: string): string {
+ const cleaned = value.replace(/[-_]+/g, ' ').trim();
+ if (!cleaned) return value;
+ return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
+}
+
+export const DevelopmentPopupContent = memo(function DevelopmentPopupContent({
+ development,
+}: {
+ development: Development;
+}) {
+ const { t } = useTranslation();
+ const min = development.min_dwellings ?? null;
+ const max = development.max_dwellings ?? null;
+
+ let dwellings: string | null = null;
+ if (min != null && max != null) {
+ dwellings =
+ min === max
+ ? t('newDevelopments.homesExact', { count: max })
+ : t('newDevelopments.homesRange', { min, max });
+ } else if (max != null) {
+ dwellings = t('newDevelopments.homesUpTo', { count: max });
+ } else if (min != null) {
+ dwellings = t('newDevelopments.homesExact', { count: min });
+ }
+
+ const sourceLabel =
+ development.source === 'homes-england'
+ ? t('newDevelopments.sourceHomesEngland')
+ : t('newDevelopments.sourceBrownfield');
+
+ return (
+
+
+ {development.name || t('newDevelopments.title')}
+
+ {dwellings && (
+
+ {dwellings}
+
+ )}
+ {development.planning_status && (
+
+ {t('newDevelopments.planningStatus')}: {prettifyStatus(development.planning_status)}
+
+ )}
+ {development.local_authority && (
+
+ {t('newDevelopments.localAuthority')}: {development.local_authority}
+
+ )}
+
{sourceLabel}
+ {development.url && (
+
+ {t('newDevelopments.viewRecord')}
+
+ )}
+
+ );
+});
diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx
index 4dc7f12..32ee222 100644
--- a/frontend/src/components/map/Filters.tsx
+++ b/frontend/src/components/map/Filters.tsx
@@ -37,6 +37,7 @@ import {
isEthnicityFeatureName,
isEthnicityFilterName,
} from '../../lib/ethnicity-filter';
+import { isQualificationFeatureName } from '../../lib/qualification-filter';
import {
SCHOOL_FILTER_NAME,
getDefaultSchoolFeatureName,
@@ -347,6 +348,10 @@ export default memo(function Filters({
maybeInsertPoiFilter(getPoiFilterName(feature.name));
continue;
}
+ // Qualification breakdown is display-only (shown as the stacked
+ // "Qualifications" composition in the area pane), so keep its seven
+ // component features out of the filter browser.
+ if (isQualificationFeatureName(feature.name)) continue;
if (!enabledFeatures.has(feature.name)) result.push(feature);
}
diff --git a/frontend/src/components/map/Map.tsx b/frontend/src/components/map/Map.tsx
index 8970251..c31a649 100644
--- a/frontend/src/components/map/Map.tsx
+++ b/frontend/src/components/map/Map.tsx
@@ -24,13 +24,24 @@ import {
getMapStyle,
getMapDataBeforeId,
getMapCenterForTargetScreenPoint,
+ selectSpacedItems,
+ type PlacedItem,
} from '../../lib/map-utils';
-import { MAP_MIN_ZOOM, MAP_BOUNDS, POI_AUTO_CARD_ZOOM_THRESHOLD } from '../../lib/consts';
+import {
+ MAP_MIN_ZOOM,
+ MAP_BOUNDS,
+ POI_AUTO_CARD_ZOOM_THRESHOLD,
+ MAX_AUTO_POI_CARDS,
+ AUTO_POI_CARD_MIN_DX,
+ AUTO_POI_CARD_MIN_DY,
+ POSTCODE_ZOOM_THRESHOLD,
+} from '../../lib/consts';
import type { SearchedLocation } from './LocationSearch';
import { LogoIcon } from '../ui/icons/LogoIcon';
import { CloseIcon } from '../ui/icons/CloseIcon';
import type { FeatureFilters } from '../../types';
import { useDeckLayers } from '../../hooks/useDeckLayers';
+import { useDevelopments } from '../../hooks/useDevelopments';
import { useMapCardLayout } from '../../hooks/useMapCardLayout';
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
import { type OverlayId } from '../../lib/overlays';
@@ -41,6 +52,7 @@ import { OverlayTileLayers } from './OverlayTileLayers';
import { MapTopCards } from './MapTopCards';
import { PoiPopupCardContent } from './PoiPopupCard';
import { ListingClusterPopupContent, ListingPopupSingleContent } from './ListingPopups';
+import { DevelopmentPopupContent } from './DevelopmentPopup';
import { HoverCardOverlay } from './HoverCardOverlay';
interface MapProps {
@@ -425,6 +437,14 @@ export default memo(function Map({
return center ? { lat: center.lat, lng: center.lng } : null;
}, []);
+ // Only fetch/show developments once zoomed in, matching the tile overlays
+ // (noise/crime/trees/borders) which all gate on POSTCODE_ZOOM_THRESHOLD.
+ const developmentsEnabled =
+ activeOverlays.has('new-developments') && viewState.zoom >= POSTCODE_ZOOM_THRESHOLD;
+ const { developments } = useDevelopments(viewportBounds ?? null, {
+ enabled: developmentsEnabled,
+ });
+
const {
layers,
popupInfo,
@@ -432,6 +452,8 @@ export default memo(function Map({
visiblePois,
listingPopup,
clearListingPopup,
+ developmentPopup,
+ clearDevelopmentPopup,
hoverPosition,
countRange,
postcodeCountRange,
@@ -444,6 +466,7 @@ export default memo(function Map({
zoom: viewState.zoom,
pois,
actualListings,
+ developments,
viewFeature,
colorRange,
filterRange,
@@ -468,7 +491,8 @@ export default memo(function Map({
return [];
}
- return visiblePois.flatMap((poi) => {
+ const candidates: PlacedItem
[] = [];
+ for (const poi of visiblePois) {
const point = map.project([poi.lng, poi.lat]);
if (
!Number.isFinite(point.x) ||
@@ -478,10 +502,18 @@ export default memo(function Map({
point.y < 0 ||
point.y > dimensions.height
) {
- return [];
+ continue;
}
- return [{ poi, x: point.x, y: point.y }];
- });
+ candidates.push({ item: poi, x: point.x, y: point.y });
+ }
+ // Cull overlapping cards so a dense area with many categories doesn't render a
+ // wall of hundreds of cards over the map.
+ return selectSpacedItems(
+ candidates,
+ AUTO_POI_CARD_MIN_DX,
+ AUTO_POI_CARD_MIN_DY,
+ MAX_AUTO_POI_CARDS
+ );
// viewState isn't read directly but drives map.project — recompute when the camera moves.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showAutoPoiCards, mapReady, visiblePois, dimensions, viewState]);
@@ -599,7 +631,7 @@ export default memo(function Map({
theme={theme}
/>
)}
- {autoPoiCards.map(({ poi, x, y }) => (
+ {autoPoiCards.map(({ item: poi, x, y }) => (
)}
+ {developmentPopup && (
+
+
+
+
+ )}
{hoverPosition && hoveredHexagonId && hoveredHexagonId !== selectedHexagonId && (
0 ? fetchedPois : EMPTY_POIS;
const overlaysZoomedIn = (mapData.currentView?.zoom ?? 0) >= POSTCODE_ZOOM_THRESHOLD;
const actualListingsFilterParam = useMemo(
() => buildFilterString(filters, features),
diff --git a/frontend/src/components/map/NumberLine.test.ts b/frontend/src/components/map/NumberLine.test.ts
new file mode 100644
index 0000000..dd104f2
--- /dev/null
+++ b/frontend/src/components/map/NumberLine.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from 'vitest';
+import { computeNumberLineLayout, type NumberLinePoint } from './NumberLine';
+
+const fmt = (v: number) => v.toFixed(0);
+
+const points: NumberLinePoint[] = [
+ { kind: 'area', label: 'This area', value: 50 },
+ { kind: 'national', label: 'National', value: 30 },
+ { kind: 'outcode', label: 'SE3', value: 49 },
+ { kind: 'sector', label: 'SE3 9', value: 51 },
+];
+
+describe('computeNumberLineLayout', () => {
+ it('returns null when there is nothing to draw', () => {
+ expect(computeNumberLineLayout(points, 0, fmt)).toBeNull();
+ expect(computeNumberLineLayout([], 300, fmt)).toBeNull();
+ });
+
+ it('places ticks ordered by value', () => {
+ const layout = computeNumberLineLayout(points, 300, fmt)!;
+ expect(layout).not.toBeNull();
+ // Items are sorted by tick position, which mirrors value order.
+ const ticks = layout.items.map((i) => i.tickX);
+ for (let i = 1; i < ticks.length; i++) {
+ expect(ticks[i]).toBeGreaterThanOrEqual(ticks[i - 1]);
+ }
+ // The smallest value (national, 30) sits left of the largest (sector, 51).
+ const national = layout.items.find((i) => i.kind === 'national')!;
+ const sector = layout.items.find((i) => i.kind === 'sector')!;
+ expect(national.tickX).toBeLessThan(sector.tickX);
+ });
+
+ it('maps the lowest value to the left edge and the highest to the right', () => {
+ const layout = computeNumberLineLayout(
+ [
+ { kind: 'national', label: 'N', value: 30 },
+ { kind: 'area', label: 'A', value: 50 },
+ { kind: 'sector', label: 'S', value: 45 },
+ ],
+ 300,
+ fmt
+ )!;
+ const low = layout.items.find((i) => i.kind === 'national')!; // 30 — lowest
+ const high = layout.items.find((i) => i.kind === 'area')!; // 50 — highest
+ expect(low.tickX).toBeCloseTo(layout.plotLeft, 5);
+ expect(high.tickX).toBeCloseTo(layout.plotRight, 5);
+ });
+
+ it('centres ticks when all values are equal', () => {
+ const layout = computeNumberLineLayout(
+ [
+ { kind: 'area', label: 'A', value: 7 },
+ { kind: 'national', label: 'N', value: 7 },
+ ],
+ 300,
+ fmt
+ )!;
+ const centre = (layout.plotLeft + layout.plotRight) / 2;
+ for (const item of layout.items) {
+ expect(item.tickX).toBeCloseTo(centre, 5);
+ }
+ });
+
+ it('spreads clustered labels so their boxes never overlap', () => {
+ const layout = computeNumberLineLayout(points, 300, fmt)!;
+ for (let i = 1; i < layout.items.length; i++) {
+ const prev = layout.items[i - 1];
+ const cur = layout.items[i];
+ // Boxes [labelX ± halfWidth] must not overlap (GAP only adds more margin).
+ expect(cur.labelX - cur.halfWidth).toBeGreaterThanOrEqual(
+ prev.labelX + prev.halfWidth - 1e-6
+ );
+ }
+ });
+
+ it('keeps labels within the plot bounds', () => {
+ const layout = computeNumberLineLayout(points, 300, fmt)!;
+ for (const item of layout.items) {
+ expect(item.labelX - item.halfWidth).toBeGreaterThanOrEqual(layout.plotLeft - 1e-6);
+ expect(item.labelX + item.halfWidth).toBeLessThanOrEqual(layout.plotRight + 1e-6);
+ }
+ });
+});
diff --git a/frontend/src/components/map/NumberLine.tsx b/frontend/src/components/map/NumberLine.tsx
new file mode 100644
index 0000000..aac6ad5
--- /dev/null
+++ b/frontend/src/components/map/NumberLine.tsx
@@ -0,0 +1,208 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+
+export type NumberLineKind = 'area' | 'national' | 'outcode' | 'sector';
+
+export interface NumberLinePoint {
+ kind: NumberLineKind;
+ /** Short label shown above the tick (e.g. "This area", "National", "SE3", "SE3 9"). */
+ label: string;
+ value: number;
+}
+
+interface NumberLineProps {
+ points: NumberLinePoint[];
+ format: (value: number) => string;
+}
+
+const HEIGHT = 54;
+const PAD_X = 4;
+const NAME_Y = 9;
+const VALUE_Y = 19;
+const LEADER_TOP = 23;
+const BASE_Y = 40;
+const GAP = 4;
+
+// Per-kind tick (stroke) and label (fill) colours. `area` is the subject and
+// is emphasised; the rest are reference points.
+const KIND_STYLE: Record = {
+ area: { tick: 'stroke-teal-600 dark:stroke-teal-400', text: 'fill-teal-700 dark:fill-teal-300' },
+ national: {
+ tick: 'stroke-warm-400 dark:stroke-warm-500',
+ text: 'fill-warm-500 dark:fill-warm-300',
+ },
+ outcode: {
+ tick: 'stroke-amber-500 dark:stroke-amber-400',
+ text: 'fill-amber-700 dark:fill-amber-400',
+ },
+ sector: {
+ tick: 'stroke-indigo-500 dark:stroke-indigo-400',
+ text: 'fill-indigo-600 dark:fill-indigo-300',
+ },
+};
+
+export interface NumberLineLayoutItem extends NumberLinePoint {
+ valueText: string;
+ /** x of the tick (the true value position on the scale). */
+ tickX: number;
+ /** x of the (de-collided) label centre. */
+ labelX: number;
+ /** half the estimated label-box width, used for collision spacing. */
+ halfWidth: number;
+}
+
+export interface NumberLineLayout {
+ items: NumberLineLayoutItem[];
+ plotLeft: number;
+ plotRight: number;
+}
+
+/**
+ * Pure layout: place each value's tick on a scale spanning the lowest→highest
+ * value (not anchored at 0, to maximise separation) and spread the labels so
+ * their boxes never overlap (nested area/sector/outcode values tend to cluster),
+ * clamped within the plot bounds while preserving order. Returns null when there
+ * is nothing to draw. Exported for testing.
+ */
+export function computeNumberLineLayout(
+ points: NumberLinePoint[],
+ width: number,
+ format: (value: number) => string
+): NumberLineLayout | null {
+ if (width <= 0 || points.length === 0) return null;
+
+ const values = points.map((p) => p.value);
+ const lo = Math.min(...values);
+ const hi = Math.max(...values);
+ const span = hi - lo;
+ const plotLeft = PAD_X;
+ const plotRight = width - PAD_X;
+ const plotW = Math.max(1, plotRight - plotLeft);
+ // Scale spans the lowest→highest value (not anchored at 0) to maximise the
+ // separation between the clustered ticks. When all values are equal, centre them.
+ const scaleX = (value: number) =>
+ span > 0 ? plotLeft + ((value - lo) / span) * plotW : plotLeft + plotW / 2;
+
+ const items: NumberLineLayoutItem[] = points.map((point) => {
+ const valueText = format(point.value);
+ // Rough text-width estimate at the 9px font (≈5px/char), padded a little.
+ const chars = Math.max(point.label.length, valueText.length);
+ const halfWidth = Math.max(9, (chars * 5) / 2 + 2);
+ const tickX = scaleX(point.value);
+ return { ...point, valueText, tickX, halfWidth, labelX: tickX };
+ });
+
+ // Spread labels left-to-right so adjacent boxes never overlap, then clamp to
+ // the plot bounds (right pass, then left pass) preserving order.
+ items.sort((a, b) => a.tickX - b.tickX);
+ for (let i = 1; i < items.length; i++) {
+ const minX = items[i - 1].labelX + items[i - 1].halfWidth + GAP + items[i].halfWidth;
+ if (items[i].labelX < minX) items[i].labelX = minX;
+ }
+ const last = items.length - 1;
+ if (last >= 0) {
+ items[last].labelX = Math.min(items[last].labelX, plotRight - items[last].halfWidth);
+ for (let i = last - 1; i >= 0; i--) {
+ const maxX = items[i + 1].labelX - items[i + 1].halfWidth - GAP - items[i].halfWidth;
+ if (items[i].labelX > maxX) items[i].labelX = maxX;
+ }
+ items[0].labelX = Math.max(items[0].labelX, plotLeft + items[0].halfWidth);
+ for (let i = 1; i < items.length; i++) {
+ const minX = items[i - 1].labelX + items[i - 1].halfWidth + GAP + items[i].halfWidth;
+ if (items[i].labelX < minX) items[i].labelX = minX;
+ }
+ }
+
+ return { items, plotLeft, plotRight };
+}
+
+/**
+ * A compact horizontal number line that places one tick per value on a scale
+ * spanning the lowest→highest value, so the selection can be read against its
+ * reference points (national / outcode / sector) at a glance. Labels are spread
+ * to avoid overlap
+ * — common since the nested area/sector/outcode values cluster — and connected
+ * back to their tick with a thin leader line.
+ */
+export default function NumberLine({ points, format }: NumberLineProps) {
+ const containerRef = useRef(null);
+ const [width, setWidth] = useState(0);
+
+ useEffect(() => {
+ const el = containerRef.current;
+ if (!el) return;
+ const observer = new ResizeObserver((entries) => {
+ const w = entries[0].contentRect.width;
+ if (w > 0) setWidth(w);
+ });
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, []);
+
+ const laidOut = useMemo(
+ () => computeNumberLineLayout(points, width, format),
+ [points, width, format]
+ );
+
+ return (
+
+ {laidOut && (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/map/OverlayTileLayers.tsx b/frontend/src/components/map/OverlayTileLayers.tsx
index 8ca2ff3..673b953 100644
--- a/frontend/src/components/map/OverlayTileLayers.tsx
+++ b/frontend/src/components/map/OverlayTileLayers.tsx
@@ -79,8 +79,43 @@ export function OverlayTileLayers({
10,
1,
],
- 'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 15, 0.8, 18, 2.2],
- 'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 15, 18, 18, 30],
+ // police.uk snaps incidents to a sparse, fixed set of anonymised
+ // "map point" anchors (tens-to-hundreds of metres apart), so a
+ // pixel-fixed blur fragments into isolated dots as you zoom in
+ // (the ground a pixel covers halves each zoom level). Grow the
+ // radius ~geometrically with zoom to hold a roughly constant
+ // ~140m ground footprint, so neighbouring anchors' kernels keep
+ // overlapping into a connected surface — kernel-density smoothing
+ // is the honest form of "interpolating between points" for this
+ // data; we deliberately do NOT triangulate/IDW, which would
+ // invent crime values across parks/water and over-claim a
+ // precision the anonymised snap-points don't have. Cap past z16 so
+ // the blur doesn't saturate the whole screen at street zoom (where
+ // an honestly-connected crime surface isn't achievable anyway).
+ // Intensity is lowered to match: wider overlapping kernels pile up
+ // density, so the old isolated-dot boost would now go all-red.
+ 'heatmap-intensity': [
+ 'interpolate',
+ ['linear'],
+ ['zoom'],
+ 14,
+ 0.6,
+ 16,
+ 0.9,
+ 18,
+ 1.2,
+ ],
+ 'heatmap-radius': [
+ 'interpolate',
+ ['exponential', 2],
+ ['zoom'],
+ 14,
+ 24,
+ 16,
+ 94,
+ 18,
+ 120,
+ ],
'heatmap-opacity': 0.72,
'heatmap-color': [
'interpolate',
diff --git a/frontend/src/components/map/filters/ActiveFiltersPanel.tsx b/frontend/src/components/map/filters/ActiveFiltersPanel.tsx
index 3556b0d..ea38d66 100644
--- a/frontend/src/components/map/filters/ActiveFiltersPanel.tsx
+++ b/frontend/src/components/map/filters/ActiveFiltersPanel.tsx
@@ -170,6 +170,7 @@ export function ActiveFiltersPanel({