Quick save

This commit is contained in:
Andras Schmelczer 2026-02-07 22:19:44 +00:00
parent e5d5819098
commit 2906b01734
25 changed files with 1070 additions and 237 deletions

View file

@ -8,6 +8,7 @@ export const MAX_RETRY_MS = 10000;
export const MAP_BOUNDS: [number, number, number, number] = [-9.5, 49, 5, 57];
export const MAP_MIN_ZOOM = 5.5;
export const BUFFER_MULTIPLIER = 1.5;
export const INITIAL_VIEW_STATE: ViewState = {
longitude: -1.5,
@ -30,7 +31,7 @@ export const ZOOM_TO_RESOLUTION_THRESHOLDS = [
{ maxZoom: Infinity, resolution: 10 },
] as const;
export const POSTCODE_ZOOM_THRESHOLD = 17.5;
export const POSTCODE_ZOOM_THRESHOLD = 16;

View file

@ -15,48 +15,6 @@ export function formatValue(value: number, fmt?: ValueFormat): string {
return `${p}${value.toFixed(1)}${s}`;
}
/** Lookup table for feature-specific formatting */
export const FEATURE_FORMATS: Record<string, ValueFormat> = {
// Property
'Last known price': { prefix: '£' },
'Price per sqm': { prefix: '£' },
'Total floor area (sqm)': { suffix: ' sqm' },
'Number of bedrooms & living rooms': { suffix: ' rooms' },
'Transaction year': { raw: true },
'Construction age': { raw: true },
// Transport
'Public transport to Bank (mins)': { suffix: ' mins' },
'Public transport to Fitzrovia (mins)': { suffix: ' mins' },
'Cycling to Bank (mins)': { suffix: ' mins' },
'Cycling to Fitzrovia (mins)': { suffix: ' mins' },
// Crime
'Anti-social behaviour (avg/yr)': { suffix: '/yr' },
'Violence and sexual offences (avg/yr)': { suffix: '/yr' },
'Criminal damage and arson (avg/yr)': { suffix: '/yr' },
'Burglary (avg/yr)': { suffix: '/yr' },
'Vehicle crime (avg/yr)': { suffix: '/yr' },
'Robbery (avg/yr)': { suffix: '/yr' },
'Other theft (avg/yr)': { suffix: '/yr' },
'Shoplifting (avg/yr)': { suffix: '/yr' },
'Drugs (avg/yr)': { suffix: '/yr' },
'Possession of weapons (avg/yr)': { suffix: '/yr' },
'Public order (avg/yr)': { suffix: '/yr' },
'Bicycle theft (avg/yr)': { suffix: '/yr' },
'Theft from the person (avg/yr)': { suffix: '/yr' },
'Other crime (avg/yr)': { suffix: '/yr' },
'Serious crime (avg/yr)': { suffix: '/yr' },
'Minor crime (avg/yr)': { suffix: '/yr' },
// Demographics
'% White': { suffix: '%' },
'% Asian': { suffix: '%' },
'% Black': { suffix: '%' },
'% Mixed': { suffix: '%' },
'% Other': { suffix: '%' },
// Environment
'Noise (dB)': { suffix: ' dB' },
'Max available download speed (Mbps)': { suffix: ' Mbps', raw: true },
};
export function formatFilterValue(value: number): string {
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
@ -81,6 +39,21 @@ export function formatNumber(value: number | undefined, decimals = 0): string {
return decimals > 0 ? value.toFixed(decimals) : Math.round(value).toLocaleString();
}
export function formatRelativeTime(isoDate: string): string {
const now = Date.now();
const then = new Date(isoDate).getTime();
const diffMs = now - then;
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 60) return 'just now';
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return `${diffMin}m ago`;
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
const diffDay = Math.floor(diffHr / 24);
if (diffDay < 30) return `${diffDay}d ago`;
return new Date(isoDate).toLocaleDateString();
}
// Calculate weighted mean from histogram with outlier bins.
// Bin 0 = [min, p1), bins 1..n-2 = [p1, p99) evenly, bin n-1 = [p99, max].
export function calculateHistogramMean(histogram: {

View file

@ -8,7 +8,7 @@ import {
DENSITY_GRADIENT_DARK,
ZOOM_TO_RESOLUTION_THRESHOLDS,
TWEMOJI_BASE,
POSTCODE_ZOOM_THRESHOLD,
BUFFER_MULTIPLIER,
} from './consts';
// Re-export constants for backwards compatibility
@ -21,9 +21,12 @@ export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
// Use absolute URL for tiles - required by MapLibre
const tileUrl = `${window.location.origin}/api/tiles/{z}/{x}/{y}`;
const baseLayers = layers('protomaps', flavor, { lang: 'en' });
const isDark = theme === 'dark';
// Reduce road layer opacity so hexagons are more visible
// In dark mode, make all text white with dark outline
const modifiedLayers = baseLayers.map((layer) => {
// Modify road opacity
if (layer.id.includes('roads_') || layer.id.includes('road_')) {
if (layer.type === 'line') {
return { ...layer, paint: { ...layer.paint, 'line-opacity': ROAD_OPACITY } };
@ -31,6 +34,20 @@ export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
return { ...layer, paint: { ...layer.paint, 'fill-opacity': ROAD_OPACITY } };
}
}
// Modify text colors in dark mode
if (isDark && layer.type === 'symbol' && layer.paint?.['text-color']) {
return {
...layer,
paint: {
...layer.paint,
'text-color': '#ffffff',
'text-halo-color': '#1a1a1a',
'text-halo-width': 1.5,
},
};
}
return layer;
});
@ -41,7 +58,7 @@ export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
protomaps: {
type: 'vector',
tiles: [tileUrl],
maxzoom: POSTCODE_ZOOM_THRESHOLD,
maxzoom: 17,
},
},
layers: modifiedLayers,
@ -139,15 +156,18 @@ export function getBoundsFromViewState(
const scale = Math.pow(2, zoom);
const worldSize = TILE_SIZE * scale;
const bufferedWidth = width * BUFFER_MULTIPLIER;
const bufferedHeight = height * BUFFER_MULTIPLIER;
const degreesPerPixelLng = 360 / worldSize;
const halfWidthDeg = (width / 2) * degreesPerPixelLng;
const halfWidthDeg = (bufferedWidth / 2) * degreesPerPixelLng;
const latRad = (clampedLat * Math.PI) / 180;
const mercatorY = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2;
const centerPixelY = mercatorY * worldSize;
const topPixelY = centerPixelY - height / 2;
const bottomPixelY = centerPixelY + height / 2;
const topPixelY = centerPixelY - bufferedHeight / 2;
const bottomPixelY = centerPixelY + bufferedHeight / 2;
const pixelYToLat = (pixelY: number): number => {
const mercY = Math.max(0.001, Math.min(0.999, pixelY / worldSize));

View file

@ -104,3 +104,33 @@ export function stateToParams(
return params;
}
export function summarizeParams(queryString: string): string {
const params = new URLSearchParams(queryString);
const parts: string[] = [];
const f = params.get('f');
if (f) {
const filterNames = f.split(',').map((seg) => {
const colonIdx = seg.indexOf(':');
return colonIdx > 0 ? seg.substring(0, colonIdx) : seg;
}).filter(Boolean);
if (filterNames.length > 0) {
parts.push(
filterNames.length <= 2
? filterNames.join(', ')
: `${filterNames.length} filters`
);
}
}
const poi = params.get('poi');
if (poi) {
const count = poi.split(',').filter(Boolean).length;
if (count > 0) {
parts.push(`${count} POI ${count === 1 ? 'category' : 'categories'}`);
}
}
return parts.length > 0 ? parts.join(' + ') : 'No filters';
}