lgtm fe
This commit is contained in:
parent
fe46cb3379
commit
dd9f00b105
8 changed files with 338 additions and 103 deletions
|
|
@ -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 (
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
{feature.detail && onShowInfo && (
|
||||
<IconButton onClick={() => onShowInfo(feature)} title="Feature info" size="md">
|
||||
<InfoIcon className="w-5 h-5 md:w-3.5 md:h-3.5" />
|
||||
showText ? (
|
||||
<IconButton onClick={() => onShowInfo(feature)} title={t('filters.aboutData')} size="md">
|
||||
<InfoIcon className="w-4 h-4" />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton onClick={() => onShowInfo(feature)} title={t('filters.aboutData')} size="md">
|
||||
<InfoIcon className="w-5 h-5 md:w-3.5 md:h-3.5" />
|
||||
</IconButton>
|
||||
)
|
||||
)}
|
||||
{showText ? (
|
||||
<IconButton
|
||||
onClick={() => onTogglePin(callbackName)}
|
||||
title={mapLabel}
|
||||
active={isEyeActive}
|
||||
size="md"
|
||||
>
|
||||
<EyeIcon filled={isEyeActive} className="w-4 h-4" />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton
|
||||
onClick={() => onTogglePin(callbackName)}
|
||||
title={mapLabel}
|
||||
active={isEyeActive}
|
||||
size="md"
|
||||
>
|
||||
<EyeIcon filled={isEyeActive} className="w-5 h-5 md:w-3.5 md:h-3.5" />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
onClick={() => onTogglePin(callbackName)}
|
||||
title={isPinned ? 'Unpin colour view' : 'Colour map by this feature'}
|
||||
active={isEyeActive}
|
||||
size="md"
|
||||
>
|
||||
<EyeIcon filled={isEyeActive} className="w-5 h-5 md:w-3.5 md:h-3.5" />
|
||||
</IconButton>
|
||||
{onAdd && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAdd(callbackName)}
|
||||
title="Add filter"
|
||||
className="p-1 rounded-md text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30 hover:bg-teal-100 dark:hover:bg-teal-800/40"
|
||||
title={t('filters.addFilterLabel')}
|
||||
aria-label={t('filters.addFilterLabel')}
|
||||
className="inline-flex items-center gap-1 rounded-md bg-teal-50 px-2 py-1 text-xs font-semibold text-teal-700 hover:bg-teal-100 dark:bg-teal-900/30 dark:text-teal-300 dark:hover:bg-teal-800/40"
|
||||
>
|
||||
<PlusIcon className="w-5 h-5 md:w-5 md:h-5" strokeWidth={2.5} />
|
||||
<PlusIcon className="w-4 h-4" strokeWidth={2.5} />
|
||||
<span>{t('filters.addFilterAction')}</span>
|
||||
</button>
|
||||
)}
|
||||
{onRemove && (
|
||||
<IconButton onClick={() => onRemove(callbackName)} title="Remove filter">
|
||||
<IconButton onClick={() => onRemove(callbackName)} title={t('filters.removeFilter')}>
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
</IconButton>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@ export function FeatureLabel({
|
|||
<button
|
||||
onClick={() => onShowInfo(feature)}
|
||||
className="p-1 -m-0.5 rounded text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 hover:bg-warm-100 dark:hover:bg-warm-700 shrink-0"
|
||||
title={t('filters.featureInfo')}
|
||||
title={t('filters.aboutData')}
|
||||
aria-label={t('filters.aboutData')}
|
||||
>
|
||||
<InfoIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -6,19 +6,16 @@ export function useLicense() {
|
|||
const [checkingOut, setCheckingOut] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<string, string> = {};
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ export function useLocationSearch(mode?: string) {
|
|||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const [open, setOpen] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const latestQueryRef = useRef('');
|
||||
const lastResultsRef = useRef<SearchResult[]>([]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<PopupInfo | null>(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] } },
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ const descriptions: Record<string, Record<string, string>> = {
|
|||
'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<string, Record<string, string>> = {
|
|||
'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<string, Record<string, string>> = {
|
|||
'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<string, Record<string, string>> = {
|
|||
'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<string, Record<string, string>> = {
|
|||
'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<string, Record<string, string>> = {
|
|||
'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<string, Record<string, string>> = {
|
|||
'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<string, Record<string, string>> = {
|
|||
'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<string, Record<string, string>> = {
|
|||
'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<string, Record<string, string>> = {
|
|||
'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',
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue