Improve UI
This commit is contained in:
parent
5fe192d25a
commit
ae29662c92
14 changed files with 221 additions and 313 deletions
|
|
@ -3,8 +3,10 @@ import type { FeatureFilters, FeatureMeta, HexagonStatsResponse, PostcodeData }
|
|||
import type { HexagonLocation } from '../lib/external-search';
|
||||
import { formatValue, calculateHistogramMean } from '../lib/format';
|
||||
import { groupFeaturesByCategory } from '../lib/features';
|
||||
import { CRIME_BREAKDOWNS } from '../lib/consts';
|
||||
import { DualHistogram, LoadingSkeleton } from './DualHistogram';
|
||||
import EnumBarChart from './EnumBarChart';
|
||||
import StackedBarChart from './StackedBarChart';
|
||||
import ExternalSearchLinks from './ExternalSearchLinks';
|
||||
import { InfoIcon, CloseIcon } from './ui/Icons';
|
||||
import { IconButton } from './ui/IconButton';
|
||||
|
|
@ -108,17 +110,67 @@ export default function AreaPane({
|
|||
);
|
||||
if (!hasData) return null;
|
||||
|
||||
// For Crime group, only show aggregate features with stacked breakdown
|
||||
const isCrimeGroup = group.name === 'Crime';
|
||||
const featuresToRender = isCrimeGroup
|
||||
? group.features.filter((f) => f.name in CRIME_BREAKDOWNS)
|
||||
: group.features;
|
||||
|
||||
return (
|
||||
<div key={group.name}>
|
||||
<h3 className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wider mb-2">
|
||||
{group.name}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{group.features.map((feature) => {
|
||||
{featuresToRender.map((feature) => {
|
||||
const numericStats = numericByName.get(feature.name);
|
||||
const enumStats = enumByName.get(feature.name);
|
||||
|
||||
if (numericStats) {
|
||||
// Check if this is a crime aggregate that should show breakdown
|
||||
const breakdown = CRIME_BREAKDOWNS[feature.name];
|
||||
if (breakdown) {
|
||||
// Build segments from component crime means
|
||||
const segments = breakdown
|
||||
.map((componentName) => {
|
||||
const componentStats = numericByName.get(componentName);
|
||||
return {
|
||||
name: componentName,
|
||||
value: componentStats?.mean ?? 0,
|
||||
};
|
||||
})
|
||||
.filter((s) => s.value > 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline mb-1.5">
|
||||
<div className="flex items-center gap-1 min-w-0 mr-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate">
|
||||
{feature.name}
|
||||
</span>
|
||||
{feature.detail && (
|
||||
<button
|
||||
onClick={() => setInfoFeature(feature)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
|
||||
title="Feature info"
|
||||
>
|
||||
<InfoIcon className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(numericStats.mean)} avg/yr
|
||||
</span>
|
||||
</div>
|
||||
<StackedBarChart segments={segments} total={numericStats.mean} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Regular numeric feature with histogram
|
||||
const globalFeature = globalFeatureByName.get(feature.name);
|
||||
const globalHistogram = globalFeature?.histogram;
|
||||
const globalMean = globalHistogram
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { memo, useState, useRef, useCallback, useMemo, useEffect } from 'react';
|
||||
import { Slider } from './ui/slider';
|
||||
import { Label } from './ui/label';
|
||||
import { Slider } from './ui/Slider';
|
||||
import { Label } from './ui/Label';
|
||||
import { SearchInput } from './ui/SearchInput';
|
||||
import { SelectionButtons } from './ui/SelectionButtons';
|
||||
import { ChevronIcon, FilterIcon, LightbulbIcon } from './ui/Icons';
|
||||
import { IconButton } from './ui/IconButton';
|
||||
import { FilterIcon, LightbulbIcon } from './ui/Icons';
|
||||
import { EmptyState } from './ui/EmptyState';
|
||||
import type { FeatureMeta, FeatureFilters } from '../types';
|
||||
import { formatFilterValue } from '../lib/format';
|
||||
|
|
@ -34,7 +33,6 @@ interface FiltersProps {
|
|||
onNavigateToSource?: (slug: string, featureName: string) => void;
|
||||
openInfoFeature?: string | null;
|
||||
onClearOpenInfoFeature?: () => void;
|
||||
onCollapse?: () => void;
|
||||
}
|
||||
|
||||
function FeatureBrowser({
|
||||
|
|
@ -152,7 +150,6 @@ export default memo(function Filters({
|
|||
onNavigateToSource,
|
||||
openInfoFeature,
|
||||
onClearOpenInfoFeature,
|
||||
onCollapse,
|
||||
}: FiltersProps) {
|
||||
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
|
||||
const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name));
|
||||
|
|
@ -187,11 +184,6 @@ export default memo(function Filters({
|
|||
className="flex flex-col bg-white dark:bg-navy-950 overflow-hidden h-full"
|
||||
>
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
{onCollapse && (
|
||||
<IconButton onClick={onCollapse} title="Collapse filters">
|
||||
<ChevronIcon direction="left" />
|
||||
</IconButton>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowPhilosophy(true)}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md flex items-center justify-center gap-2"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Map as MapGL, useControl } from 'react-map-gl/maplibre';
|
|||
import type { MapRef } from 'react-map-gl/maplibre';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import { H3HexagonLayer } from '@deck.gl/geo-layers';
|
||||
import { IconLayer, PolygonLayer } from '@deck.gl/layers';
|
||||
import { IconLayer, PolygonLayer, TextLayer } from '@deck.gl/layers';
|
||||
import type { PickingInfo } from '@deck.gl/core';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import type {
|
||||
|
|
@ -11,7 +11,6 @@ import type {
|
|||
PostcodeData,
|
||||
ViewState,
|
||||
ViewChangeParams,
|
||||
Bounds,
|
||||
POI,
|
||||
FeatureMeta,
|
||||
} from '../types';
|
||||
|
|
@ -23,7 +22,6 @@ import {
|
|||
getBoundsFromViewState,
|
||||
emojiToTwemojiUrl,
|
||||
getMapStyle,
|
||||
POSTCODE_ZOOM_THRESHOLD,
|
||||
} from '../lib/map-utils';
|
||||
import { INITIAL_VIEW_STATE, MAP_MIN_ZOOM, MAP_BOUNDS } from '../lib/consts';
|
||||
import PostcodeSearch, { type SearchedPostcode } from './PostcodeSearch';
|
||||
|
|
@ -39,6 +37,18 @@ function osmIdToUrl(id: string): string | null {
|
|||
return `https://www.openstreetmap.org/${typeMap[match[1]]}/${match[2]}`;
|
||||
}
|
||||
|
||||
/** Calculate the centroid of a polygon from its vertices */
|
||||
function polygonCentroid(vertices: [number, number][]): [number, number] {
|
||||
if (vertices.length === 0) return [0, 0];
|
||||
let sumLng = 0;
|
||||
let sumLat = 0;
|
||||
for (const [lng, lat] of vertices) {
|
||||
sumLng += lng;
|
||||
sumLat += lat;
|
||||
}
|
||||
return [sumLng / vertices.length, sumLat / vertices.length];
|
||||
}
|
||||
|
||||
interface MapProps {
|
||||
data: HexagonData[];
|
||||
postcodeData: PostcodeData[];
|
||||
|
|
@ -437,6 +447,30 @@ export default memo(function Map({
|
|||
[postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback]
|
||||
);
|
||||
|
||||
const postcodeLabelsLayer = useMemo(
|
||||
() =>
|
||||
new TextLayer<PostcodeData>({
|
||||
id: 'postcode-labels',
|
||||
data: postcodeData,
|
||||
getPosition: (d) => polygonCentroid(d.vertices),
|
||||
getText: (d) => d.postcode,
|
||||
getSize: 12,
|
||||
getColor: theme === 'dark' ? [220, 220, 220, 220] : [40, 40, 40, 220],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 600,
|
||||
outlineWidth: 2,
|
||||
outlineColor: theme === 'dark' ? [30, 30, 30, 200] : [255, 255, 255, 200],
|
||||
sizeUnits: 'pixels',
|
||||
sizeMinPixels: 10,
|
||||
sizeMaxPixels: 14,
|
||||
billboard: false,
|
||||
pickable: false,
|
||||
}),
|
||||
[postcodeData, theme]
|
||||
);
|
||||
|
||||
const poiLayer = useMemo(
|
||||
() =>
|
||||
new IconLayer<POI>({
|
||||
|
|
@ -488,12 +522,14 @@ export default memo(function Map({
|
|||
}, [searchedPostcode, searchedPostcodeHasData]);
|
||||
|
||||
const layers = useMemo(() => {
|
||||
const baseLayers = usePostcodeView ? [postcodeLayer, poiLayer] : [hexLayer, poiLayer];
|
||||
const baseLayers = usePostcodeView
|
||||
? [postcodeLayer, postcodeLabelsLayer, poiLayer]
|
||||
: [hexLayer, poiLayer];
|
||||
if (searchedPostcodeHighlightLayer) {
|
||||
return [...baseLayers, searchedPostcodeHighlightLayer];
|
||||
}
|
||||
return baseLayers;
|
||||
}, [usePostcodeView, hexLayer, postcodeLayer, poiLayer, searchedPostcodeHighlightLayer]);
|
||||
}, [usePostcodeView, hexLayer, postcodeLayer, postcodeLabelsLayer, poiLayer, searchedPostcodeHighlightLayer]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 h-full relative" ref={containerRef}>
|
||||
|
|
|
|||
82
frontend/src/components/StackedBarChart.tsx
Normal file
82
frontend/src/components/StackedBarChart.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { useMemo } from 'react';
|
||||
import { CRIME_SEGMENT_COLORS } from '../lib/consts';
|
||||
import { formatValue } from '../lib/format';
|
||||
|
||||
interface Segment {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface StackedBarChartProps {
|
||||
segments: Segment[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** Shorten crime category names for the legend */
|
||||
function shortenCrimeName(name: string): string {
|
||||
return name
|
||||
.replace(' (avg/yr)', '')
|
||||
.replace('and sexual offences', '')
|
||||
.replace('and arson', '')
|
||||
.replace('from the person', '')
|
||||
.replace('Possession of weapons', 'Weapons')
|
||||
.replace('Anti-social behaviour', 'Anti-social')
|
||||
.replace('Criminal damage', 'Damage')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export default function StackedBarChart({ segments, total }: StackedBarChartProps) {
|
||||
const sortedSegments = useMemo(
|
||||
() => [...segments].sort((a, b) => b.value - a.value),
|
||||
[segments]
|
||||
);
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div className="text-xs text-warm-400 dark:text-warm-500 italic">No data</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{/* Stacked bar */}
|
||||
<div className="flex h-4 rounded overflow-hidden bg-warm-200 dark:bg-warm-700">
|
||||
{sortedSegments.map((segment, i) => {
|
||||
const pct = (segment.value / total) * 100;
|
||||
if (pct < 0.5) return null; // Skip tiny segments
|
||||
return (
|
||||
<div
|
||||
key={segment.name}
|
||||
className="h-full transition-all"
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
backgroundColor: CRIME_SEGMENT_COLORS[i % CRIME_SEGMENT_COLORS.length],
|
||||
}}
|
||||
title={`${shortenCrimeName(segment.name)}: ${formatValue(segment.value)} (${pct.toFixed(1)}%)`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend - compact grid */}
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5">
|
||||
{sortedSegments.map((segment, i) => (
|
||||
<div key={segment.name} className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-2 h-2 rounded-sm shrink-0"
|
||||
style={{
|
||||
backgroundColor: CRIME_SEGMENT_COLORS[i % CRIME_SEGMENT_COLORS.length],
|
||||
}}
|
||||
/>
|
||||
<span className="text-[10px] text-warm-600 dark:text-warm-400">
|
||||
{shortenCrimeName(segment.name)}
|
||||
</span>
|
||||
<span className="text-[10px] text-warm-400 dark:text-warm-500">
|
||||
{formatValue(segment.value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
interface CheckboxListProps {
|
||||
items: string[];
|
||||
selected: string[] | Set<string>;
|
||||
onChange: (selected: string[]) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CheckboxList({ items, selected, onChange, className = '' }: CheckboxListProps) {
|
||||
const selectedSet = selected instanceof Set ? selected : new Set(selected);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(item: string) => {
|
||||
const newSelected = selectedSet.has(item)
|
||||
? [...selectedSet].filter((v) => v !== item)
|
||||
: [...selectedSet, item];
|
||||
onChange(newSelected);
|
||||
},
|
||||
[selectedSet, onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`space-y-0.5 ${className}`}>
|
||||
{items.map((item) => (
|
||||
<label
|
||||
key={item}
|
||||
className="flex items-center gap-1.5 text-sm cursor-pointer dark:text-warm-300"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSet.has(item)}
|
||||
onChange={() => handleToggle(item)}
|
||||
className="rounded accent-teal-600"
|
||||
/>
|
||||
{item}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CheckboxListWithSetProps {
|
||||
items: string[];
|
||||
selected: Set<string>;
|
||||
onChange: (selected: Set<string>) => void;
|
||||
className?: string;
|
||||
itemClassName?: string;
|
||||
}
|
||||
|
||||
export function CheckboxListWithSet({
|
||||
items,
|
||||
selected,
|
||||
onChange,
|
||||
className = '',
|
||||
itemClassName = '',
|
||||
}: CheckboxListWithSetProps) {
|
||||
const handleToggle = useCallback(
|
||||
(item: string) => {
|
||||
const newSet = new Set(selected);
|
||||
if (newSet.has(item)) {
|
||||
newSet.delete(item);
|
||||
} else {
|
||||
newSet.add(item);
|
||||
}
|
||||
onChange(newSet);
|
||||
},
|
||||
[selected, onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{items.map((item) => (
|
||||
<label
|
||||
key={item}
|
||||
className={`flex items-center gap-2 cursor-pointer dark:text-warm-300 ${itemClassName}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(item)}
|
||||
onChange={() => handleToggle(item)}
|
||||
className="rounded accent-teal-600"
|
||||
/>
|
||||
<span className="text-sm flex-1">{item}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
// Shared icon components with consistent sizing and styling
|
||||
|
||||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,81 +0,0 @@
|
|||
import { useRef, useEffect, useCallback } from 'react';
|
||||
import { isAbortError, logNonAbortError } from '../lib/api';
|
||||
|
||||
const DEFAULT_DEBOUNCE_MS = 150;
|
||||
|
||||
interface UseDebouncedFetchOptions {
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
interface UseDebouncedFetchResult {
|
||||
fetch: (url: string, onSuccess: (data: unknown) => void) => void;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export function useDebouncedFetch(
|
||||
options: UseDebouncedFetchOptions = {}
|
||||
): UseDebouncedFetchResult {
|
||||
const { debounceMs = DEFAULT_DEBOUNCE_MS } = options;
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = null;
|
||||
}
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchFn = useCallback(
|
||||
(url: string, onSuccess: (data: unknown) => void) => {
|
||||
// Clear any pending debounce
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
// Abort any in-flight request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { signal: abortControllerRef.current.signal });
|
||||
const json = await res.json();
|
||||
onSuccess(json);
|
||||
} catch (err) {
|
||||
logNonAbortError(`Failed to fetch ${url}`, err);
|
||||
}
|
||||
}, debounceMs);
|
||||
},
|
||||
[debounceMs]
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancel();
|
||||
};
|
||||
}, [cancel]);
|
||||
|
||||
return { fetch: fetchFn, cancel };
|
||||
}
|
||||
|
||||
// Typed version with generic
|
||||
export function useTypedDebouncedFetch<T>(
|
||||
options: UseDebouncedFetchOptions = {}
|
||||
): {
|
||||
fetch: (url: string, onSuccess: (data: T) => void) => void;
|
||||
cancel: () => void;
|
||||
} {
|
||||
const result = useDebouncedFetch(options);
|
||||
return {
|
||||
fetch: result.fetch as (url: string, onSuccess: (data: T) => void) => void,
|
||||
cancel: result.cancel,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
|
||||
interface UseInfoPopupResult<T> {
|
||||
item: T | null;
|
||||
isOpen: boolean;
|
||||
open: (item: T) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export function useInfoPopup<T>(): UseInfoPopupResult<T> {
|
||||
const [item, setItem] = useState<T | null>(null);
|
||||
|
||||
const open = useCallback((newItem: T) => {
|
||||
setItem(newItem);
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setItem(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
item,
|
||||
isOpen: item !== null,
|
||||
open,
|
||||
close,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
|
||||
interface UseSearchResult<T> {
|
||||
query: string;
|
||||
setQuery: (query: string) => void;
|
||||
filtered: T[];
|
||||
}
|
||||
|
||||
export function useSearch<T>(
|
||||
items: T[],
|
||||
getSearchableText: (item: T) => string
|
||||
): UseSearchResult<T> {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!query.trim()) return items;
|
||||
const lower = query.toLowerCase();
|
||||
return items.filter((item) => getSearchableText(item).toLowerCase().includes(lower));
|
||||
}, [items, query, getSearchableText]);
|
||||
|
||||
return { query, setQuery, filtered };
|
||||
}
|
||||
|
||||
// Variant for searching groups with nested items
|
||||
export function useGroupSearch<G extends { name: string }, T>(
|
||||
groups: G[],
|
||||
getItems: (group: G) => T[],
|
||||
getSearchableText: (item: T) => string,
|
||||
createGroup: (group: G, filteredItems: T[]) => G
|
||||
): { query: string; setQuery: (query: string) => void; filtered: G[] } {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!query.trim()) return groups;
|
||||
const lower = query.toLowerCase();
|
||||
|
||||
return groups
|
||||
.map((group) => {
|
||||
// If group name matches, return whole group
|
||||
if (group.name.toLowerCase().includes(lower)) return group;
|
||||
|
||||
// Otherwise filter items
|
||||
const items = getItems(group);
|
||||
const matchingItems = items.filter((item) =>
|
||||
getSearchableText(item).toLowerCase().includes(lower)
|
||||
);
|
||||
|
||||
if (matchingItems.length === 0) return null;
|
||||
return createGroup(group, matchingItems);
|
||||
})
|
||||
.filter((g): g is G => g !== null);
|
||||
}, [groups, query, getItems, getSearchableText, createGroup]);
|
||||
|
||||
return { query, setQuery, filtered };
|
||||
}
|
||||
|
|
@ -5,10 +5,10 @@ import type { ViewState } from '../types';
|
|||
// =============================================================================
|
||||
|
||||
/** Geographic bounds constraining map panning [west, south, east, north] */
|
||||
export const MAP_BOUNDS: [number, number, number, number] = [-12, 49, 4, 62];
|
||||
export const MAP_BOUNDS: [number, number, number, number] = [-9.5, 49, 5, 57];
|
||||
|
||||
/** Minimum zoom level (can't zoom out further) */
|
||||
export const MAP_MIN_ZOOM = 5;
|
||||
export const MAP_MIN_ZOOM = 5.5;
|
||||
|
||||
/** Maximum zoom level for tile fetching (map extrapolates beyond this) */
|
||||
export const TILE_MAX_ZOOM = 15;
|
||||
|
|
@ -74,3 +74,43 @@ export const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/
|
|||
|
||||
/** OpenStreetMap attribution HTML */
|
||||
export const OSM_ATTRIBUTION = '© <a href="https://openstreetmap.org">OpenStreetMap</a>';
|
||||
|
||||
// =============================================================================
|
||||
// Crime Category Breakdowns
|
||||
// =============================================================================
|
||||
|
||||
/** Component crimes that make up each aggregate crime metric */
|
||||
export const CRIME_BREAKDOWNS: Record<string, string[]> = {
|
||||
'Serious crime (avg/yr)': [
|
||||
'Violence and sexual offences (avg/yr)',
|
||||
'Robbery (avg/yr)',
|
||||
'Burglary (avg/yr)',
|
||||
'Possession of weapons (avg/yr)',
|
||||
],
|
||||
'Minor crime (avg/yr)': [
|
||||
'Anti-social behaviour (avg/yr)',
|
||||
'Criminal damage and arson (avg/yr)',
|
||||
'Shoplifting (avg/yr)',
|
||||
'Bicycle theft (avg/yr)',
|
||||
'Theft from the person (avg/yr)',
|
||||
'Other theft (avg/yr)',
|
||||
'Vehicle crime (avg/yr)',
|
||||
'Public order (avg/yr)',
|
||||
'Drugs (avg/yr)',
|
||||
'Other crime (avg/yr)',
|
||||
],
|
||||
};
|
||||
|
||||
/** Colors for crime breakdown segments (designed for 10 distinct categories) */
|
||||
export const CRIME_SEGMENT_COLORS = [
|
||||
'#ef4444', // red-500
|
||||
'#f97316', // orange-500
|
||||
'#eab308', // yellow-500
|
||||
'#22c55e', // green-500
|
||||
'#14b8a6', // teal-500
|
||||
'#06b6d4', // cyan-500
|
||||
'#3b82f6', // blue-500
|
||||
'#8b5cf6', // violet-500
|
||||
'#d946ef', // fuchsia-500
|
||||
'#ec4899', // pink-500
|
||||
];
|
||||
|
|
|
|||
|
|
@ -22,15 +22,3 @@ export function groupFeaturesByCategory(features: FeatureMeta[]): FeatureGroup[]
|
|||
|
||||
return groups;
|
||||
}
|
||||
|
||||
// Feature lookup utilities
|
||||
export function getFeatureByName(
|
||||
name: string,
|
||||
features: FeatureMeta[]
|
||||
): FeatureMeta | undefined {
|
||||
return features.find((f) => f.name === name);
|
||||
}
|
||||
|
||||
export function createFeatureMap(features: FeatureMeta[]): Map<string, FeatureMeta> {
|
||||
return new Map(features.map((f) => [f.name, f]));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,5 @@
|
|||
import type { Property } from '../types';
|
||||
|
||||
// Field aliases: maps human-readable names to snake_case names
|
||||
// The server may return either depending on source
|
||||
const FIELD_ALIASES: Record<string, string[]> = {
|
||||
price: ['Last known price', 'latest_price'],
|
||||
pricePerSqm: ['Price per sqm', 'price_per_sqm'],
|
||||
floorArea: ['Total floor area (sqm)', 'total_floor_area'],
|
||||
rooms: ['Rooms (including bedrooms & bathrooms)', 'number_habitable_rooms'],
|
||||
constructionAge: ['Approximate construction age', 'construction_age_band'],
|
||||
councilTax: ['Council tax (£/yr)'],
|
||||
councilTaxD: ['Council tax Band D (£/yr)'],
|
||||
};
|
||||
|
||||
export function getPropertyNumber(
|
||||
property: Property,
|
||||
field: keyof typeof FIELD_ALIASES
|
||||
): number | undefined {
|
||||
const keys = FIELD_ALIASES[field];
|
||||
if (!keys) return undefined;
|
||||
|
||||
for (const key of keys) {
|
||||
const v = property[key];
|
||||
if (v !== undefined && v !== null && typeof v === 'number') {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Generic getter for any field names (for dynamic lookups)
|
||||
export function getNum(property: Property, ...keys: string[]): number | undefined {
|
||||
for (const key of keys) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue