This commit is contained in:
Andras Schmelczer 2026-05-09 10:22:44 +01:00
parent fe46cb3379
commit dd9f00b105
8 changed files with 338 additions and 103 deletions

View file

@ -8,7 +8,6 @@ import {
ZOOM_TO_RESOLUTION_THRESHOLDS,
TWEMOJI_BASE,
BUFFER_MULTIPLIER,
ENUM_PALETTE,
POI_CATEGORY_LOGOS,
type GradientStop,
} from './consts';
@ -78,19 +77,21 @@ export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
// In dark mode, make all text white with dark outline
const modifiedLayers = baseLayers
.filter((layer) => !layer.id.includes('buildings'))
.map((layer) => {
.map((original) => {
let layer = original;
// 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 } };
layer = { ...layer, paint: { ...layer.paint, 'line-opacity': ROAD_OPACITY } };
} else if (layer.type === 'fill') {
return { ...layer, paint: { ...layer.paint, 'fill-opacity': ROAD_OPACITY } };
layer = { ...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 = {
...layer,
paint: {
...layer.paint,
@ -234,9 +235,32 @@ export function getBoundsFromViewState(
return { south, west, north, east };
}
export function getLatitudeAtVerticalPixelOffset(
latitude: number,
zoom: number,
pixelOffsetY: number
): number {
const worldSize = TILE_SIZE * Math.pow(2, zoom);
const pixelY = latitudeToWorldY(latitude, worldSize) + pixelOffsetY;
return worldYToLatitude(pixelY, worldSize);
}
export function getBoundsWithBottomScreenInset(
bounds: [number, number, number, number],
zoom: number,
bottomInsetPx: number
): [number, number, number, number] {
if (bottomInsetPx <= 0) return bounds;
const [west, south, east, north] = bounds;
return [west, getLatitudeAtVerticalPixelOffset(south, zoom, bottomInsetPx), east, north];
}
export function emojiToTwemojiUrl(emoji: string): string {
const codePoint = emoji.codePointAt(0);
if (!codePoint) return `${TWEMOJI_BASE}1f4cd.png`;
if (!codePoint) {
throw new Error('Cannot build a Twemoji URL without an emoji');
}
const hex = codePoint.toString(16);
return `${TWEMOJI_BASE}${hex}.png`;
}
@ -287,7 +311,7 @@ function inferPoiIconCategory(category: string, name?: string): string | undefin
export function getPoiIconUrl(
category: string,
emoji: string,
_emoji: string,
iconCategory?: string,
name?: string
): string {
@ -295,13 +319,17 @@ export function getPoiIconUrl(
if (resolvedIconCategory && POI_CATEGORY_LOGOS[resolvedIconCategory]) {
return POI_CATEGORY_LOGOS[resolvedIconCategory];
}
return POI_CATEGORY_LOGOS[category] ?? emojiToTwemojiUrl(emoji);
const categoryLogo = POI_CATEGORY_LOGOS[category];
if (!categoryLogo) {
throw new Error(`Missing POI icon for category '${category}'`);
}
return categoryLogo;
}
/** Look up a discrete color from the enum palette by index (wraps if > palette size). */
export function enumIndexToColor(
index: number,
palette: [number, number, number][] = ENUM_PALETTE
palette: [number, number, number][]
): [number, number, number] {
const i = Math.round(Math.max(0, index)) % palette.length;
return palette[i];
@ -324,7 +352,7 @@ export function getFeatureFillColor(
isDark: boolean,
alpha: number,
enumCount: number = 0,
enumPalette?: [number, number, number][],
enumPalette?: [number, number, number][] | null,
featureGradient: GradientStop[] = FEATURE_GRADIENT
): [number, number, number, number] {
if (colorRange) {
@ -343,6 +371,9 @@ export function getFeatureFillColor(
// Discrete coloring for enum features (used as base; PieHexExtension overrides when active)
if (enumCount > 0) {
if (!enumPalette) {
throw new Error('Enum feature fill requested without an enum color palette');
}
const rgb = enumIndexToColor(Math.round(value as number), enumPalette);
return [...rgb, alpha] as [number, number, number, number];
}

View file

@ -5,6 +5,7 @@ import {
type TravelTimeEntry,
type TravelTimeInitial,
} from '../hooks/useTravelTime';
import { INITIAL_VIEW_STATE } from './consts';
import {
SCHOOL_FILTER_NAME,
createSchoolFilterKey,
@ -21,13 +22,56 @@ import {
isSpecificCrimeFeatureName,
isSpecificCrimeFilterName,
} from './crime-filter';
import {
ETHNICITIES_FILTER_NAME,
createEthnicityFilterKey,
getEthnicityFeatureName,
isEthnicityFeatureName,
isEthnicityFilterName,
} from './ethnicity-filter';
import {
POI_DISTANCE_FILTER_NAME,
POI_COUNT_2KM_FILTER_NAME,
POI_COUNT_5KM_FILTER_NAME,
createPoiFilterKey,
createPoiDistanceFilterKey,
getPoiDistanceFeatureName,
getPoiFilterName,
isPoiDistanceFeatureName,
isPoiDistanceFilterName,
type PoiFilterName,
} from './poi-distance-filter';
function parseFilters(params: URLSearchParams): FeatureFilters | undefined {
const POI_NONE_PARAM = '__none';
export interface UrlState {
viewState: ViewState;
filters: FeatureFilters;
poiCategories: Set<string>;
tab: 'properties' | 'area';
travelTime?: TravelTimeInitial;
postcode?: string;
share?: string;
}
function parseFilters(params: URLSearchParams): FeatureFilters {
const filterParams = params.getAll('filter');
const schoolParams = params.getAll('school');
const crimeParams = params.getAll('crime');
if (filterParams.length === 0 && schoolParams.length === 0 && crimeParams.length === 0) {
return undefined;
const ethnicityParams = params.getAll('ethnicity');
const poiDistanceParams = params.getAll('poiDistance');
const poiCount2KmParams = params.getAll('poiCount2km');
const poiCount5KmParams = params.getAll('poiCount5km');
if (
filterParams.length === 0 &&
schoolParams.length === 0 &&
crimeParams.length === 0 &&
ethnicityParams.length === 0 &&
poiDistanceParams.length === 0 &&
poiCount2KmParams.length === 0 &&
poiCount5KmParams.length === 0
) {
return {};
}
const filters: FeatureFilters = {};
@ -82,20 +126,65 @@ function parseFilters(params: URLSearchParams): FeatureFilters | undefined {
filters[createSpecificCrimeFilterKey(featureName, index)] = [min, max];
});
return Object.keys(filters).length > 0 ? filters : undefined;
ethnicityParams.forEach((entry, index) => {
const parts = entry.split(':');
if (parts.length < 3) return;
const featureName = parts.slice(0, -2).join(':');
const min = Number(parts[parts.length - 2]);
const max = Number(parts[parts.length - 1]);
if (!isEthnicityFeatureName(featureName) || isNaN(min) || isNaN(max)) {
return;
}
filters[createEthnicityFilterKey(featureName, index)] = [min, max];
});
poiDistanceParams.forEach((entry, index) => {
const parts = entry.split(':');
if (parts.length < 3) return;
const featureName = decodeURIComponent(parts.slice(0, -2).join(':'));
const min = Number(parts[parts.length - 2]);
const max = Number(parts[parts.length - 1]);
if (!isPoiDistanceFeatureName(featureName) || isNaN(min) || isNaN(max)) {
return;
}
filters[createPoiDistanceFilterKey(featureName, index)] = [min, max];
});
const parsePoiCountParams = (
entries: string[],
filterName: PoiFilterName,
startIndex: number
) => {
entries.forEach((entry, index) => {
const parts = entry.split(':');
if (parts.length < 3) return;
const featureName = decodeURIComponent(parts.slice(0, -2).join(':'));
const min = Number(parts[parts.length - 2]);
const max = Number(parts[parts.length - 1]);
if (getPoiFilterName(featureName) !== filterName || isNaN(min) || isNaN(max)) {
return;
}
filters[createPoiFilterKey(filterName, featureName, startIndex + index)] = [min, max];
});
};
parsePoiCountParams(poiCount2KmParams, POI_COUNT_2KM_FILTER_NAME, poiDistanceParams.length);
parsePoiCountParams(
poiCount5KmParams,
POI_COUNT_5KM_FILTER_NAME,
poiDistanceParams.length + poiCount2KmParams.length
);
return filters;
}
export function parseUrlState(): {
viewState?: ViewState;
filters?: FeatureFilters;
poiCategories?: Set<string>;
tab?: 'properties' | 'area';
travelTime?: TravelTimeInitial;
postcode?: string;
share?: string;
} {
export function parseUrlState(): UrlState {
const params = new URLSearchParams(window.location.search);
const result: ReturnType<typeof parseUrlState> = {};
const result: UrlState = {
viewState: INITIAL_VIEW_STATE,
filters: parseFilters(params),
poiCategories: new Set(),
tab: 'area',
};
// Share-link code: grants bbox-scoped access to the area the link references
// even for unlicensed users. The backend looks the code up against PocketBase.
@ -117,13 +206,16 @@ export function parseUrlState(): {
}
}
// Filters: repeated `filter` params
result.filters = parseFilters(params);
// POI categories: repeated `poi` params
const poiParams = params.getAll('poi');
if (poiParams.length > 0) {
result.poiCategories = new Set(poiParams.filter(Boolean));
if (poiParams.includes(POI_NONE_PARAM)) {
result.poiCategories = new Set();
} else {
result.poiCategories = new Set(
poiParams.filter((value) => value && value !== POI_NONE_PARAM)
);
}
}
// Tab: full name
@ -209,6 +301,27 @@ export function stateToParams(
continue;
}
const ethnicityFeatureName = getEthnicityFeatureName(name);
if (ethnicityFeatureName && isEthnicityFilterName(name)) {
const [min, max] = value as [number, number];
params.append('ethnicity', `${ethnicityFeatureName}:${min}:${max}`);
continue;
}
const poiDistanceFeatureName = getPoiDistanceFeatureName(name);
if (poiDistanceFeatureName && isPoiDistanceFilterName(name)) {
const [min, max] = value as [number, number];
const filterName = getPoiFilterName(name);
const paramName =
filterName === POI_COUNT_2KM_FILTER_NAME
? 'poiCount2km'
: filterName === POI_COUNT_5KM_FILTER_NAME
? 'poiCount5km'
: 'poiDistance';
params.append(paramName, `${encodeURIComponent(poiDistanceFeatureName)}:${min}:${max}`);
continue;
}
const meta = features.find((f) => f.name === name);
if (meta?.type === 'enum') {
params.append('filter', `${name}:${(value as string[]).join('|')}`);
@ -218,8 +331,12 @@ export function stateToParams(
}
}
for (const category of selectedPOICategories) {
params.append('poi', category);
if (selectedPOICategories.size === 0) {
params.append('poi', POI_NONE_PARAM);
} else {
for (const category of selectedPOICategories) {
params.append('poi', category);
}
}
if (rightPaneTab === 'properties') {
@ -255,18 +372,45 @@ export function summarizeParams(queryString: string): string {
const filterParams = params.getAll('filter');
const schoolParams = params.getAll('school');
const crimeParams = params.getAll('crime');
if (filterParams.length > 0 || schoolParams.length > 0 || crimeParams.length > 0) {
const ethnicityParams = params.getAll('ethnicity');
const poiDistanceParams = params.getAll('poiDistance');
const poiCount2KmParams = params.getAll('poiCount2km');
const poiCount5KmParams = params.getAll('poiCount5km');
if (
filterParams.length > 0 ||
schoolParams.length > 0 ||
crimeParams.length > 0 ||
ethnicityParams.length > 0 ||
poiDistanceParams.length > 0 ||
poiCount2KmParams.length > 0 ||
poiCount5KmParams.length > 0
) {
const filterNames = filterParams
.map((entry) => {
const colonIdx = entry.indexOf(':');
const name = colonIdx > 0 ? entry.substring(0, colonIdx) : entry;
return isSpecificCrimeFeatureName(name) ? SPECIFIC_CRIMES_FILTER_NAME : name;
if (isSpecificCrimeFeatureName(name)) return SPECIFIC_CRIMES_FILTER_NAME;
if (isEthnicityFeatureName(name)) return ETHNICITIES_FILTER_NAME;
if (isPoiDistanceFeatureName(name)) return POI_DISTANCE_FILTER_NAME;
return name;
})
.filter((n) => n);
for (let i = 0; i < schoolParams.length; i++) filterNames.push(SCHOOL_FILTER_NAME);
for (let i = 0; i < crimeParams.length; i++) {
filterNames.push(SPECIFIC_CRIMES_FILTER_NAME);
}
for (let i = 0; i < ethnicityParams.length; i++) {
filterNames.push(ETHNICITIES_FILTER_NAME);
}
for (let i = 0; i < poiDistanceParams.length; i++) {
filterNames.push(POI_DISTANCE_FILTER_NAME);
}
for (let i = 0; i < poiCount2KmParams.length; i++) {
filterNames.push(POI_COUNT_2KM_FILTER_NAME);
}
for (let i = 0; i < poiCount5KmParams.length; i++) {
filterNames.push(POI_COUNT_5KM_FILTER_NAME);
}
if (filterNames.length > 0) {
parts.push(
filterNames.length <= 2
@ -278,7 +422,7 @@ export function summarizeParams(queryString: string): string {
const poiParams = params.getAll('poi');
if (poiParams.length > 0) {
const count = poiParams.filter(Boolean).length;
const count = poiParams.filter((value) => value && value !== POI_NONE_PARAM).length;
if (count > 0) {
parts.push(
count === 1