Quick save
This commit is contained in:
parent
e5d5819098
commit
2906b01734
25 changed files with 1070 additions and 237 deletions
|
|
@ -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;
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue