From 2f149503bb52398401ea146137c6c892ced0ccfc Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 17 May 2026 17:20:19 +0100 Subject: [PATCH] all is well --- Pasted image 20260515211038.png | Bin 0 -> 45041 bytes finder/constants.py | 14 +- finder/homecouk.py | 55 ++++- finder/listing_filters.py | 63 +++++ finder/rightmove.py | 109 ++++----- finder/scraper.py | 101 +++++--- finder/storage.py | 5 +- finder/transform.py | 81 ++++++- finder/zoopla.py | 68 ++++-- frontend/Pasted image 20260515211038.png | Bin 0 -> 45041 bytes frontend/src/components/map/AreaPane.tsx | 221 +++++++++++++++--- .../src/components/map/DualHistogram.test.ts | 26 +++ frontend/src/components/map/DualHistogram.tsx | 128 ++++++++-- frontend/src/components/map/EnumBarChart.tsx | 83 +++++++ .../src/components/map/HistogramLegend.tsx | 41 +--- frontend/src/components/map/MapPage.tsx | 12 + frontend/src/components/map/POIPane.tsx | 4 +- .../src/components/map/PropertiesPane.tsx | 16 +- .../src/components/map/StackedBarChart.tsx | 70 +++++- .../src/components/map/StackedEnumChart.tsx | 72 ++++++ .../map/filters/ActiveFiltersPanel.tsx | 2 +- .../components/map/filters/AddFilterPanel.tsx | 68 +++--- frontend/src/components/ui/InfoPopup.tsx | 26 +-- frontend/src/components/ui/MobileMenu.tsx | 2 +- frontend/src/hooks/useHexagonSelection.ts | 2 - frontend/src/hooks/useLocationSearch.ts | 2 +- frontend/src/hooks/useNearbyStations.ts | 59 +++++ frontend/src/hooks/usePoiLayers.ts | 2 +- .../src/hooks/useRetainedScrollTop.test.tsx | 80 +++++++ frontend/src/hooks/useRetainedScrollTop.ts | 63 +++++ frontend/src/i18n/locales/de.ts | 2 + frontend/src/i18n/locales/en.ts | 2 + frontend/src/i18n/locales/fr.ts | 2 + frontend/src/i18n/locales/hi.ts | 2 + frontend/src/i18n/locales/hu.ts | 2 + frontend/src/i18n/locales/zh.ts | 2 + frontend/src/index.css | 11 + frontend/src/lib/consts.ts | 14 ++ frontend/src/lib/map-utils.test.ts | 6 +- frontend/src/lib/map-utils.ts | 77 +++++- frontend/src/lib/nearby-stations.test.ts | 65 ++++++ frontend/src/lib/nearby-stations.ts | 71 ++++++ frontend/src/types.ts | 1 - pipeline/transform/test_transform_poi.py | 31 ++- pipeline/transform/transform_poi.py | 70 +++--- property-data2/.gitignore | 2 + server-rs/src/consts.rs | 10 +- server-rs/src/data/poi.rs | 10 + server-rs/src/routes/actual_listings.rs | 13 +- server-rs/src/routes/places.rs | 8 +- server-rs/src/routes/postcode_properties.rs | 6 +- server-rs/src/routes/properties.rs | 7 +- server-rs/src/routes/stats.rs | 8 +- 53 files changed, 1543 insertions(+), 354 deletions(-) create mode 100644 Pasted image 20260515211038.png create mode 100644 finder/listing_filters.py create mode 100644 frontend/Pasted image 20260515211038.png create mode 100644 frontend/src/components/map/DualHistogram.test.ts create mode 100644 frontend/src/hooks/useNearbyStations.ts create mode 100644 frontend/src/hooks/useRetainedScrollTop.test.tsx create mode 100644 frontend/src/hooks/useRetainedScrollTop.ts create mode 100644 frontend/src/lib/nearby-stations.test.ts create mode 100644 frontend/src/lib/nearby-stations.ts create mode 100644 property-data2/.gitignore diff --git a/Pasted image 20260515211038.png b/Pasted image 20260515211038.png new file mode 100644 index 0000000000000000000000000000000000000000..8b494dcb113111fb244cb2c399463ba3fe7bcea8 GIT binary patch literal 45041 zcmce;2RPU5+duvxMIn@sEe#E!tdx+gluAO8kWG{o*+f~Tl!k^qD|_!yNkVo=8ulnF z!vA&M_x-H@^Blk9IeyP`{5rn({p~j2_y}0Z zVv;F^^5UAhl7hCY;YjzDv)ZSYX(t*kT)V|XW4b}>j<`d`I`L3d^~-I$T!hoLGqb0} zx<2k1ZhX>mThHu%QoxyK7FKozKYYKG_U-0>z2o(JhIf~|yk5Pl`c%N5I}r6`cGziY z;_-ON-W4COet&eifsRwNfNm51>@g9Pl~=+a26gi9Opb^k{4o{X%z{5}jMT{2L{!`) zpWEywa2=mhTcx-TpS#6yZ8Q1g#=pOD&wt|^uRU$N6WrwI6rY}^q^!JU$BrFOn+MN} za`4~qn!7}PgI5$k-OG#&pA*UQC2rGNFCMSS-4U6c_I^oAZHKV1;*|oLL1esJgnp6%G!SX0$s-z|i{N*pcF^WSWHVT(oA&h)bA?8G~ z*74&4vNjtdaOtd%tOW7f1N{#r&1Fzu;b5b8Yi+)&dLi z+<7_I#IK97_idH4EI|L4y`flHD_&Pz8&yQKPW zsXj1y*FKqfF3o*r#4_#dq%1Wxwb<@?iS~|;6{mW|*tv>We>6lVPye~|byzp^>ls%! z$@00=z1}5@<3A2ZAGpGudb&DBIry&Y;5Q38U!&IL#aXd@w{PoeLOeETJ`k4}Ug4d7 zM)G{aZRMMT?b^jJ_j5Zi8U;7Gt2q?`$~JM z2mSN2rT&?jCxlqnh%?YB<=M%8D851;{DX%*Z_PasgL1)e@!F>qRl=qBUL++k*WLA` zH8*GPsKoE5rNuyVVXKuCAXFx0IT4lMUmMr9RF&+1}rm z;7voxv-|c%#cB=ZvzdyFUH98`=kFz_3=PAA*>8KNFyDUcS(Vm{SG{}pZhHp@AHP^Z z8U^n|&%CpKjn_VkHh7x0#?>u)?UpUoLff}XE~pqEXLXqPd~t5v{IdPfR|gC6q27j* zI~)WhRvx~$t8%tiBHnz6JFucDu{6WC2q{PQHw0q6_{qAQ?HU;-TJ(2vmzdtyy_r4F4 z+4~yzX0uI6$4#=VHMT^{1)q~#WS|UmU8F4idU8zcS5?Pc!$^bZ+u zyWel3+sGr6&3F2R;a%RXq#({pY+mi_JrT6_<*8)*N#k|6s`~kc$NNGfB8(5rTO7_W zXp!O(5fQ1qzjs^Tz<~c)w|fkk%3FXD&$^mG4-}>=4bQmc`c<*#NYGI%VWyc z7jAu(UX%W5L25_^#pG%J3xU-XtmMA_QNJ^JmmEerH#ASaeXDV{@D9zH$Ti`)bP{K* zO}GmzJH=^iD|r>=t{IoGwW^_w$+ogH=E^TNAz|U_!RBr8@m{(*W~(x@ zvOYZZVXv&@&C+$Dp&UF|YSAsjz!|zqUHwN~chH~H?Adla*D5Qi)6Pz>cw1K&zXcUW zyWWFI-Pw7Dj?tFUufqHCkM~gtH|##CDX5sO`g3*hLsF;q9fz+qH7Y)}y!3&n+4?za z{e*5vKXB!6D=F=4S@zs^l-o}~T}8L6_Pn`yxK4Ukxla82Vb$E97k?`T9K?1Ts$t9SJ#^>+6~)(= zv#PgYxir+KrKY=kg<@c+e@Th#(I*G5$6gQo`);du9ExzDaZ8=T+FHJn@UBy_!uNKc6Hi^Y`K-4U8SR(O-)>gCd}}awo@~Xg@ps;N@2DpVf6hz>6cy3hefw_wv$h_Pn=_OWx+R71-OW1J{0D+<4KXHLzLim!AtMi1yCTD!rZ!QM_&W zM*BC%-LE#b3OMA0s^Mj2dU!|n1?#EdVc{pePUHo0t5?iUEDAUT7 zc`Tio5~~jEr<0U)S;~=-mNuFTV%I7wEy@nYg{N)b{H&AOMuC}`Z6->Oc~97<*Xbws zxMeE~adUHK`Wz#DU^XnE0VkVKF>)8Ws)l znr57vpI>q6+&2e-!2@`;!YV}}oDyqP7U##jTul!H#vJ%)M%_wWtq1 z6-M&0Dxs5K4)=??O>wk$?N>XNe%x=BA&1P!70(T;fgVE^=DTeP*k~;uDlH1dMZebn zpSJY>MYK9V!%Oh^z-WU$_hQ}}o8q`z4Dduwq?i2xn-P}_@esotyW_4M?bPO!4G_ctGtQ^vFBr=!;1vBA)7 zaLd`!t9trYWHYCw?XnQ(Y|c;)W)lzj3L@YH&a#nV8w6p_tSXdY{&9l~A&qcSv zWEV%={j1vA{x8+RP0tr%GBek;y*Q_;qOyu%mET_%J=&R5H&P$SYp0*icClodO~!G+ zyS-g^^R{jM@9yu7l5yJM>+5SjHE=v@9&I<)X}C2?>LTx@%a^Gr0RaKz@A}ym=XJdL zYZx6z+F8bnf33CqluxBC+ruRH#~Yi{sJqZ9L0>x{=ZWY1>YkpSiC@2}TGP*HdMGO^ zs~tP0di3bpSNbX&#`93VL$FUikB^6Yds8~T1KZ1f3zM|%^sn%v?Z&(KkC^+}~ zx56_mHKt}otJ$$`ask*2%v@abIr^c+#Roz-#Hev$uD|M~j-NO|tsOdXBwA*}l`B^U zI&*eDH!N9$KK1phDf)6tnqgvdUw^oq%83)}9!OmbGpP+zNNg4m5n;k}_pLB0|CoGI zvu|)_w2MQ=QRo^IU)ax|m)>MuwMRJ_YR?edZCD(Xoh{zcnq_`$a68kZT0SrC3l}a( z+kd7!ijI!&R#I|Fx9!S(nUfRAn-Hjd_38no)KRHoJFH(+T$;-<-HzmsYZ$Z^CqvfSVy0vR-BDNgdQ*#>lq`E?LF?X@_3E@7}tK=In=@AVJOrfVcrgV}^Dr-$3bTqlf@w09_} zsKms@y_zaMxG=IexNnl4efQI2PY!Acu`<(8EiEnSzBHoxXqBTEosoO7w=^M#PV=E; z^#cj(r)|UW@k&!nE;A!~sYOM$U7?qw-0ahB+<#~lXFpon6dxa-?>x$>wc&Aqm30fb zWupq}KU2-eEIuZ&P(II34>K#X^!4@8(b0*vP|IU)0(4RVFanqe2nw!PxpJjNa{?pp zm9H#W`rEc`a~$vCp^&w$t)ml@lthQuUYUF{@k6fdnXLKFk)9H+#>PetInM(#BOQXH zr9XJ{Z^q%-{rT0O{(vdP_ZyYis-e`EzxbUM9`9qmL*l zOWUyf{i32cJ{3A0c6-CQVZ(;L&K&FZTwAWns;XP}?_WP2tMUw$LAS^m*vRUFlJ zm$R?@GRwAZTRS;93E<)%9?nFgAS(L;4@n`l<-z+id3HSb+JN*kd7Jj^Sx45YPHHvR z#bO`x$5&S28L{P@$+c0a4dW5B`Y41_VuIeyvEMnZ!|D1!h4zUPCroa8a%dbomRER_ zhGPAk_qOfSY(`d=Ut;1m3jf}{Cp$y2@#b6U|^sRM!8F#+E|ILDZ7hp9U2#-`=W;qI~p`^m+Yk+dB^* z2H)Ox><}|ewq^4wtnH+8Wd|c7B6gXXezcO98DCy}{)*Iv$B!QmbY%T7NRKRX=wJ2N zYv~Fr8=LXFD6zTOvSr&1&98OVN37*NK*!F`9$eJPDQRONnSSA{yqPG+5zE%rRyKOJ zUGlii$8JN(R;L%O{R0F0BTpNOEmmUh8@i85WMAy%U6>uO{r&4l{ZRL(PZKSt&pmVK zuQ99rX46x=5%n)h+F_H7(~v1AualEgH7YP!eg{3~DW_u9&~!3$_`Lpzq1Alp?;oGz zTgB3SPA2+!jShRuummS#w*IKWIlm>n%*;$?R#yMEb6z}@m*=ZiHAgTgFhhm$HmQB5 z`V(s!8_ow$?mX^HOC2ZNMr4#s4SxRodGq1Jon1qy9Iv&@F8?@cYG(Fe$+kZ4 zWOJ|TGcA5}W-2Nwdq+o_lvT_jts*lG z{%wNtPVv6Jep_W*8@opQIutEs+#9L5XoBSs$*_p#X1Xp?QBeO6ka97@;d0{mai6>% zH~J!%adN9{yL63C(U+ue-Q42na^}z>>Z=~hc`Fj`-D5|owbPfDHGj_y&90)OVs#V^D?AxVg&#;5^VZESjr8~&Hf2~VTDO;R*Xc-!rk#1PR3Hl0P zh~6DA<+y1+@aOb&x>G2fEO&8nF?n2Z@$u*1L~NmWCq8zAm~l=;U!N1(;X{HZ*Ptco zRY#*`0`RmSR7M_edu}j^Rv6W@42W^_=1uRsUQaGVLqoG10RaIZk0ArijAvHYOb46d z@f2TuD!44XYV%kg?a#8bYm8i*cJE#*6TNodzJ0n`W{igv62>KRm|jNrYTw{_E$NnLoo8)Htls@z$+d$A2Tnrs&GAt=MNmve7|H&wTy-o@wu> z55qU=hG=c%l2cmz1?jH5wl?woUV26@Nu?u47&J6ACf;m&LOIp9F#MwIT54*lUV3ma z{lVW~*MI%`mH*&DUNAAOWNqDSi$((xI$H0?vhJI9>{tU#K<=h@&xc9#1NH*j#l%>+ zr0iaecI5&0;kk*TYhH!6^fE867VXEBU219L!pvwEodzB=%A^zk&cr}$_=D+|$P>v* zuCCHhdYnhv1x6)pzZvT5TcxC@rM>DYagUGxFg%^Gyjo-2Qy|8D7*$*%x2!Y%@IBKu z0rfT+hmxkMSOvdT7M5=<87r(MTOW(ijiDV?LPe;{%DZWOny8!NC-01yx= zW6=BP{f7?^z3T4f*?Ic8lBwzDwcOI)fE0SCwA?-)YRa|k5;ZEP>^htO!Dejn=eIiS zFp#+bFj_4MK6=XU-@n;@O}YG%wWgHy=^yjy<8h|-s=%NUD9o-{V?qbKOF%b${FvyxN7#NGp<@eY9#}w zxSI3*)hT0F-G6_(cU)ID2+yv6y6s%qux0MhRS%C0t;{bdIREqOn*{rhv8qhu zj(Uo(40IPs0aIym?dz$ZO`0BTrhVeIv=6IJ%x%g-+`5$kd!O`Qvv*MrwbM#Ds~H)I z^7ypTV&d!VV}G@I)o>O3KIieCP%NvMb7h_ho#UkoYxUDxwYhkBLTh>583a{AyaNLR zTRusuE6vudKX$;OZMhyCG1%VJhN6X?Wi2>tQe33TOe4L2e?N8`kzSOP{HqQ6fjh;y zq=k0us00kWdGB6eQj*ZS2NJ0@A7Wdq#7t@!HgDM?fXaQ&^Y^zWZcO59)~u;~8@aRZ z`}b?8YsR==9f!o+7XQuKixJJu%@-buz2jgH(~J_O0vrvxeq8~js3weu^vW2lt%Tu# zYu7em$J9TRs>M2GS-;*SUTYfZywzB{P$_YCrheW zrNJVl<&is2e~3}0fA?6nsI|H|RVTeNjAuVHH+RUs%ili^9vv&Z5foGoR7ai#pXVGE zmV(Qo=i=%0M3`PK+_N|HgS2%1{IkH;ZBMTHy7YRmqvzECF2sQ$WLoLd8k?A`;N#;n zH8*dQ_8tB4BM=C$W$ydu(b3S#%0tc6BZ`4*1r8kee2Jk?DmC>UCC7paKZj2O9<<0&l8CHMDdH{datRznv47Plns!Qcr{8dtrQX zyP*vQQFc8@PfwQyGs{RyQgh=3+G+u(KXDaoSGea4Rgj&X9dLMzo`;oH3dg#2#>a=j zqIvgSdX=X09$i=I*s){5zrOKa1nS;Iad;KP5>sI$TJ(M~;_70d9&eMyTjkV^>X~rv z%F5)h297XNO8@*kJT= zl!JlbeEAjgeEFv_Co~CZ_hTE)!HZ0$=bZNL3nCjN;yRi3W&U)!tt8 z?k+uF5FL;Jal20(yCLI@~^QAyy ze5M%qwiSGVTpILWUw%5*sYcZd^9Ek*+K0St1GkSI^g&H@9{Y6t&FyW9@h9%T{|(;y zw8d)j_m3wJzuT7-7c;Z7(;jE7=e-@oyB4yEgk*`Ki14;d6qy?U%T$!kTw8ht<|7`^ zDhO-$oFCjKZuK##Q_mY}KHiA=d>)*{=;&y@&_}~G{M-YJDq}mjP}YW*gyMV1`R4jw=;)ciw0pVTW>ux#A8UAW*!n#|^CEKt6y6I3(`o7NQKEJS#v}WuX$s!e?Ev!9)zaKr~#;+-FYimneMS&jh(rwy` z+$o;7ZlU9L!kN+A`1kGGn0orfs+7u)?B}lffL9m)`B?{6r(k2V>*(VH*YVu?)AM=` z>FYn3`U&MFT|bA2Km>=Q%^O$xuNK<1>lN_fORr^5n@{;H0JT-VE9v=fY|=^7rApV? zS;ES4Jl#Yy|jMYQGO|@wlonbDfVs09{cmtLGKCGd(sGuGJ#GL z`|>5TNli#a+4ADW_Rda?y0LwKBRl`#;9$!&^>bB6jvlQctB1TQ@EA17C<$vefb=6j zfRv2zd+^NY%CX5*cf^-;h@k~eSx{Ej6{u4y$C_J zUcGv?e2cw-lc%RAI^N#>TYubA}@VEcgX6IIXSW?oK?L`fb*9F854Z$}!F0NKo)oDYH!876Kcji;T$aB(LJ53lX8CESjjyw=wZI#1$&SQFm+w2%4+Aff7W43G2)29@WR{YW0 z5!qCy+ahu=NbM+?&Yrz{FPt%Glf5+!W>)F5g7)U!yVtfHzDuD%^4U(*FH-z8G%}-R zIx{cO?=~p(#}^q7eN47@atb)~teh0GV{(hEAV`y6suP?lF9DJUo=iM?exyAk!QSj+ zj`itcD9>zrWrkbRt5DQnSqBwd{!!W8-JQ1Vnf2hN<2LNxKrmY30iX*8#Rzrjr0ZXp zJe;%>edgU0IX=Svg@nrS7MYDQ5p34hF!N%zDl{ckX;fmB>ub&Ss_9 zmkivsSx8lTv2gm_^7+Q63MDcC3hewU;#FB~0O;55vJj)zh%tP*6E35$l* z10~-`mZfSc89%@#~)2VWQxG~!pxFi{{qyP^puv;`e@OKz8 zflr?F@~z!&{{B(L=L)~cv7+$@-79ZmOJlcJG&C^H%*+tO1v}wpbaZI=!THzXQ$ja3 z0g43Tvb2kKpsJ|HHolg0dr5c7ed&4(onHn2sN^6zCGjjTebe}ZN1X7~@z-}v_(8NW zLPSJ^wx$RdoQR?d%w*pU#^1bi$A7ZF76svRPMcvBHj7ns-{kak^^FZXnb)oJT|v9n z2&GMD{T<#Y$j+n+0}8-ERhXNbGn)8Z0j4bu9SBZN@K|?Itjl;$Q}a+9+zw*gkcR?< zMFdmV@Au6=#2;pdr&s*@`$Kyd7pd+^HcrkPs2Ai%;g`6MWYm&!Z-O7&ba*`rOZVQI zqAsLcva(*TzC9C6c(d0Q1+TULj8g<~xPo6yCe0JWXo2Doir1C(> zC%yxe)r_mZFA?+skwdr4v&6FTo`aK@=0jBtsFTL{?zsl9r4^wnei!QQo(E-sWW0?u zY~Z1&CF#FaG9&%3CHN_T8#djA8zAD{HspkUhw6yi%YtpZQ=e9Lj@BMMU|Yc5f(psVzS1*c5A$(GK2p+8%2X(bXk8M?tz@<%I)1)>gy5eB5(!WAhz*i{;!OO54on? zJVLpS73Sv~Rn`BBa&|4rrl*Kz)StBY)))StXl<`X5ruM$Hn)7r+2g~7Mf0ZJlyVbB z>WtD^YqaXS&Qzx^&a})n3Z|s~<=;NMczi-W^smfW_s)MJkJ8;U?EQ{hEG@aaRpL)9 zCVK*1ufKA)m&x+0=@}-?XwPNi=CZm$-`+fp-?8Adw!eohfA}A$A3wwR+Omy5sdaQ+ zT#@?vmVi%=T#8NSM)0iXzCXP9;km*75H6`O5_cP*D*rossknQygXaHb?(%;|R{wiR zPlsN`2%S`AxUw)11#ZM-FFebzXxv1zYL#(gjB?X%J-9mFv9W@i1O@)1(fu3x{N zIo{FqqY);mO-E++m%^Ia;_W9BqImNDa)FcupLW{$vUq4|v) zH*P+D9M+Y0@o*97hLtu9xbs!lp21esg-zDf+)TtDZ1NbmXZa5O*QQvXTHXAD1^o)9 zG9pKT_YB=uuL-D(`40M_3=3}(((Wc%Ssn)m2iRsSzQ2#QX1jw77WkOO%mL5E>C=P_ z6M0l8RicHn_t&LMmrSef=H!nHyx}A~oa2D2C|tk4lASuXD1SlW;i~fT6zK0^NNJ&z z3xeel7Z>&PS@U3A;(65hpwQ3&jrdAve(+Z|3s)364Ku?!{QTMIk&M$CKnC1mAhyQR z`0^5ydBbCg7G4=gIy5mA@K5MbtI~CX(d-4Jr8(sOOsoJfHgO)Mr6LL6#uX_5rhtbO z3aQ`#nS{G>O4)^=%2dE>K&w#J(74fY1-!b7K;xj8Aei^epiYv*QW|P%6iV^((gHx8 z-=Syn;k>dFoz^d&#l_LkuH_a@mEd4-?GKfjnClN$OxE561%%IIibZQf46iuTSv8cR zs-p4IV0g1|IZ*mOcf0f|lf(oo8rTz$nT>zPA7DJei4FniqLdFdC$d1%TwIu4K`EUd z+Nq?f+E2dg{P|~Jt(Gq*4(qs}8)0Xy-4VIQm%qHOjvn?{e*b}tTay_fFWT_ma!Yfu z)qi%g+{$Tp?&nV6J-f53XCJEw1d3<*`5buo0mya%8kn`VflsHw*C3A)??JORM_(A7 z<{B~<@JadrZ!)6HZePMvUdW4$jqNC-BK=Jfh#v5H1*mcD=;$aF1ZYloFc=|5X6;}}+InT3>_V&zAuKrZd$j{g9v`r{3K0DHt_kvEIfst_o zq}2-<@Pao{GGP4}BQ&WudbvT?JcUQf?hywY+g563K|#T405b=u#hWO>-^a&A5)u<1 z%H|$7cy4_sDoUuZpx`>?)ytQn4xqQjAdaTi0H-^tnXzJQWpseAAflh7;qUC~3I_RL z+3zy8i<)_1YETd#X&VH^aTKxDl-;>%m;2$|5)b=yxbFT~dBH&a>sKl;dtv!o=A~`e zsiRvw4*YN=Bsh4zlT5VUDAucz0`n^{rnnP~gpt)k=;@a66 z87z3?w)0gxMMT(ZLpBOhGjBT*Wi;P^lU}{ob0M8hKIF!Yb(b$)I#SETBV#(Rm{Qje z8pONHDyOh!tH=5*6_+*Ys#&+IN0Wb8+i1J2@>6&udft~#9y>E7yNl4@cH}gIZ<^Ym zgYTu^29W#(g5Gv&W?LH@CHNQQj%oNkf4_*SPSs4n`wI8a_&9MLy!3>AA~5KhVCTiVi%$~ zAbTw-Syp!B%$YO0bYD{L-@orVl$>}Z$0hc8o9v~pba3EA@%UhTycWM>dGTU6wirRT zC<;lFVTDaiCrFCo-;rGpp8d|R-~_xIx<2p(mO4$|#ooKN6vX_kY?*u=+>I}EJ+Q8g zi`Vu8_&_@ z(9qEE^Yc@PU2PqjBJ%SwU?z+ZdR#CRh`$m5sGL42Mc+USKr#}JM&DRT&qn00e7ETx zhzTRE)`$qjRV;GC^o4y3kWd5jpoM1veTF!Uu<#z+*eWZp>~R|wCb<>pphT`ad-m)* ztFc7d9h)t4^smlzm?H+H0GD{{ktlCCB9EjU4(YE!g4w3Gln3>o0&a{L(ENWih^Owz zBo8}<4jPQzOV{23)<}m$4H%iF6y+)CY;|=t1PyPYV3w^WsYyvU@Al?I-V5WmS~SlbZNA+V|ILw!!<-%NzE*$DJ188^&dxwbY*fiI z3d}~L9QLD~5}!&vxG-nnlb4sLr-^D#;$ToXzJSYqun@=Ah*+Fqu(WlOb)QkfTSf5{ zbsG98cffnnF~hU{nAY$bO%_i^5z#p$ zbc9B0pbhPMeptzLvq7S^M+s61x2pBGnV{m{Gt_9EjyaPVIu@=H%30CX#kGC=c8StF z$gR(ienJZPFYCIyJiUcvhI?5_Us`UCIk;p z70oY8@7s62{pES$VPM<6D}BBh_9KLzQ|V7=fIwp#Eljym*uvUA#$CMYMH_w8d@{d< zOAo^yaH8>oCB|Z5&;_y^x>6-|a_@njf#4}z&^p`Ttu3LC!i8IYSrYuZMVWJ3zIu(i{y{(*qF_{Wib&#Yv}{}bg-+q#+OfGab3 zVMay>!H0w>A^na-&04DkJK&a*Igj^EQO8%dsm&okw&DhbLAz06ww8iCZ5=|M9GWK?by@ ztMh}2M0+mz4gmK#HvFJ3>gLs}SEXZ=OfU4vWb5SCA1qbY*YNU&DcJpBcUzJf=FEu1 zmVrbfQ4WA|Ayz3Q9*us9%LddaADLNz3*QeZ#})mF)Q^x52K2zR!I%1M<{&yMdMgzJ(OSXdo6z?;#w2ycu1$Y-G1;1K; zO-zqaZBSP5^bEbY7QQK*g)|4Hcq}%gkTEevK?P|(DhB&vxB?pI!YY%^j zjZU~(vQvzVA0~cyTgCTcL{DIpn!@0zo2AK2XNzO53>n5~m+$R1 z@FrmM(L9JR|Jy^G|1ZRY|Koh=|1WO8m|nVkaFzOx1`Q@KLs$2Or{|W<8`C;s6Z$1u zxSTNU;jzF-p~S^^mk*uFKzGJOj*`9oNy~jRV;$F5(9ZM~_$W4JUa*eae6BWPOD`0; zLx{&OicyFxt2;N%jx@`w%MFYQwMTE6O{;7>##@UYWYHM2#n5dZ=RZT90|(XAk6wFH z_M$_KINy0L-f)7F0X)%q=<7`GZQAm?F#A(XPy0|u$9=0Q86FVjTTh+@wxr(e*Rgxv z0rxUpCl;I%Y?ks=Ye(P6pM@MUJ*OpvkSI4d&jr2}l-=h_gT8#(L@DpcN(AG7H%|ni z3KI!Ib;Us0@QI0;gnl4+x#eQE!1ZfmJQl(@4w$;8PhX0QJtTbSjPb`LF}MW@5B`}o z%xr76_&8_R*!KF(o0_PM7cuJV>ux211?^fIvxdBVC^%I?C#f=&L zw}CUH9lk_mhu~-Mit2Dhd>p#;NZON?f^-TZ(L13$;$o#?drWp+bhj6ue3|`~iA9;E zHjLNDFj29{nT5`JK^ik$_I+Ovd1Y0r`%4TnmJrxJmKYf||9v=NW+}wQ{`XI13}%JZ zb}8!~(NrH!Ed4DL#&cLaHTk*vZDvQepXaau;PuG6%`;lQF(`7{$qo|WlW4v|obd|H z^=UdVWYV{)Bqn zJICSH^#G0v={kZ353a=waZ^N5#2rU6^+>ncautTDU_|+=I(nnIA@Qp}%tPjzO}o)h zr@g-KyDEBF5=iv=HO3WB)6@JPp7Fp4>CL;;Ekmh!$ZTrVy#q;L;1*}_@d>@@u$RZW zHSL<=i+gul)Gu6!x7_Kg7&z0Po_z7Ifv_thCop`3(FDQ$J5T-ju( zGJ`lbq425j!j-P2Cw++A3Mi_Ad2Pf5YsD?Wz_=|=#%EvJT3cI-#ay^JUcT}ZE|f_+ zm0q2~ysn8MT=Jq1Hps}Qj8TyiE=CHFCo?&a^hRe&b~~?DEIimMPZd}l~B)( zcwX%GsMoUPX?r$rXIVCrJ5QUGIAmTf%IvX?pWW-Kt+XlU5e zWQRV}?DUPzet6pL&K=!@voL?MWH1U?=ry(w)`k05n(bPV(=Ul%*tq!We`ognf4Pj( zgeII#^W@(vNFVCwm&!-x80)y})O{4h-G{}RdjiK59LMATb>^2(z3DI9e@I7%?Jqf$ z4d$|^=Rw4>kwvB_*^L4bpesfN=9g&rmiGeC&M=KZawP=?1xEtYWfBhf)gY(p;E<7>rUJ${2hPgE z#&!*vr_az{h$yS1E@YT(!2)kf?7~~ttp3*TrGasf5G&!Nz!G)^`#i3rvsGB^_JapE zNT3-X50{j}mQV93Hb+(<^6_6d?VW8?`n2!!aRKO*x?(A(yKZ0+$$qRdK4vLWA5jv*bSWHfUSdir}rDNNZrb1Md{PC*Ab z2;E1+t^FMo@0(DeW&h;uM9cvQNDRXu2(lk8;&Xc_XJRhbpvFCoM_r6x)Uk=fAy;Bqste zMS`|}{r2rm?$DFg!M;<-K(5(BrYfN65^EctrwN(s0LKQMz7-KcO+j>|2A9|f_n16f zkW^xj;uOe{1UTzL0oG8NRk93I^bqErDlG}%G?_V=erJ6Fbw%G+f3v~w6lf+5$Y!xyeM zB3sbVYqewBhDqoq+ooe3u7H__+$p%cXwhAV`j1#W_TS!aAW4R)UswOhWSzYEU(Z;@ z$uKsoyA@wqS;_g(fyWGe!A9^O$mqj7uh$=kr#pfWQHh#XAN@~bP3ST?7-jrt zCc}bA){k=qo_Y(9*r6L7dSf=rN_0Okpxw+K@vK#6-hvWWL?O| z$!md_q=1t@$a|X_@9oXLc@;x##1ul6$C5?rED-ur)=MLCkQNe_fWWMXF999&AyWW& zBW|IJk>G1`i&N_n1NX+ZRWG`Q(g82js59GAV(AuEHkqc1+IM;D{LE-D0!k3u$}!nM zArUuB))~XWAq)7XfFdFA2tusD)R5BIvyTS#Ya~t*-ve>>FkaakeN};ia4s;WawyNP z`$)4DQtJ?0SEJ`sQHXR!rkmi$;9KrLS66aMY_UStVK;_95MZg%!NC~9iC&Nk_q$%X zq7d7t8$$Uf#k67-gLh(LVsa0pM({{rkt8<}QVfWw01@$II(NaZ&=BhIaIqbb7uz15)za5_T8(Gj7%>+X7e5X%yE)F08PXAo`7Jd!vUT}hz* z?AZ`(S;EQSDpg})i<-UTAbA3e$4JpjU?=2zF3QY|_lB3a&yq0;lp?_5mBbn+&xk@i z_;>G)z{{(INIW<=2=E2wpE+kjvIGJ2>@OYqRS1VA87rjLu{I6*-F6}h;5eX#>q7!N zxbITGOd7a+Ra@JinO8$lc97W#+PK$@XoirpiInM+!txl)0CJ;%gv16oZbXq<%Xg6B z^y$+_9v|3<>oRLAy83$$dA(2vU)9tQK1`HNg#1V-Eh1vw0f@U0;c!3>Mp98xt_j_M zTCD2on%^-UG|&T`hOO^)`#|4k#Iv!%kx(4@RB*D>U}t}{IV7bhNn6fUOuQi|OJuC{ z@c00#AQ{V~P!L)0N8C1>CMm@oFDj@A8sMXwGZ|!tNAQa3y4ha0Y`!)_F;s zyn&2bY;iF+iPJMO>9~A-&4S?kc}x;uMDFI`z5l4azto+Q#S=S#pjX1nez@f@Dyb#)Zo-i}-{3VZ1r0KywQV zf`C^xreQ%tM-!V{+Q(K7nrLvzwv%0e1PVqxm?;yu{kH5^B>=zOt{bCAlR^%P$Ocpt2m*eV`@KJY z)J7(O3e#Hy&52AXKu?^3EGSPA24NfA3mKh+FP)twpTi$&W#{0qxD^(^T*^fx9|aX= z8i4IA&V5h^T*nH2kkA{qjN@y^Kd|G;K+t!}{e6S+%L^4q#QFNkun0R=2NOLEX z%d;gzJy(A4ppn3@Ag(v;q4jt;R*O$XE`0JdA|W$${y)EeUGO{hpG13~kxLI2s`uD_ zGDk7A=qWDfaHiA@CLnSci+R4Llin#SyC|k!`Ir#HH9}F;@mMHz@R`E0eFn^O(hXuF zW7Ml2C6libsFKGP3W~G*?wRsKwrQB-L&hcc0|IvUXd~;TGmWEh;9|av%i(~CK8$6Aa5FVN8AucfH8?! z{&|1~oxG5$p+oB^aLeeRn3I=B;zzNn;W0ot7G1gN*&pM8J1O3(?ANfg^`mZ&J$ttBI`4 zw>LimuuqM2tOtA{#yrxt(A_U&v?df1OG{jw6*=u%^i566)9E@UFoY8ZWgwppCOeTq zl=u^?bMy^NIG1-~+tA|vb#tsX!1FTI51ckvaRrkXMfes8sw4OJRNIJb1#Tz$YD2^L zG25vNkJiBbE$iRL7~>@~$a7&iyQq$A7{fNXQHiCK9BsFTrsp}!hCiZ7)p7*{uCrA7iY℘W(-g#9p?e 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 literal 0 HcmV?d00001 diff --git a/finder/constants.py b/finder/constants.py index 558e625..c456f62 100644 --- a/finder/constants.py +++ b/finder/constants.py @@ -82,7 +82,7 @@ PROPERTY_TYPE_MAP = { "Farm / Barn": "Other", "Farm House": "Other", "House": "Detached", - "House of Multiple Occupation": "Flats/Maisonettes", + "House of Multiple Occupation": "Other", "House Share": "Other", "Not Specified": "Other", "Chalet": "Other", @@ -90,15 +90,15 @@ PROPERTY_TYPE_MAP = { "Coach House": "Other", "Character Property": "Other", "Cluster House": "Other", - "Retirement Property": "Flats/Maisonettes", + "Retirement Property": "Other", "Parking": "Other", "Plot": "Other", "Garages": "Other", "Mews": "Terraced", "Property": "Other", "Flat Share": "Other", - "Block of Apartments": "Flats/Maisonettes", - "Private Halls": "Flats/Maisonettes", + "Block of Apartments": "Other", + "Private Halls": "Other", "Terraced Bungalow": "Terraced", "Equestrian Facility": "Other", "Ground Maisonette": "Flats/Maisonettes", @@ -107,13 +107,13 @@ PROPERTY_TYPE_MAP = { "Farm Land": "Other", "House Boat": "Other", "Barn": "Other", - "Serviced Apartments": "Flats/Maisonettes", + "Serviced Apartments": "Other", # Space-separated variants (from home.co.uk underscore/hyphen normalization) "Semi Detached": "Semi-Detached", "Semi Detached Bungalow": "Semi-Detached", "End Of Terrace": "Terraced", "End Terrace": "Terraced", - "Block Of Apartments": "Flats/Maisonettes", + "Block Of Apartments": "Other", # Lowercase variants (from home.co.uk / Rightmove APIs) "house": "Detached", "bungalow": "Other", @@ -121,7 +121,7 @@ PROPERTY_TYPE_MAP = { "land": "Other", "other": "Other", "not-specified": "Other", - "retirement-property": "Flats/Maisonettes", + "retirement-property": "Other", "equestrian-facility": "Other", "flat": "Flats/Maisonettes", "detached": "Detached", diff --git a/finder/homecouk.py b/finder/homecouk.py index 1e47d40..09a2401 100644 --- a/finder/homecouk.py +++ b/finder/homecouk.py @@ -19,7 +19,12 @@ from constants import ( RETRY_BASE_DELAY, ) from spatial import PostcodeSpatialIndex -from transform import normalize_postcode, normalize_sub_type, validate_floor_area +from transform import ( + normalize_postcode, + normalize_sub_type, + parse_int_value, + validate_floor_area, +) log = logging.getLogger("homecouk") @@ -170,11 +175,19 @@ def parse_floor_area(description: str | None) -> float | None: """Try to extract floor area from description text like '789 sq.ft.' or '73 sq.m.'.""" if not description: return None - m = re.search(r"([\d,]+(?:\.\d+)?)\s*sq\.?\s*ft", description, re.IGNORECASE) + m = re.search( + r"([\d,]+(?:\.\d+)?)\s*(?:sq\.?\s*ft|square\s+feet|ft(?:\^?2|²))", + description, + re.IGNORECASE, + ) if m: sqft = float(m.group(1).replace(",", "")) return validate_floor_area(round(sqft * 0.092903, 1)) - m = re.search(r"([\d,]+(?:\.\d+)?)\s*sq\.?\s*m", description, re.IGNORECASE) + m = re.search( + r"([\d,]+(?:\.\d+)?)\s*(?:sq\.?\s*m|square\s+met(?:er|re)s?|m(?:\^?2|²))", + description, + re.IGNORECASE, + ) if m: return validate_floor_area(round(float(m.group(1).replace(",", "")), 1)) return None @@ -237,6 +250,15 @@ def map_property_type(raw_type: str | None) -> str: # Home.co.uk uses types like "House", "Flat", "Apartment", "Detached", etc. # Try common patterns lower = raw_type.lower() + excluded_flat_like = ( + "block of apartment", + "house of multiple occupation", + "private halls", + "retirement", + "serviced apartment", + ) + if any(term in lower for term in excluded_flat_like): + return "Other" if ( "flat" in lower or "apartment" in lower @@ -269,8 +291,10 @@ def transform_property( log.debug("Coords outside England: lat=%.4f lng=%.4f — skipping", lat, lng) return None - price = prop.get("price") or prop.get("latest_price") - if not price or int(price) <= 0: + price = parse_int_value(prop.get("price")) or parse_int_value( + prop.get("latest_price") + ) + if not price or price <= 0: return None # Home.co.uk provides postcodes directly, but fall back to spatial index @@ -281,10 +305,10 @@ def transform_property( log.debug("No postcode for property at %.4f, %.4f — skipping", lat, lng) return None - raw_beds = prop.get("bedrooms", 0) or 0 - raw_baths = prop.get("bathrooms", 0) or 0 - bedrooms = raw_beds if raw_beds <= MAX_BEDROOMS else 0 - bathrooms = raw_baths if raw_baths <= MAX_BEDROOMS else 0 + raw_beds = parse_int_value(prop.get("bedrooms")) or 0 + raw_baths = parse_int_value(prop.get("bathrooms")) or 0 + bedrooms = raw_beds if 0 <= raw_beds <= MAX_BEDROOMS else 0 + bathrooms = raw_baths if 0 <= raw_baths <= MAX_BEDROOMS else 0 if raw_beds > MAX_BEDROOMS or raw_baths > MAX_BEDROOMS: log.warning( "home.co.uk %s: implausible beds=%d baths=%d (capped to 0)", @@ -318,7 +342,7 @@ def transform_property( "Leasehold/Freehold": parse_tenure(prop), "Property type": map_property_type(listing_type), "Property sub-type": normalize_sub_type(listing_type), - "price": int(price), + "price": price, "price_frequency": "", "Price qualifier": price_qualifier, "Total floor area (sqm)": parse_floor_area(prop.get("description")), @@ -362,7 +386,16 @@ def search_outcode( break for prop in raw_props: - transformed = transform_property(prop, pc_index) + try: + transformed = transform_property(prop, pc_index) + except Exception as exc: + log.warning( + "home.co.uk %s property %s failed to transform: %s", + outcode, + prop.get("listing_id") or prop.get("property_id") or "?", + exc, + ) + continue if transformed: properties.append(transformed) if max_properties is not None and len(properties) >= max_properties: diff --git a/finder/listing_filters.py b/finder/listing_filters.py new file mode 100644 index 0000000..f1eca04 --- /dev/null +++ b/finder/listing_filters.py @@ -0,0 +1,63 @@ +"""Shared target filters for manual buy-listing scrapes.""" + +import math +from typing import Any + +BUY_MAX_PRICE = 1_000_000 +BUY_MIN_BEDROOMS = 2 +BUY_MAX_BEDROOMS = 5 +BUY_ALLOWED_BATHROOMS = frozenset({2, 3}) +BUY_MIN_FLOOR_AREA_SQM = 90.0 +BUY_MAX_FLOOR_AREA_SQM = 170.0 +BUY_PROPERTY_TYPES = frozenset({"Flats/Maisonettes"}) + +BUY_MIN_FLOOR_AREA_SQFT = round(BUY_MIN_FLOOR_AREA_SQM / 0.092903) +BUY_MAX_FLOOR_AREA_SQFT = round(BUY_MAX_FLOOR_AREA_SQM / 0.092903) + + +def _number(value: Any) -> float | None: + if value is None: + return None + try: + number = float(value) + except (TypeError, ValueError): + return None + if not math.isfinite(number): + return None + return number + + +def _int(value: Any) -> int | None: + number = _number(value) + if number is None or not number.is_integer(): + return None + return int(number) + + +def matches_strict_buy_listing_filter(prop: dict) -> bool: + """Exact filter used to guard scraped/output datasets.""" + if "price" in prop: + price = _number(prop.get("price")) + else: + price = _number(prop.get("Asking price")) + if price is None or price <= 0 or price >= BUY_MAX_PRICE: + return False + + bedrooms = _int(prop.get("Bedrooms")) + if bedrooms is None or ( + bedrooms < BUY_MIN_BEDROOMS or bedrooms > BUY_MAX_BEDROOMS + ): + return False + + property_type = prop.get("Property type") + if property_type not in BUY_PROPERTY_TYPES: + return False + + bathrooms = _int(prop.get("Bathrooms")) + if bathrooms not in BUY_ALLOWED_BATHROOMS: + return False + + floor_area = _number(prop.get("Total floor area (sqm)")) + if floor_area is None: + return False + return BUY_MIN_FLOOR_AREA_SQM <= floor_area <= BUY_MAX_FLOOR_AREA_SQM diff --git a/finder/rightmove.py b/finder/rightmove.py index 3c831a5..0a3d7a2 100644 --- a/finder/rightmove.py +++ b/finder/rightmove.py @@ -10,6 +10,15 @@ from constants import ( TYPEAHEAD_URL, ) from http_client import fetch_with_retry +from listing_filters import ( + BUY_ALLOWED_BATHROOMS, + BUY_MAX_BEDROOMS, + BUY_MAX_FLOOR_AREA_SQFT, + BUY_MAX_PRICE, + BUY_MIN_BEDROOMS, + BUY_MIN_FLOOR_AREA_SQFT, + matches_strict_buy_listing_filter, +) from spatial import PostcodeSpatialIndex from transform import transform_property @@ -22,12 +31,23 @@ outcode_cache: dict[str, str] = {} # Requesting index >= 1008 returns HTTP 400. _MAX_INDEX = 1008 -# Property type filters for splitting overcapped searches. Each sub-query -# gets its own 1008 cap, so we can recover listings beyond the unfiltered limit. -_PROPERTY_TYPES = [ - "detached", "semi-detached", "terraced", "flat", - "bungalow", "park-home", "land", -] +_BASE_BUY_SEARCH_PARAMS = { + "propertyTypes": "flat", + "minBedrooms": str(BUY_MIN_BEDROOMS), + "maxBedrooms": str(BUY_MAX_BEDROOMS), + "minBathrooms": str(min(BUY_ALLOWED_BATHROOMS)), + "maxBathrooms": str(max(BUY_ALLOWED_BATHROOMS)), + "minSize": str(BUY_MIN_FLOOR_AREA_SQFT), + "maxSize": str(BUY_MAX_FLOOR_AREA_SQFT), + "maxPrice": str(BUY_MAX_PRICE - 1), +} + + +def _buy_search_params(extra_params: dict | None = None) -> dict: + params = dict(_BASE_BUY_SEARCH_PARAMS) + if extra_params: + params.update(extra_params) + return params def resolve_outcode_id(client: httpx.Client, outcode: str) -> str | None: @@ -92,8 +112,18 @@ def _paginate( break for prop in raw_props: - transformed = transform_property(prop, outcode, pc_index) - if transformed: + try: + transformed = transform_property(prop, outcode, pc_index) + except Exception as exc: + log.warning( + "Rightmove %s/%s property %s failed to transform: %s", + outcode, + channel_cfg["channel"], + prop.get("id", "?"), + exc, + ) + continue + if transformed and matches_strict_buy_listing_filter(transformed): properties.append(transformed) if max_properties is not None and len(properties) >= max_properties: return properties, result_count @@ -105,6 +135,15 @@ def _paginate( if index >= result_count: break + if index >= _MAX_INDEX: + log.warning( + "%s/%s: %d filtered results exceed Rightmove's %d-result page cap", + outcode, + channel_cfg["channel"], + result_count, + _MAX_INDEX, + ) + break time.sleep(DELAY_BETWEEN_PAGES) @@ -121,54 +160,20 @@ def search_outcode( ) -> list[dict]: """Paginate through search results for one outcode+channel. Returns transformed properties. - When the unfiltered result count exceeds 1008 (Rightmove's hard pagination cap), - re-queries per property type to recover listings beyond the cap. + Search requests set the supported Rightmove filters directly: flats, + 2-5 bedrooms, 2-3 bathrooms, 969-1830 sq ft, and asking price below £1m. """ - properties, result_count = _paginate( - client, outcode_id, outcode, channel_cfg, pc_index, max_properties=max_properties + properties, _ = _paginate( + client, + outcode_id, + outcode, + channel_cfg, + pc_index, + extra_params=_buy_search_params(), + max_properties=max_properties, ) if max_properties is not None and len(properties) >= max_properties: return properties[:max_properties] - if result_count <= _MAX_INDEX: - return properties - - # Hit the 1008 cap — re-search per property type to get full coverage - ch = channel_cfg["channel"] - log.info( - "%s/%s: %d results exceed %d cap, splitting by property type", - outcode, ch, result_count, _MAX_INDEX, - ) - - all_by_id: dict[str, dict] = {p["id"]: p for p in properties} - - for pt in _PROPERTY_TYPES: - pt_props, _ = _paginate( - client, outcode_id, outcode, channel_cfg, pc_index, - extra_params={"propertyTypes": pt}, - max_properties=max_properties, - ) - new = 0 - for p in pt_props: - if p["id"] not in all_by_id: - all_by_id[p["id"]] = p - new += 1 - if ( - max_properties is not None - and len(all_by_id) >= max_properties - ): - break - if new: - log.debug("%s/%s type=%s: +%d new properties", outcode, ch, pt, new) - if max_properties is not None and len(all_by_id) >= max_properties: - break - - log.info( - "%s/%s: type split recovered %d → %d properties", - outcode, ch, len(properties), len(all_by_id), - ) - properties = list(all_by_id.values()) - if max_properties is not None: - return properties[:max_properties] return properties diff --git a/finder/scraper.py b/finder/scraper.py index 6eb6eee..14bbd87 100644 --- a/finder/scraper.py +++ b/finder/scraper.py @@ -19,6 +19,7 @@ from homecouk import load_cookies as load_homecouk_cookies from homecouk import make_client as make_homecouk_client from homecouk import search_outcode as homecouk_search_outcode from http_client import make_client +from listing_filters import matches_strict_buy_listing_filter from rightmove import resolve_outcode_id from rightmove import search_outcode as rightmove_search_outcode from spatial import PostcodeSpatialIndex @@ -181,11 +182,11 @@ def _source_names(sources: str | Iterable[str] | None) -> list[str]: requested = [str(source).strip().lower() for source in sources] requested = [source for source in requested if source] - if "all" in requested: - return list(SOURCE_ORDER) - unknown = sorted(set(requested) - set(SOURCE_ORDER)) + unknown = sorted(set(requested) - set(SOURCE_ORDER) - {"all"}) if unknown: raise ValueError(f"Unknown source(s): {', '.join(unknown)}") + if "all" in requested: + return list(SOURCE_ORDER) return [source for source in SOURCE_ORDER if source in requested] @@ -196,19 +197,28 @@ def _dedup_key(prop: dict) -> tuple: def _merge_properties(source_results: dict[str, list[dict]]) -> tuple[list[dict], dict, int]: merged: dict[str, dict] = {} seen_keys: set[tuple] = set() + seen_ids: set[str] = set() counts = {source: 0 for source in SOURCE_ORDER} deduped = 0 for source in SOURCE_ORDER: for prop in source_results.get(source, []): prop_id = prop.get("id") - key = _dedup_key(prop) - if (prop_id is not None and prop_id in merged) or key in seen_keys: - deduped += 1 - continue - storage_key = prop_id if prop_id is not None else f"{source}:{len(merged)}" + if prop_id is not None: + prop_id = str(prop_id) + if prop_id in seen_ids: + deduped += 1 + continue + seen_ids.add(prop_id) + storage_key = prop_id + else: + key = _dedup_key(prop) + if key in seen_keys: + deduped += 1 + continue + seen_keys.add(key) + storage_key = f"{source}:{len(merged)}" merged[storage_key] = prop - seen_keys.add(key) counts[source] += 1 return list(merged.values()), counts, deduped @@ -241,13 +251,22 @@ def _store_properties( if remaining == 0: return 0 - eligible = [prop for prop in props if _property_is_londonish(prop)] - dropped = len(props) - len(eligible) - if dropped: + londonish = [prop for prop in props if _property_is_londonish(prop)] + dropped_outside_area = len(props) - len(londonish) + if dropped_outside_area: log.debug( "%s dropped %d properties outside the Greater London-ish postcode filter", source, - dropped, + dropped_outside_area, + ) + + eligible = [prop for prop in londonish if matches_strict_buy_listing_filter(prop)] + dropped_non_matching = len(londonish) - len(eligible) + if dropped_non_matching: + log.debug( + "%s dropped %d properties outside the strict buy-listing filters", + source, + dropped_non_matching, ) selected = eligible if remaining is None else eligible[:remaining] @@ -367,20 +386,16 @@ def _scrape_homecouk( log.info("home.co.uk cap reached") return - remaining = _source_remaining( - results, "homecouk", max_properties_per_source - ) - if remaining == 0: - log.info("home.co.uk cap reached") - return - for attempt in range(2): try: + # home.co.uk cannot express the full filter set at source. + # Fetch the outcode page set first; _store_properties applies + # the strict filter and source cap after transformation. props = homecouk_search_outcode( client, outcode, pc_index, - max_properties=remaining, + max_properties=None, ) added = _store_properties( results, @@ -442,19 +457,17 @@ def _scrape_zoopla( log.info("Zoopla cap reached") return - remaining = _source_remaining(results, "zoopla", max_properties_per_source) - if remaining == 0: - log.info("Zoopla cap reached") - return - for attempt in range(2): try: + # Zoopla source-side filters are unverified here. Fetch the + # outcode page set first; _store_properties applies the + # strict filter and source cap after transformation. props, _ = zoopla_search_outcode( page, outcode, pc_index, pc_coords, - max_properties=remaining, + max_properties=None, ) added = _store_properties( results, @@ -506,9 +519,6 @@ def run_scrape( output_base = Path(output_dir) if output_dir is not None else DATA_DIR output_base.mkdir(parents=True, exist_ok=True) - if "zoopla" in selected_sources and pc_coords is None: - pc_coords = build_postcode_coords() - errors: list[str] = [] results = {source: [] for source in SOURCE_ORDER} started_at = time.time() @@ -539,7 +549,8 @@ def run_scrape( ) if "zoopla" in selected_sources: - assert pc_coords is not None + if pc_coords is None: + pc_coords = build_postcode_coords() _scrape_zoopla( selected_outcodes, pc_index, @@ -551,19 +562,36 @@ def run_scrape( merged, source_counts, deduped = _merge_properties(results) output_path = output_base / "online_listings_buy.parquet" - write_parquet(merged, output_path) + if merged: + write_parquet(merged, output_path) + else: + if output_path.exists(): + output_path.unlink() + log.warning("No strict properties to write to %s", output_path) + + filtered = [prop for prop in merged if matches_strict_buy_listing_filter(prop)] + filtered_output_path = output_base / "online_listings_buy_filtered.parquet" + if filtered: + write_parquet(filtered, filtered_output_path) + else: + if filtered_output_path.exists(): + filtered_output_path.unlink() + log.warning("No strict-filtered properties to write to %s", filtered_output_path) counts = { "total": len(merged), + "filtered_total": len(filtered), "deduped": deduped, "sources": source_counts, } + source_summary = " ".join( + f"{source}:{source_counts[source]}" for source in SOURCE_ORDER + ) log.info( - "Sale scrape complete: %d unique (rightmove:%d homecouk:%d zoopla:%d deduped:%d)", + "Sale scrape complete: %d unique, %d strict-filtered (%s deduped:%d)", len(merged), - source_counts["rightmove"], - source_counts["homecouk"], - source_counts["zoopla"], + len(filtered), + source_summary, deduped, ) @@ -575,6 +603,7 @@ def run_scrape( }, "counts": counts, "path": str(output_path), + "filtered_path": str(filtered_output_path), "errors": errors, "elapsed_seconds": round(time.time() - started_at, 3), } diff --git a/finder/storage.py b/finder/storage.py index 605c39f..3d21083 100644 --- a/finder/storage.py +++ b/finder/storage.py @@ -45,9 +45,10 @@ def write_parquet(properties: list[dict], path: Path) -> None: remapped = 0 for p in properties: sub_type = p.get("Property sub-type", "") - if sub_type and sub_type != "Unknown": + current_type = p.get("Property type") + if sub_type and sub_type != "Unknown" and current_type in (None, "", "Other"): new_type = map_property_type(sub_type) - if new_type != p.get("Property type"): + if new_type != current_type: p["Property type"] = new_type remapped += 1 if remapped: diff --git a/finder/transform.py b/finder/transform.py index 4066ed2..a55fdad 100644 --- a/finder/transform.py +++ b/finder/transform.py @@ -1,4 +1,5 @@ import logging +import math import re from constants import MAX_BEDROOMS, PROPERTY_TYPE_MAP, RIGHTMOVE_BASE @@ -29,17 +30,43 @@ def validate_floor_area(sqm: float | None) -> float | None: return sqm +def parse_int_value(value) -> int | None: + """Parse an integer-like API value without truncating decimals.""" + if value is None or isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, float): + if not math.isfinite(value) or not value.is_integer(): + return None + return int(value) + if isinstance(value, str): + cleaned = value.strip().replace(",", "").replace("£", "") + if not re.fullmatch(r"\d+", cleaned): + return None + return int(cleaned) + return None + + def parse_display_size(display_size: str | None) -> float | None: """Parse displaySize like '499 sq. ft.' or '4,124 sq. ft.' to sqm.""" if not display_size: return None # Try sq. ft. first - m = re.search(r"([\d,]+(?:\.\d+)?)\s*sq\.?\s*ft", display_size, re.IGNORECASE) + m = re.search( + r"([\d,]+(?:\.\d+)?)\s*(?:sq\.?\s*ft|square\s+feet|ft(?:\^?2|²))", + display_size, + re.IGNORECASE, + ) if m: sqft = float(m.group(1).replace(",", "")) return validate_floor_area(round(sqft * 0.092903, 1)) # Try sq. m. - m = re.search(r"([\d,]+(?:\.\d+)?)\s*sq\.?\s*m", display_size, re.IGNORECASE) + m = re.search( + r"([\d,]+(?:\.\d+)?)\s*(?:sq\.?\s*m|square\s+met(?:er|re)s?|m(?:\^?2|²))", + display_size, + re.IGNORECASE, + ) if m: return validate_floor_area(round(float(m.group(1).replace(",", "")), 1)) return None @@ -86,7 +113,21 @@ def map_property_type(sub_type: str | None) -> str: return canonical # Keyword fallback for compound types not in the map lower = sub_type.lower() - if "flat" in lower or "apartment" in lower or "maisonette" in lower or "studio" in lower: + excluded_flat_like = ( + "block of apartment", + "house of multiple occupation", + "private halls", + "retirement", + "serviced apartment", + ) + if any(term in lower for term in excluded_flat_like): + return "Other" + if ( + "flat" in lower + or "apartment" in lower + or "maisonette" in lower + or "studio" in lower + ): return "Flats/Maisonettes" if "semi" in lower and "detach" in lower: return "Semi-Detached" @@ -158,10 +199,10 @@ def transform_property( lat, lng = fix_coords(raw_lat, raw_lng) price_obj = prop.get("price", {}) - amount = price_obj.get("amount") + amount = parse_int_value(price_obj.get("amount")) if not amount: return None - price = int(amount) + price = amount if price <= 0: return None @@ -172,14 +213,23 @@ def transform_property( # POA / Auction listings have unreliable prices — treat as no price pq_lower = price_qualifier.lower() - if "poa" in pq_lower or "auction" in pq_lower: + non_comparable_price_terms = ( + "poa", + "auction", + "shared ownership", + "shared equity", + "part buy", + "part rent", + "from", + ) + if any(term in pq_lower for term in non_comparable_price_terms): return None sub_type = prop.get("propertySubType", "") - raw_beds = prop.get("bedrooms", 0) or 0 - raw_baths = prop.get("bathrooms", 0) or 0 - bedrooms = raw_beds if raw_beds <= MAX_BEDROOMS else 0 - bathrooms = raw_baths if raw_baths <= MAX_BEDROOMS else 0 + raw_beds = parse_int_value(prop.get("bedrooms")) or 0 + raw_baths = parse_int_value(prop.get("bathrooms")) or 0 + bedrooms = raw_beds if 0 <= raw_beds <= MAX_BEDROOMS else 0 + bathrooms = raw_baths if 0 <= raw_baths <= MAX_BEDROOMS else 0 if raw_beds > MAX_BEDROOMS or raw_baths > MAX_BEDROOMS: log.warning( "Rightmove %s: implausible beds=%d baths=%d (capped to 0)", @@ -197,8 +247,15 @@ def transform_property( log.debug("No England postcode for property at %.4f, %.4f — skipping", lat, lng) return None + property_url = prop.get("propertyUrl") or "" + if not isinstance(property_url, str): + property_url = "" + listing_id = prop.get("id") or property_url + if not listing_id: + return None + return { - "id": prop.get("id"), + "id": listing_id, "Bedrooms": bedrooms, "Bathrooms": bathrooms, "Number of bedrooms & living rooms": bedrooms + bathrooms, @@ -213,7 +270,7 @@ def transform_property( "price_frequency": "", "Price qualifier": price_qualifier, "Total floor area (sqm)": parse_display_size(prop.get("displaySize")), - "Listing URL": RIGHTMOVE_BASE + prop.get("propertyUrl", ""), + "Listing URL": RIGHTMOVE_BASE + property_url if property_url else "", "Listing features": key_features, "first_visible_date": prop.get("firstVisibleDate", ""), } diff --git a/finder/zoopla.py b/finder/zoopla.py index cb3e9b4..dcd70ee 100644 --- a/finder/zoopla.py +++ b/finder/zoopla.py @@ -24,7 +24,7 @@ import time from constants import DELAY_BETWEEN_PAGES, MAX_BEDROOMS, PROPERTY_TYPE_MAP, ZOOPLA_BASE from spatial import PostcodeSpatialIndex -from transform import normalize_sub_type, validate_floor_area +from transform import normalize_sub_type, parse_int_value, validate_floor_area log = logging.getLogger("zoopla") @@ -106,7 +106,8 @@ _EXTRACT_LISTINGS_JS = r"""() => { const bedsMatch = text.match(/(\d+)\s*beds?/i); const bathsMatch = text.match(/(\d+)\s*baths?/i); const recMatch = text.match(/(\d+)\s*reception/i); - const areaMatch = text.match(/([\d,]+)\s*sq\.?\s*ft/i); + const areaSqftMatch = text.match(/([\d,]+(?:\.\d+)?)\s*(?:sq\.?\s*ft|square\s+feet|ft(?:\^?2|²))/i); + const areaSqmMatch = text.match(/([\d,]+(?:\.\d+)?)\s*(?:sq\.?\s*m|square\s+met(?:er|re)s?|m(?:\^?2|²))/i); let tenure = ''; if (/leasehold/i.test(text)) tenure = 'Leasehold'; @@ -141,7 +142,8 @@ _EXTRACT_LISTINGS_JS = r"""() => { beds: bedsMatch && parseInt(bedsMatch[1]) <= 20 ? parseInt(bedsMatch[1]) : null, baths: bathsMatch && parseInt(bathsMatch[1]) <= 20 ? parseInt(bathsMatch[1]) : null, receptions: recMatch && parseInt(recMatch[1]) <= 20 ? parseInt(recMatch[1]) : null, - floor_area_sqft: areaMatch ? parseInt(areaMatch[1].replace(/,/g, '')) : null, + floor_area_sqft: areaSqftMatch ? parseInt(areaSqftMatch[1].replace(/,/g, '')) : null, + floor_area_sqm: areaSqmMatch ? parseFloat(areaSqmMatch[1].replace(/,/g, '')) : null, address, tenure, property_type, }); } @@ -181,7 +183,8 @@ _EXTRACT_LISTINGS_JS = r"""() => { const bedsMatch = text.match(/(\d+)\s*beds?/i); const bathsMatch = text.match(/(\d+)\s*baths?/i); const recMatch = text.match(/(\d+)\s*reception/i); - const areaMatch = text.match(/([\d,]+)\s*sq\.?\s*ft/i); + const areaSqftMatch = text.match(/([\d,]+(?:\.\d+)?)\s*(?:sq\.?\s*ft|square\s+feet|ft(?:\^?2|²))/i); + const areaSqmMatch = text.match(/([\d,]+(?:\.\d+)?)\s*(?:sq\.?\s*m|square\s+met(?:er|re)s?|m(?:\^?2|²))/i); let address = ''; for (const line of lines) { @@ -225,7 +228,8 @@ _EXTRACT_LISTINGS_JS = r"""() => { beds: bedsMatch && parseInt(bedsMatch[1]) <= 20 ? parseInt(bedsMatch[1]) : null, baths: bathsMatch && parseInt(bathsMatch[1]) <= 20 ? parseInt(bathsMatch[1]) : null, receptions: recMatch && parseInt(recMatch[1]) <= 20 ? parseInt(recMatch[1]) : null, - floor_area_sqft: areaMatch ? parseInt(areaMatch[1].replace(/,/g, '')) : null, + floor_area_sqft: areaSqftMatch ? parseInt(areaSqftMatch[1].replace(/,/g, '')) : null, + floor_area_sqm: areaSqmMatch ? parseFloat(areaSqmMatch[1].replace(/,/g, '')) : null, address, tenure, property_type, }); } @@ -611,7 +615,22 @@ def _map_property_type(raw_type: str | None) -> str: return canonical # Keyword fallback lower = raw_type.lower() - if "flat" in lower or "apartment" in lower or "maisonette" in lower or "studio" in lower or "penthouse" in lower: + excluded_flat_like = ( + "block of apartment", + "house of multiple occupation", + "private halls", + "retirement", + "serviced apartment", + ) + if any(term in lower for term in excluded_flat_like): + return "Other" + if ( + "flat" in lower + or "apartment" in lower + or "maisonette" in lower + or "studio" in lower + or "penthouse" in lower + ): return "Flats/Maisonettes" if "semi" in lower and "detach" in lower: return "Semi-Detached" @@ -634,8 +653,8 @@ def transform_property( Zoopla search cards do not include coordinates, so we resolve lat/lng from postcodes extracted from the address text.""" - price = raw.get("price") - if not price or int(price) <= 0: + price = parse_int_value(raw.get("price")) + if not price or price <= 0: return None address = raw.get("address", "") @@ -670,10 +689,10 @@ def transform_property( if not (49 <= lat <= 56 and -7 <= lng <= 2): return None - raw_beds = raw.get("beds") or 0 - raw_baths = raw.get("baths") or 0 - bedrooms = raw_beds if raw_beds <= MAX_BEDROOMS else 0 - bathrooms = raw_baths if raw_baths <= MAX_BEDROOMS else 0 + raw_beds = parse_int_value(raw.get("beds")) or 0 + raw_baths = parse_int_value(raw.get("baths")) or 0 + bedrooms = raw_beds if 0 <= raw_beds <= MAX_BEDROOMS else 0 + bathrooms = raw_baths if 0 <= raw_baths <= MAX_BEDROOMS else 0 if raw_beds > MAX_BEDROOMS or raw_baths > MAX_BEDROOMS: log.warning( "Zoopla %s: implausible beds=%d baths=%d (capped to 0)", @@ -683,9 +702,13 @@ def transform_property( # Floor area: convert sq ft to sq m floor_area_sqm = None - sqft = raw.get("floor_area_sqft") - if sqft: - floor_area_sqm = validate_floor_area(round(sqft * 0.092903, 1)) + raw_sqm = raw.get("floor_area_sqm") + if raw_sqm: + floor_area_sqm = validate_floor_area(round(float(raw_sqm), 1)) + else: + sqft = raw.get("floor_area_sqft") + if sqft: + floor_area_sqm = validate_floor_area(round(float(sqft) * 0.092903, 1)) listing_id = raw.get("id", "") listing_url = raw.get("url", "") @@ -704,7 +727,7 @@ def transform_property( "Leasehold/Freehold": raw.get("tenure") or None, "Property type": _map_property_type(raw.get("property_type")), "Property sub-type": normalize_sub_type(raw.get("property_type")), - "price": int(price), + "price": price, "price_frequency": "", "Price qualifier": "", "Total floor area (sqm)": floor_area_sqm, @@ -760,7 +783,18 @@ def search_outcode( properties = [] dropped = 0 for raw in raw_listings: - transformed = transform_property(raw, pc_index, pc_coords, search_outcode=outcode) + try: + transformed = transform_property( + raw, pc_index, pc_coords, search_outcode=outcode + ) + except Exception as exc: + log.warning( + "Zoopla %s property %s failed to transform: %s", + outcode, + raw.get("id", "?"), + exc, + ) + transformed = None if transformed: properties.append(transformed) else: diff --git a/frontend/Pasted image 20260515211038.png b/frontend/Pasted image 20260515211038.png new file mode 100644 index 0000000000000000000000000000000000000000..8b494dcb113111fb244cb2c399463ba3fe7bcea8 GIT binary patch literal 45041 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℘W(-g#9p?e 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 literal 0 HcmV?d00001 diff --git a/frontend/src/components/map/AreaPane.tsx b/frontend/src/components/map/AreaPane.tsx index 8ded744..03e23e3 100644 --- a/frontend/src/components/map/AreaPane.tsx +++ b/frontend/src/components/map/AreaPane.tsx @@ -1,14 +1,16 @@ -import { useMemo, useState } from 'react'; +import { useMemo, useState, type MutableRefObject, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { ts } from '../../i18n/server'; import type { FeatureFilters, + FeatureGroup, FeatureMeta, FilterExclusion, HexagonStatsResponse, } from '../../types'; import { travelFieldKey, type TravelTimeEntry } from '../../hooks/useTravelTime'; import type { HexagonLocation } from '../../lib/external-search'; +import { formatStationDistance, type NearbyStation } from '../../lib/nearby-stations'; import { formatValue, formatFilterValue, @@ -16,19 +18,22 @@ import { roundedPercentages, } from '../../lib/format'; import { groupFeaturesByCategory } from '../../lib/features'; +import { getPoiCategoryLogoUrl } from '../../lib/map-utils'; import { PARTY_FEATURE_COLORS, STACKED_GROUPS, STACKED_ENUM_GROUPS, STACKED_SEGMENT_COLORS, } from '../../lib/consts'; +import { useNearbyStations } from '../../hooks/useNearbyStations'; +import { useRetainedScrollTop } from '../../hooks/useRetainedScrollTop'; import { DualHistogram, LoadingSkeleton } from './DualHistogram'; import EnumBarChart from './EnumBarChart'; import StackedBarChart from './StackedBarChart'; import StackedEnumChart from './StackedEnumChart'; import PriceHistoryChart from './PriceHistoryChart'; import ExternalSearchLinks from './ExternalSearchLinks'; -import { InfoIcon } from '../ui/icons'; +import { InfoIcon, TransitIcon } from '../ui/icons'; import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader'; import { FeatureInfoPopup } from '../ui/FeatureInfoPopup'; import { EmptyState } from '../ui/EmptyState'; @@ -54,6 +59,9 @@ interface AreaPaneProps { shareCode?: string; isGroupExpanded: (name: string) => boolean; onToggleGroup: (name: string) => void; + scrollTopRef?: MutableRefObject; + scrollRestoreKey?: string | null; + scrollSaveDisabled?: boolean; } function normalizePercentageSegments(segments: T[]): T[] { @@ -75,6 +83,136 @@ function filterValueFormat(feature?: FeatureMeta) { }; } +const STATION_GROUP_NAME = 'Transport'; +const STATION_GROUP_NAMES = new Set([STATION_GROUP_NAME, 'Public Transport']); + +function MetricTextLabel({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +function MetricFeatureLabel({ + feature, + onShowInfo, + label, + aboutLabel, +}: { + feature: FeatureMeta; + onShowInfo: (feature: FeatureMeta) => void; + label?: string; + aboutLabel: string; +}) { + return ( +
+ {label ?? ts(feature.name)} + {feature.detail && ( + + )} +
+ ); +} + +function MetricRow({ + label, + chart, + value, + valueTitle, + className = '', +}: { + label: ReactNode; + chart?: ReactNode; + value?: ReactNode; + valueTitle?: string; + className?: string; +}) { + return ( +
+
{label}
+
{chart}
+
+ {value} +
+
+ ); +} + +function NearbyStationsCard({ location }: { location: HexagonLocation }) { + const { t } = useTranslation(); + const origin = useMemo( + () => ({ lat: location.lat, lon: location.lon }), + [location.lat, location.lon] + ); + const { stations, loading } = useNearbyStations(origin); + + return ( +
+
+ + {t('areaPane.closestStations')} + {loading && ( + + )} +
+ {stations.length > 0 ? ( +
    + {stations.map((station) => ( + + ))} +
+ ) : ( +
+ {loading ? t('common.loading') : t('areaPane.noNearbyStations')} +
+ )} +
+ ); +} + +function NearbyStationRow({ station }: { station: NearbyStation }) { + const icon = getPoiCategoryLogoUrl(station.category, station.icon_category); + + return ( +
  • + {icon ? ( + + ) : ( + + )} +
    +
    + {station.name} +
    +
    {ts(station.category)}
    +
    + + {formatStationDistance(station.distanceKm)} + +
  • + ); +} + export default function AreaPane({ stats, globalFeatures, @@ -91,6 +229,9 @@ export default function AreaPane({ shareCode, isGroupExpanded, onToggleGroup, + scrollTopRef, + scrollRestoreKey, + scrollSaveDisabled, }: AreaPaneProps) { const { t } = useTranslation(); const propertyCount = stats?.count; @@ -99,7 +240,19 @@ export default function AreaPane({ const filteredStatsEmpty = filtersActive && statsUseFilters && stats?.count === 0; const showFlipToggleCallout = filteredStatsEmpty && unfilteredCount !== 0; const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]); + const displayFeatureGroups = useMemo(() => { + if (!hexagonLocation || featureGroups.some((group) => STATION_GROUP_NAMES.has(group.name))) { + return featureGroups; + } + + return [{ name: STATION_GROUP_NAME, features: [] }, ...featureGroups]; + }, [featureGroups, hexagonLocation]); const [infoFeature, setInfoFeature] = useState(null); + const { scrollRef, onScroll } = useRetainedScrollTop({ + restoreKey: scrollRestoreKey ?? hexagonId, + scrollTopRef, + suspendSave: scrollSaveDisabled ?? (loading && stats == null), + }); const numericByName = useMemo(() => { if (!stats) return new Map(); @@ -164,7 +317,7 @@ export default function AreaPane({ <>
    -
    +
    @@ -300,20 +453,22 @@ export default function AreaPane({ {stats.price_history && (() => { const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year))); - return uniqueYears.size > 1; - })() && ( -
    - - {t('areaPane.priceHistory')} - - -
    - )} - {featureGroups.map((group) => { + return uniqueYears.size > 1 ? ( +
    + + {t('areaPane.priceHistory')} + + +
    + ) : null; + })()} + {displayFeatureGroups.map((group) => { + const showNearbyStations = + hexagonLocation != null && STATION_GROUP_NAMES.has(group.name); const hasData = group.features.some( (feature) => numericByName.has(feature.name) || enumByName.has(feature.name) ); - if (!hasData) return null; + if (!hasData && !showNearbyStations) return null; const stackedCharts = STACKED_GROUPS[group.name]; const stackedEnumCharts = STACKED_ENUM_GROUPS[group.name]; @@ -332,10 +487,11 @@ export default function AreaPane({ name={group.name} expanded={expanded} onToggle={() => onToggleGroup(group.name)} - className="px-3 py-2.5 text-sm font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 z-10 hover:bg-warm-100 dark:hover:bg-warm-800" + className="area-pane-group-header sticky top-0 z-10 bg-white px-3 pb-1.5 pt-4 text-[11px] font-bold uppercase tracking-wide text-warm-500 hover:bg-warm-50 dark:bg-navy-950 dark:text-warm-400 dark:hover:bg-navy-900" /> {expanded && ( -
    +
    + {showNearbyStations && } {stackedCharts?.map((chart) => { const segments = chart.components .map((name) => ({ @@ -445,21 +601,17 @@ export default function AreaPane({ : undefined; return ( -
    -
    - - - {formatValue(numericStats.mean, feature)} - -
    - {numericStats.histogram && + } + chart={ + numericStats.histogram && (globalHistogram ? ( ) : ( - ))} -
    + )) + } + value={formatValue(numericStats.mean, feature)} + valueTitle={ + globalMean != null + ? `${t('areaPane.nationalAvg')}: ${formatValue(globalMean)}` + : undefined + } + /> ); } diff --git a/frontend/src/components/map/DualHistogram.test.ts b/frontend/src/components/map/DualHistogram.test.ts new file mode 100644 index 0000000..92aac74 --- /dev/null +++ b/frontend/src/components/map/DualHistogram.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { compactHistogramLabel } from './DualHistogram'; + +describe('compactHistogramLabel', () => { + it('rounds low-cardinality count labels to integers', () => { + const fmt = (value: number) => value.toFixed(2); + const labels = [0, 0.99, 2.98, 4.96, 5.95].map((center, index) => + compactHistogramLabel(index, 5, 0, 5.95, center, fmt, true) + ); + + expect(labels).toEqual(['0', '1', '3', '5', '6+']); + }); + + it('labels the first integer count bucket as zero when it means below one', () => { + const fmt = (value: number) => value.toFixed(2); + + expect(compactHistogramLabel(0, 5, 0.99, 5.95, 0.99, fmt, true)).toBe('0'); + }); + + it('keeps fractional labels when integer labels are not requested', () => { + const fmt = (value: number) => value.toFixed(2); + + expect(compactHistogramLabel(1, 5, 0, 5.95, 0.99, fmt, false)).toBe('0.99'); + }); +}); diff --git a/frontend/src/components/map/DualHistogram.tsx b/frontend/src/components/map/DualHistogram.tsx index 76a8ecf..bd2670f 100644 --- a/frontend/src/components/map/DualHistogram.tsx +++ b/frontend/src/components/map/DualHistogram.tsx @@ -30,6 +30,42 @@ function pickTicks(min: number, max: number, count: number): number[] { return ticks; } +function isLowCardinalityHistogram(counts: number[], p1: number, p99: number): boolean { + return counts.length > 0 && counts.length <= 10 && p99 > p1 && p99 - p1 <= 10; +} + +export function compactHistogramLabel( + index: number, + barCount: number, + p1: number, + p99: number, + center: number, + formatLabel: (value: number) => string, + integerLabels = false +): string { + const formatAxisValue = (value: number) => + integerLabels ? Math.round(value).toLocaleString() : formatLabel(value); + + if (barCount <= 1) return formatAxisValue(center); + + const middleBins = barCount - 2; + if (index === 0) { + if (!integerLabels) return `<${formatLabel(p1)}`; + const firstBoundary = Math.ceil(p1); + return firstBoundary <= 1 ? '0' : `<${firstBoundary.toLocaleString()}`; + } + if (index === barCount - 1) { + if (!integerLabels) return `${formatLabel(p99)}+`; + return `${Math.ceil(p99).toLocaleString()}+`; + } + + const middleWidth = middleBins > 0 ? (p99 - p1) / middleBins : 0; + if (Math.abs(middleWidth - 1) < 0.001) { + return formatAxisValue(p1 + index - 1); + } + return formatAxisValue(center); +} + export function DualHistogram({ localCounts, globalCounts, @@ -38,6 +74,8 @@ export function DualHistogram({ globalMean, meanLabel, formatLabel, + compact = false, + integerAxisLabels = false, }: { localCounts: number[]; globalCounts: number[]; @@ -46,9 +84,15 @@ export function DualHistogram({ globalMean?: number; meanLabel?: string; formatLabel?: (value: number) => string; + compact?: boolean; + integerAxisLabels?: boolean; }) { const { t } = useTranslation(); - const targetBars = 25; + const showCompactAxisLabels = + compact && + isLowCardinalityHistogram(localCounts, p1, p99) && + isLowCardinalityHistogram(globalCounts, p1, p99); + const targetBars = compact ? (showCompactAxisLabels ? localCounts.length : 16) : 25; const localBars = downsampleBars(localCounts, targetBars); const globalBars = downsampleBars(globalCounts, targetBars); @@ -59,6 +103,8 @@ export function DualHistogram({ const fmt = formatLabel ?? ((v: number) => (Number.isInteger(v) ? v.toLocaleString() : v.toFixed(1))); + if (barCount === 0) return null; + // Compute center value for each bar. // Bar 0 = low outlier, bars 1..n-2 = middle (p1 to p99), bar n-1 = high outlier. const middleBins = Math.max(barCount - 2, 0); @@ -97,6 +143,60 @@ export function DualHistogram({ ? { right: 0 } : { left: '50%', transform: 'translateX(-50%)' }; + if (compact) { + const axisLabels = showCompactAxisLabels + ? barCenters.map((center, index) => + compactHistogramLabel(index, barCount, p1, p99, center, fmt, integerAxisLabels) + ) + : []; + const chartTitle = [ + `${fmt(p1)} - ${fmt(p99)}`, + globalMean != null ? `${meanLabel ?? t('areaPane.nationalAvg')}: ${fmt(globalMean)}` : null, + ] + .filter(Boolean) + .join('\n'); + + return ( +
    +
    + {Array.from({ length: barCount }).map((_, index) => { + const globalHeight = (globalBars[index] / globalMax) * 100; + const localHeight = (localBars[index] / localMax) * 100; + return ( +
    +
    0 ? 8 : 0)}%` }} + /> + {localBars[index] > 0 && ( +
    + )} +
    + ); + })} +
    + {showCompactAxisLabels && ( +
    + {axisLabels.map((label, index) => ( + + {label} + + ))} +
    + )} +
    + ); + } + return (
    @@ -152,35 +252,29 @@ export function DualHistogram({ function SkeletonHistogram() { return ( -
    -
    -
    -
    -
    -
    - {Array.from({ length: 15 }).map((_, i) => ( +
    +
    +
    + {Array.from({ length: 12 }).map((_, i) => (
    ))}
    -
    -
    -
    -
    +
    ); } export function LoadingSkeleton() { return ( -
    +
    {[0, 1, 2].map((groupIdx) => (
    -
    -
    +
    +
    {Array.from({ length: groupIdx === 0 ? 3 : 2 }).map((_, i) => ( ))} diff --git a/frontend/src/components/map/EnumBarChart.tsx b/frontend/src/components/map/EnumBarChart.tsx index e8177e6..b98ef9e 100644 --- a/frontend/src/components/map/EnumBarChart.tsx +++ b/frontend/src/components/map/EnumBarChart.tsx @@ -1,16 +1,34 @@ import { ts } from '../../i18n/server'; import { getEnumValueColor } from '../../lib/consts'; +function shortenAxisLabel(label: string, total: number): string { + if (label.length <= 3) return label; + const parts = label.split(/[\s/&-]+/).filter(Boolean); + if (parts.length > 1) { + return parts + .map((part) => Array.from(part)[0]) + .join('') + .slice(0, 3); + } + return Array.from(label) + .slice(0, total <= 5 ? 3 : 2) + .join(''); +} + export default function EnumBarChart({ counts, globalCounts, featureName, + compact = false, }: { counts: Record; globalCounts?: Record; featureName: string; + compact?: boolean; }) { const entries = Object.entries(counts).sort(([, countA], [, countB]) => countB - countA); + if (entries.length === 0) return null; + const localTotal = entries.reduce((sum, [, c]) => sum + c, 0); // When global counts are available, normalize both to percentages for comparison @@ -28,6 +46,71 @@ export default function EnumBarChart({ // Fallback to raw count scaling when no global data const maxCount = Math.max(...entries.map(([, count]) => count), 1); + if (compact) { + const title = entries + .map(([label, count]) => { + const localPct = localTotal > 0 ? (count / localTotal) * 100 : 0; + const globalPct = + hasGlobal && globalTotal > 0 ? ((globalCounts[label] ?? 0) / globalTotal) * 100 : null; + return `${ts(label)}: ${count.toLocaleString()} (${localPct.toFixed(1)}%)${ + globalPct != null ? ` / ${globalPct.toFixed(1)}%` : '' + }`; + }) + .join('\n'); + + return ( +
    +
    + {entries.map(([label, count]) => { + const localPct = localTotal > 0 ? count / localTotal : 0; + const globalPct = hasGlobal ? (globalCounts[label] ?? 0) / globalTotal : 0; + const localHeight = hasGlobal + ? maxPct > 0 + ? (localPct / maxPct) * 100 + : 0 + : (count / maxCount) * 100; + const globalHeight = hasGlobal && maxPct > 0 ? (globalPct / maxPct) * 100 : 0; + const color = getEnumValueColor(featureName, label); + + return ( +
    + {hasGlobal && ( +
    0 ? 8 : 0)}%` }} + /> + )} + {count > 0 && ( +
    + )} +
    + ); + })} +
    +
    + {entries.map(([label]) => { + const translated = ts(label); + return ( + + {shortenAxisLabel(translated, entries.length)} + + ); + })} +
    +
    + ); + } + return (
    {entries.map(([label, count]) => { diff --git a/frontend/src/components/map/HistogramLegend.tsx b/frontend/src/components/map/HistogramLegend.tsx index 874615b..a15433d 100644 --- a/frontend/src/components/map/HistogramLegend.tsx +++ b/frontend/src/components/map/HistogramLegend.tsx @@ -3,35 +3,18 @@ import { useTranslation } from 'react-i18next'; export default function HistogramLegend() { const { t } = useTranslation(); return ( -
    -
    -
    -
    - - - {t('histogramLegend.tealBars')} - {' '} - {t('histogramLegend.tealBarsDesc')} - -
    -
    -
    - - - {t('histogramLegend.greyBars')} - {' '} - {t('histogramLegend.greyBarsDesc')} - -
    -
    -
    - - - {t('histogramLegend.dashedLine')} - {' '} - {t('histogramLegend.dashedLineDesc')} - -
    +
    +
    +
    + + {t('histogramLegend.tealBars')} + +
    +
    +
    + + {t('histogramLegend.greyBars')} +
    ); diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index 2cb7d35..cc32247 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -147,6 +147,8 @@ export default function MapPage({ const pendingCurrentLocationFlyToRef = useRef<{ lat: number; lng: number } | null>(null); const pendingLocationSearchFlyToRef = useRef(null); const mobileDrawerPanelRectRef = useRef(null); + const areaPaneScrollTopRef = useRef(0); + const propertiesPaneScrollTopRef = useRef(0); const getMobileMapFlyToOptions = useCallback((): MapFlyToOptions | undefined => { if (!isMobile) return undefined; @@ -558,6 +560,11 @@ export default function MapPage({ shareCode={shareCode} isGroupExpanded={isAreaGroupExpanded} onToggleGroup={toggleAreaGroup} + scrollTopRef={areaPaneScrollTopRef} + scrollRestoreKey={ + selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null + } + scrollSaveDisabled={loadingAreaStats && areaStats == null} /> ); @@ -570,6 +577,11 @@ export default function MapPage({ loading={loadingProperties} hexagonId={selectedHexagon?.id || null} onLoadMore={handleLoadMoreProperties} + scrollTopRef={propertiesPaneScrollTopRef} + scrollRestoreKey={ + selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null + } + scrollSaveDisabled={loadingProperties && properties.length === 0} /> ); diff --git a/frontend/src/components/map/POIPane.tsx b/frontend/src/components/map/POIPane.tsx index 366c9a2..472aa03 100644 --- a/frontend/src/components/map/POIPane.tsx +++ b/frontend/src/components/map/POIPane.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { ts } from '../../i18n/server'; import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups'; import { trackEvent } from '../../lib/analytics'; -import { POI_CATEGORY_LOGOS } from '../../lib/consts'; +import { getPoiCategoryLogoUrl } from '../../lib/map-utils'; import type { POICategoryGroup } from '../../types'; import InfoPopup from '../ui/InfoPopup'; import { SearchInput } from '../ui/SearchInput'; @@ -188,7 +188,7 @@ export default function POIPane({
    {group.categories.map((category) => { - const logo = POI_CATEGORY_LOGOS[category]; + const logo = getPoiCategoryLogoUrl(category); return ( void; onNavigateToSource?: (slug: string) => void; + scrollTopRef?: MutableRefObject; + scrollRestoreKey?: string | null; + scrollSaveDisabled?: boolean; } export function PropertiesPane({ @@ -26,10 +30,18 @@ export function PropertiesPane({ hexagonId, onLoadMore, onNavigateToSource, + scrollTopRef, + scrollRestoreKey, + scrollSaveDisabled, }: PropertiesPaneProps) { const { t } = useTranslation(); const [search, setSearch] = useState(''); const [showInfo, setShowInfo] = useState(false); + const { scrollRef, onScroll } = useRetainedScrollTop({ + restoreKey: scrollRestoreKey ?? hexagonId, + scrollTopRef, + suspendSave: scrollSaveDisabled ?? (loading && properties.length === 0), + }); useEffect(() => { setSearch(''); @@ -60,7 +72,7 @@ export function PropertiesPane({ return (
    0} /> -
    +
    {showInfo && ( ; + compact?: boolean; } /** Strip common suffixes/prefixes to produce short legend labels */ @@ -28,7 +29,27 @@ function shortenLabel(name: string): string { .trim(); } -export default function StackedBarChart({ segments, total, colorMap }: StackedBarChartProps) { +function shortenAxisLabel(name: string, total: number): string { + const label = shortenLabel(name); + if (label.length <= 3) return label; + const parts = label.split(/[\s/&-]+/).filter(Boolean); + if (parts.length > 1) { + return parts + .map((part) => Array.from(part)[0]) + .join('') + .slice(0, 3); + } + return Array.from(label) + .slice(0, total <= 5 ? 3 : 2) + .join(''); +} + +export default function StackedBarChart({ + segments, + total, + colorMap, + compact = false, +}: StackedBarChartProps) { const { t } = useTranslation(); const sortedSegments = useMemo(() => [...segments].sort((a, b) => b.value - a.value), [segments]); const roundedPcts = useMemo( @@ -55,6 +76,53 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa return color; }; + if (compact) { + const maxValue = Math.max(...sortedSegments.map((segment) => segment.value), 1); + const showAxisLabels = sortedSegments.length <= 8; + const title = sortedSegments + .map((segment, i) => { + const label = shortenLabel(ts(segment.name)); + return `${label}: ${formatValue(segment.value)} (${roundedPcts[i].toFixed(1)}%)`; + }) + .join('\n'); + + return ( +
    +
    + {sortedSegments.map((segment) => { + const height = (segment.value / maxValue) * 100; + return ( +
    + ); + })} +
    + {showAxisLabels && ( +
    + {sortedSegments.map((segment) => { + const label = shortenLabel(ts(segment.name)); + return ( + + {shortenAxisLabel(label, sortedSegments.length)} + + ); + })} +
    + )} +
    + ); + } + return (
    {/* Stacked bar */} diff --git a/frontend/src/components/map/StackedEnumChart.tsx b/frontend/src/components/map/StackedEnumChart.tsx index 33f2775..229091b 100644 --- a/frontend/src/components/map/StackedEnumChart.tsx +++ b/frontend/src/components/map/StackedEnumChart.tsx @@ -7,6 +7,7 @@ interface StackedEnumChartProps { components: { label: string; stats: EnumFeatureStats }[]; valueOrder: string[]; valueColors: string[]; + compact?: boolean; } /** Strip common suffixes to produce short row labels */ @@ -14,10 +15,24 @@ function shortenLabel(name: string): string { return name.replace(/ risk$/, ''); } +function shortenAxisLabel(name: string): string { + const label = shortenLabel(name); + if (label.length <= 3) return label; + const parts = label.split(/[\s/&-]+/).filter(Boolean); + if (parts.length > 1) { + return parts + .map((part) => Array.from(part)[0]) + .join('') + .slice(0, 3); + } + return Array.from(label).slice(0, 3).join(''); +} + export default function StackedEnumChart({ components, valueOrder, valueColors, + compact = false, }: StackedEnumChartProps) { const { t } = useTranslation(); const visibleRows = components.filter(({ stats }) => { @@ -35,6 +50,63 @@ export default function StackedEnumChart({ ); } + if (compact) { + return ( +
    + {visibleRows.map(({ label, stats }) => { + const counts = valueOrder.map((value) => stats.counts[value] ?? 0); + const total = counts.reduce((a, b) => a + b, 0); + const roundedPcts = roundedPercentages(counts, total, 0); + const title = valueOrder + .map((value, i) => `${ts(value)}: ${counts[i]} (${roundedPcts[i]}%)`) + .join('\n'); + + return ( +
    + + {shortenLabel(ts(label))} + +
    + {valueOrder.map((value, i) => { + const count = counts[i]; + const pct = (count / total) * 100; + if (pct < 0.5) return null; + return ( +
    + ); + })} +
    +
    + ); + })} +
    + {valueOrder.map((value) => ( + + {shortenAxisLabel(ts(value))} + + ))} +
    +
    + ); + } + return (
    {visibleRows.map(({ label, stats }) => { diff --git a/frontend/src/components/map/filters/ActiveFiltersPanel.tsx b/frontend/src/components/map/filters/ActiveFiltersPanel.tsx index b7bb163..4b6736d 100644 --- a/frontend/src/components/map/filters/ActiveFiltersPanel.tsx +++ b/frontend/src/components/map/filters/ActiveFiltersPanel.tsx @@ -110,7 +110,7 @@ export function ActiveFiltersPanel({ > {(!collapsed || !isLicensed) && (
    -
    - {!collapsed && ( + {!collapsed && ( +
    - )} - {!isLicensed && ( -
    -

    - {t('filters.upgradePrompt')} -

    -

    - {t('filters.oneTimeLifetime')} -

    - - - - - - - -
    - )} -
    +
    + )} + {!isLicensed && ( +
    +

    + {t('filters.upgradePrompt')} +

    +

    + {t('filters.oneTimeLifetime')} +

    + + + + + + + +
    + )}
    )}
    diff --git a/frontend/src/components/ui/InfoPopup.tsx b/frontend/src/components/ui/InfoPopup.tsx index 87f4edf..adbddaf 100644 --- a/frontend/src/components/ui/InfoPopup.tsx +++ b/frontend/src/components/ui/InfoPopup.tsx @@ -1,5 +1,7 @@ -import { useRef, useCallback, useEffect, useId, type ReactNode } from 'react'; +import { useCallback, useEffect, useId, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; import { useClickOutside } from '../../hooks/useClickOutside'; +import { useModalA11y } from '../../hooks/useModalA11y'; import { CloseIcon } from './icons'; import { IconButton } from './IconButton'; @@ -11,8 +13,7 @@ interface InfoPopupProps { } export default function InfoPopup({ title, children, onClose, sourceLink }: InfoPopupProps) { - const popupRef = useRef(null); - const previouslyFocusedRef = useRef(null); + const popupRef = useModalA11y(); const titleId = useId(); const handleClose = useCallback(() => { @@ -29,20 +30,9 @@ export default function InfoPopup({ title, children, onClose, sourceLink }: Info return () => document.removeEventListener('keydown', handleKeyDown); }, [onClose]); - useEffect(() => { - previouslyFocusedRef.current = document.activeElement as HTMLElement | null; - const firstFocusable = popupRef.current?.querySelector( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' - ); - (firstFocusable ?? popupRef.current)?.focus(); - return () => { - previouslyFocusedRef.current?.focus?.(); - }; - }, []); - - return ( + const popup = (
    ); + + if (typeof document === 'undefined') return popup; + + return createPortal(popup, document.body); } diff --git a/frontend/src/components/ui/MobileMenu.tsx b/frontend/src/components/ui/MobileMenu.tsx index 2964505..33415d8 100644 --- a/frontend/src/components/ui/MobileMenu.tsx +++ b/frontend/src/components/ui/MobileMenu.tsx @@ -160,7 +160,7 @@ export default function MobileMenu({
    {/* Menu panel */}
    -
    +
    {t('mobileMenu.menu')}