Refactor UI
This commit is contained in:
parent
ce4c0cc08c
commit
34a4d0ba86
32 changed files with 1726 additions and 845 deletions
|
|
@ -3,6 +3,25 @@ import type { FeatureMeta, FeatureFilters } from '../types';
|
|||
const INITIAL_RETRY_MS = 1000;
|
||||
const MAX_RETRY_MS = 10000;
|
||||
|
||||
// Error handling utilities
|
||||
export function isAbortError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === 'AbortError';
|
||||
}
|
||||
|
||||
export function logNonAbortError(label: string, error: unknown): void {
|
||||
if (!isAbortError(error)) {
|
||||
console.error(`${label}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// API URL helper
|
||||
export function apiUrl(endpoint: string, params?: URLSearchParams): string {
|
||||
const base = getApiBaseUrl();
|
||||
const path = endpoint.startsWith('/') ? endpoint : `/api/${endpoint}`;
|
||||
const query = params?.toString();
|
||||
return query ? `${base}${path}?${query}` : `${base}${path}`;
|
||||
}
|
||||
|
||||
export async function fetchWithRetry<T>(
|
||||
url: string,
|
||||
onSuccess: (data: T) => void,
|
||||
|
|
|
|||
76
frontend/src/lib/consts.ts
Normal file
76
frontend/src/lib/consts.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import type { ViewState } from '../types';
|
||||
|
||||
// =============================================================================
|
||||
// Map Bounds & Zoom
|
||||
// =============================================================================
|
||||
|
||||
/** Geographic bounds constraining map panning [west, south, east, north] */
|
||||
export const MAP_BOUNDS: [number, number, number, number] = [-12, 49, 4, 62];
|
||||
|
||||
/** Minimum zoom level (can't zoom out further) */
|
||||
export const MAP_MIN_ZOOM = 5;
|
||||
|
||||
/** Maximum zoom level for tile fetching (map extrapolates beyond this) */
|
||||
export const TILE_MAX_ZOOM = 15;
|
||||
|
||||
/** Initial map view state */
|
||||
export const INITIAL_VIEW_STATE: ViewState = {
|
||||
longitude: -1.5,
|
||||
latitude: 53.5,
|
||||
zoom: 6,
|
||||
pitch: 0,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Zoom Thresholds
|
||||
// =============================================================================
|
||||
|
||||
/** Zoom level at which we switch from H3 hexagons to postcode polygons */
|
||||
export const POSTCODE_ZOOM_THRESHOLD = 15;
|
||||
|
||||
/**
|
||||
* Zoom to H3 resolution mapping thresholds.
|
||||
* 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: 10.5, resolution: 8 },
|
||||
{ maxZoom: 12, resolution: 9 },
|
||||
{ maxZoom: Infinity, resolution: 10 },
|
||||
] as const;
|
||||
|
||||
// =============================================================================
|
||||
// Color Gradients
|
||||
// =============================================================================
|
||||
|
||||
/** Feature value gradient (green → yellow → red → purple) */
|
||||
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||
{ t: 0, color: [46, 204, 113] },
|
||||
{ t: 0.33, color: [241, 196, 15] },
|
||||
{ t: 0.66, color: [231, 76, 60] },
|
||||
{ t: 1, color: [142, 68, 173] },
|
||||
];
|
||||
|
||||
/** Property density gradient (teal → blue → purple) */
|
||||
export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||
{ t: 0, color: [130, 234, 220] },
|
||||
{ t: 0.5, color: [20, 140, 180] },
|
||||
{ t: 1, color: [88, 28, 140] },
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// External URLs
|
||||
// =============================================================================
|
||||
|
||||
/** Protomaps font glyphs URL */
|
||||
export const GLYPHS_URL = 'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf';
|
||||
|
||||
/** Protomaps sprite base URL */
|
||||
export const SPRITE_URL_BASE = 'https://protomaps.github.io/basemaps-assets/sprites/v4';
|
||||
|
||||
/** Twemoji CDN base URL */
|
||||
export const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/';
|
||||
|
||||
/** OpenStreetMap attribution HTML */
|
||||
export const OSM_ATTRIBUTION = '© <a href="https://openstreetmap.org">OpenStreetMap</a>';
|
||||
36
frontend/src/lib/features.ts
Normal file
36
frontend/src/lib/features.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type { FeatureMeta } from '../types';
|
||||
|
||||
export interface FeatureGroup {
|
||||
name: string;
|
||||
features: FeatureMeta[];
|
||||
}
|
||||
|
||||
export function groupFeaturesByCategory(features: FeatureMeta[]): FeatureGroup[] {
|
||||
const groups: FeatureGroup[] = [];
|
||||
const seen = new Map<string, FeatureMeta[]>();
|
||||
|
||||
for (const feature of features) {
|
||||
const groupName = feature.group || 'Other';
|
||||
let arr = seen.get(groupName);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
seen.set(groupName, arr);
|
||||
groups.push({ name: groupName, features: arr });
|
||||
}
|
||||
arr.push(feature);
|
||||
}
|
||||
|
||||
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]));
|
||||
}
|
||||
|
|
@ -22,3 +22,27 @@ export function formatAge(value: number, approximate = true): string {
|
|||
if (value >= 1000) return approximate ? `~${Math.round(value)}` : `${Math.round(value)}`;
|
||||
return Math.round(value).toString();
|
||||
}
|
||||
|
||||
// Format number with optional decimals, used in PropertyCard
|
||||
export function formatNumber(value: number | undefined, decimals = 0): string {
|
||||
if (value === undefined) return '';
|
||||
return decimals > 0 ? value.toFixed(decimals) : Math.round(value).toLocaleString();
|
||||
}
|
||||
|
||||
// Calculate weighted mean from histogram
|
||||
export function calculateHistogramMean(histogram: {
|
||||
min: number;
|
||||
bin_width: number;
|
||||
counts: number[];
|
||||
}): number | undefined {
|
||||
if (!histogram.counts.length) return undefined;
|
||||
const totalCount = histogram.counts.reduce((a, b) => a + b, 0);
|
||||
if (totalCount === 0) return undefined;
|
||||
|
||||
let weightedSum = 0;
|
||||
for (let i = 0; i < histogram.counts.length; i++) {
|
||||
const binCenter = histogram.min + (i + 0.5) * histogram.bin_width;
|
||||
weightedSum += binCenter * histogram.counts[i];
|
||||
}
|
||||
return weightedSum / totalCount;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,47 @@
|
|||
import type { ViewState, Bounds } from '../types';
|
||||
import type { StyleSpecification } from 'maplibre-gl';
|
||||
import { layers, namedFlavor } from '@protomaps/basemaps';
|
||||
import {
|
||||
GLYPHS_URL,
|
||||
SPRITE_URL_BASE,
|
||||
TILE_MAX_ZOOM,
|
||||
OSM_ATTRIBUTION,
|
||||
FEATURE_GRADIENT,
|
||||
DENSITY_GRADIENT,
|
||||
ZOOM_TO_RESOLUTION_THRESHOLDS,
|
||||
TWEMOJI_BASE,
|
||||
} from './consts';
|
||||
|
||||
// Self-hosted tile styles from server
|
||||
export const MAP_STYLE_LIGHT = '/api/tiles/style.json?theme=light';
|
||||
export const MAP_STYLE_DARK = '/api/tiles/style.json?theme=dark';
|
||||
// Re-export constants for backwards compatibility
|
||||
export { FEATURE_GRADIENT as GRADIENT, DENSITY_GRADIENT, POSTCODE_ZOOM_THRESHOLD } from './consts';
|
||||
|
||||
export const GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||
{ t: 0, color: [46, 204, 113] },
|
||||
{ t: 0.33, color: [241, 196, 15] },
|
||||
{ t: 0.66, color: [231, 76, 60] },
|
||||
{ t: 1, color: [142, 68, 173] },
|
||||
];
|
||||
|
||||
export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||
{ t: 0, color: [130, 234, 220] },
|
||||
{ t: 0.5, color: [20, 140, 180] },
|
||||
{ t: 1, color: [88, 28, 140] },
|
||||
];
|
||||
export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
|
||||
const flavor = namedFlavor(theme);
|
||||
// Use absolute URL for tiles - required by MapLibre
|
||||
const tileUrl = `${window.location.origin}/api/tiles/{z}/{x}/{y}`;
|
||||
return {
|
||||
version: 8,
|
||||
glyphs: GLYPHS_URL,
|
||||
sprite: `${SPRITE_URL_BASE}/${theme}`,
|
||||
sources: {
|
||||
protomaps: {
|
||||
type: 'vector',
|
||||
tiles: [tileUrl],
|
||||
maxzoom: TILE_MAX_ZOOM,
|
||||
attribution: OSM_ATTRIBUTION,
|
||||
},
|
||||
},
|
||||
layers: layers('protomaps', flavor, { lang: 'en' }),
|
||||
} as StyleSpecification;
|
||||
}
|
||||
|
||||
export function normalizedToColor(t: number): [number, number, number] {
|
||||
if (t <= 0) return GRADIENT[0].color;
|
||||
if (t >= 1) return GRADIENT[GRADIENT.length - 1].color;
|
||||
if (t <= 0) return FEATURE_GRADIENT[0].color;
|
||||
if (t >= 1) return FEATURE_GRADIENT[FEATURE_GRADIENT.length - 1].color;
|
||||
|
||||
for (let i = 0; i < GRADIENT.length - 1; i++) {
|
||||
const lo = GRADIENT[i];
|
||||
const hi = GRADIENT[i + 1];
|
||||
for (let i = 0; i < FEATURE_GRADIENT.length - 1; i++) {
|
||||
const lo = FEATURE_GRADIENT[i];
|
||||
const hi = FEATURE_GRADIENT[i + 1];
|
||||
if (t >= lo.t && t <= hi.t) {
|
||||
const frac = (t - lo.t) / (hi.t - lo.t);
|
||||
return [
|
||||
|
|
@ -33,7 +51,7 @@ export function normalizedToColor(t: number): [number, number, number] {
|
|||
];
|
||||
}
|
||||
}
|
||||
return GRADIENT[GRADIENT.length - 1].color;
|
||||
return FEATURE_GRADIENT[FEATURE_GRADIENT.length - 1].color;
|
||||
}
|
||||
|
||||
export function countToColor(t: number): [number, number, number] {
|
||||
|
|
@ -55,17 +73,11 @@ export function countToColor(t: number): [number, number, number] {
|
|||
return DENSITY_GRADIENT[DENSITY_GRADIENT.length - 1].color;
|
||||
}
|
||||
|
||||
/** Zoom threshold at which we switch from hexagons to postcode polygons */
|
||||
export const POSTCODE_ZOOM_THRESHOLD = 15;
|
||||
|
||||
export function zoomToResolution(zoom: number): number {
|
||||
if (zoom < 6) return 5;
|
||||
if (zoom < 7) return 6;
|
||||
if (zoom < 9.5) return 8;
|
||||
if (zoom < 11) return 9;
|
||||
if (zoom < 13) return 10;
|
||||
if (zoom < 15) return 11;
|
||||
return 12;
|
||||
for (const { maxZoom, resolution } of ZOOM_TO_RESOLUTION_THRESHOLDS) {
|
||||
if (zoom < maxZoom) return resolution;
|
||||
}
|
||||
return ZOOM_TO_RESOLUTION_THRESHOLDS[ZOOM_TO_RESOLUTION_THRESHOLDS.length - 1].resolution;
|
||||
}
|
||||
|
||||
export function getBoundsFromViewState(
|
||||
|
|
@ -103,8 +115,6 @@ export function getBoundsFromViewState(
|
|||
return { south, west, north, east };
|
||||
}
|
||||
|
||||
const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/';
|
||||
|
||||
export function emojiToTwemojiUrl(emoji: string): string {
|
||||
const codePoint = emoji.codePointAt(0);
|
||||
if (!codePoint) return `${TWEMOJI_BASE}1f4cd.png`;
|
||||
|
|
|
|||
38
frontend/src/lib/property-fields.ts
Normal file
38
frontend/src/lib/property-fields.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
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) {
|
||||
const v = property[key];
|
||||
if (v !== undefined && v !== null && typeof v === 'number') return v;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue