More fixes
This commit is contained in:
parent
791bc6976b
commit
14a3555cf1
21 changed files with 549 additions and 99 deletions
|
|
@ -59,16 +59,6 @@ export default memo(function HoverCard({ x, y, id, isPostcode, data, filters, fe
|
|||
transform: 'translate(-50%, -100%)',
|
||||
}}
|
||||
>
|
||||
{/* Arrow */}
|
||||
<div
|
||||
className="absolute w-3 h-3 bg-white dark:bg-warm-800 rotate-45"
|
||||
style={{
|
||||
left: '50%',
|
||||
bottom: -6,
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import type {
|
|||
} from '../../types';
|
||||
|
||||
import { zoomToResolution, getBoundsFromViewState, getMapStyle } from '../../lib/map-utils';
|
||||
import { INITIAL_VIEW_STATE, MAP_MIN_ZOOM, MAP_BOUNDS } from '../../lib/consts';
|
||||
import { INITIAL_VIEW_STATE, MAP_MIN_ZOOM, MAP_BOUNDS, POI_GROUP_COLORS, POI_DEFAULT_COLOR } from '../../lib/consts';
|
||||
import LocationSearch, { type SearchedLocation } from './LocationSearch';
|
||||
import MapLegend from './MapLegend';
|
||||
import HoverCard from './HoverCard';
|
||||
|
|
@ -263,25 +263,49 @@ export default memo(function Map({
|
|||
))}
|
||||
{popupInfo && (
|
||||
<div
|
||||
className="absolute bg-white dark:bg-warm-800 rounded shadow-lg p-2 text-sm dark:text-white"
|
||||
className="absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg text-sm dark:text-white pointer-events-none"
|
||||
style={{
|
||||
left: popupInfo.x,
|
||||
top: popupInfo.y - 40,
|
||||
top: popupInfo.y - 50,
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
<strong className="dark:text-white">{popupInfo.name}</strong>
|
||||
<div className="text-warm-500 dark:text-warm-300 text-xs">{popupInfo.category}</div>
|
||||
{osmIdToUrl(popupInfo.id) && (
|
||||
<a
|
||||
href={osmIdToUrl(popupInfo.id)!}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 text-xs"
|
||||
>
|
||||
View on OSM
|
||||
</a>
|
||||
{popupInfo.isCluster ? (
|
||||
<div className="px-3 py-2 text-center">
|
||||
<div className="text-lg font-bold text-teal-600 dark:text-teal-400">
|
||||
{popupInfo.clusterCount}
|
||||
</div>
|
||||
<div className="text-warm-500 dark:text-warm-400 text-xs">places</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg leading-none">{popupInfo.emoji}</span>
|
||||
<div>
|
||||
<div className="font-semibold dark:text-warm-100">{popupInfo.name}</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-warm-500 dark:text-warm-400">
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: `rgb(${(POI_GROUP_COLORS[popupInfo.group] || POI_DEFAULT_COLOR).join(',')})`,
|
||||
}}
|
||||
/>
|
||||
{popupInfo.category}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{osmIdToUrl(popupInfo.id) && (
|
||||
<a
|
||||
href={osmIdToUrl(popupInfo.id)!}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 text-xs mt-1 block pointer-events-auto"
|
||||
>
|
||||
View on OSM
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -135,17 +135,6 @@ function PropertyLoadingSkeleton() {
|
|||
);
|
||||
}
|
||||
|
||||
const LISTING_STATUS_STYLES: Record<string, string> = {
|
||||
'For sale': 'bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300',
|
||||
'For rent': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
'Historical sale': 'bg-warm-100 text-warm-600 dark:bg-warm-700 dark:text-warm-300',
|
||||
};
|
||||
|
||||
function ListingStatusBadge({ status }: { status: string }) {
|
||||
const style = LISTING_STATUS_STYLES[status] ?? LISTING_STATUS_STYLES['Historical sale'];
|
||||
return <span className={`text-xs font-medium px-1.5 py-0.5 rounded ${style}`}>{status}</span>;
|
||||
}
|
||||
|
||||
function PropertyCard({ property }: { property: Property }) {
|
||||
const price = getNum(property, 'Last known price');
|
||||
const estimatedPrice = getNum(property, 'Estimated current price');
|
||||
|
|
@ -161,18 +150,13 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
const bathrooms = getNum(property, 'Bathrooms');
|
||||
const listingDate = getNum(property, 'Listing date');
|
||||
|
||||
const listingStatus = property.listing_status;
|
||||
|
||||
return (
|
||||
<div className="p-4 border-b border-warm-100 dark:border-warm-800 hover:bg-warm-50 dark:hover:bg-warm-800">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="font-semibold dark:text-warm-100">
|
||||
{property.address || 'Unknown Address'}
|
||||
</div>
|
||||
<div className="text-sm text-warm-600 dark:text-warm-400">{property.postcode}</div>
|
||||
<div>
|
||||
<div className="font-semibold dark:text-warm-100">
|
||||
{property.address || 'Unknown Address'}
|
||||
</div>
|
||||
{listingStatus && <ListingStatusBadge status={listingStatus} />}
|
||||
<div className="text-sm text-warm-600 dark:text-warm-400">{property.postcode}</div>
|
||||
</div>
|
||||
|
||||
{property.property_sub_type && (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useCallback, useRef, useState, useMemo, useEffect } from 'react';
|
||||
import { H3HexagonLayer } from '@deck.gl/geo-layers';
|
||||
import { GeoJsonLayer, IconLayer, TextLayer } from '@deck.gl/layers';
|
||||
import { GeoJsonLayer, IconLayer, 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,
|
||||
|
|
@ -12,7 +13,16 @@ import type {
|
|||
FeatureMeta,
|
||||
Bounds,
|
||||
} from '../types';
|
||||
import { DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../lib/consts';
|
||||
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,
|
||||
} from '../lib/consts';
|
||||
import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils';
|
||||
import {
|
||||
type TravelTimeEntry,
|
||||
|
|
@ -55,7 +65,18 @@ interface PopupInfo {
|
|||
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;
|
||||
}
|
||||
|
||||
export function useDeckLayers({
|
||||
|
|
@ -204,6 +225,8 @@ export function useDeckLayers({
|
|||
y: info.y,
|
||||
name: info.object.name,
|
||||
category: info.object.category,
|
||||
group: info.object.group,
|
||||
emoji: info.object.emoji,
|
||||
id: info.object.id,
|
||||
});
|
||||
} else {
|
||||
|
|
@ -217,6 +240,30 @@ export function useDeckLayers({
|
|||
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;
|
||||
|
|
@ -461,24 +508,147 @@ export function useDeckLayers({
|
|||
[postcodeData, theme]
|
||||
);
|
||||
|
||||
const poiLayer = useMemo(
|
||||
// --- 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: pois,
|
||||
data: visiblePois,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: (d) => ({
|
||||
url: emojiToTwemojiUrl(d.emoji),
|
||||
width: 72,
|
||||
height: 72,
|
||||
}),
|
||||
getSize: 24,
|
||||
sizeMinPixels: 20,
|
||||
sizeMaxPixels: 40,
|
||||
pickable: true,
|
||||
onHover: stablePoiHover,
|
||||
getSize: 18,
|
||||
sizeUnits: 'pixels',
|
||||
pickable: false,
|
||||
transitions: { getSize: { duration: 300, enter: () => [0] } },
|
||||
}),
|
||||
[pois, stablePoiHover]
|
||||
[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
|
||||
|
|
@ -511,13 +681,18 @@ export function useDeckLayers({
|
|||
});
|
||||
}, [selectedPostcodeGeometry, selectedHexagonId, marchTime]);
|
||||
|
||||
const poiLayers = useMemo(
|
||||
() => [poiShadowLayer, poiBackgroundLayer, poiIconLayer, clusterCircleLayer, clusterTextLayer],
|
||||
[poiShadowLayer, poiBackgroundLayer, poiIconLayer, clusterCircleLayer, clusterTextLayer]
|
||||
);
|
||||
|
||||
const layers = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const baseLayers: any[] = usePostcodeView
|
||||
? zoom >= 16
|
||||
? [postcodeLayer, postcodeLabelsLayer, poiLayer]
|
||||
: [postcodeLayer, poiLayer]
|
||||
: [hexLayer, poiLayer];
|
||||
? [postcodeLayer, postcodeLabelsLayer, ...poiLayers]
|
||||
: [postcodeLayer, ...poiLayers]
|
||||
: [hexLayer, ...poiLayers];
|
||||
if (marchingAntsLayer) baseLayers.push(marchingAntsLayer);
|
||||
return baseLayers;
|
||||
}, [
|
||||
|
|
@ -526,7 +701,7 @@ export function useDeckLayers({
|
|||
hexLayer,
|
||||
postcodeLayer,
|
||||
postcodeLabelsLayer,
|
||||
poiLayer,
|
||||
poiLayers,
|
||||
marchingAntsLayer,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -16,13 +16,20 @@ interface SelectedHexagon {
|
|||
resolution: number;
|
||||
}
|
||||
|
||||
interface JourneyDest {
|
||||
mode: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
interface UseHexagonSelectionOptions {
|
||||
filters: FeatureFilters;
|
||||
features: FeatureMeta[];
|
||||
resolution: number;
|
||||
/** First transit destination — used to pick the best central_postcode for journey display. */
|
||||
journeyDest?: JourneyDest | null;
|
||||
}
|
||||
|
||||
export function useHexagonSelection({ filters, features, resolution }: UseHexagonSelectionOptions) {
|
||||
export function useHexagonSelection({ filters, features, resolution, journeyDest }: UseHexagonSelectionOptions) {
|
||||
const [selectedHexagon, setSelectedHexagon] = useState<SelectedHexagon | null>(null);
|
||||
const [properties, setProperties] = useState<Property[]>([]);
|
||||
const [propertiesTotal, setPropertiesTotal] = useState(0);
|
||||
|
|
@ -46,11 +53,15 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
|
|||
if (fields) {
|
||||
params.set('fields', fields.join(','));
|
||||
}
|
||||
if (journeyDest) {
|
||||
params.set('journey_mode', journeyDest.mode);
|
||||
params.set('journey_slug', journeyDest.slug);
|
||||
}
|
||||
const response = await fetch(apiUrl('hexagon-stats', params), authHeaders({ signal }));
|
||||
assertOk(response, 'hexagon-stats');
|
||||
return (await response.json()) as HexagonStatsResponse;
|
||||
},
|
||||
[filters, features]
|
||||
[filters, features, journeyDest]
|
||||
);
|
||||
|
||||
const fetchPostcodeStats = useCallback(
|
||||
|
|
|
|||
46
frontend/src/hooks/useTravelDestinations.ts
Normal file
46
frontend/src/hooks/useTravelDestinations.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { useState, useEffect, useRef } from 'react';
|
||||
import { authHeaders, logNonAbortError } from '../lib/api';
|
||||
import type { TransportMode } from './useTravelTime';
|
||||
|
||||
export interface Destination {
|
||||
name: string;
|
||||
slug: string;
|
||||
place_type: string;
|
||||
city?: string;
|
||||
}
|
||||
|
||||
/** Fetches all travel-time destinations for a mode once, with client-side caching. */
|
||||
export function useTravelDestinations(mode: TransportMode) {
|
||||
const [destinations, setDestinations] = useState<Destination[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const cacheRef = useRef<Partial<Record<TransportMode, Destination[]>>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (cacheRef.current[mode]) {
|
||||
setDestinations(cacheRef.current[mode]!);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
|
||||
fetch(
|
||||
`/api/travel-destinations?mode=${mode}`,
|
||||
authHeaders({ signal: controller.signal }),
|
||||
)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then((data: { destinations: Destination[] }) => {
|
||||
cacheRef.current[mode] = data.destinations;
|
||||
setDestinations(data.destinations);
|
||||
})
|
||||
.catch((err) => logNonAbortError('travel destinations', err))
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
return () => controller.abort();
|
||||
}, [mode]);
|
||||
|
||||
return { destinations, loading };
|
||||
}
|
||||
|
|
@ -47,7 +47,7 @@ export function useTravelTime(initial?: TravelTimeInitial) {
|
|||
(index: number, slug: string, label: string) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((entry, i) =>
|
||||
i === index ? { ...entry, slug, label, timeRange: null } : entry
|
||||
i === index ? { ...entry, slug, label, timeRange: slug ? [0, 120] : null } : entry
|
||||
)
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ export const INITIAL_VIEW_STATE: ViewState = {
|
|||
* Returns the H3 resolution to use for a given zoom level.
|
||||
*/
|
||||
export const ZOOM_TO_RESOLUTION_THRESHOLDS = [
|
||||
{ maxZoom: 7.5, resolution: 5 },
|
||||
{ maxZoom: 9.5, resolution: 6 },
|
||||
{ maxZoom: 7, resolution: 5 },
|
||||
{ maxZoom: 9, resolution: 6 },
|
||||
{ maxZoom: 10.5, resolution: 7 },
|
||||
{ maxZoom: 11.5, resolution: 8 },
|
||||
{ maxZoom: 13, resolution: 9 },
|
||||
|
|
@ -68,6 +68,41 @@ export const GLYPHS_URL = '/assets/fonts/{fontstack}/{range}.pbf';
|
|||
/** Twemoji base URL (served locally from public/assets/) */
|
||||
export const TWEMOJI_BASE = '/assets/twemoji/';
|
||||
|
||||
/** POI group → RGB color for category-coded map markers */
|
||||
export const POI_GROUP_COLORS: Record<string, [number, number, number]> = {
|
||||
'Public Transport': [59, 130, 246],
|
||||
Leisure: [249, 115, 22],
|
||||
Education: [139, 92, 246],
|
||||
Health: [239, 68, 68],
|
||||
'Emergency Services': [220, 38, 38],
|
||||
Other: [107, 114, 128],
|
||||
Groceries: [34, 197, 94],
|
||||
'Local Businesses': [245, 158, 11],
|
||||
Culture: [236, 72, 153],
|
||||
Services: [6, 182, 212],
|
||||
Shops: [99, 102, 241],
|
||||
};
|
||||
|
||||
/** Default color for unknown POI groups */
|
||||
export const POI_DEFAULT_COLOR: [number, number, number] = [107, 114, 128];
|
||||
|
||||
/** Categories only shown when zoomed in past MINOR_POI_ZOOM_THRESHOLD */
|
||||
export const MINOR_POI_CATEGORIES = new Set([
|
||||
'Bus stop',
|
||||
'Taxi rank',
|
||||
'EV Charging',
|
||||
'Playground',
|
||||
]);
|
||||
|
||||
/** Zoom level below which minor POI categories are hidden */
|
||||
export const MINOR_POI_ZOOM_THRESHOLD = 14;
|
||||
|
||||
/** Supercluster grouping radius in pixels */
|
||||
export const POI_CLUSTER_RADIUS = 50;
|
||||
|
||||
/** Zoom level at which supercluster stops clustering */
|
||||
export const POI_CLUSTER_MAX_ZOOM = 15;
|
||||
|
||||
/**
|
||||
* Groups whose features should be collapsed into stacked bar charts.
|
||||
* Keyed by feature group name. Each entry defines one stacked chart.
|
||||
|
|
@ -153,8 +188,8 @@ export const STACKED_ENUM_GROUPS: Record<
|
|||
},
|
||||
{
|
||||
label: 'Leasehold/Freehold',
|
||||
feature: 'Leasehold/Freehold',
|
||||
components: ['Leasehold/Freehold'],
|
||||
feature: 'Leashold/Freehold',
|
||||
components: ['Leashold/Freehold'],
|
||||
valueOrder: ['Freehold', 'Leasehold'],
|
||||
valueColors: ['#3b82f6', '#f59e0b'],
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue