diff --git a/docker-compose.yml b/docker-compose.yml
index 8d739bf..390d769 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -52,7 +52,7 @@ services:
screenshot:
init: true
- build: ./screenshot
+ build: /volumes/syncthing/Projects/property-map/screenshot
environment:
PORT: "8002"
APP_URL: http://frontend:3001
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 12fc9fa..2e85169 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -197,7 +197,7 @@ export default function App() {
setShowLicenseSuccess(true);
}
// Always refresh auth on startup to pick up server-side subscription changes
- refreshAuth().catch(() => {});
+ refreshAuth().catch(() => { });
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const savedSearches = useSavedSearches(user?.id ?? null);
@@ -271,8 +271,8 @@ export default function App() {
{ page: activePage },
'',
pageToPath(activePage, inviteCode ?? undefined) +
- window.location.search +
- window.location.hash
+ window.location.search +
+ window.location.hash
);
}
const handlePopState = (e: PopStateEvent) => {
@@ -355,8 +355,8 @@ export default function App() {
initialLoading={initialLoading}
theme={theme}
pendingInfoFeature={null}
- onClearPendingInfoFeature={() => {}}
- onNavigateTo={() => {}}
+ onClearPendingInfoFeature={() => { }}
+ onNavigateTo={() => { }}
screenshotMode
ogMode={isOgMode}
initialTravelTime={urlState.travelTime}
diff --git a/frontend/src/components/map/DualHistogram.tsx b/frontend/src/components/map/DualHistogram.tsx
index 2b0c04b..77e45e2 100644
--- a/frontend/src/components/map/DualHistogram.tsx
+++ b/frontend/src/components/map/DualHistogram.tsx
@@ -119,7 +119,10 @@ export function DualHistogram({
})}
{showMeanMarker && (
-
+
void;
onRemove?: (name: string) => void;
onAdd?: (name: string) => void;
- showText?: boolean;
}
export function FeatureActions({
@@ -24,59 +22,36 @@ 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 &&
- (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"
- >
-
+ {feature.detail && onShowInfo && (
+ onShowInfo(feature)} title="Feature info" size="md">
+
)}
+ onTogglePin(callbackName)}
+ title={isPinned ? 'Unpin colour view' : 'Colour map by this feature'}
+ active={isEyeActive}
+ size="md"
+ >
+
+
{onAdd && (
)}
{onRemove && (
- onRemove(callbackName)} title={t('filters.removeFilter')}>
+ onRemove(callbackName)} title="Remove filter">
)}
diff --git a/frontend/src/components/ui/FeatureLabel.tsx b/frontend/src/components/ui/FeatureLabel.tsx
index b0fd36a..4ba39d8 100644
--- a/frontend/src/components/ui/FeatureLabel.tsx
+++ b/frontend/src/components/ui/FeatureLabel.tsx
@@ -45,8 +45,7 @@ export function FeatureLabel({
diff --git a/frontend/src/components/ui/Header.tsx b/frontend/src/components/ui/Header.tsx
index 830b71c..bf0675d 100644
--- a/frontend/src/components/ui/Header.tsx
+++ b/frontend/src/components/ui/Header.tsx
@@ -97,8 +97,8 @@ export default function Header({
const [copied, setCopied] = useState(false);
const [sharing, setSharing] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
- const [isDashboardTabletSidebarWidth, setIsDashboardTabletSidebarWidth] = useState(
- () => window.matchMedia(DASHBOARD_TABLET_SIDEBAR_QUERY).matches
+ const [isDashboardTabletSidebarWidth, setIsDashboardTabletSidebarWidth] = useState(() =>
+ window.matchMedia(DASHBOARD_TABLET_SIDEBAR_QUERY).matches
);
const useSidebarNav = isMobile || (activePage === 'dashboard' && isDashboardTabletSidebarWidth);
diff --git a/frontend/src/hooks/useDeckLayers.ts b/frontend/src/hooks/useDeckLayers.ts
index 876a781..556bcda 100644
--- a/frontend/src/hooks/useDeckLayers.ts
+++ b/frontend/src/hooks/useDeckLayers.ts
@@ -268,27 +268,27 @@ export function useDeckLayers({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pieProps: any = isEnum
? {
- extensions: [new PieHexExtension(requireEnumPalette(enumPaletteRef.current))],
- getCenter: (d: HexagonData) => [d.lon, d.lat],
- getRatios0: (d: HexagonData) => {
- const r = distToRatios(d[distKey]);
- return [r[0], r[1], r[2], r[3]];
- },
- getRatios1: (d: HexagonData) => {
- const r = distToRatios(d[distKey]);
- return [r[4], r[5], r[6], r[7]];
- },
- getRatios2: (d: HexagonData) => {
- const r = distToRatios(d[distKey]);
- return [r[8], r[9]];
- },
- updateTriggers: {
- getCenter: [colorTrigger, data],
- getRatios0: [colorTrigger, data],
- getRatios1: [colorTrigger, data],
- getRatios2: [colorTrigger, data],
- },
- }
+ extensions: [new PieHexExtension(requireEnumPalette(enumPaletteRef.current))],
+ getCenter: (d: HexagonData) => [d.lon, d.lat],
+ getRatios0: (d: HexagonData) => {
+ const r = distToRatios(d[distKey]);
+ return [r[0], r[1], r[2], r[3]];
+ },
+ getRatios1: (d: HexagonData) => {
+ const r = distToRatios(d[distKey]);
+ return [r[4], r[5], r[6], r[7]];
+ },
+ getRatios2: (d: HexagonData) => {
+ const r = distToRatios(d[distKey]);
+ return [r[8], r[9]];
+ },
+ updateTriggers: {
+ getCenter: [colorTrigger, data],
+ getRatios0: [colorTrigger, data],
+ getRatios1: [colorTrigger, data],
+ getRatios2: [colorTrigger, data],
+ },
+ }
: {};
return new H3HexagonLayer({
diff --git a/frontend/src/hooks/useLicense.ts b/frontend/src/hooks/useLicense.ts
index 34d2707..b911ebf 100644
--- a/frontend/src/hooks/useLicense.ts
+++ b/frontend/src/hooks/useLicense.ts
@@ -6,16 +6,19 @@ export function useLicense() {
const [checkingOut, setCheckingOut] = useState(false);
const [error, setError] = useState(null);
- const startCheckout = useCallback(async () => {
- trackEvent('Checkout Start', { has_referral: 'false' });
+ const startCheckout = useCallback(async (referralCode?: string) => {
+ trackEvent('Checkout Start', { has_referral: String(!!referralCode) });
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: JSON.stringify(body),
}),
});
assertOk(res, 'Checkout');
diff --git a/frontend/src/hooks/useLocationSearch.ts b/frontend/src/hooks/useLocationSearch.ts
index 6f492c3..bbf8466 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 | null>(null);
+ const debounceRef = useRef>();
const latestQueryRef = useRef('');
const lastResultsRef = useRef([]);
diff --git a/frontend/src/hooks/usePoiLayers.ts b/frontend/src/hooks/usePoiLayers.ts
index 912b73e..955d995 100644
--- a/frontend/src/hooks/usePoiLayers.ts
+++ b/frontend/src/hooks/usePoiLayers.ts
@@ -6,6 +6,7 @@ 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,
@@ -39,30 +40,6 @@ 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);
@@ -162,7 +139,7 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
id: 'poi-shadow',
data: visiblePois,
getPosition: (d) => [d.lng, d.lat],
- getRadius: (d) => (hasBundledPoiLogo(d) ? 0 : 16),
+ getRadius: 16,
radiusUnits: 'pixels',
getFillColor: isDark ? [0, 0, 0, 50] : [0, 0, 0, 25],
pickable: false,
@@ -177,17 +154,11 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
id: 'poi-background',
data: visiblePois,
getPosition: (d) => [d.lng, d.lat],
- getRadius: (d) => (hasBundledPoiLogo(d) ? 24 : 14),
+ getRadius: 14,
radiusUnits: 'pixels',
- 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]),
+ getFillColor: isDark ? [41, 37, 36, 255] : [255, 255, 255, 255],
getLineColor: (d) => {
- if (hasBundledPoiLogo(d)) return [0, 0, 0, 0] as [number, number, number, number];
- const c = getPoiGroupColor(d.group);
+ const c = POI_GROUP_COLORS[d.group] || POI_DEFAULT_COLOR;
return [c[0], c[1], c[2], 255] as [number, number, number, number];
},
getLineWidth: 2.5,
@@ -206,16 +177,12 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
id: 'poi-icons',
data: visiblePois,
getPosition: (d) => [d.lng, d.lat],
- getIcon: (d) => {
- const url = getPoiIconUrlForPoi(d);
- const isLogo = isBundledPoiIcon(url);
- return {
- url,
- width: isLogo ? 96 : 72,
- height: isLogo ? 48 : 72,
- };
- },
- getSize: getPoiIconSize,
+ getIcon: (d) => ({
+ url: getPoiIconUrl(d.category, d.emoji, d.icon_category, d.name),
+ width: 72,
+ height: 72,
+ }),
+ getSize: 18,
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 240c677..b2e63ce 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 modélisée du prix actuel',
+ 'Estimated current price': 'Estimation du prix actuel ajusté à l’inflation',
'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,15 +48,14 @@ 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':
- '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é)',
+ '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)',
'Health Deprivation and Disability Score':
- 'Centile de défaveur santé et handicap (plus élevé = meilleurs résultats)',
- 'Housing Conditions Score':
- 'Centile des conditions de logement (plus élevé = meilleures conditions)',
+ 'Score de santé et handicap (plus élevé = meilleurs résultats)',
+ 'Housing Conditions Score': 'Qualité et état du logement (plus élevé = meilleur)',
'Air Quality and Road Safety Score':
- 'Centile air et sécurité routière (plus élevé = meilleures conditions)',
+ 'Qualité de l’air et sécurité routière (plus élevé = meilleur)',
'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',
@@ -108,7 +107,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': 'Modellierter aktueller Schätzwert der Immobilie',
+ 'Estimated current price': 'Inflationsbereinigter 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',
@@ -138,15 +137,14 @@ 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':
- '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)',
+ '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)',
'Health Deprivation and Disability Score':
- '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)',
+ '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)',
'Serious crime per 1k residents (avg/yr)':
'Rate schwerer Straftaten pro 1.000 Einwohner pro Jahr',
'Minor crime per 1k residents (avg/yr)':
@@ -201,7 +199,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': '当地私人租赁的平均月租',
@@ -222,12 +220,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)': '严重犯罪类别年度总计',
@@ -271,7 +269,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': 'क्षेत्र का औसत निजी मासिक किराया',
@@ -294,14 +292,12 @@ 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': 'शिक्षा और कौशल वंचना percentile (अधिक = कम वंचना)',
- 'Income Score': 'आय वंचना percentile (अधिक = कम वंचना)',
- 'Employment Score': 'रोजगार वंचना percentile (अधिक = कम वंचना)',
- 'Health Deprivation and Disability Score':
- 'स्वास्थ्य और विकलांगता वंचना percentile (अधिक = बेहतर परिणाम)',
- 'Housing Conditions Score': 'आवास स्थिति percentile (अधिक = बेहतर स्थिति)',
- 'Air Quality and Road Safety Score':
- 'हवा की गुणवत्ता और सड़क सुरक्षा percentile (अधिक = बेहतर स्थिति)',
+ '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)': 'प्रति 1,000 निवासियों सालाना गंभीर अपराध दर',
'Minor crime per 1k residents (avg/yr)': 'प्रति 1,000 निवासियों सालाना मामूली अपराध दर',
'Serious crime (avg/yr)': 'गंभीर अपराध श्रेणियों का सालाना कुल',
@@ -346,7 +342,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': 'Modellezett becsült jelenlegi érték',
+ 'Estimated current price': 'Inflációval korrigált 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',
@@ -378,14 +374,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':
- '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)',
+ '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)',
'Health Deprivation and Disability Score':
- '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)',
+ '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)',
'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/index.css b/frontend/src/index.css
index 42ca9b5..b4698a0 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -1,6 +1,6 @@
@config "../tailwind.config.js";
-@import 'tailwindcss';
+@import "tailwindcss";
html,
body,
@@ -66,7 +66,7 @@ h3 {
}
.home-content-surface {
- --home-hex-pattern: url('/home-hex-pattern.svg');
+ --home-hex-pattern: url("/home-hex-pattern.svg");
--home-pointer-active: 0;
--home-pointer-x: 50%;
--home-pointer-y: 50%;
@@ -124,7 +124,7 @@ h3 {
}
.dark .home-content-surface {
- --home-hex-pattern: url('/home-hex-pattern-dark.svg');
+ --home-hex-pattern: url("/home-hex-pattern-dark.svg");
background: linear-gradient(180deg, #121827 0%, #0a0e1a 42%, #10211f 100%);
}
diff --git a/frontend/src/lib/map-utils.ts b/frontend/src/lib/map-utils.ts
index 55ad8f6..51db978 100644
--- a/frontend/src/lib/map-utils.ts
+++ b/frontend/src/lib/map-utils.ts
@@ -8,6 +8,7 @@ import {
ZOOM_TO_RESOLUTION_THRESHOLDS,
TWEMOJI_BASE,
BUFFER_MULTIPLIER,
+ ENUM_PALETTE,
POI_CATEGORY_LOGOS,
type GradientStop,
} from './consts';
@@ -77,21 +78,19 @@ 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((original) => {
- let layer = original;
-
+ .map((layer) => {
// Modify road opacity
if (layer.id.includes('roads_') || layer.id.includes('road_')) {
if (layer.type === 'line') {
- layer = { ...layer, paint: { ...layer.paint, 'line-opacity': ROAD_OPACITY } };
+ return { ...layer, paint: { ...layer.paint, 'line-opacity': ROAD_OPACITY } };
} else if (layer.type === 'fill') {
- layer = { ...layer, paint: { ...layer.paint, 'fill-opacity': ROAD_OPACITY } };
+ return { ...layer, paint: { ...layer.paint, 'fill-opacity': ROAD_OPACITY } };
}
}
// Modify text colors in dark mode
if (isDark && layer.type === 'symbol' && layer.paint?.['text-color']) {
- layer = {
+ return {
...layer,
paint: {
...layer.paint,
@@ -235,32 +234,9 @@ 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) {
- throw new Error('Cannot build a Twemoji URL without an emoji');
- }
+ if (!codePoint) return `${TWEMOJI_BASE}1f4cd.png`;
const hex = codePoint.toString(16);
return `${TWEMOJI_BASE}${hex}.png`;
}
@@ -311,7 +287,7 @@ function inferPoiIconCategory(category: string, name?: string): string | undefin
export function getPoiIconUrl(
category: string,
- _emoji: string,
+ emoji: string,
iconCategory?: string,
name?: string
): string {
@@ -319,17 +295,13 @@ export function getPoiIconUrl(
if (resolvedIconCategory && POI_CATEGORY_LOGOS[resolvedIconCategory]) {
return POI_CATEGORY_LOGOS[resolvedIconCategory];
}
- const categoryLogo = POI_CATEGORY_LOGOS[category];
- if (!categoryLogo) {
- throw new Error(`Missing POI icon for category '${category}'`);
- }
- return categoryLogo;
+ return POI_CATEGORY_LOGOS[category] ?? emojiToTwemojiUrl(emoji);
}
/** Look up a discrete color from the enum palette by index (wraps if > palette size). */
export function enumIndexToColor(
index: number,
- palette: [number, number, number][]
+ palette: [number, number, number][] = ENUM_PALETTE
): [number, number, number] {
const i = Math.round(Math.max(0, index)) % palette.length;
return palette[i];
@@ -352,7 +324,7 @@ export function getFeatureFillColor(
isDark: boolean,
alpha: number,
enumCount: number = 0,
- enumPalette?: [number, number, number][] | null,
+ enumPalette?: [number, number, number][],
featureGradient: GradientStop[] = FEATURE_GRADIENT
): [number, number, number, number] {
if (colorRange) {
@@ -371,9 +343,6 @@ 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/poi-distance-filter.ts b/frontend/src/lib/poi-distance-filter.ts
index 7c4cd87..5c63a98 100644
--- a/frontend/src/lib/poi-distance-filter.ts
+++ b/frontend/src/lib/poi-distance-filter.ts
@@ -77,6 +77,10 @@ const POI_FILTER_CONFIGS: Record<
},
};
+function isPoiFilterNameValue(name: string): name is PoiFilterName {
+ return POI_FILTER_NAMES.includes(name as PoiFilterName);
+}
+
function getConfig(filterName: PoiFilterName) {
return POI_FILTER_CONFIGS[filterName];
}
diff --git a/frontend/src/lib/url-state.ts b/frontend/src/lib/url-state.ts
index 244def2..c201b83 100644
--- a/frontend/src/lib/url-state.ts
+++ b/frontend/src/lib/url-state.ts
@@ -5,7 +5,6 @@ import {
type TravelTimeEntry,
type TravelTimeInitial,
} from '../hooks/useTravelTime';
-import { INITIAL_VIEW_STATE } from './consts';
import {
SCHOOL_FILTER_NAME,
createSchoolFilterKey,
@@ -22,56 +21,13 @@ 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';
-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 {
+function parseFilters(params: URLSearchParams): FeatureFilters | undefined {
const filterParams = params.getAll('filter');
const schoolParams = params.getAll('school');
const crimeParams = params.getAll('crime');
- 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 {};
+ if (filterParams.length === 0 && schoolParams.length === 0 && crimeParams.length === 0) {
+ return undefined;
}
const filters: FeatureFilters = {};
@@ -126,65 +82,20 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
filters[createSpecificCrimeFilterKey(featureName, index)] = [min, max];
});
- 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;
+ return Object.keys(filters).length > 0 ? filters : undefined;
}
-export function parseUrlState(): UrlState {
+export function parseUrlState(): {
+ viewState?: ViewState;
+ filters?: FeatureFilters;
+ poiCategories?: Set;
+ tab?: 'properties' | 'area';
+ travelTime?: TravelTimeInitial;
+ postcode?: string;
+ share?: string;
+} {
const params = new URLSearchParams(window.location.search);
- const result: UrlState = {
- viewState: INITIAL_VIEW_STATE,
- filters: parseFilters(params),
- poiCategories: new Set(),
- tab: 'area',
- };
+ const result: ReturnType = {};
// 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.
@@ -206,16 +117,13 @@ export function parseUrlState(): UrlState {
}
}
+ // Filters: repeated `filter` params
+ result.filters = parseFilters(params);
+
// POI categories: repeated `poi` params
const poiParams = params.getAll('poi');
if (poiParams.length > 0) {
- if (poiParams.includes(POI_NONE_PARAM)) {
- result.poiCategories = new Set();
- } else {
- result.poiCategories = new Set(
- poiParams.filter((value) => value && value !== POI_NONE_PARAM)
- );
- }
+ result.poiCategories = new Set(poiParams.filter(Boolean));
}
// Tab: full name
@@ -301,27 +209,6 @@ 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('|')}`);
@@ -331,12 +218,8 @@ export function stateToParams(
}
}
- if (selectedPOICategories.size === 0) {
- params.append('poi', POI_NONE_PARAM);
- } else {
- for (const category of selectedPOICategories) {
- params.append('poi', category);
- }
+ for (const category of selectedPOICategories) {
+ params.append('poi', category);
}
if (rightPaneTab === 'properties') {
@@ -372,45 +255,18 @@ export function summarizeParams(queryString: string): string {
const filterParams = params.getAll('filter');
const schoolParams = params.getAll('school');
const crimeParams = params.getAll('crime');
- 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
- ) {
+ if (filterParams.length > 0 || schoolParams.length > 0 || crimeParams.length > 0) {
const filterNames = filterParams
.map((entry) => {
const colonIdx = entry.indexOf(':');
const name = colonIdx > 0 ? entry.substring(0, colonIdx) : entry;
- 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;
+ return isSpecificCrimeFeatureName(name) ? SPECIFIC_CRIMES_FILTER_NAME : 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
@@ -422,7 +278,7 @@ export function summarizeParams(queryString: string): string {
const poiParams = params.getAll('poi');
if (poiParams.length > 0) {
- const count = poiParams.filter((value) => value && value !== POI_NONE_PARAM).length;
+ const count = poiParams.filter(Boolean).length;
if (count > 0) {
parts.push(
count === 1
diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js
index c5f1f60..41373a0 100644
--- a/frontend/webpack.config.js
+++ b/frontend/webpack.config.js
@@ -46,14 +46,7 @@ module.exports = (env, argv) => {
test: /\.css$/,
use: [
isProduction ? MiniCssExtractPlugin.loader : 'style-loader',
- {
- loader: 'css-loader',
- options: {
- url: {
- filter: (url) => !url.startsWith('/'),
- },
- },
- },
+ 'css-loader',
'postcss-loader',
],
},
diff --git a/pipeline/download/map_assets.py b/pipeline/download/map_assets.py
index 9456177..1de7d84 100644
--- a/pipeline/download/map_assets.py
+++ b/pipeline/download/map_assets.py
@@ -1,15 +1,9 @@
import argparse
-import base64
-import json
-import re
import sys
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed
-from io import BytesIO
from pathlib import Path
-from PIL import Image, ImageDraw
-
from pipeline.transform.transform_poi import NAPTAN_EMOJIS, _CATEGORIES
GLYPHS_BASE = "https://protomaps.github.io/basemaps-assets/fonts"
@@ -20,80 +14,53 @@ POI_ICON_BASE = "https://geolytix.github.io/MapIcons"
# Font stacks used by @protomaps/basemaps with lang='en'
FONT_STACKS = ["Noto Sans Regular", "Noto Sans Italic", "Noto Sans Medium"]
+# Fallback emoji not in any category
+_FALLBACK_EMOJIS = ["📍"]
+
POI_ICON_PATHS = [
- "brands_2023/supermarkets/farmfoods.svg",
- "brands_2023/supermarkets/heron_foods.svg",
- "brands_2023/supermarkets/little_waitrose.svg",
- "brands_2024/amazon_fresh.svg",
- "brands_2024/booths.svg",
- "brands_2024/budgens.svg",
- "brands_2024/cook.svg",
- "brands_2024/dunnes_stores.svg",
- "brands_2024/iceland.svg",
- "brands_2024/makro.svg",
- "brands_2024/mns.svg",
- "brands_2024/morrisons_daily.svg",
- "brands_2024/sainsburys_local.svg",
- "brands_2024/wholefoods.svg",
- "logos/aldi.svg",
- "logos/asda.svg",
- "logos/centra.svg",
- "logos/coop.svg",
- "logos/lidl.svg",
- "logos/morrisons.svg",
- "logos/planet_organic.svg",
- "logos/sainsburys.svg",
- "logos/spar.svg",
- "logos/tesco.svg",
- "logos/tesco_express.svg",
- "logos/tesco_extra.svg",
- "logos/waitrose.svg",
+ "asda/asda_express_24px.svg",
+ "asda/asda_green_basket_24px.svg",
+ "asda/asda_green_trolley_24px.svg",
+ "asda/asda_living_24px.svg",
+ "asda/asda_pfs_24px.svg",
+ "asda/asda_primary.svg",
+ "asda/asda_superstore_green_trolley_24px.svg",
+ "brands/aldi_24px.svg",
+ "brands/amazon_fresh_alt_24px.svg",
+ "brands/booths_24px.svg",
+ "brands/budgens_24px.svg",
+ "brands/centra_24px.svg",
+ "brands/cook.svg",
+ "brands/coop_24px.svg",
+ "brands/costco_24px.svg",
+ "brands/dunnes_stores_24px.svg",
+ "brands/farmfoods_updated_24px.svg",
+ "brands/heron_24px.svg",
+ "brands/iceland_24px.svg",
+ "brands/iceland_food_warehouse_24px.svg",
+ "brands/lidl_24px.svg",
+ "brands/little_waitrose_24px.svg",
+ "brands/makro_24px.svg",
+ "brands/mns_24px.svg",
+ "brands/mns_food_24px.svg",
+ "brands/mns_high_street_24px.svg",
+ "brands/mns_hospital_24px.svg",
+ "brands/mns_moto_24px.svg",
+ "brands/mns_outlet_24px.svg",
+ "brands/morrisons_24px.svg",
+ "brands/morrisons_daily_24px.svg",
+ "brands/sainsburys_24px.svg",
+ "brands/sainsburys_local_24px.svg",
+ "brands/spar_24px.svg",
+ "brands/tesco_24px.svg",
+ "brands/tesco_express_24px.svg",
+ "brands/tesco_extra_24px.svg",
+ "brands/waitrose_24px.svg",
+ "brands/wholefoods_24px.svg",
+ "logos/planet_organic_24px.svg",
"public_transport/london_tube.svg",
- "visuals/mns.svg",
]
-DERIVED_POI_ICON_PATHS = [
- ("costco_logo", "brands/costco.svg", "logos/costco.svg"),
- (
- "embedded_png",
- "brands/iceland_food_warehouse_24px.svg",
- "logos/the_food_warehouse.png",
- ),
-]
-
-POI_ICON_SVG_CROPS = {
- "brands_2023/supermarkets/farmfoods.svg": (1.293, 7.314, 15.48, 3.293),
- "brands_2023/supermarkets/heron_foods.svg": (0.062, 6.68, 17.995, 5.325),
- "brands_2023/supermarkets/little_waitrose.svg": (0.916, 5.645, 16.365, 6.719),
- "brands_2024/amazon_fresh.svg": (3.817, 1.646, 16.367, 16.358),
- "brands_2024/booths.svg": (1.456, 7.143, 15.313, 3.512),
- "brands_2024/budgens.svg": (2.251, 2.278, 13.6, 13.612),
- "brands_2024/cook.svg": (5.028, 5.493, 13.945, 9.648),
- "brands_2024/dunnes_stores.svg": (4.375, 7.732, 15.249, 5.055),
- "brands_2024/iceland.svg": (1.136, 6.823, 16.067, 4.302),
- "brands_2024/makro.svg": (4.411, 6.098, 16.397, 5.428),
- "brands_2024/mns.svg": (4.042, 6.986, 16.171, 6.724),
- "brands_2024/morrisons_daily.svg": (3.341, 4.414, 17.317, 8.248),
- "brands_2024/sainsburys_local.svg": (4.58, 1.61, 14.84, 14.849),
- "brands_2024/wholefoods.svg": (4.17, 2.193, 15.659, 15.668),
- "logos/aldi.svg": (4.813, 2.563, 14.374, 14.383),
- "logos/asda.svg": (3.91, 7.135, 16.181, 5.442),
- "logos/centra.svg": (3.36, 7.35, 17.28, 4.651),
- "logos/coop.svg": (6.407, 4.658, 11.187, 11.793),
- "logos/costco.svg": (70.61, 144.908, 256.67, 85.825),
- "logos/lidl.svg": (4.938, 2.973, 13.985, 13.985),
- "logos/morrisons.svg": (5.231, 2.985, 13.538, 13.398),
- "logos/planet_organic.svg": (5.528, 3.564, 12.943, 12.943),
- "logos/sainsburys.svg": (7.502, 3.572, 8.996, 12.646),
- "logos/spar.svg": (4.933, 2.968, 14.133, 13.853),
- "logos/tesco.svg": (4.338, 6.865, 15.324, 5.359),
- "logos/tesco_express.svg": (5.231, 5.933, 13.538, 8.345),
- "logos/tesco_extra.svg": (4.933, 5.775, 14.133, 8.519),
- "logos/waitrose.svg": (5.528, 6.09, 12.943, 9.855),
-}
-
-POI_ICON_SVG_INTRINSIC_MAX = 512
-
def collect_twemoji_codes() -> list[str]:
"""Derive twemoji hex codes from transform_poi categories.
@@ -109,6 +76,9 @@ def collect_twemoji_codes() -> list[str]:
for emoji in NAPTAN_EMOJIS.values():
emojis.add(emoji)
+ for emoji in _FALLBACK_EMOJIS:
+ emojis.add(emoji)
+
# First codepoint hex, matching frontend logic
return sorted({f"{ord(e[0]):x}" for e in emojis})
@@ -127,214 +97,6 @@ def download_file(url: str, dest: Path) -> tuple[bool, str]:
return False, url
-def download_text(url: str) -> str:
- with urllib.request.urlopen(url) as response:
- return response.read().decode("utf-8")
-
-
-def build_costco_logo(marker_svg: str) -> str:
- start = marker_svg.find('")
- if start < 0 or end < 0:
- raise ValueError("Costco marker SVG layout changed")
-
- logo_group = marker_svg[start : end + 4]
- return (
- '\n'
- '\n"
- )
-
-
-def trim_white_png(png_bytes: bytes) -> bytes:
- image = Image.open(BytesIO(png_bytes)).convert("RGBA")
- pixels = image.load()
-
- for y in range(image.height):
- for x in range(image.width):
- red, green, blue, alpha = pixels[x, y]
- if red > 245 and green > 245 and blue > 245:
- pixels[x, y] = (red, green, blue, 0)
-
- alpha_box = image.getchannel("A").getbbox()
- if alpha_box:
- image = image.crop(alpha_box)
-
- out = BytesIO()
- image.save(out, format="PNG")
- return out.getvalue()
-
-
-def extract_embedded_png(marker_svg: str) -> bytes:
- match = re.search(r"base64,([^\"']+)", marker_svg)
- if not match:
- raise ValueError("POI marker SVG did not contain an embedded PNG")
- return trim_white_png(base64.b64decode(match.group(1)))
-
-
-def svg_intrinsic_size(width: float, height: float) -> tuple[int, int]:
- if width <= 0 or height <= 0:
- return (POI_ICON_SVG_INTRINSIC_MAX, POI_ICON_SVG_INTRINSIC_MAX)
- if width >= height:
- return (
- POI_ICON_SVG_INTRINSIC_MAX,
- max(1, round(POI_ICON_SVG_INTRINSIC_MAX * height / width)),
- )
- return (
- max(1, round(POI_ICON_SVG_INTRINSIC_MAX * width / height)),
- POI_ICON_SVG_INTRINSIC_MAX,
- )
-
-
-def set_svg_geometry(svg_text: str, crop: tuple[float, float, float, float]) -> str:
- x, y, width, height = crop
- view_box = f"{x:g} {y:g} {width:g} {height:g}"
- intrinsic_width, intrinsic_height = svg_intrinsic_size(width, height)
-
- svg_text = re.sub(r'viewBox="[^"]+"', f'viewBox="{view_box}"', svg_text, count=1)
- if 'viewBox="' not in svg_text:
- svg_text = re.sub(r"