From dd9f00b105719aca00861d500223b460686971ad Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 9 May 2026 10:22:44 +0100 Subject: [PATCH] lgtm fe --- frontend/src/components/ui/FeatureIcons.tsx | 53 ++++-- frontend/src/components/ui/FeatureLabel.tsx | 3 +- frontend/src/hooks/useLicense.ts | 9 +- frontend/src/hooks/useLocationSearch.ts | 2 +- frontend/src/hooks/usePoiLayers.ts | 55 ++++-- frontend/src/i18n/descriptions.ts | 78 ++++---- frontend/src/lib/map-utils.ts | 51 ++++-- frontend/src/lib/url-state.ts | 190 +++++++++++++++++--- 8 files changed, 338 insertions(+), 103 deletions(-) diff --git a/frontend/src/components/ui/FeatureIcons.tsx b/frontend/src/components/ui/FeatureIcons.tsx index 489d307..fcbc0db 100644 --- a/frontend/src/components/ui/FeatureIcons.tsx +++ b/frontend/src/components/ui/FeatureIcons.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import type { FeatureMeta } from '../../types'; import { EyeIcon, InfoIcon, PlusIcon, CloseIcon } from './icons'; import { IconButton } from './IconButton'; @@ -11,6 +12,7 @@ interface FeatureActionsProps { onShowInfo?: (feature: FeatureMeta) => void; onRemove?: (name: string) => void; onAdd?: (name: string) => void; + showText?: boolean; } export function FeatureActions({ @@ -22,36 +24,59 @@ export function FeatureActions({ onShowInfo, onRemove, onAdd, + showText = false, }: FeatureActionsProps) { + const { t } = useTranslation(); const isEyeActive = isPinned || isPreviewing; const callbackName = actionName ?? feature.name; + const mapLabel = isPinned ? t('filters.clearColourMap') : t('filters.colourMap'); return (
{feature.detail && onShowInfo && ( - onShowInfo(feature)} title="Feature info" size="md"> - + showText ? ( + onShowInfo(feature)} title={t('filters.aboutData')} size="md"> + + + ) : ( + onShowInfo(feature)} title={t('filters.aboutData')} size="md"> + + + ) + )} + {showText ? ( + onTogglePin(callbackName)} + title={mapLabel} + active={isEyeActive} + size="md" + > + + + ) : ( + onTogglePin(callbackName)} + title={mapLabel} + active={isEyeActive} + size="md" + > + )} - onTogglePin(callbackName)} - title={isPinned ? 'Unpin colour view' : 'Colour map by this feature'} - active={isEyeActive} - size="md" - > - - {onAdd && ( )} {onRemove && ( - onRemove(callbackName)} title="Remove filter"> + onRemove(callbackName)} title={t('filters.removeFilter')}> )} diff --git a/frontend/src/components/ui/FeatureLabel.tsx b/frontend/src/components/ui/FeatureLabel.tsx index 4ba39d8..b0fd36a 100644 --- a/frontend/src/components/ui/FeatureLabel.tsx +++ b/frontend/src/components/ui/FeatureLabel.tsx @@ -45,7 +45,8 @@ export function FeatureLabel({ diff --git a/frontend/src/hooks/useLicense.ts b/frontend/src/hooks/useLicense.ts index b911ebf..34d2707 100644 --- a/frontend/src/hooks/useLicense.ts +++ b/frontend/src/hooks/useLicense.ts @@ -6,19 +6,16 @@ export function useLicense() { const [checkingOut, setCheckingOut] = useState(false); const [error, setError] = useState(null); - const startCheckout = useCallback(async (referralCode?: string) => { - trackEvent('Checkout Start', { has_referral: String(!!referralCode) }); + const startCheckout = useCallback(async () => { + trackEvent('Checkout Start', { has_referral: 'false' }); setCheckingOut(true); setError(null); try { - const body: Record = {}; - if (referralCode) body.referral_code = referralCode; - const res = await fetch(apiUrl('checkout'), { method: 'POST', ...authHeaders({ headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), + body: JSON.stringify({}), }), }); assertOk(res, 'Checkout'); diff --git a/frontend/src/hooks/useLocationSearch.ts b/frontend/src/hooks/useLocationSearch.ts index bbf8466..6f492c3 100644 --- a/frontend/src/hooks/useLocationSearch.ts +++ b/frontend/src/hooks/useLocationSearch.ts @@ -83,7 +83,7 @@ export function useLocationSearch(mode?: string) { const [activeIndex, setActiveIndex] = useState(-1); const [open, setOpen] = useState(false); const abortRef = useRef(null); - const debounceRef = useRef>(); + const debounceRef = useRef | null>(null); const latestQueryRef = useRef(''); const lastResultsRef = useRef([]); diff --git a/frontend/src/hooks/usePoiLayers.ts b/frontend/src/hooks/usePoiLayers.ts index 955d995..912b73e 100644 --- a/frontend/src/hooks/usePoiLayers.ts +++ b/frontend/src/hooks/usePoiLayers.ts @@ -6,7 +6,6 @@ import Supercluster from 'supercluster'; import type { POI } from '../types'; import { POI_GROUP_COLORS, - POI_DEFAULT_COLOR, MINOR_POI_CATEGORIES, MINOR_POI_ZOOM_THRESHOLD, POI_CLUSTER_RADIUS, @@ -40,6 +39,30 @@ interface UsePoiLayersProps { isDark: boolean; } +function getPoiIconUrlForPoi(poi: POI): string { + return getPoiIconUrl(poi.category, poi.emoji, poi.icon_category, poi.name); +} + +function isBundledPoiIcon(url: string): boolean { + return url.startsWith('/assets/poi-icons/'); +} + +function hasBundledPoiLogo(poi: POI): boolean { + return isBundledPoiIcon(getPoiIconUrlForPoi(poi)); +} + +function getPoiGroupColor(group: string): [number, number, number] { + const color = POI_GROUP_COLORS[group]; + if (!color) { + throw new Error(`Missing POI group color for '${group}'`); + } + return color; +} + +function getPoiIconSize(poi: POI): number { + return hasBundledPoiLogo(poi) ? 24 : 18; +} + export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) { const [popupInfo, setPopupInfo] = useState(null); @@ -139,7 +162,7 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) { id: 'poi-shadow', data: visiblePois, getPosition: (d) => [d.lng, d.lat], - getRadius: 16, + getRadius: (d) => (hasBundledPoiLogo(d) ? 0 : 16), radiusUnits: 'pixels', getFillColor: isDark ? [0, 0, 0, 50] : [0, 0, 0, 25], pickable: false, @@ -154,11 +177,17 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) { id: 'poi-background', data: visiblePois, getPosition: (d) => [d.lng, d.lat], - getRadius: 14, + getRadius: (d) => (hasBundledPoiLogo(d) ? 24 : 14), radiusUnits: 'pixels', - getFillColor: isDark ? [41, 37, 36, 255] : [255, 255, 255, 255], + getFillColor: (d) => + hasBundledPoiLogo(d) + ? ([0, 0, 0, 0] as [number, number, number, number]) + : isDark + ? ([41, 37, 36, 255] as [number, number, number, number]) + : ([255, 255, 255, 255] as [number, number, number, number]), getLineColor: (d) => { - const c = POI_GROUP_COLORS[d.group] || POI_DEFAULT_COLOR; + if (hasBundledPoiLogo(d)) return [0, 0, 0, 0] as [number, number, number, number]; + const c = getPoiGroupColor(d.group); return [c[0], c[1], c[2], 255] as [number, number, number, number]; }, getLineWidth: 2.5, @@ -177,12 +206,16 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) { id: 'poi-icons', data: visiblePois, getPosition: (d) => [d.lng, d.lat], - getIcon: (d) => ({ - url: getPoiIconUrl(d.category, d.emoji, d.icon_category, d.name), - width: 72, - height: 72, - }), - getSize: 18, + getIcon: (d) => { + const url = getPoiIconUrlForPoi(d); + const isLogo = isBundledPoiIcon(url); + return { + url, + width: isLogo ? 96 : 72, + height: isLogo ? 48 : 72, + }; + }, + getSize: getPoiIconSize, sizeUnits: 'pixels', pickable: false, transitions: { getSize: { duration: 300, enter: () => [0] } }, diff --git a/frontend/src/i18n/descriptions.ts b/frontend/src/i18n/descriptions.ts index b2e63ce..240c677 100644 --- a/frontend/src/i18n/descriptions.ts +++ b/frontend/src/i18n/descriptions.ts @@ -16,7 +16,7 @@ const descriptions: Record> = { 'Property type': 'Type de bien : individuel, jumelé, mitoyen, appartement ou autre', 'Leasehold/Freehold': 'Indique si le bien est en bail ou en pleine propriété', 'Last known price': 'Dernier prix de vente enregistré au Land Registry', - 'Estimated current price': 'Estimation du prix actuel ajusté à l’inflation', + 'Estimated current price': 'Estimation modélisée du prix actuel', 'Price per sqm': 'Prix de vente divisé par la surface totale', 'Est. price per sqm': 'Prix actuel estimé divisé par la surface totale', 'Estimated monthly rent': 'Loyer mensuel privé moyen pour le secteur', @@ -48,14 +48,15 @@ const descriptions: Record> = { 'Outstanding secondary schools within 5km': 'Collèges/lycées notés Excellent par Ofsted dans un rayon de 5 km', 'Education, Skills and Training Score': - 'Score de qualité éducative du secteur (plus élevé = meilleur)', - 'Income Score': 'Taux de précarité de revenu, inversé (plus élevé = moins précaire)', - 'Employment Score': 'Taux de précarité d’emploi, inversé (plus élevé = moins précaire)', + 'Centile de défaveur éducative (plus élevé = moins défavorisé)', + 'Income Score': 'Centile de défaveur de revenu (plus élevé = moins défavorisé)', + 'Employment Score': 'Centile de défaveur d’emploi (plus élevé = moins défavorisé)', 'Health Deprivation and Disability Score': - 'Score de santé et handicap (plus élevé = meilleurs résultats)', - 'Housing Conditions Score': 'Qualité et état du logement (plus élevé = meilleur)', + 'Centile de défaveur santé et handicap (plus élevé = meilleurs résultats)', + 'Housing Conditions Score': + 'Centile des conditions de logement (plus élevé = meilleures conditions)', 'Air Quality and Road Safety Score': - 'Qualité de l’air et sécurité routière (plus élevé = meilleur)', + 'Centile air et sécurité routière (plus élevé = meilleures conditions)', 'Serious crime per 1k residents (avg/yr)': 'Taux de crimes graves pour 1 000 habitants par an', 'Minor crime per 1k residents (avg/yr)': 'Taux de délits mineurs pour 1 000 habitants par an', 'Serious crime (avg/yr)': 'Agrégat des catégories de crimes graves par an', @@ -107,7 +108,7 @@ const descriptions: Record> = { 'Immobilientyp: freistehend, Doppelhaushälfte, Reihenhaus, Wohnung oder sonstige', 'Leasehold/Freehold': 'Ob die Immobilie Erbbaurecht oder Volleigentum ist', 'Last known price': 'Letzter Verkaufspreis laut Land Registry', - 'Estimated current price': 'Inflationsbereinigter Schätzwert der Immobilie', + 'Estimated current price': 'Modellierter aktueller Schätzwert der Immobilie', 'Price per sqm': 'Verkaufspreis geteilt durch die Gesamtfläche', 'Est. price per sqm': 'Geschätzter aktueller Preis geteilt durch die Gesamtfläche', 'Estimated monthly rent': 'Durchschnittliche monatliche Privatmiete in der Gegend', @@ -137,14 +138,15 @@ const descriptions: Record> = { 'Von Ofsted mit Hervorragend bewertete Grundschulen im Umkreis von 5 km', 'Outstanding secondary schools within 5km': 'Von Ofsted mit Hervorragend bewertete weiterführende Schulen im Umkreis von 5 km', - 'Education, Skills and Training Score': 'Bildungsqualitätsscore der Gegend (höher = besser)', - 'Income Score': 'Einkommensbenachteiligungsrate, invertiert (höher = weniger benachteiligt)', - 'Employment Score': - 'Beschäftigungsbenachteiligungsrate, invertiert (höher = weniger benachteiligt)', + 'Education, Skills and Training Score': + 'Bildungs- und Ausbildungsbenachteiligungs-Perzentil (höher = weniger benachteiligt)', + 'Income Score': 'Einkommensbenachteiligungs-Perzentil (höher = weniger benachteiligt)', + 'Employment Score': 'Beschäftigungsbenachteiligungs-Perzentil (höher = weniger benachteiligt)', 'Health Deprivation and Disability Score': - 'Gesundheits- und Behinderungsscore (höher = bessere Ergebnisse)', - 'Housing Conditions Score': 'Wohnqualität und -zustand (höher = besser)', - 'Air Quality and Road Safety Score': 'Luftqualität und Verkehrssicherheit (höher = besser)', + 'Gesundheits- und Behinderungsbenachteiligungs-Perzentil (höher = bessere Ergebnisse)', + 'Housing Conditions Score': 'Perzentil der Wohnbedingungen (höher = bessere Bedingungen)', + 'Air Quality and Road Safety Score': + 'Perzentil für Luftqualität und Verkehrssicherheit (höher = bessere Bedingungen)', 'Serious crime per 1k residents (avg/yr)': 'Rate schwerer Straftaten pro 1.000 Einwohner pro Jahr', 'Minor crime per 1k residents (avg/yr)': @@ -199,7 +201,7 @@ const descriptions: Record> = { 'Property type': '房产类型:独立式、半独立式、联排、公寓或其他', 'Leasehold/Freehold': '该房产是租赁产权还是永久产权', 'Last known price': 'Land Registry记录的最近一次售价', - 'Estimated current price': '经通胀调整后的当前估计价值', + 'Estimated current price': '模型估算的当前价格', 'Price per sqm': '售价除以总建筑面积', 'Est. price per sqm': '估计当前价格除以总建筑面积', 'Estimated monthly rent': '当地私人租赁的平均月租', @@ -220,12 +222,12 @@ const descriptions: Record> = { 'Outstanding secondary schools within 2km': 'Ofsted评为优秀的2公里内中学', 'Outstanding primary schools within 5km': 'Ofsted评为优秀的5公里内小学', 'Outstanding secondary schools within 5km': 'Ofsted评为优秀的5公里内中学', - 'Education, Skills and Training Score': '当地教育质量得分(越高越好)', - 'Income Score': '收入贫困率,反向指标(越高越不贫困)', - 'Employment Score': '就业贫困率,反向指标(越高越不贫困)', - 'Health Deprivation and Disability Score': '健康与残障得分(越高健康状况越好)', - 'Housing Conditions Score': '住房质量和状况(越高越好)', - 'Air Quality and Road Safety Score': '空气质量和道路安全(越高越好)', + 'Education, Skills and Training Score': '教育与技能贫困百分位(越高越不贫困)', + 'Income Score': '收入贫困百分位(越高越不贫困)', + 'Employment Score': '就业贫困百分位(越高越不贫困)', + 'Health Deprivation and Disability Score': '健康与残障贫困百分位(越高结果越好)', + 'Housing Conditions Score': '住房条件百分位(越高条件越好)', + 'Air Quality and Road Safety Score': '空气质量和道路安全百分位(越高条件越好)', 'Serious crime per 1k residents (avg/yr)': '每千人每年严重犯罪率', 'Minor crime per 1k residents (avg/yr)': '每千人每年轻微犯罪率', 'Serious crime (avg/yr)': '严重犯罪类别年度总计', @@ -269,7 +271,7 @@ const descriptions: Record> = { 'Property type': 'संपत्ति प्रकार: अलग, अर्ध-स्वतंत्र, कतारबद्ध, फ्लैट या अन्य', 'Leasehold/Freehold': 'बताता है कि संपत्ति लीजहोल्ड है या फ्रीहोल्ड', 'Last known price': 'Land Registry में दर्ज अंतिम बिक्री कीमत', - 'Estimated current price': 'महंगाई और स्थानीय कीमत बदलाव से समायोजित मौजूदा अनुमानित मूल्य', + 'Estimated current price': 'मॉडल से अनुमानित मौजूदा मूल्य', 'Price per sqm': 'बिक्री कीमत को कुल फर्श क्षेत्र से विभाजित किया गया', 'Est. price per sqm': 'मौजूदा अनुमानित कीमत को कुल फर्श क्षेत्र से विभाजित किया गया', 'Estimated monthly rent': 'क्षेत्र का औसत निजी मासिक किराया', @@ -292,12 +294,14 @@ const descriptions: Record> = { 'Outstanding secondary schools within 2km': '2 किमी के भीतर Ofsted Outstanding सेकेंडरी स्कूल', 'Outstanding primary schools within 5km': '5 किमी के भीतर Ofsted Outstanding प्राइमरी स्कूल', 'Outstanding secondary schools within 5km': '5 किमी के भीतर Ofsted Outstanding सेकेंडरी स्कूल', - 'Education, Skills and Training Score': 'स्थानीय शिक्षा गुणवत्ता स्कोर (अधिक = बेहतर)', - 'Income Score': 'आय वंचना दर, उलटी की गई (अधिक = कम वंचना)', - 'Employment Score': 'रोजगार वंचना दर, उलटी की गई (अधिक = कम वंचना)', - 'Health Deprivation and Disability Score': 'स्वास्थ्य और विकलांगता स्कोर (अधिक = बेहतर परिणाम)', - 'Housing Conditions Score': 'आवास गुणवत्ता और स्थिति (अधिक = बेहतर)', - 'Air Quality and Road Safety Score': 'हवा की गुणवत्ता और सड़क सुरक्षा (अधिक = बेहतर)', + 'Education, Skills and Training Score': 'शिक्षा और कौशल वंचना percentile (अधिक = कम वंचना)', + 'Income Score': 'आय वंचना percentile (अधिक = कम वंचना)', + 'Employment Score': 'रोजगार वंचना percentile (अधिक = कम वंचना)', + 'Health Deprivation and Disability Score': + 'स्वास्थ्य और विकलांगता वंचना percentile (अधिक = बेहतर परिणाम)', + 'Housing Conditions Score': 'आवास स्थिति percentile (अधिक = बेहतर स्थिति)', + 'Air Quality and Road Safety Score': + 'हवा की गुणवत्ता और सड़क सुरक्षा percentile (अधिक = बेहतर स्थिति)', 'Serious crime per 1k residents (avg/yr)': 'प्रति 1,000 निवासियों सालाना गंभीर अपराध दर', 'Minor crime per 1k residents (avg/yr)': 'प्रति 1,000 निवासियों सालाना मामूली अपराध दर', 'Serious crime (avg/yr)': 'गंभीर अपराध श्रेणियों का सालाना कुल', @@ -342,7 +346,7 @@ const descriptions: Record> = { 'Property type': 'Ingatlantípus: különálló, ikerház, sorház, lakás vagy egyéb', 'Leasehold/Freehold': 'Az ingatlan bérleti jogú vagy teljes tulajdonú', 'Last known price': 'A Land Registry-ben rögzített utolsó eladási ár', - 'Estimated current price': 'Inflációval korrigált becsült jelenlegi érték', + 'Estimated current price': 'Modellezett becsült jelenlegi érték', 'Price per sqm': 'Eladási ár osztva az összes alapterülettel', 'Est. price per sqm': 'Becsült jelenlegi ár osztva az összes alapterülettel', 'Estimated monthly rent': 'A környék átlagos havi magánbérleti díja', @@ -374,14 +378,14 @@ const descriptions: Record> = { 'Outstanding secondary schools within 5km': 'Ofsted által Kiváló minősítésű középiskolák 5 km-en belül', 'Education, Skills and Training Score': - 'A környék oktatási minőségi pontszáma (magasabb = jobb)', - 'Income Score': 'Jövedelmi deprivációs ráta, invertálva (magasabb = kevésbé hátrányos)', - 'Employment Score': - 'Foglalkoztatási deprivációs ráta, invertálva (magasabb = kevésbé hátrányos)', + 'Oktatási és készségbeli deprivációs percentilis (magasabb = kevésbé hátrányos)', + 'Income Score': 'Jövedelmi deprivációs percentilis (magasabb = kevésbé hátrányos)', + 'Employment Score': 'Foglalkoztatási deprivációs percentilis (magasabb = kevésbé hátrányos)', 'Health Deprivation and Disability Score': - 'Egészségügyi és fogyatékossági pontszám (magasabb = jobb eredmények)', - 'Housing Conditions Score': 'Lakásminőség és állapot (magasabb = jobb)', - 'Air Quality and Road Safety Score': 'Levegőminőség és közlekedésbiztonság (magasabb = jobb)', + 'Egészségügyi és fogyatékossági deprivációs percentilis (magasabb = jobb eredmények)', + 'Housing Conditions Score': 'Lakáskörülmények percentilise (magasabb = jobb körülmények)', + 'Air Quality and Road Safety Score': + 'Levegőminőség és közlekedésbiztonság percentilise (magasabb = jobb körülmények)', 'Serious crime per 1k residents (avg/yr)': 'Súlyos bűncselekmények aránya 1000 lakosra évente', 'Minor crime per 1k residents (avg/yr)': 'Kisebb bűncselekmények aránya 1000 lakosra évente', 'Serious crime (avg/yr)': 'Súlyos bűncselekményi kategóriák éves összesítése', diff --git a/frontend/src/lib/map-utils.ts b/frontend/src/lib/map-utils.ts index 51db978..55ad8f6 100644 --- a/frontend/src/lib/map-utils.ts +++ b/frontend/src/lib/map-utils.ts @@ -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]; } diff --git a/frontend/src/lib/url-state.ts b/frontend/src/lib/url-state.ts index c201b83..244def2 100644 --- a/frontend/src/lib/url-state.ts +++ b/frontend/src/lib/url-state.ts @@ -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; + 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; - tab?: 'properties' | 'area'; - travelTime?: TravelTimeInitial; - postcode?: string; - share?: string; -} { +export function parseUrlState(): UrlState { const params = new URLSearchParams(window.location.search); - const result: ReturnType = {}; + 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