Working
This commit is contained in:
parent
14a3555cf1
commit
7e92bf112e
34 changed files with 1214437 additions and 224 deletions
6
frontend/package-lock.json
generated
6
frontend/package-lock.json
generated
|
|
@ -17,12 +17,14 @@
|
|||
"@protomaps/basemaps": "^5.7.0",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-slider": "^1.1.0",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"maplibre-gl": "^4.0.0",
|
||||
"pocketbase": "^0.26.8",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-joyride": "^2.9.3",
|
||||
"react-map-gl": "^7.1.0"
|
||||
"react-map-gl": "^7.1.0",
|
||||
"supercluster": "^8.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.0",
|
||||
|
|
@ -4729,7 +4731,6 @@
|
|||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
|
||||
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
|
|
@ -13484,7 +13485,6 @@
|
|||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
|
||||
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"kdbush": "^4.0.2"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,12 +22,14 @@
|
|||
"@protomaps/basemaps": "^5.7.0",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-slider": "^1.1.0",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"maplibre-gl": "^4.0.0",
|
||||
"pocketbase": "^0.26.8",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-joyride": "^2.9.3",
|
||||
"react-map-gl": "^7.1.0"
|
||||
"react-map-gl": "^7.1.0",
|
||||
"supercluster": "^8.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.0",
|
||||
|
|
|
|||
|
|
@ -31,8 +31,8 @@ export default memo(function HoverCard({ x, y, id, isPostcode, data, filters, fe
|
|||
|
||||
const results: { name: string; value: string }[] = [];
|
||||
|
||||
// Show stats for active filters (up to 4)
|
||||
for (const name of activeFilterNames.slice(0, 4)) {
|
||||
// Show stats for active filters (up to 4), excluding Listing status
|
||||
for (const name of activeFilterNames.filter((n) => n !== 'Listing status').slice(0, 4)) {
|
||||
const val = data[`avg_${name}`] ?? data[`min_${name}`];
|
||||
if (val == null || typeof val !== 'number') continue;
|
||||
const meta = featureMap.get(name);
|
||||
|
|
@ -50,14 +50,31 @@ export default memo(function HoverCard({ x, y, id, isPostcode, data, filters, fe
|
|||
const displayStats = getDisplayStats();
|
||||
const count = data?.count;
|
||||
|
||||
const cardStyle = {
|
||||
left: x,
|
||||
top: y - 12,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
};
|
||||
|
||||
// Loading state: show skeleton when data hasn't arrived yet
|
||||
if (!data) {
|
||||
return (
|
||||
<div
|
||||
className="absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg p-3 text-sm pointer-events-none z-50 min-w-[140px]"
|
||||
style={cardStyle}
|
||||
>
|
||||
<div className="animate-pulse space-y-2">
|
||||
<div className="h-3.5 w-20 bg-warm-200 dark:bg-warm-600 rounded" />
|
||||
<div className="h-2.5 w-14 bg-warm-100 dark:bg-warm-700 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg p-3 text-sm dark:text-white pointer-events-none z-50 min-w-[180px] max-w-[260px]"
|
||||
style={{
|
||||
left: x,
|
||||
top: y - 12,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
}}
|
||||
style={cardStyle}
|
||||
>
|
||||
<div className="relative">
|
||||
{/* Header */}
|
||||
|
|
@ -89,11 +106,9 @@ export default memo(function HoverCard({ x, y, id, isPostcode, data, filters, fe
|
|||
)}
|
||||
|
||||
{/* Hint */}
|
||||
{data && (
|
||||
<div className="text-[10px] text-warm-400 dark:text-warm-400 mt-2 text-center">
|
||||
Click for details
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[10px] text-warm-400 dark:text-warm-400 mt-2 text-center">
|
||||
Click for details
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,47 @@
|
|||
import { formatValue } from '../../lib/format';
|
||||
import { FEATURE_GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../../lib/consts';
|
||||
import { FEATURE_GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK, ENUM_PALETTE } from '../../lib/consts';
|
||||
import { gradientToCss } from '../../lib/utils';
|
||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import { TickerValue } from '../ui/TickerValue';
|
||||
|
||||
function EnumSwatches({ values }: { values: string[] }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{values.map((label, i) => {
|
||||
const color = ENUM_PALETTE[i % ENUM_PALETTE.length];
|
||||
return (
|
||||
<div key={label} className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-3 h-3 rounded-sm shrink-0"
|
||||
style={{ backgroundColor: `rgb(${color[0]},${color[1]},${color[2]})` }}
|
||||
/>
|
||||
<span className="text-warm-600 dark:text-warm-300 truncate">{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineEnumSwatches({ values }: { values: string[] }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-1 min-w-[40%] flex-wrap">
|
||||
{values.map((label, i) => {
|
||||
const color = ENUM_PALETTE[i % ENUM_PALETTE.length];
|
||||
return (
|
||||
<div key={label} className="flex items-center gap-1">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-sm shrink-0"
|
||||
style={{ backgroundColor: `rgb(${color[0]},${color[1]},${color[2]})` }}
|
||||
/>
|
||||
<span className="text-warm-500 dark:text-warm-400 whitespace-nowrap text-[11px]">{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MapLegend({
|
||||
featureLabel,
|
||||
range,
|
||||
|
|
@ -27,6 +65,7 @@ export default function MapLegend({
|
|||
suffix?: string;
|
||||
raw?: boolean;
|
||||
}) {
|
||||
const isEnum = enumValues && enumValues.length > 0;
|
||||
const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
|
||||
const gradientStyle =
|
||||
mode === 'density' ? gradientToCss(densityGradient) : gradientToCss(FEATURE_GRADIENT);
|
||||
|
|
@ -36,18 +75,14 @@ export default function MapLegend({
|
|||
const rangeMin =
|
||||
mode === 'density' ? (
|
||||
<TickerValue text={formatValue(range[0])} />
|
||||
) : enumValues && enumValues.length > 0 ? (
|
||||
<span>{enumValues[0]}</span>
|
||||
) : (
|
||||
) : isEnum ? null : (
|
||||
<TickerValue text={formatValue(range[0], fmt) + (suffix || '')} />
|
||||
);
|
||||
|
||||
const rangeMax =
|
||||
mode === 'density' ? (
|
||||
<TickerValue text={formatValue(range[1])} />
|
||||
) : enumValues && enumValues.length > 0 ? (
|
||||
<span>{enumValues[enumValues.length - 1]}</span>
|
||||
) : (
|
||||
) : isEnum ? null : (
|
||||
<TickerValue text={formatValue(range[1], fmt) + (suffix || '')} />
|
||||
);
|
||||
|
||||
|
|
@ -66,11 +101,15 @@ export default function MapLegend({
|
|||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-[40%] text-warm-500 dark:text-warm-400">
|
||||
{rangeMin}
|
||||
<div className="h-2.5 rounded flex-1 min-w-[40px]" style={{ background: gradientStyle }} />
|
||||
{rangeMax}
|
||||
</div>
|
||||
{isEnum ? (
|
||||
<InlineEnumSwatches values={enumValues} />
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-[40%] text-warm-500 dark:text-warm-400">
|
||||
{rangeMin}
|
||||
<div className="h-2.5 rounded flex-1 min-w-[40px]" style={{ background: gradientStyle }} />
|
||||
{rangeMax}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -89,11 +128,17 @@ export default function MapLegend({
|
|||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-3 rounded" style={{ background: gradientStyle }} />
|
||||
<div className="flex justify-between mt-1 text-warm-600 dark:text-warm-200">
|
||||
{rangeMin}
|
||||
{rangeMax}
|
||||
</div>
|
||||
{isEnum ? (
|
||||
<EnumSwatches values={enumValues} />
|
||||
) : (
|
||||
<>
|
||||
<div className="h-3 rounded" style={{ background: gradientStyle }} />
|
||||
<div className="flex justify-between mt-1 text-warm-600 dark:text-warm-200">
|
||||
{rangeMin}
|
||||
{rangeMax}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ export default function MapPage({
|
|||
useState<Set<string>>(initialPOICategories);
|
||||
|
||||
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 600, 'left');
|
||||
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(288, 200, 500, 'right');
|
||||
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 500, 'right');
|
||||
|
||||
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
||||
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { FeatureMeta } from '../../types';
|
||||
import { InfoIcon } from './icons';
|
||||
import { getFeatureIcon } from '../../lib/feature-icons';
|
||||
import { getGroupIcon } from '../../lib/group-icons';
|
||||
|
||||
const MODE_LABELS: Record<string, string> = {
|
||||
|
|
@ -22,7 +23,9 @@ export function FeatureLabel({
|
|||
size = 'xs',
|
||||
}: FeatureLabelProps) {
|
||||
const textClass = size === 'sm' ? 'text-sm' : 'text-xs';
|
||||
const GroupIcon = feature.group ? getGroupIcon(feature.group) : null;
|
||||
const iconClass = 'w-3.5 h-3.5 text-teal-600 dark:text-teal-400 shrink-0';
|
||||
const featureIcon = getFeatureIcon(feature.name, iconClass);
|
||||
const GroupIcon = !featureIcon && feature.group ? getGroupIcon(feature.group) : null;
|
||||
const modeTag =
|
||||
feature.modes && feature.modes.length > 0
|
||||
? feature.modes.map((m) => MODE_LABELS[m] || m).join(' · ')
|
||||
|
|
@ -32,9 +35,8 @@ export function FeatureLabel({
|
|||
<div
|
||||
className={`flex ${size === 'xs' ? 'items-center' : 'items-start'} gap-1 min-w-0 ${className}`}
|
||||
>
|
||||
{GroupIcon && (
|
||||
<GroupIcon className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400 shrink-0" />
|
||||
)}
|
||||
{featureIcon}
|
||||
{GroupIcon && <GroupIcon className={iconClass} />}
|
||||
<span
|
||||
className={`${textClass} text-warm-700 dark:text-warm-300 ${size === 'xs' ? 'truncate' : ''}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -143,6 +143,13 @@ export function useDeckLayers({
|
|||
const colorFeatureMetaRef = useRef(colorFeatureMeta);
|
||||
colorFeatureMetaRef.current = colorFeatureMeta;
|
||||
|
||||
// Track enum value count for discrete coloring (0 = numeric/continuous)
|
||||
const enumCountRef = useRef(0);
|
||||
enumCountRef.current =
|
||||
colorFeatureMeta?.type === 'enum' && colorFeatureMeta.values
|
||||
? colorFeatureMeta.values.length
|
||||
: 0;
|
||||
|
||||
// --- Count ranges ---
|
||||
const countRange = useMemo(() => {
|
||||
if (data.length === 0) return { min: 0, max: 1 };
|
||||
|
|
@ -360,7 +367,8 @@ export function useDeckLayers({
|
|||
0,
|
||||
densityGradientRef.current,
|
||||
dark,
|
||||
255
|
||||
255,
|
||||
enumCountRef.current
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -433,7 +441,8 @@ export function useDeckLayers({
|
|||
0,
|
||||
densityGradientRef.current,
|
||||
dark,
|
||||
180
|
||||
180,
|
||||
enumCountRef.current
|
||||
);
|
||||
}
|
||||
const cr = postcodeCountRangeRef.current;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,15 @@ function looksLikePostcode(s: string) {
|
|||
return POSTCODE_RE.test(s.trim());
|
||||
}
|
||||
|
||||
/** Normalize a UK postcode: uppercase, strip spaces, insert canonical space before inward code. */
|
||||
function normalizePostcode(s: string): string {
|
||||
const stripped = s.replace(/\s+/g, '').toUpperCase();
|
||||
if (stripped.length >= 5) {
|
||||
return stripped.slice(0, -3) + ' ' + stripped.slice(-3);
|
||||
}
|
||||
return stripped;
|
||||
}
|
||||
|
||||
export type SearchResult =
|
||||
| { type: 'postcode'; label: string }
|
||||
| { type: 'place'; name: string; slug: string; place_type: string; lat: number; lon: number; city?: string };
|
||||
|
|
@ -35,7 +44,7 @@ export function useLocationSearch(mode?: string) {
|
|||
}
|
||||
|
||||
if (!mode && looksLikePostcode(trimmed)) {
|
||||
setResults([{ type: 'postcode', label: trimmed.toUpperCase() }]);
|
||||
setResults([{ type: 'postcode', label: normalizePostcode(trimmed) }]);
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
|
|
@ -97,7 +106,7 @@ export function useLocationSearch(mode?: string) {
|
|||
if (activeIndex >= 0 && activeIndex < results.length) {
|
||||
onSelect(results[activeIndex]);
|
||||
} else if (looksLikePostcode(query)) {
|
||||
onSelect({ type: 'postcode', label: query.trim().toUpperCase() });
|
||||
onSelect({ type: 'postcode', label: normalizePostcode(query) });
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export const MAP_MIN_ZOOM = 5.5;
|
|||
export const BUFFER_MULTIPLIER = 1.5;
|
||||
|
||||
/** Inner London free zone bounds (south, west, north, east) — must match server FREE_ZONE_BOUNDS */
|
||||
export const FREE_ZONE_BOUNDS = { south: 51.42, west: -0.34, north: 51.60, east: 0.14 };
|
||||
export const FREE_ZONE_BOUNDS = { south: 51.44, west: -0.31, north: 51.59, east: 0.05 };
|
||||
|
||||
export const INITIAL_VIEW_STATE: ViewState = {
|
||||
longitude: (FREE_ZONE_BOUNDS.west + FREE_ZONE_BOUNDS.east) / 2,
|
||||
|
|
@ -183,8 +183,8 @@ export const STACKED_ENUM_GROUPS: Record<
|
|||
label: 'Property type',
|
||||
feature: 'Property type',
|
||||
components: ['Property type'],
|
||||
valueOrder: ['Detached', 'Semi-Detached', 'Terraced', 'Flats/Maisonettes'],
|
||||
valueColors: ['#8b5cf6', '#3b82f6', '#14b8a6', '#f59e0b'],
|
||||
valueOrder: ['Detached', 'Semi-Detached', 'Terraced', 'Flats/Maisonettes', 'Other'],
|
||||
valueColors: ['#8b5cf6', '#3b82f6', '#14b8a6', '#f59e0b', '#6b7280'],
|
||||
},
|
||||
{
|
||||
label: 'Leasehold/Freehold',
|
||||
|
|
@ -212,6 +212,23 @@ export const STACKED_ENUM_GROUPS: Record<
|
|||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Maximally-distinguishable palette for discrete enum features on the map.
|
||||
* 10 colors chosen for perceptual distinctness in both light and dark modes.
|
||||
*/
|
||||
export const ENUM_PALETTE: [number, number, number][] = [
|
||||
[59, 130, 246], // blue-500
|
||||
[249, 115, 22], // orange-500
|
||||
[139, 92, 246], // violet-500
|
||||
[34, 197, 94], // green-500
|
||||
[239, 68, 68], // red-500
|
||||
[6, 182, 212], // cyan-500
|
||||
[236, 72, 153], // pink-500
|
||||
[245, 158, 11], // amber-500
|
||||
[20, 184, 166], // teal-500
|
||||
[107, 114, 128], // gray-500
|
||||
];
|
||||
|
||||
/** Colors for stacked bar segments */
|
||||
export const SEGMENT_COLORS = [
|
||||
'#ef4444', // red-500
|
||||
|
|
|
|||
|
|
@ -19,13 +19,9 @@ const PROPERTY_TYPE_MAP: Record<
|
|||
onthemarket: 'semi-detached',
|
||||
zoopla: 'semi_detached',
|
||||
},
|
||||
'Mid-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' },
|
||||
'End-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' },
|
||||
'Enclosed Mid-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' },
|
||||
'Enclosed End-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' },
|
||||
Terraced: { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' },
|
||||
'Flats/Maisonettes': { rightmove: 'flat', onthemarket: 'flats', zoopla: 'flat' },
|
||||
Bungalow: { rightmove: 'bungalow', onthemarket: 'bungalow', zoopla: 'bungalow' },
|
||||
'Park home': { rightmove: 'park-home', onthemarket: 'property', zoopla: '' },
|
||||
Other: { rightmove: '', onthemarket: 'property', zoopla: '' },
|
||||
};
|
||||
|
||||
export const H3_RADIUS_MILES: Record<number, number> = {
|
||||
|
|
|
|||
509
frontend/src/lib/feature-icons.tsx
Normal file
509
frontend/src/lib/feature-icons.tsx
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
import type { ReactNode, ReactElement } from 'react';
|
||||
|
||||
/**
|
||||
* Per-feature SVG icon paths. Each feature gets a unique icon within its group.
|
||||
* All paths render inside a shared 24x24 viewBox with stroke="currentColor".
|
||||
*/
|
||||
const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
|
||||
// ── Properties in the area ───────────────────
|
||||
'Last known price': (
|
||||
<>
|
||||
<path d="M7 20h10" />
|
||||
<path d="M10 20V9a4 4 0 018 0" />
|
||||
<path d="M6 14h8" />
|
||||
</>
|
||||
),
|
||||
'Estimated current price': (
|
||||
<>
|
||||
<polyline points="4 16 8 12 13 15 20 6" />
|
||||
<polyline points="15 6 21 6 21 12" />
|
||||
</>
|
||||
),
|
||||
'Price per sqm': (
|
||||
<>
|
||||
<rect x="3" y="3" width="7" height="7" />
|
||||
<rect x="14" y="3" width="7" height="7" />
|
||||
<rect x="14" y="14" width="7" height="7" />
|
||||
<rect x="3" y="14" width="7" height="7" />
|
||||
</>
|
||||
),
|
||||
'Est. price per sqm': (
|
||||
<>
|
||||
<rect x="3" y="7" width="18" height="10" rx="1" />
|
||||
<path d="M7 7v4m4-4v6m4-6v4m4-4v6" />
|
||||
</>
|
||||
),
|
||||
'Total floor area (sqm)': (
|
||||
<>
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<polyline points="9 21 3 21 3 15" />
|
||||
<line x1="21" y1="3" x2="14" y2="10" />
|
||||
<line x1="3" y1="21" x2="10" y2="14" />
|
||||
</>
|
||||
),
|
||||
'Interior height (m)': (
|
||||
<>
|
||||
<line x1="12" y1="2" x2="12" y2="22" />
|
||||
<polyline points="8 6 12 2 16 6" />
|
||||
<polyline points="8 18 12 22 16 18" />
|
||||
</>
|
||||
),
|
||||
'Number of bedrooms & living rooms': (
|
||||
<>
|
||||
<rect x="5" y="2" width="14" height="20" rx="1" />
|
||||
<circle cx="15" cy="12" r="1" fill="currentColor" />
|
||||
</>
|
||||
),
|
||||
'Estimated monthly rent': (
|
||||
<>
|
||||
<circle cx="8" cy="15" r="5" />
|
||||
<path d="M12 11l9-9" />
|
||||
<path d="M17 2h4v4" />
|
||||
</>
|
||||
),
|
||||
'Date of last transaction': (
|
||||
<>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</>
|
||||
),
|
||||
'Construction age': (
|
||||
<>
|
||||
<path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z" />
|
||||
</>
|
||||
),
|
||||
'Asking price': (
|
||||
<>
|
||||
<path d="M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z" />
|
||||
<line x1="7" y1="7" x2="7.01" y2="7" />
|
||||
</>
|
||||
),
|
||||
'Asking rent (monthly)': (
|
||||
<>
|
||||
<circle cx="9" cy="9" r="7" />
|
||||
<path d="M15.58 8.42A7 7 0 0122 15a7 7 0 01-7 7 7 7 0 01-6.58-4.58" />
|
||||
</>
|
||||
),
|
||||
Bedrooms: (
|
||||
<>
|
||||
<path d="M2 4v16" />
|
||||
<path d="M2 8h18a2 2 0 012 2v10" />
|
||||
<path d="M2 17h20" />
|
||||
<path d="M6 4v4" />
|
||||
</>
|
||||
),
|
||||
Bathrooms: (
|
||||
<>
|
||||
<path d="M4 12h16a1 1 0 011 1v3a4 4 0 01-4 4H7a4 4 0 01-4-4v-3a1 1 0 011-1z" />
|
||||
<path d="M6 12V5a2 2 0 012-2h3" />
|
||||
<line x1="14" y1="4" x2="17" y2="4" />
|
||||
</>
|
||||
),
|
||||
'Listing date': (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</>
|
||||
),
|
||||
'Listing status': (
|
||||
<>
|
||||
<line x1="8" y1="6" x2="21" y2="6" />
|
||||
<line x1="8" y1="12" x2="21" y2="12" />
|
||||
<line x1="8" y1="18" x2="21" y2="18" />
|
||||
<line x1="3" y1="6" x2="3.01" y2="6" />
|
||||
<line x1="3" y1="12" x2="3.01" y2="12" />
|
||||
<line x1="3" y1="18" x2="3.01" y2="18" />
|
||||
</>
|
||||
),
|
||||
'Leasehold/Freehold': (
|
||||
<>
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</>
|
||||
),
|
||||
'Property type': (
|
||||
<>
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
</>
|
||||
),
|
||||
'Current energy rating': (
|
||||
<>
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
|
||||
</>
|
||||
),
|
||||
'Potential energy rating': (
|
||||
<>
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||||
</>
|
||||
),
|
||||
|
||||
// ── Transport ────────────────────────────────
|
||||
'Train or tube stations within 1km': (
|
||||
<>
|
||||
<rect x="4" y="3" width="16" height="14" rx="2" />
|
||||
<path d="M4 11h16" />
|
||||
<circle cx="8" cy="15" r="1" fill="currentColor" />
|
||||
<circle cx="16" cy="15" r="1" fill="currentColor" />
|
||||
<path d="M8 21l-2-4h12l-2 4" />
|
||||
</>
|
||||
),
|
||||
'Distance to nearest train or tube station (km)': (
|
||||
<>
|
||||
<path d="M12 2v8" />
|
||||
<path d="M4.93 10.93l2.83 2.83" />
|
||||
<path d="M2 18h2" />
|
||||
<path d="M20 18h2" />
|
||||
<path d="M19.07 10.93l-2.83 2.83" />
|
||||
<circle cx="12" cy="18" r="4" />
|
||||
<line x1="12" y1="18" x2="12" y2="15" />
|
||||
</>
|
||||
),
|
||||
|
||||
// ── Education ────────────────────────────────
|
||||
'Education, Skills and Training Score': (
|
||||
<>
|
||||
<path d="M2 3h6a4 4 0 014 4 4 4 0 014-4h6v18a2 2 0 01-2 2h-4a4 4 0 00-4 4 4 4 0 00-4-4H4a2 2 0 01-2-2z" />
|
||||
</>
|
||||
),
|
||||
'Good+ 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" />
|
||||
</>
|
||||
),
|
||||
'Good+ 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" />
|
||||
</>
|
||||
),
|
||||
|
||||
// ── Deprivation ──────────────────────────────
|
||||
'Income Score (rate)': (
|
||||
<>
|
||||
<rect x="2" y="6" width="20" height="14" rx="2" />
|
||||
<path d="M2 10h20" />
|
||||
<path d="M6 14h4m4 0h4" />
|
||||
</>
|
||||
),
|
||||
'Employment Score (rate)': (
|
||||
<>
|
||||
<rect x="2" y="7" width="20" height="14" rx="2" />
|
||||
<path d="M16 3h-8a2 2 0 00-2 2v2h12V5a2 2 0 00-2-2z" />
|
||||
<path d="M12 12v4" />
|
||||
</>
|
||||
),
|
||||
'Health Deprivation and Disability Score': (
|
||||
<>
|
||||
<path d="M20.42 4.58a5.4 5.4 0 00-7.65 0L12 5.34l-.77-.76a5.4 5.4 0 00-7.65 7.65L12 20.65l8.42-8.42a5.4 5.4 0 000-7.65z" />
|
||||
</>
|
||||
),
|
||||
'Living Environment Score': (
|
||||
<>
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
|
||||
<path d="M9 16l2 2 4-4" />
|
||||
</>
|
||||
),
|
||||
'Indoors Sub-domain Score': (
|
||||
<>
|
||||
<path d="M20 9V6a2 2 0 00-2-2H6a2 2 0 00-2 2v3" />
|
||||
<path d="M2 11v5a2 2 0 002 2h1v3h2v-3h10v3h2v-3h1a2 2 0 002-2v-5a3 3 0 00-3-3H5a3 3 0 00-3 3z" />
|
||||
</>
|
||||
),
|
||||
'Outdoors Sub-domain Score': (
|
||||
<>
|
||||
<path d="M11 20A7 7 0 019.8 6.9C15.5 4.9 20 9 20 9s-3.4 5.4-3.4 9c0 .6 0 1.2-.1 1.8" />
|
||||
<path d="M12 10a3.5 3.5 0 00-5 5" />
|
||||
</>
|
||||
),
|
||||
|
||||
// ── Crime summary ────────────────────────────
|
||||
'Serious crime (avg/yr)': (
|
||||
<>
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</>
|
||||
),
|
||||
'Minor crime (avg/yr)': (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</>
|
||||
),
|
||||
'Serious crime per 1k residents (avg/yr)': (
|
||||
<>
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
<circle cx="12" cy="18" r="3" />
|
||||
<path d="M8 3h8" />
|
||||
</>
|
||||
),
|
||||
'Minor crime per 1k residents (avg/yr)': (
|
||||
<>
|
||||
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 00-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 010 7.75" />
|
||||
</>
|
||||
),
|
||||
|
||||
// ── Crime ────────────────────────────────────
|
||||
'Anti-social behaviour (avg/yr)': (
|
||||
<>
|
||||
<path d="M3 11l18-5v12L3 13v-2z" />
|
||||
<path d="M11.6 16.8a3 3 0 015.8 0" />
|
||||
</>
|
||||
),
|
||||
'Violence and sexual offences (avg/yr)': (
|
||||
<>
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</>
|
||||
),
|
||||
'Criminal damage and arson (avg/yr)': (
|
||||
<>
|
||||
<path d="M12 12c2-2.67 4-4 4-6.5a4 4 0 10-8 0c0 2.5 2 3.83 4 6.5z" />
|
||||
<path d="M10 17c0 1.1.9 2 2 2s2-.9 2-2c0-1.33-1-2-2-3-1 1-2 1.67-2 3z" />
|
||||
</>
|
||||
),
|
||||
'Burglary (avg/yr)': (
|
||||
<>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" />
|
||||
<path d="M7 11V7a5 5 0 0110 0v4" />
|
||||
<path d="M12 17v-2" />
|
||||
</>
|
||||
),
|
||||
'Vehicle crime (avg/yr)': (
|
||||
<>
|
||||
<path d="M5 17h14v-5l-2-5H7L5 12z" />
|
||||
<circle cx="7.5" cy="17.5" r="2.5" />
|
||||
<circle cx="16.5" cy="17.5" r="2.5" />
|
||||
</>
|
||||
),
|
||||
'Robbery (avg/yr)': (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="4" y1="8" x2="20" y2="8" />
|
||||
<line x1="4" y1="16" x2="20" y2="16" />
|
||||
<line x1="12" y1="2" x2="12" y2="8" />
|
||||
</>
|
||||
),
|
||||
'Other theft (avg/yr)': (
|
||||
<>
|
||||
<path d="M18 8a6 6 0 00-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.73 21a2 2 0 01-3.46 0" />
|
||||
</>
|
||||
),
|
||||
'Shoplifting (avg/yr)': (
|
||||
<>
|
||||
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z" />
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<path d="M16 10a4 4 0 01-8 0" />
|
||||
</>
|
||||
),
|
||||
'Drugs (avg/yr)': (
|
||||
<>
|
||||
<path d="M10.5 1.5H8a6.5 6.5 0 000 13h8a6.5 6.5 0 000-13h-2.5" />
|
||||
<line x1="12" y1="1" x2="12" y2="14" />
|
||||
</>
|
||||
),
|
||||
'Possession of weapons (avg/yr)': (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="22" y1="12" x2="18" y2="12" />
|
||||
<line x1="6" y1="12" x2="2" y2="12" />
|
||||
<line x1="12" y1="6" x2="12" y2="2" />
|
||||
<line x1="12" y1="22" x2="12" y2="18" />
|
||||
</>
|
||||
),
|
||||
'Public order (avg/yr)': (
|
||||
<>
|
||||
<line x1="1" y1="12" x2="3" y2="12" />
|
||||
<polyline points="8 8 12 4 16 8" />
|
||||
<line x1="12" y1="4" x2="12" y2="20" />
|
||||
<polyline points="8 16 12 20 16 16" />
|
||||
<line x1="21" y1="12" x2="23" y2="12" />
|
||||
</>
|
||||
),
|
||||
'Bicycle theft (avg/yr)': (
|
||||
<>
|
||||
<circle cx="6" cy="17" r="3" />
|
||||
<circle cx="18" cy="17" r="3" />
|
||||
<path d="M6 17l4-8h4l2 4 2-4" />
|
||||
</>
|
||||
),
|
||||
'Theft from the person (avg/yr)': (
|
||||
<>
|
||||
<rect x="2" y="6" width="20" height="14" rx="2" />
|
||||
<path d="M2 10h20" />
|
||||
<path d="M6 14h4m4 0h4" />
|
||||
</>
|
||||
),
|
||||
'Other crime (avg/yr)': (
|
||||
<>
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="12" y1="12" x2="12" y2="16" />
|
||||
<line x1="12" y1="18" x2="12.01" y2="18" />
|
||||
</>
|
||||
),
|
||||
|
||||
// ── Demographics ─────────────────────────────
|
||||
'% White': (
|
||||
<>
|
||||
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 00-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 010 7.75" />
|
||||
</>
|
||||
),
|
||||
'% South Asian': (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M2 12h20" />
|
||||
<path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z" />
|
||||
</>
|
||||
),
|
||||
'% East Asian': (
|
||||
<>
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</>
|
||||
),
|
||||
'% Black': (
|
||||
<>
|
||||
<path d="M21.21 15.89A10 10 0 118 2.83" />
|
||||
<path d="M22 12A10 10 0 0012 2v10z" />
|
||||
</>
|
||||
),
|
||||
'% Mixed': (
|
||||
<>
|
||||
<circle cx="9" cy="12" r="7" />
|
||||
<circle cx="15" cy="12" r="7" />
|
||||
</>
|
||||
),
|
||||
'% Other': (
|
||||
<>
|
||||
<line x1="4" y1="9" x2="20" y2="9" />
|
||||
<line x1="4" y1="15" x2="20" y2="15" />
|
||||
<line x1="10" y1="3" x2="8" y2="21" />
|
||||
<line x1="16" y1="3" x2="14" y2="21" />
|
||||
</>
|
||||
),
|
||||
|
||||
// ── Amenities ────────────────────────────────
|
||||
'Number of restaurants within 2km': (
|
||||
<>
|
||||
<path d="M3 2v8c0 1.1.9 2 2 2h2v10h2V12h2a2 2 0 002-2V2" />
|
||||
<path d="M7 2v4" />
|
||||
<path d="M19 2v20" />
|
||||
<path d="M19 8a3 3 0 00-3-3" />
|
||||
</>
|
||||
),
|
||||
'Number of grocery shops and supermarkets within 2km': (
|
||||
<>
|
||||
<circle cx="9" cy="21" r="1" />
|
||||
<circle cx="20" cy="21" r="1" />
|
||||
<path d="M1 1h4l2.68 13.39a2 2 0 002 1.61h9.72a2 2 0 002-1.61L23 6H6" />
|
||||
</>
|
||||
),
|
||||
'Number of parks within 2km': (
|
||||
<>
|
||||
<path d="M12 22v-7" />
|
||||
<path d="M17 15H7l2-4H5l7-9 7 9h-4l2 4z" />
|
||||
</>
|
||||
),
|
||||
|
||||
// ── Environment ──────────────────────────────
|
||||
'Noise (dB)': (
|
||||
<>
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
<path d="M19.07 4.93a10 10 0 010 14.14" />
|
||||
<path d="M15.54 8.46a5 5 0 010 7.07" />
|
||||
</>
|
||||
),
|
||||
'Max available download speed (Mbps)': (
|
||||
<>
|
||||
<path d="M5 12.55a11 11 0 0114.08 0" />
|
||||
<path d="M1.42 9a16 16 0 0121.16 0" />
|
||||
<path d="M8.53 16.11a6 6 0 016.95 0" />
|
||||
<line x1="12" y1="20" x2="12.01" y2="20" />
|
||||
</>
|
||||
),
|
||||
'Environmental risk': (
|
||||
<>
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</>
|
||||
),
|
||||
'Collapsible deposits risk': (
|
||||
<>
|
||||
<polyline points="12 2 2 7 12 12 22 7 12 2" />
|
||||
<polyline points="2 17 12 22 22 17" />
|
||||
<polyline points="2 12 12 17 22 12" />
|
||||
</>
|
||||
),
|
||||
'Compressible ground risk': (
|
||||
<>
|
||||
<line x1="12" y1="2" x2="12" y2="22" />
|
||||
<polyline points="16 6 12 2 8 6" />
|
||||
<polyline points="16 18 12 22 8 18" />
|
||||
<line x1="4" y1="12" x2="20" y2="12" />
|
||||
</>
|
||||
),
|
||||
'Landslide risk': (
|
||||
<>
|
||||
<path d="M8 3l4 8 5-5 5 15H2L8 3z" />
|
||||
</>
|
||||
),
|
||||
'Running sand risk': (
|
||||
<>
|
||||
<path d="M2 6c2-1 4-1 6 0s4 1 6 0 4-1 6 0" />
|
||||
<path d="M2 12c2-1 4-1 6 0s4 1 6 0 4-1 6 0" />
|
||||
<path d="M2 18c2-1 4-1 6 0s4 1 6 0 4-1 6 0" />
|
||||
</>
|
||||
),
|
||||
'Shrink-swell risk': (
|
||||
<>
|
||||
<line x1="2" y1="12" x2="22" y2="12" />
|
||||
<polyline points="6 8 2 12 6 16" />
|
||||
<polyline points="18 8 22 12 18 16" />
|
||||
</>
|
||||
),
|
||||
'Soluble rocks risk': (
|
||||
<>
|
||||
<path d="M12 2.69l5.66 5.66a8 8 0 11-11.31 0z" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a complete SVG icon element for a given feature name, or null if unmapped.
|
||||
*/
|
||||
export function getFeatureIcon(
|
||||
featureName: string,
|
||||
className: string,
|
||||
): ReactElement | null {
|
||||
const paths = FEATURE_ICON_PATHS[featureName];
|
||||
if (!paths) return null;
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{paths}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
ZOOM_TO_RESOLUTION_THRESHOLDS,
|
||||
TWEMOJI_BASE,
|
||||
BUFFER_MULTIPLIER,
|
||||
ENUM_PALETTE,
|
||||
} from './consts';
|
||||
|
||||
const ROAD_OPACITY = 0.4;
|
||||
|
|
@ -193,9 +194,16 @@ export function emojiToTwemojiUrl(emoji: string): string {
|
|||
return `${TWEMOJI_BASE}${hex}.png`;
|
||||
}
|
||||
|
||||
/** Look up a discrete color from the enum palette by index (wraps if > palette size). */
|
||||
export function enumIndexToColor(index: number): [number, number, number] {
|
||||
const i = Math.round(Math.max(0, index)) % ENUM_PALETTE.length;
|
||||
return ENUM_PALETTE[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared fill-color logic for hex and postcode layers.
|
||||
* When a viewFeature is active, normalizes by colorRange and applies the feature gradient.
|
||||
* For enum features (enumCount > 0), uses discrete palette colors instead of gradient.
|
||||
* Otherwise falls back to density-based coloring using countRange.
|
||||
*/
|
||||
export function getFeatureFillColor(
|
||||
|
|
@ -207,7 +215,8 @@ export function getFeatureFillColor(
|
|||
countNormalized: number,
|
||||
densityGradient: GradientStop[],
|
||||
isDark: boolean,
|
||||
alpha: number
|
||||
alpha: number,
|
||||
enumCount: number = 0
|
||||
): [number, number, number, number] {
|
||||
if (colorRange) {
|
||||
if (value == null)
|
||||
|
|
@ -222,6 +231,13 @@ export function getFeatureFillColor(
|
|||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Discrete coloring for enum features
|
||||
if (enumCount > 0) {
|
||||
const rgb = enumIndexToColor(Math.round(value as number));
|
||||
return [...rgb, alpha] as [number, number, number, number];
|
||||
}
|
||||
|
||||
const range = colorRange[1] - colorRange[0];
|
||||
if (range === 0)
|
||||
return [...FEATURE_GRADIENT[0].color, alpha] as [number, number, number, number];
|
||||
|
|
|
|||
|
|
@ -15,8 +15,7 @@ module.exports = (env, argv) => {
|
|||
filename: 'bundle.js',
|
||||
clean: true,
|
||||
|
||||
// Dev needs '/' for HMR WebSocket; prod uses '' for relative paths through proxies
|
||||
publicPath: isProduction ? '' : '/',
|
||||
publicPath: '/',
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.ts', '.tsx', '.js'],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue