Last night
This commit is contained in:
parent
2906b01734
commit
42ee2d4c51
47 changed files with 848 additions and 478 deletions
|
|
@ -1,4 +1,3 @@
|
|||
data_sources/
|
||||
.venv
|
||||
**/node_modules
|
||||
**/dist
|
||||
|
|
@ -9,10 +8,3 @@ server-rs/target
|
|||
__pycache__
|
||||
analyses/
|
||||
*.log
|
||||
|
||||
# Exclude data files except the ones we need
|
||||
data/*
|
||||
!data/wide.parquet
|
||||
!data/filtered_uk_pois.parquet
|
||||
!data/uk.pmtiles
|
||||
!data/postcodes
|
||||
|
|
|
|||
11
Dockerfile
11
Dockerfile
|
|
@ -20,12 +20,11 @@ WORKDIR /app
|
|||
COPY --from=server /app/server-rs/target/release/property-map-server ./
|
||||
COPY --from=frontend /app/frontend/dist ./dist/
|
||||
|
||||
# Copy data files into the image
|
||||
COPY data/wide.parquet ./data/
|
||||
COPY data/filtered_uk_pois.parquet ./data/
|
||||
COPY data/uk.pmtiles ./data/
|
||||
COPY data/postcodes ./data/postcodes/
|
||||
# COPY property-data/wide.parquet ./data/
|
||||
# COPY property-data/filtered_uk_pois.parquet ./data/
|
||||
# COPY property-data/uk.pmtiles ./data/
|
||||
# COPY property-data/postcodes ./data/postcodes/
|
||||
|
||||
EXPOSE 8001
|
||||
ENTRYPOINT ["./property-map-server"]
|
||||
CMD ["--data", "/app/data/wide.parquet", "--pois", "/app/data/filtered_uk_pois.parquet", "--tiles", "/app/data/uk.pmtiles", "--postcodes", "/app/data/postcodes"]
|
||||
CMD ["--data", "/app/data/wide.parquet", "--pois", "/app/data/filtered_uk_pois.parquet", "--tiles", "/app/data/uk.pmtiles", "--postcodes", "/app/data/new_postcode_boundaries"]
|
||||
|
|
|
|||
67
README.md
67
README.md
|
|
@ -60,17 +60,13 @@ We give you all the data and tools to become an Well-informed Buyer through the
|
|||
|
||||
- scraping
|
||||
- fix frontend
|
||||
- price history
|
||||
- map hexagons
|
||||
- dragging
|
||||
- account management
|
||||
- stripe
|
||||
- saved views
|
||||
- update texts
|
||||
- friendlier filtering
|
||||
- fix plausible
|
||||
- move data to raid
|
||||
- loading animation on right pane
|
||||
- extract all user-facing texts into a yaml file for easy editing
|
||||
|
||||
- register domain
|
||||
|
|
@ -90,52 +86,33 @@ FAQ:
|
|||
## outstadning prompts
|
||||
|
||||
|
||||
Ensure the website is dynamically scalable and looks great on mobiles. The home/data sources/faq pages should be easy, just ensure responsive scaling. For the dashboard, we need a different layout. Split the screen vertically into 60:40. The top half is the map while the bottom is the filter section. Add a menu drawer on the right hand side where the area/properties/poi tabs will live. When the user clicks on a hexagon, the drawer appears covering 90% of the screen. Theres a large X button to dismiss it but clicking outside works too.
|
||||
Add licensing to the app. By default, anonymous users can use the map but only in central london. if they try zooming out, the server refuses to provide data and the users will be prompted to buy a lifetime license to continue (or zoom back in). Just before buying a license, they have to register by providing their email address and password, then they need to complete the stripe check out workflow. Implement the full pocketbase/server/frontend integration. For admins, give an option to generate an invite link, opening which prompts you to register and gives you a free license forever. Have a cool animation with party poppers on the successful acquiring of a license. For non-admin users, allow inviting friends for 30% off the price. Also add a support page that shows my email address, and add a FAQ on the same page too. While doing this, protect the server against DOS-ing.
|
||||
|
||||
|
||||
-
|
||||
- the area stastics are missing for postcodes, they only work for hexagons
|
||||
- in the mobile view, move the property density and previewing colour spectrum to the bottom half of the screen.
|
||||
- make the no active filters have less padding on phone
|
||||
- add blue/green rollout
|
||||
- rename OgImageQuery to ScreenshotQuery
|
||||
- make the eye and plus icons and their touch targets twice the size
|
||||
|
||||
|
||||
Look at the frontend. Once you're logged in, you can save your searches and then look at your saved searches. Add a save search button for logged in users in the header. Also add a saved searches page which shows you the time of saving, the filters selected and a screenshot of the map. Use a similar approach we use to generate og images.
|
||||
|
||||
|
||||
Add licensing to the app. By default, anonymous users can use the map but only in central london. if they try zooming out, the server refuses to provide data and the users will be prompted to buy a lifetime license to continue (or zoom back in). Just before buying a license, they have to register by providing their email address and password, then they need to complete the stripe check out workflow. Implement the full pocketbase/server/frontend integration. For admins, give an option to generate an invite link, opening which prompts you to register and gives you a free license forever. Have a cool animation with party poppers on the successful acquiring of a license. For non-admin users, allow inviting friends for 30% off the price. Also add a support page that shows my email address, and add a FAQ on the same page too. While doing this, protect the server against DOS-ing.
|
||||
|
||||
- put price history under property and on the top of the tab add a description for the histograms
|
||||
|
||||
- Can we embedd google street view in an iframe? If so, add it underneath the area page
|
||||
|
||||
- You can reach out to ollama at http://ollama:11434/v1. Add a server endpoint that takes the selected filters and results of them shown on the frontend and generates a brief description of the area using qwen3:14b. Make sure that loading the llm summary doesn't block showing anything on the screen and instead add a placeholder animation in the Area tab where this functionality belongs.
|
||||
|
||||
- Ensure that the Area & Properties tabs have a loading animation when waiting for data to be fetched
|
||||
|
||||
- Add a forgot password workflow for the login page relying on pocketbase
|
||||
|
||||
- Make sure the frontend asks for 25% more area than what the user sees on their viewport when fetching hexagons and postcodes to ensure a smoother panning experience.
|
||||
|
||||
- The area tab is suppoed to show links to zoopla, rightmove, etc. but it doesn't. Investigate the issue
|
||||
|
||||
- The colours of the log in popup are not consistent with the rest of the website, fix it.
|
||||
|
||||
- Show a register button in the header too and make it green like the other buttons
|
||||
|
||||
- Don't support profile pictures or full names to avoid GDPR requirements.
|
||||
|
||||
- the min: number; max: number; p1: number; p99: number; counts: number[] histogram schema doesn't make sense. Have on the min max, width and counts in the schema but ensure min corresponds with the center of the bin and max with the center of the last bin
|
||||
|
||||
- move the FEATURE_FORMATS definition to the backend's features.rs
|
||||
|
||||
|
||||
wide = wide.with_columns(
|
||||
(
|
||||
pl.col("date_of_transfer").dt.year()
|
||||
+ (pl.col("date_of_transfer").dt.month() - 1) / 12
|
||||
)
|
||||
.cast(pl.Float32)
|
||||
.alias("transaction_year"),
|
||||
)
|
||||
|
||||
is ugly, just put the actual datetime inside the parquet and call the column date of last transaction
|
||||
|
||||
- make the text on the map white when in dark mode
|
||||
|
||||
- move download_map_assets.py into the download module where all the other downloads live. Also, try to find a different way of downloading all icons without hardcoding them
|
||||
|
||||
|
||||
## name ideas
|
||||
|
||||
|
||||
perfect postcodes
|
||||
|
||||
golden postcodes
|
||||
|
||||
calculated move
|
||||
|
||||
the spec
|
||||
|
||||
geologic
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ vars:
|
|||
INSPIRE_OUTPUT: "{{.DATA_DIR}}/inspire"
|
||||
OA_BOUNDARIES_OUTPUT: "{{.DATA_DIR}}/oa_boundaries.gpkg"
|
||||
UPRN_LOOKUP_OUTPUT: "{{.DATA_DIR}}/uprn_lookup.parquet"
|
||||
POSTCODE_BOUNDARIES_OUTPUT: "{{.DATA_DIR}}/postcode_boundaries"
|
||||
POSTCODE_BOUNDARIES_OUTPUT: "{{.DATA_DIR}}/new_postcode_boundaries"
|
||||
|
||||
tasks:
|
||||
download:tiles:
|
||||
|
|
@ -247,8 +247,6 @@ tasks:
|
|||
- download:oa-boundaries
|
||||
- download:inspire
|
||||
- download:uprn-lookup
|
||||
status:
|
||||
- test -d {{.POSTCODE_BOUNDARIES_OUTPUT}}/units
|
||||
cmds:
|
||||
- >-
|
||||
uv run python -m pipeline.transform.postcode_boundaries
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { fetchWithRetry, apiUrl } from './lib/api';
|
|||
import { parseUrlState } from './lib/url-state';
|
||||
import { INITIAL_VIEW_STATE } from './lib/consts';
|
||||
import { useTheme } from './hooks/useTheme';
|
||||
import { useIsMobile } from './hooks/useIsMobile';
|
||||
import { useAuth } from './hooks/useAuth';
|
||||
import { useSavedSearches } from './hooks/useSavedSearches';
|
||||
|
||||
|
|
@ -87,6 +88,7 @@ export default function App() {
|
|||
});
|
||||
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const isMobile = useIsMobile();
|
||||
const {
|
||||
user,
|
||||
loading: authLoading,
|
||||
|
|
@ -207,7 +209,7 @@ export default function App() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<div className="h-full flex flex-col">
|
||||
<Header
|
||||
activePage={activePage}
|
||||
onPageChange={navigateTo}
|
||||
|
|
@ -227,6 +229,7 @@ export default function App() {
|
|||
setShowAuthModal(true);
|
||||
}}
|
||||
onLogout={logout}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
{activePage === 'home' ? (
|
||||
<HomePage onOpenDashboard={() => navigateTo('dashboard')} theme={theme} />
|
||||
|
|
@ -257,6 +260,7 @@ export default function App() {
|
|||
onClearPendingInfoFeature={() => setPendingInfoFeature(null)}
|
||||
onNavigateTo={navigateTo}
|
||||
onExportStateChange={setExportState}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
)}
|
||||
{showAuthModal && (
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ const FAQ_ITEMS: FAQItem[] = [
|
|||
{
|
||||
question: 'Does this work on mobile?',
|
||||
answer:
|
||||
'The app is designed for desktop browsers where you have enough screen space for the map, filter panel, and POI/properties panel side by side. It will load on mobile but the experience is best on a larger screen.',
|
||||
'Yes. On mobile, the dashboard uses a vertical split layout with the map on top and a tabbed panel below for filters, area stats, properties, and POIs. Tapping a hexagon opens a full-screen drawer with the details. The full desktop experience with side-by-side panels is available on screens 768px and wider.',
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export default function HomePage({
|
|||
|
||||
<div className="relative" style={{ zIndex: 1 }}>
|
||||
{/* Hero */}
|
||||
<div className="max-w-3xl mx-auto px-6 pt-20 pb-24">
|
||||
<div className="max-w-3xl mx-auto px-6 pt-12 pb-16 md:pt-20 md:pb-24">
|
||||
<div
|
||||
ref={heroRef}
|
||||
className="fade-in-section backdrop-blur-sm bg-warm-50/60 dark:bg-navy-950/60 rounded-2xl p-8 -mx-2"
|
||||
|
|
@ -48,7 +48,7 @@ export default function HomePage({
|
|||
<p className="text-teal-600 font-semibold tracking-wide uppercase text-sm mb-4">
|
||||
Find where to live, not just what's for sale
|
||||
</p>
|
||||
<h1 className="text-5xl font-extrabold text-navy-950 dark:text-warm-100 mb-6 leading-[1.1] tracking-tight">
|
||||
<h1 className="text-3xl md:text-5xl font-extrabold text-navy-950 dark:text-warm-100 mb-6 leading-[1.1] tracking-tight">
|
||||
Every neighbourhood
|
||||
<br />
|
||||
in England & Wales.
|
||||
|
|
@ -158,7 +158,7 @@ export default function HomePage({
|
|||
<div className="grid grid-cols-3 gap-6 text-center">
|
||||
{STATS.map((s) => (
|
||||
<div key={s.label}>
|
||||
<div className="text-3xl font-extrabold text-teal-600">{s.value}</div>
|
||||
<div className="text-2xl md:text-3xl font-extrabold text-teal-600">{s.value}</div>
|
||||
<div className="text-sm text-warm-500 dark:text-warm-400 mt-1">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import StackedBarChart from './StackedBarChart';
|
|||
import StackedEnumChart from './StackedEnumChart';
|
||||
import PriceHistoryChart from './PriceHistoryChart';
|
||||
import ExternalSearchLinks from './ExternalSearchLinks';
|
||||
import { InfoIcon, CloseIcon } from '../ui/icons';
|
||||
import { InfoIcon, CloseIcon, ChevronIcon } from '../ui/icons';
|
||||
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
||||
import { LightbulbIcon } from '../ui/icons/LightbulbIcon';
|
||||
import { IconButton } from '../ui/IconButton';
|
||||
|
|
@ -58,6 +58,7 @@ export default function AreaPane({
|
|||
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
|
||||
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||
const [aiSummaryExpanded, setAiSummaryExpanded] = useState(true);
|
||||
|
||||
const toggleGroup = (name: string) =>
|
||||
setCollapsedGroups((prev) => {
|
||||
|
|
@ -133,41 +134,53 @@ export default function AreaPane({
|
|||
<ExternalSearchLinks location={hexagonLocation} filters={filters} />
|
||||
)}
|
||||
|
||||
{/* AI Summary Card */}
|
||||
{(aiSummary || aiSummaryLoading || aiSummaryError) && (
|
||||
<div className="px-3 pt-3 pb-1">
|
||||
<div className="bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-800/50 rounded p-2.5">
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<LightbulbIcon className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400" />
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">AI Summary</span>
|
||||
</div>
|
||||
{aiSummaryError ? (
|
||||
<div className="text-xs text-warm-600 dark:text-warm-400">
|
||||
<span>Failed to generate summary. </span>
|
||||
{onRetryAiSummary && (
|
||||
<button
|
||||
onClick={onRetryAiSummary}
|
||||
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : aiSummaryLoading ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-3 bg-teal-200/60 dark:bg-teal-800/40 rounded animate-pulse w-full" />
|
||||
<div className="h-3 bg-teal-200/60 dark:bg-teal-800/40 rounded animate-pulse w-4/5" />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-warm-700 dark:text-warm-300 leading-relaxed">
|
||||
{aiSummary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* AI Summary Card */}
|
||||
{(aiSummary || aiSummaryLoading || aiSummaryError) && (
|
||||
<div className="px-3 pt-3 pb-1">
|
||||
<div className="bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-800/50 rounded p-2.5">
|
||||
<button
|
||||
onClick={() => setAiSummaryExpanded(!aiSummaryExpanded)}
|
||||
className="w-full flex items-center justify-between gap-1.5 mb-1.5"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<LightbulbIcon className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400" />
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">AI Summary</span>
|
||||
</div>
|
||||
<ChevronIcon
|
||||
direction={aiSummaryExpanded ? 'down' : 'right'}
|
||||
className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400"
|
||||
/>
|
||||
</button>
|
||||
{aiSummaryExpanded && (
|
||||
<>
|
||||
{aiSummaryError ? (
|
||||
<div className="text-xs text-warm-600 dark:text-warm-400">
|
||||
<span>Failed to generate summary. </span>
|
||||
{onRetryAiSummary && (
|
||||
<button
|
||||
onClick={onRetryAiSummary}
|
||||
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : aiSummaryLoading ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-3 bg-teal-200/60 dark:bg-teal-800/40 rounded animate-pulse w-full" />
|
||||
<div className="h-3 bg-teal-200/60 dark:bg-teal-800/40 rounded animate-pulse w-4/5" />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-warm-700 dark:text-warm-300 leading-relaxed">
|
||||
{aiSummary}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{loading && !stats ? (
|
||||
<LoadingSkeleton />
|
||||
) : stats ? (
|
||||
|
|
|
|||
|
|
@ -88,10 +88,10 @@ function FeatureBrowser({
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="p-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<div className="shrink-0 p-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<SearchInput value={search} onChange={setSearch} placeholder="Search features..." />
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto flex flex-col">
|
||||
<div className="md:min-h-0 md:flex-1 md:overflow-y-auto flex flex-col">
|
||||
{grouped.map((group) => {
|
||||
const isExpanded = isSearching || expandedGroups.has(group.name);
|
||||
return (
|
||||
|
|
@ -187,7 +187,7 @@ export default memo(function Filters({
|
|||
const [activeInfoFeature, setActiveInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col bg-white dark:bg-navy-950 overflow-hidden h-full">
|
||||
<div className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full">
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<button
|
||||
onClick={() => setShowPhilosophy(true)}
|
||||
|
|
@ -197,7 +197,7 @@ export default memo(function Filters({
|
|||
Finding the Perfect Postcode
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex flex-col max-h-[65%]">
|
||||
<div className="shrink-0 md:shrink md:min-h-0 flex flex-col md:max-h-[65%]">
|
||||
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">
|
||||
|
|
@ -215,7 +215,7 @@ export default memo(function Filters({
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-3">
|
||||
<div className="md:flex-1 md:overflow-y-auto p-3 space-y-3">
|
||||
{enabledFeatureList.length === 0 && (
|
||||
<EmptyState
|
||||
icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
|
||||
|
|
@ -308,11 +308,11 @@ export default memo(function Filters({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 flex flex-col border-t border-warm-200 dark:border-warm-700">
|
||||
<div className="shrink-0 md:shrink md:min-h-0 md:flex-1 flex flex-col border-t border-warm-200 dark:border-warm-700">
|
||||
<div className="shrink-0 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">Add Filter</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 flex flex-col">
|
||||
<div className="md:min-h-0 md:flex-1 flex flex-col">
|
||||
<FeatureBrowser
|
||||
availableFeatures={availableFeatures}
|
||||
allFeatures={features}
|
||||
|
|
|
|||
|
|
@ -27,9 +27,9 @@ export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }:
|
|||
|
||||
// Show stats for active filters (up to 4)
|
||||
for (const name of activeFilterNames.slice(0, 4)) {
|
||||
const minVal = data[`min_${name}`];
|
||||
if (minVal != null && typeof minVal === 'number') {
|
||||
results.push({ name, value: formatValue(minVal) });
|
||||
const val = data[`avg_${name}`] ?? data[`min_${name}`];
|
||||
if (val != null && typeof val === 'number') {
|
||||
results.push({ name, value: formatValue(val) });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ import type {
|
|||
ViewChangeParams,
|
||||
POI,
|
||||
FeatureMeta,
|
||||
Bounds,
|
||||
} from '../../types';
|
||||
import { cellToLatLng } from 'h3-js';
|
||||
import {
|
||||
GRADIENT,
|
||||
normalizedToColor,
|
||||
|
|
@ -63,6 +65,7 @@ interface MapProps {
|
|||
filters?: FeatureFilters;
|
||||
searchedPostcode?: SearchedPostcode | null;
|
||||
onPostcodeSearched?: (postcode: SearchedPostcode | null) => void;
|
||||
bounds?: Bounds | null;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -114,6 +117,7 @@ export default memo(function Map({
|
|||
filters = {},
|
||||
searchedPostcode,
|
||||
onPostcodeSearched,
|
||||
bounds: viewportBounds,
|
||||
}: MapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW_STATE);
|
||||
|
|
@ -199,13 +203,18 @@ export default memo(function Map({
|
|||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const d of data) {
|
||||
if (viewportBounds) {
|
||||
const [lat, lng] = cellToLatLng(d.h3);
|
||||
if (lat < viewportBounds.south || lat > viewportBounds.north || lng < viewportBounds.west || lng > viewportBounds.east) continue;
|
||||
}
|
||||
const c = d.count as number;
|
||||
if (c < min) min = c;
|
||||
if (c > max) max = c;
|
||||
}
|
||||
if (min === Infinity) return { min: 0, max: 1 };
|
||||
if (min === max) return { min, max: min + 1 };
|
||||
return { min, max };
|
||||
}, [data]);
|
||||
}, [data, viewportBounds]);
|
||||
|
||||
const colorFeatureMeta = useMemo(
|
||||
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
|
||||
|
|
@ -259,13 +268,18 @@ export default memo(function Map({
|
|||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const d of postcodeData) {
|
||||
if (viewportBounds) {
|
||||
const [lng, lat] = d.properties.centroid as [number, number];
|
||||
if (lat < viewportBounds.south || lat > viewportBounds.north || lng < viewportBounds.west || lng > viewportBounds.east) continue;
|
||||
}
|
||||
const c = d.properties.count;
|
||||
if (c < min) min = c;
|
||||
if (c > max) max = c;
|
||||
}
|
||||
if (min === Infinity) return { min: 0, max: 1 };
|
||||
if (min === max) return { min, max: min + 1 };
|
||||
return { min, max };
|
||||
}, [postcodeData]);
|
||||
}, [postcodeData, viewportBounds]);
|
||||
|
||||
const postcodeCountRangeRef = useRef(postcodeCountRange);
|
||||
postcodeCountRangeRef.current = postcodeCountRange;
|
||||
|
|
@ -324,7 +338,7 @@ export default memo(function Map({
|
|||
const cfm = colorFeatureMetaRef.current;
|
||||
const dark = isDarkRef.current;
|
||||
if (vf && clr && cfm) {
|
||||
const val = d[`min_${vf}`];
|
||||
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
|
||||
if (val == null) return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
|
||||
if (fr) {
|
||||
const minVal = d[`min_${vf}`] as number;
|
||||
|
|
@ -392,7 +406,7 @@ export default memo(function Map({
|
|||
const cfm = colorFeatureMetaRef.current;
|
||||
const dark = isDarkRef.current;
|
||||
if (vf && clr && cfm) {
|
||||
const val = d[`min_${vf}`];
|
||||
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
|
||||
if (val == null) return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
|
||||
if (fr) {
|
||||
const minVal = d[`min_${vf}`] as number;
|
||||
|
|
@ -402,15 +416,15 @@ export default memo(function Map({
|
|||
}
|
||||
}
|
||||
const range = clr[1] - clr[0];
|
||||
if (range === 0) return [...GRADIENT[0].color, 255] as [number, number, number, number];
|
||||
if (range === 0) return [...GRADIENT[0].color, 180] as [number, number, number, number];
|
||||
const t = ((val as number) - clr[0]) / range;
|
||||
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)));
|
||||
return [...rgb, 255] as [number, number, number, number];
|
||||
return [...rgb, 180] as [number, number, number, number];
|
||||
}
|
||||
const cr = postcodeCountRangeRef.current;
|
||||
const c = d.count;
|
||||
const t = (c - cr.min) / (cr.max - cr.min);
|
||||
return [...countToColor(Math.max(0, Math.min(1, t)), densityGradientRef.current), 255] as [
|
||||
return [...countToColor(Math.max(0, Math.min(1, t)), densityGradientRef.current), 180] as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
|
|
@ -442,6 +456,8 @@ export default memo(function Map({
|
|||
pickable: true,
|
||||
onClick: handlePostcodeClick,
|
||||
onHover: handlePostcodeHoverCallback,
|
||||
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
|
||||
beforeId: 'landuse_park',
|
||||
}),
|
||||
[postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback]
|
||||
);
|
||||
|
|
@ -572,22 +588,9 @@ export default memo(function Map({
|
|||
) : (
|
||||
<>
|
||||
<PostcodeSearch onFlyTo={handleFlyTo} onPostcodeSearched={onPostcodeSearched} />
|
||||
{viewSource === 'eye' && viewFeature && (
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-white dark:bg-warm-800 rounded-lg shadow-lg px-5 py-3">
|
||||
<span className="text-lg font-semibold text-navy-950 dark:text-white">
|
||||
Previewing “{viewFeature}”
|
||||
</span>
|
||||
<button
|
||||
onClick={onCancelPin}
|
||||
className="px-4 py-1.5 rounded-md bg-warm-200 dark:bg-warm-700 text-warm-700 dark:text-white hover:bg-warm-300 dark:hover:bg-warm-600 font-medium text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{viewFeature && colorRange && colorFeatureMeta ? (
|
||||
<MapLegend
|
||||
featureLabel={colorFeatureMeta.name}
|
||||
featureLabel={viewSource === 'eye' ? `Previewing \u201c${colorFeatureMeta.name}\u201d` : colorFeatureMeta.name}
|
||||
range={colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={onCancelPin}
|
||||
|
|
@ -598,7 +601,7 @@ export default memo(function Map({
|
|||
) : (
|
||||
<MapLegend
|
||||
featureLabel="Property density"
|
||||
range={[0, 0]}
|
||||
range={usePostcodeView ? [postcodeCountRange.min, postcodeCountRange.max] : [countRange.min, countRange.max]}
|
||||
showCancel={false}
|
||||
onCancel={onCancelPin}
|
||||
mode="density"
|
||||
|
|
|
|||
|
|
@ -41,8 +41,8 @@ export default function MapLegend({
|
|||
<div className="flex justify-between mt-1 text-warm-600 dark:text-warm-200">
|
||||
{mode === 'density' ? (
|
||||
<>
|
||||
<span>Few</span>
|
||||
<span>Many</span>
|
||||
<span>{formatValue(range[0])}</span>
|
||||
<span>{formatValue(range[1])}</span>
|
||||
</>
|
||||
) : enumValues && enumValues.length > 0 ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import Filters from './Filters';
|
|||
import POIPane from './POIPane';
|
||||
import { PropertiesPane } from './PropertiesPane';
|
||||
import AreaPane from './AreaPane';
|
||||
import MobileDrawer from './MobileDrawer';
|
||||
import DataSources from '../data-sources/DataSources';
|
||||
import { TabButton } from '../ui/TabButton';
|
||||
import { useMapData } from '../../hooks/useMapData';
|
||||
|
|
@ -24,6 +25,8 @@ export interface ExportState {
|
|||
exporting: boolean;
|
||||
}
|
||||
|
||||
type MobileBottomTab = 'filters' | 'pois';
|
||||
|
||||
interface MapPageProps {
|
||||
features: FeatureMeta[];
|
||||
poiCategoryGroups: POICategoryGroup[];
|
||||
|
|
@ -39,6 +42,7 @@ interface MapPageProps {
|
|||
onExportStateChange?: (state: ExportState) => void;
|
||||
screenshotMode?: boolean;
|
||||
ogMode?: boolean;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
export default function MapPage({
|
||||
|
|
@ -56,6 +60,7 @@ export default function MapPage({
|
|||
onExportStateChange,
|
||||
screenshotMode,
|
||||
ogMode,
|
||||
isMobile = false,
|
||||
}: MapPageProps) {
|
||||
const [searchedPostcode, setSearchedPostcode] = useState<SearchedPostcode | null>(null);
|
||||
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<string>>(initialPOICategories);
|
||||
|
|
@ -63,6 +68,10 @@ export default function MapPage({
|
|||
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 600, 'left');
|
||||
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(288, 200, 500, 'right');
|
||||
|
||||
// Mobile state
|
||||
const [mobileBottomTab, setMobileBottomTab] = useState<MobileBottomTab>('filters');
|
||||
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
||||
|
||||
// Initialize filters first
|
||||
const {
|
||||
filters,
|
||||
|
|
@ -123,6 +132,15 @@ export default function MapPage({
|
|||
selection.setRightPaneTab(initialTab);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// On mobile, open drawer and switch tab when hexagon is clicked
|
||||
const handleMobileHexagonClick = useCallback((id: string, isPostcode?: boolean) => {
|
||||
selection.handleHexagonClick(id, isPostcode);
|
||||
if (id) {
|
||||
setMobileDrawerOpen(true);
|
||||
setMobileBottomTab('area');
|
||||
}
|
||||
}, [selection.handleHexagonClick]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Compute hexagon location for external links
|
||||
const hexagonLocation = useMemo(() => {
|
||||
const hexId = selection.selectedHexagon?.id;
|
||||
|
|
@ -194,7 +212,7 @@ export default function MapPage({
|
|||
|
||||
if (screenshotMode) {
|
||||
return (
|
||||
<div className="h-screen w-screen">
|
||||
<div className="h-full w-full">
|
||||
<Map
|
||||
data={mapData.data}
|
||||
postcodeData={mapData.postcodeData}
|
||||
|
|
@ -215,11 +233,164 @@ export default function MapPage({
|
|||
theme={theme}
|
||||
screenshotMode
|
||||
ogMode={ogMode}
|
||||
bounds={mapData.bounds}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Shared pane content renderers
|
||||
const renderAreaPane = () => (
|
||||
<AreaPane
|
||||
stats={selection.areaStats}
|
||||
globalFeatures={features}
|
||||
loading={selection.loadingAreaStats}
|
||||
hexagonId={selection.selectedHexagon?.id || null}
|
||||
isPostcode={selection.selectedHexagon?.type === 'postcode'}
|
||||
postcodeData={
|
||||
selection.selectedHexagon?.type === 'postcode'
|
||||
? mapData.postcodeData.find((f) => f.properties.postcode === selection.selectedHexagon?.id) || null
|
||||
: null
|
||||
}
|
||||
onViewProperties={selection.handleViewPropertiesFromArea}
|
||||
onClose={selection.handleCloseSelection}
|
||||
hexagonLocation={hexagonLocation}
|
||||
filters={filters}
|
||||
onNavigateToSource={(slug, featureName) => onNavigateTo('data-sources', slug, featureName)}
|
||||
aiSummary={aiSummary.summary}
|
||||
aiSummaryLoading={aiSummary.loading}
|
||||
aiSummaryError={aiSummary.error}
|
||||
onRetryAiSummary={aiSummary.retry}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderPropertiesPane = () => (
|
||||
<PropertiesPane
|
||||
properties={selection.properties}
|
||||
total={selection.propertiesTotal}
|
||||
loading={selection.loadingProperties}
|
||||
hexagonId={selection.selectedHexagon?.id || null}
|
||||
onLoadMore={selection.handleLoadMoreProperties}
|
||||
onClose={selection.handleCloseSelection}
|
||||
onNavigateToSource={(slug) => onNavigateTo('data-sources', slug)}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderPOIPane = () => (
|
||||
<POIPane
|
||||
groups={poiCategoryGroups}
|
||||
selectedCategories={selectedPOICategories}
|
||||
onCategoriesChange={setSelectedPOICategories}
|
||||
poiCount={pois.length}
|
||||
onNavigateToSource={(slug) => onNavigateTo('data-sources', slug)}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderFilters = () => (
|
||||
<Filters
|
||||
features={features}
|
||||
filters={filters}
|
||||
activeFeature={activeFeature}
|
||||
dragValue={dragValue}
|
||||
enabledFeatures={enabledFeatures}
|
||||
onAddFilter={handleAddFilter}
|
||||
onRemoveFilter={handleRemoveFilter}
|
||||
onFilterChange={handleFilterChange}
|
||||
onDragStart={handleDragStart}
|
||||
onDragChange={handleDragChange}
|
||||
onDragEnd={handleDragEnd}
|
||||
zoom={mapData.zoom}
|
||||
itemCount={mapData.usePostcodeView ? mapData.postcodeData.length : mapData.data.length}
|
||||
usePostcodeView={mapData.usePostcodeView}
|
||||
pinnedFeature={pinnedFeature}
|
||||
onTogglePin={handleTogglePin}
|
||||
onCancelPin={handleCancelPin}
|
||||
onNavigateToSource={(slug, featureName) => onNavigateTo('data-sources', slug, featureName)}
|
||||
openInfoFeature={pendingInfoFeature}
|
||||
onClearOpenInfoFeature={onClearPendingInfoFeature}
|
||||
/>
|
||||
);
|
||||
|
||||
// Mobile layout
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden relative">
|
||||
{initialLoading && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<SpinnerIcon className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" />
|
||||
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">Connecting to server...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Map — 45% */}
|
||||
<div className="relative overflow-hidden" style={{ flex: '45 0 0' }}>
|
||||
<Map
|
||||
data={mapData.data}
|
||||
postcodeData={mapData.postcodeData}
|
||||
usePostcodeView={mapData.usePostcodeView}
|
||||
pois={pois}
|
||||
onViewChange={mapData.handleViewChange}
|
||||
viewFeature={viewFeature}
|
||||
colorRange={mapData.colorRange}
|
||||
filterRange={filterRange}
|
||||
viewSource={viewSource}
|
||||
onCancelPin={handleCancelPin}
|
||||
features={features}
|
||||
selectedHexagonId={selection.selectedHexagon?.id || null}
|
||||
hoveredHexagonId={selection.hoveredHexagon}
|
||||
onHexagonClick={handleMobileHexagonClick}
|
||||
onHexagonHover={selection.handleHexagonHover}
|
||||
initialViewState={initialViewState}
|
||||
theme={theme}
|
||||
filters={filters}
|
||||
searchedPostcode={searchedPostcode}
|
||||
onPostcodeSearched={setSearchedPostcode}
|
||||
bounds={mapData.bounds}
|
||||
/>
|
||||
{mapData.loading && (
|
||||
<div className="absolute bottom-2 left-2 bg-white dark:bg-navy-800 dark:text-warm-200 px-2 py-1 rounded shadow text-xs">
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
<DataSources onNavigate={() => onNavigateTo('data-sources')} />
|
||||
</div>
|
||||
|
||||
{/* Bottom panel — 55% */}
|
||||
<div className="bg-white dark:bg-navy-950 border-t border-warm-200 dark:border-navy-700 overflow-hidden flex flex-col" style={{ flex: '55 0 0' }}>
|
||||
{/* Tab bar */}
|
||||
<div className="flex shrink-0 border-b border-warm-200 dark:border-navy-700 text-sm">
|
||||
<TabButton label="Filters" isActive={mobileBottomTab === 'filters'} onClick={() => setMobileBottomTab('filters')} />
|
||||
<TabButton label="POIs" isActive={mobileBottomTab === 'pois'} onClick={() => setMobileBottomTab('pois')} />
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{mobileBottomTab === 'pois' ? (
|
||||
<div className="h-full overflow-y-auto">
|
||||
{renderPOIPane()}
|
||||
</div>
|
||||
) : (
|
||||
renderFilters()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile drawer for full-screen hexagon details */}
|
||||
{mobileDrawerOpen && selection.selectedHexagon && (
|
||||
<MobileDrawer
|
||||
onClose={() => setMobileDrawerOpen(false)}
|
||||
renderArea={renderAreaPane}
|
||||
renderProperties={renderPropertiesPane}
|
||||
renderPOIs={renderPOIPane}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop layout (unchanged)
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden relative">
|
||||
{initialLoading && (
|
||||
|
|
@ -234,28 +405,7 @@ export default function MapPage({
|
|||
{/* Left Pane */}
|
||||
<div className="flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden" style={{ width: leftPaneWidth }}>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<Filters
|
||||
features={features}
|
||||
filters={filters}
|
||||
activeFeature={activeFeature}
|
||||
dragValue={dragValue}
|
||||
enabledFeatures={enabledFeatures}
|
||||
onAddFilter={handleAddFilter}
|
||||
onRemoveFilter={handleRemoveFilter}
|
||||
onFilterChange={handleFilterChange}
|
||||
onDragStart={handleDragStart}
|
||||
onDragChange={handleDragChange}
|
||||
onDragEnd={handleDragEnd}
|
||||
zoom={mapData.zoom}
|
||||
itemCount={mapData.usePostcodeView ? mapData.postcodeData.length : mapData.data.length}
|
||||
usePostcodeView={mapData.usePostcodeView}
|
||||
pinnedFeature={pinnedFeature}
|
||||
onTogglePin={handleTogglePin}
|
||||
onCancelPin={handleCancelPin}
|
||||
onNavigateToSource={(slug, featureName) => onNavigateTo('data-sources', slug, featureName)}
|
||||
openInfoFeature={pendingInfoFeature}
|
||||
onClearOpenInfoFeature={onClearPendingInfoFeature}
|
||||
/>
|
||||
{renderFilters()}
|
||||
</div>
|
||||
<div
|
||||
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
|
||||
|
|
@ -288,6 +438,7 @@ export default function MapPage({
|
|||
filters={filters}
|
||||
searchedPostcode={searchedPostcode}
|
||||
onPostcodeSearched={setSearchedPostcode}
|
||||
bounds={mapData.bounds}
|
||||
/>
|
||||
{mapData.loading && (
|
||||
<div className="absolute bottom-4 left-4 bg-white dark:bg-navy-800 dark:text-warm-200 px-3 py-1 rounded shadow">
|
||||
|
|
@ -314,45 +465,11 @@ export default function MapPage({
|
|||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{selection.rightPaneTab === 'area' ? (
|
||||
<AreaPane
|
||||
stats={selection.areaStats}
|
||||
globalFeatures={features}
|
||||
loading={selection.loadingAreaStats}
|
||||
hexagonId={selection.selectedHexagon?.id || null}
|
||||
isPostcode={selection.selectedHexagon?.type === 'postcode'}
|
||||
postcodeData={
|
||||
selection.selectedHexagon?.type === 'postcode'
|
||||
? mapData.postcodeData.find((f) => f.properties.postcode === selection.selectedHexagon?.id) || null
|
||||
: null
|
||||
}
|
||||
onViewProperties={selection.handleViewPropertiesFromArea}
|
||||
onClose={selection.handleCloseSelection}
|
||||
hexagonLocation={hexagonLocation}
|
||||
filters={filters}
|
||||
onNavigateToSource={(slug, featureName) => onNavigateTo('data-sources', slug, featureName)}
|
||||
aiSummary={aiSummary.summary}
|
||||
aiSummaryLoading={aiSummary.loading}
|
||||
aiSummaryError={aiSummary.error}
|
||||
onRetryAiSummary={aiSummary.retry}
|
||||
/>
|
||||
renderAreaPane()
|
||||
) : selection.rightPaneTab === 'properties' ? (
|
||||
<PropertiesPane
|
||||
properties={selection.properties}
|
||||
total={selection.propertiesTotal}
|
||||
loading={selection.loadingProperties}
|
||||
hexagonId={selection.selectedHexagon?.id || null}
|
||||
onLoadMore={selection.handleLoadMoreProperties}
|
||||
onClose={selection.handleCloseSelection}
|
||||
onNavigateToSource={(slug) => onNavigateTo('data-sources', slug)}
|
||||
/>
|
||||
renderPropertiesPane()
|
||||
) : (
|
||||
<POIPane
|
||||
groups={poiCategoryGroups}
|
||||
selectedCategories={selectedPOICategories}
|
||||
onCategoriesChange={setSelectedPOICategories}
|
||||
poiCount={pois.length}
|
||||
onNavigateToSource={(slug) => onNavigateTo('data-sources', slug)}
|
||||
/>
|
||||
renderPOIPane()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
59
frontend/src/components/map/MobileDrawer.tsx
Normal file
59
frontend/src/components/map/MobileDrawer.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import { TabButton } from '../ui/TabButton';
|
||||
|
||||
type DrawerTab = 'area' | 'properties' | 'pois';
|
||||
|
||||
interface MobileDrawerProps {
|
||||
onClose: () => void;
|
||||
renderArea: () => React.ReactNode;
|
||||
renderProperties: () => React.ReactNode;
|
||||
renderPOIs: () => React.ReactNode;
|
||||
}
|
||||
|
||||
export default function MobileDrawer({
|
||||
onClose,
|
||||
renderArea,
|
||||
renderProperties,
|
||||
renderPOIs,
|
||||
}: MobileDrawerProps) {
|
||||
const [tab, setTab] = useState<DrawerTab>('area');
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col">
|
||||
{/* Backdrop — top 10% */}
|
||||
<div className="h-[10%] bg-black/50" onClick={onClose} />
|
||||
|
||||
{/* Panel — bottom 90% */}
|
||||
<div className="h-[90%] bg-white dark:bg-navy-950 rounded-t-2xl flex flex-col shadow-xl overflow-hidden">
|
||||
{/* Tab bar + close */}
|
||||
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm shrink-0">
|
||||
<TabButton label="Area" isActive={tab === 'area'} onClick={() => setTab('area')} />
|
||||
<TabButton label="Properties" isActive={tab === 'properties'} onClick={() => setTab('properties')} />
|
||||
<TabButton label="POIs" isActive={tab === 'pois'} onClick={() => setTab('pois')} />
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-auto flex items-center justify-center w-10 h-10 rounded-lg hover:bg-warm-100 dark:hover:bg-navy-800"
|
||||
aria-label="Close drawer"
|
||||
>
|
||||
<CloseIcon className="w-5 h-5 text-warm-500 dark:text-warm-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{tab === 'area' ? renderArea() : tab === 'properties' ? renderProperties() : renderPOIs()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ export default function AuthModal({
|
|||
}: {
|
||||
onClose: () => void;
|
||||
onLogin: (email: string, password: string) => Promise<void>;
|
||||
onRegister: (email: string, password: string, name?: string) => Promise<void>;
|
||||
onRegister: (email: string, password: string) => Promise<void>;
|
||||
onForgotPassword: (email: string) => Promise<void>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
|
@ -25,7 +25,6 @@ export default function AuthModal({
|
|||
const [view, setView] = useState<View>(initialTab);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [resetSent, setResetSent] = useState(false);
|
||||
|
||||
const switchView = useCallback(
|
||||
|
|
@ -45,7 +44,7 @@ export default function AuthModal({
|
|||
await onLogin(email, password);
|
||||
onClose();
|
||||
} else if (view === 'register') {
|
||||
await onRegister(email, password, name || undefined);
|
||||
await onRegister(email, password);
|
||||
onClose();
|
||||
} else {
|
||||
await onForgotPassword(email);
|
||||
|
|
@ -55,7 +54,7 @@ export default function AuthModal({
|
|||
// Error is handled by the hook
|
||||
}
|
||||
},
|
||||
[view, email, password, name, onLogin, onRegister, onForgotPassword, onClose]
|
||||
[view, email, password, onLogin, onRegister, onForgotPassword, onClose]
|
||||
);
|
||||
|
||||
const title =
|
||||
|
|
@ -107,21 +106,6 @@ export default function AuthModal({
|
|||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
||||
{view === 'register' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
||||
placeholder="Your name (optional)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
|
||||
Email
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
import { DownloadIcon } from './icons/DownloadIcon';
|
||||
import { BookmarkIcon } from './icons/BookmarkIcon';
|
||||
import { MapPinIcon } from './icons/MapPinIcon';
|
||||
import { CheckIcon } from './icons/CheckIcon';
|
||||
import { ClipboardIcon } from './icons/ClipboardIcon';
|
||||
import { CloseIcon } from './icons/CloseIcon';
|
||||
import { MenuIcon } from './icons/MenuIcon';
|
||||
import { SunIcon } from './icons/SunIcon';
|
||||
import { MoonIcon } from './icons/MoonIcon';
|
||||
import { SpinnerIcon } from './icons/SpinnerIcon';
|
||||
|
|
@ -25,6 +27,7 @@ export default function Header({
|
|||
onLoginClick,
|
||||
onRegisterClick,
|
||||
onLogout,
|
||||
isMobile,
|
||||
}: {
|
||||
activePage: Page;
|
||||
onPageChange: (page: Page) => void;
|
||||
|
|
@ -38,8 +41,25 @@ export default function Header({
|
|||
onLoginClick: () => void;
|
||||
onRegisterClick: () => void;
|
||||
onLogout: () => void;
|
||||
isMobile: boolean;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
// Close menu on Escape
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setMenuOpen(false);
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [menuOpen]);
|
||||
|
||||
// Close menu when switching away from mobile
|
||||
useEffect(() => {
|
||||
if (!isMobile) setMenuOpen(false);
|
||||
}, [isMobile]);
|
||||
|
||||
const handleShare = useCallback(() => {
|
||||
const url = window.location.href;
|
||||
|
|
@ -69,8 +89,24 @@ export default function Header({
|
|||
: 'text-warm-300 hover:bg-navy-800 hover:text-white'
|
||||
}`;
|
||||
|
||||
const mobileNavItem = (page: Page, label: string) => (
|
||||
<button
|
||||
key={page}
|
||||
className={`w-full text-left px-4 py-3 text-base font-medium rounded ${
|
||||
activePage === page ? 'bg-navy-700 text-white' : 'text-warm-300 hover:bg-navy-800 hover:text-white'
|
||||
}`}
|
||||
onClick={() => {
|
||||
onPageChange(page);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<header className="h-12 bg-navy-900 text-white flex items-center justify-between px-4 shrink-0">
|
||||
<header className="h-12 bg-navy-900 text-white flex items-center px-4 shrink-0">
|
||||
{/* Left: Logo + nav */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
|
||||
|
|
@ -79,25 +115,32 @@ export default function Header({
|
|||
<MapPinIcon className="w-5 h-5 text-teal-400" />
|
||||
<span className="font-semibold text-lg">Narrowit</span>
|
||||
</button>
|
||||
<nav className="flex items-center gap-2">
|
||||
<button className={tabClass('dashboard')} onClick={() => onPageChange('dashboard')}>
|
||||
Dashboard
|
||||
</button>
|
||||
<button className={tabClass('data-sources')} onClick={() => onPageChange('data-sources')}>
|
||||
Data Sources
|
||||
</button>
|
||||
<button className={tabClass('faq')} onClick={() => onPageChange('faq')}>
|
||||
FAQ
|
||||
</button>
|
||||
{user && (
|
||||
<button className={tabClass('saved-searches')} onClick={() => onPageChange('saved-searches')}>
|
||||
Saved
|
||||
|
||||
{/* Desktop nav */}
|
||||
{!isMobile && (
|
||||
<nav className="flex items-center gap-2">
|
||||
<button className={tabClass('dashboard')} onClick={() => onPageChange('dashboard')}>
|
||||
Dashboard
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
{user && (
|
||||
<button className={tabClass('saved-searches')} onClick={() => onPageChange('saved-searches')}>
|
||||
Saved
|
||||
</button>
|
||||
)}
|
||||
<button className={tabClass('data-sources')} onClick={() => onPageChange('data-sources')}>
|
||||
Data Sources
|
||||
</button>
|
||||
<button className={tabClass('faq')} onClick={() => onPageChange('faq')}>
|
||||
FAQ
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{activePage === 'dashboard' && (
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
{/* Desktop-only dashboard actions */}
|
||||
{!isMobile && activePage === 'dashboard' && (
|
||||
<>
|
||||
{onSaveSearch && (
|
||||
<button
|
||||
|
|
@ -140,32 +183,166 @@ export default function Header({
|
|||
</button>
|
||||
</>
|
||||
)}
|
||||
{user ? (
|
||||
<UserMenu user={user} onLogout={onLogout} />
|
||||
) : (
|
||||
|
||||
{/* Desktop-only auth */}
|
||||
{!isMobile && (
|
||||
<>
|
||||
<button
|
||||
onClick={onLoginClick}
|
||||
className="px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
<button
|
||||
onClick={onRegisterClick}
|
||||
className="px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
{user ? (
|
||||
<UserMenu user={user} onLogout={onLogout} />
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={onLoginClick}
|
||||
className="px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
<button
|
||||
onClick={onRegisterClick}
|
||||
className="px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={onToggleTheme}
|
||||
className="flex items-center justify-center w-8 h-8 rounded bg-navy-800 hover:bg-navy-700 transition-colors"
|
||||
title={`Theme: ${theme}`}
|
||||
>
|
||||
{theme === 'light' ? <SunIcon className="w-4 h-4" /> : <MoonIcon className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
{/* Mobile auth CTA (logged out only) */}
|
||||
{isMobile && !user && (
|
||||
<button
|
||||
onClick={onRegisterClick}
|
||||
className="px-4 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-semibold"
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Theme toggle (desktop only) */}
|
||||
{!isMobile && (
|
||||
<button
|
||||
onClick={onToggleTheme}
|
||||
className="flex items-center justify-center w-8 h-8 rounded bg-navy-800 hover:bg-navy-700 transition-colors"
|
||||
title={`Theme: ${theme}`}
|
||||
>
|
||||
{theme === 'light' ? <SunIcon className="w-4 h-4" /> : <MoonIcon className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mobile hamburger */}
|
||||
{isMobile && (
|
||||
<button
|
||||
onClick={() => setMenuOpen(true)}
|
||||
className="flex items-center justify-center w-10 h-10 -mr-2 rounded hover:bg-navy-800 transition-colors"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<MenuIcon className="w-6 h-6" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile slide-in menu */}
|
||||
{isMobile && menuOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
/>
|
||||
{/* Menu panel */}
|
||||
<div className="fixed top-0 right-0 bottom-0 w-64 bg-navy-900 z-50 flex flex-col shadow-xl">
|
||||
<div className="flex items-center justify-between px-4 h-12 border-b border-navy-700">
|
||||
<span className="font-semibold">Menu</span>
|
||||
<button
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="flex items-center justify-center w-10 h-10 -mr-2 rounded hover:bg-navy-800 transition-colors"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<CloseIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex-1 flex flex-col gap-1 p-3 overflow-y-auto">
|
||||
{mobileNavItem('dashboard', 'Dashboard')}
|
||||
{user && mobileNavItem('saved-searches', 'Saved')}
|
||||
{mobileNavItem('data-sources', 'Data Sources')}
|
||||
{mobileNavItem('faq', 'FAQ')}
|
||||
|
||||
{/* Dashboard actions */}
|
||||
{activePage === 'dashboard' && (
|
||||
<div className="mt-3 pt-3 border-t border-navy-700 flex flex-col gap-1">
|
||||
{onSaveSearch && (
|
||||
<button
|
||||
onClick={() => { onSaveSearch(); setMenuOpen(false); }}
|
||||
disabled={savingSearch}
|
||||
className="w-full flex items-center gap-2 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded disabled:opacity-50"
|
||||
>
|
||||
{savingSearch ? <SpinnerIcon className="w-5 h-5 animate-spin" /> : <BookmarkIcon className="w-5 h-5" />}
|
||||
Save
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { handleShare(); setMenuOpen(false); }}
|
||||
className="w-full flex items-center gap-2 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded"
|
||||
>
|
||||
{copied ? <CheckIcon className="w-5 h-5" /> : <ClipboardIcon className="w-5 h-5" />}
|
||||
{copied ? 'Copied!' : 'Share'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onExport?.(); setMenuOpen(false); }}
|
||||
disabled={!onExport || exporting}
|
||||
className="w-full flex items-center gap-2 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded disabled:opacity-50"
|
||||
>
|
||||
<DownloadIcon className="w-5 h-5" />
|
||||
{exporting ? 'Exporting...' : 'Export'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Theme toggle + Auth section at bottom */}
|
||||
<div className="p-3 border-t border-navy-700 flex flex-col gap-3">
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={() => { onToggleTheme(); }}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded transition-colors"
|
||||
>
|
||||
{theme === 'light' ? <SunIcon className="w-5 h-5" /> : <MoonIcon className="w-5 h-5" />}
|
||||
<span>Theme: {theme === 'light' ? 'Light' : 'Dark'}</span>
|
||||
</button>
|
||||
|
||||
{/* Auth buttons */}
|
||||
<div>
|
||||
{user ? (
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<span className="text-sm text-warm-300 truncate">{user.email}</span>
|
||||
<button
|
||||
onClick={() => { onLogout(); setMenuOpen(false); }}
|
||||
className="text-sm text-warm-400 hover:text-white"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { onLoginClick(); setMenuOpen(false); }}
|
||||
className="flex-1 px-3 py-2.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm text-center"
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onRegisterClick(); setMenuOpen(false); }}
|
||||
className="flex-1 px-3 py-2.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium text-center"
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,14 +17,14 @@ export default function UserMenu({ user, onLogout }: { user: AuthUser; onLogout:
|
|||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [open]);
|
||||
|
||||
const initial = (user.name || user.email)[0].toUpperCase();
|
||||
const initial = user.email[0].toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className="flex items-center justify-center w-8 h-8 rounded-full bg-teal-600 text-white text-sm font-medium hover:bg-teal-700"
|
||||
title={user.name || user.email}
|
||||
title={user.email}
|
||||
>
|
||||
{initial}
|
||||
</button>
|
||||
|
|
@ -32,12 +32,7 @@ export default function UserMenu({ user, onLogout }: { user: AuthUser; onLogout:
|
|||
{open && (
|
||||
<div className="absolute right-0 top-10 w-56 bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg shadow-lg z-50">
|
||||
<div className="px-4 py-3 border-b border-warm-200 dark:border-warm-700">
|
||||
{user.name && (
|
||||
<p className="text-sm font-medium text-navy-950 dark:text-warm-100 truncate">
|
||||
{user.name}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-warm-500 dark:text-warm-400 truncate">{user.email}</p>
|
||||
<p className="text-sm font-medium text-navy-950 dark:text-warm-100 truncate">{user.email}</p>
|
||||
</div>
|
||||
<div className="p-1">
|
||||
<button
|
||||
|
|
|
|||
17
frontend/src/components/ui/icons/MenuIcon.tsx
Normal file
17
frontend/src/components/ui/icons/MenuIcon.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MenuIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,3 +5,4 @@ export { PlusIcon } from './PlusIcon';
|
|||
export { ChevronIcon } from './ChevronIcon';
|
||||
export { FilterIcon } from './FilterIcon';
|
||||
export { LightbulbIcon } from './LightbulbIcon';
|
||||
export { MenuIcon } from './MenuIcon';
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export function useAreaSummary({
|
|||
name: f.name,
|
||||
mean: f.mean,
|
||||
})),
|
||||
enum_stats: stats.enum_features.map((f) => ({
|
||||
enum_stats: stats.enum_features.filter(f => !FORBIDDEN_FEATURES.includes(f.name)).map((f) => ({
|
||||
name: f.name,
|
||||
counts: f.counts,
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import pb from '../lib/pocketbase';
|
|||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -15,8 +13,6 @@ function recordToUser(record: any): AuthUser {
|
|||
return {
|
||||
id: record.id || '',
|
||||
email: record.email || '',
|
||||
name: record.name || '',
|
||||
avatar: record.avatar || '',
|
||||
verified: record.verified || false,
|
||||
};
|
||||
}
|
||||
|
|
@ -58,7 +54,7 @@ export function useAuth() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (email: string, password: string, name?: string) => {
|
||||
const register = useCallback(async (email: string, password: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
|
|
@ -66,7 +62,6 @@ export function useAuth() {
|
|||
email,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
name: name || '',
|
||||
});
|
||||
// Auto-login after registration
|
||||
const result = await pb.collection('users').authWithPassword(email, password);
|
||||
|
|
|
|||
|
|
@ -68,12 +68,13 @@ export function useHexagonSelection({
|
|||
const minVal = props[`min_${f.name}`];
|
||||
const maxVal = props[`max_${f.name}`];
|
||||
if (typeof minVal !== 'number' || typeof maxVal !== 'number') continue;
|
||||
const avgVal = props[`avg_${f.name}`];
|
||||
numeric_features.push({
|
||||
name: f.name,
|
||||
count: props.count,
|
||||
min: minVal,
|
||||
max: maxVal,
|
||||
mean: (minVal + maxVal) / 2,
|
||||
mean: typeof avgVal === 'number' ? avgVal : (minVal + maxVal) / 2,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
16
frontend/src/hooks/useIsMobile.ts
Normal file
16
frontend/src/hooks/useIsMobile.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
|
||||
const MOBILE_QUERY = '(max-width: 767px)';
|
||||
|
||||
export function useIsMobile(): boolean {
|
||||
const [isMobile, setIsMobile] = useState(() => window.matchMedia(MOBILE_QUERY).matches);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(MOBILE_QUERY);
|
||||
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||
mql.addEventListener('change', handler);
|
||||
return () => mql.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
|
|
@ -10,6 +10,19 @@ import type {
|
|||
} from '../types';
|
||||
import { buildFilterString, apiUrl, logNonAbortError, authHeaders } from '../lib/api';
|
||||
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/map-utils';
|
||||
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
|
||||
import { cellToLatLng } from 'h3-js';
|
||||
|
||||
/** Return the p-th percentile (0–100) from a sorted array via linear interpolation. */
|
||||
function percentile(sorted: number[], p: number): number {
|
||||
if (sorted.length === 0) return 0;
|
||||
if (sorted.length === 1) return sorted[0];
|
||||
const idx = (p / 100) * (sorted.length - 1);
|
||||
const lo = Math.floor(idx);
|
||||
const hi = Math.ceil(idx);
|
||||
if (lo === hi) return sorted[lo];
|
||||
return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo);
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 150;
|
||||
|
||||
|
|
@ -118,7 +131,8 @@ export function useMapData({
|
|||
|
||||
const data = dragData ?? rawData;
|
||||
|
||||
// Compute actual min/max from visible data for the viewed feature
|
||||
// Compute p5/p95 from visible data for the viewed feature
|
||||
// Only considers hexagons/postcodes whose center falls within the viewport bounds
|
||||
const dataRange = useMemo((): [number, number] | null => {
|
||||
if (!viewFeature) return null;
|
||||
const meta = features.find((f) => f.name === viewFeature);
|
||||
|
|
@ -126,32 +140,34 @@ export function useMapData({
|
|||
|
||||
if (activeFeature && !dragData) return null;
|
||||
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
const vals: number[] = [];
|
||||
|
||||
if (usePostcodeView) {
|
||||
if (postcodeData.length === 0) return null;
|
||||
for (const feat of postcodeData) {
|
||||
const val = feat.properties[`min_${viewFeature}`];
|
||||
if (typeof val === 'number' && !isNaN(val)) {
|
||||
min = Math.min(min, val);
|
||||
max = Math.max(max, val);
|
||||
if (bounds) {
|
||||
const [lng, lat] = feat.properties.centroid as [number, number];
|
||||
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east) continue;
|
||||
}
|
||||
const val = feat.properties[`avg_${viewFeature}`] ?? feat.properties[`min_${viewFeature}`];
|
||||
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
|
||||
}
|
||||
} else {
|
||||
if (data.length === 0) return null;
|
||||
for (const item of data) {
|
||||
const val = item[`min_${viewFeature}`];
|
||||
if (typeof val === 'number' && !isNaN(val)) {
|
||||
min = Math.min(min, val);
|
||||
max = Math.max(max, val);
|
||||
if (bounds) {
|
||||
const [lat, lng] = cellToLatLng(item.h3);
|
||||
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east) continue;
|
||||
}
|
||||
const val = item[`avg_${viewFeature}`] ?? item[`min_${viewFeature}`];
|
||||
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
|
||||
}
|
||||
}
|
||||
|
||||
if (min === Infinity || max === -Infinity) return null;
|
||||
return [min, max];
|
||||
}, [viewFeature, data, dragData, postcodeData, usePostcodeView, features, activeFeature]);
|
||||
if (vals.length === 0) return null;
|
||||
vals.sort((a, b) => a - b);
|
||||
return [percentile(vals, COLOR_RANGE_LOW_PERCENTILE), percentile(vals, COLOR_RANGE_HIGH_PERCENTILE)];
|
||||
}, [viewFeature, data, dragData, postcodeData, usePostcodeView, features, activeFeature, bounds]);
|
||||
|
||||
// Color range for the legend and hex coloring
|
||||
const colorRange = useMemo((): [number, number] | null => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
const DOMAIN = 'narrowit.schmelczer.dev';
|
||||
const ENDPOINT = '/status';
|
||||
const ENDPOINT = 'https://stats.schmelczer.dev/status';
|
||||
const IS_DEV = process.env.NODE_ENV !== 'production';
|
||||
|
||||
type EventOptions = {
|
||||
|
|
@ -32,8 +32,9 @@ function sendEvent(name: string, options?: EventOptions) {
|
|||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
credentials: 'omit',
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
}).catch(() => { });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ html,
|
|||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
height: 100dvh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@ export const INITIAL_RETRY_MS = 1000;
|
|||
export const MAX_RETRY_MS = 10000;
|
||||
|
||||
|
||||
/** Lower percentile for color-range clipping (0–100) */
|
||||
export const COLOR_RANGE_LOW_PERCENTILE = 5;
|
||||
/** Upper percentile for color-range clipping (0–100) */
|
||||
export const COLOR_RANGE_HIGH_PERCENTILE = 95;
|
||||
|
||||
export const MAP_BOUNDS: [number, number, number, number] = [-9.5, 49, 5, 57];
|
||||
export const MAP_MIN_ZOOM = 5.5;
|
||||
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
|
|||
protomaps: {
|
||||
type: 'vector',
|
||||
tiles: [tileUrl],
|
||||
maxzoom: 17,
|
||||
maxzoom: 15,
|
||||
},
|
||||
},
|
||||
layers: modifiedLayers,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
const path = require('path');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
const CopyWebpackPlugin = require('copy-webpack-plugin');
|
||||
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
|
|
@ -50,22 +51,34 @@ module.exports = (env, argv) => {
|
|||
new HtmlWebpackPlugin({
|
||||
template: './src/index.html',
|
||||
}),
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [{ from: 'public', noErrorOnMissing: true }],
|
||||
}),
|
||||
...(isProduction
|
||||
? [new MiniCssExtractPlugin()]
|
||||
: [new ReactRefreshWebpackPlugin()]),
|
||||
],
|
||||
devServer: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
port: 3001,
|
||||
allowedHosts: 'all',
|
||||
client: {
|
||||
webSocketURL: 'auto://0.0.0.0:0/ws',
|
||||
},
|
||||
historyApiFallback: {
|
||||
index: 'index.html',
|
||||
index: '/index.html',
|
||||
},
|
||||
hot: true,
|
||||
liveReload: true,
|
||||
proxy: [
|
||||
{
|
||||
context: ['/api'],
|
||||
target: 'http://localhost:8001',
|
||||
target: process.env.API_PROXY_TARGET || 'http://localhost:8001',
|
||||
},
|
||||
{
|
||||
context: ['/pb'],
|
||||
target: process.env.PB_PROXY_TARGET || 'http://localhost:8090',
|
||||
pathRewrite: { '^/pb': '' },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -98,10 +98,9 @@ def main():
|
|||
if checkpoint_path.exists():
|
||||
checkpoint_df = pl.read_parquet(checkpoint_path)
|
||||
# Deduplicate checkpoint rows per postcode, preferring rows with data
|
||||
checkpoint_df = (
|
||||
checkpoint_df.sort("public_transport_quick_minutes", nulls_last=True)
|
||||
.unique(subset=["postcode"], keep="first")
|
||||
)
|
||||
checkpoint_df = checkpoint_df.sort(
|
||||
"public_transport_quick_minutes", nulls_last=True
|
||||
).unique(subset=["postcode"], keep="first")
|
||||
completed_postcodes = set(checkpoint_df["postcode"].to_list())
|
||||
prior_results = [
|
||||
JourneyResult(
|
||||
|
|
@ -145,9 +144,9 @@ def main():
|
|||
results_df = results_to_dataframe(all_results)
|
||||
|
||||
all_postcodes = {r.postcode for r in all_results}
|
||||
coords_df = postcodes_df.filter(
|
||||
pl.col("postcode").is_in(all_postcodes)
|
||||
).select(["postcode", "lat", "long"])
|
||||
coords_df = postcodes_df.filter(pl.col("postcode").is_in(all_postcodes)).select(
|
||||
["postcode", "lat", "long"]
|
||||
)
|
||||
results_df = coords_df.join(results_df, on="postcode", how="left")
|
||||
|
||||
results_df = results_df.with_columns(
|
||||
|
|
|
|||
|
|
@ -113,7 +113,12 @@ def _build_wide(
|
|||
*[pl.col(c).fill_nan(None) for c in noise_cols],
|
||||
)
|
||||
.with_columns(
|
||||
pl.max_horizontal(*noise_cols).fill_null(0).alias("noise_lden_db"),
|
||||
pl.max_horizontal(*noise_cols).alias("noise_lden_db"),
|
||||
)
|
||||
.with_columns(
|
||||
pl.col("noise_lden_db")
|
||||
.fill_null(pl.col("noise_lden_db").min())
|
||||
.alias("noise_lden_db"),
|
||||
)
|
||||
.select("postcode", "noise_lden_db")
|
||||
)
|
||||
|
|
@ -153,8 +158,8 @@ def _build_wide(
|
|||
wide = wide.with_columns(
|
||||
pl.when(pl.col("pp_property_type").is_in(["Terraced", "Semi-Detached"]))
|
||||
.then(pl.col("built_form"))
|
||||
.otherwise(pl.col("epc_property_type"))
|
||||
.alias("epc_property_type")
|
||||
.otherwise(pl.col("pp_property_type"))
|
||||
.alias("property_type")
|
||||
)
|
||||
|
||||
wide = (
|
||||
|
|
@ -191,12 +196,13 @@ def _build_wide(
|
|||
"Barriers to Housing and Services Score",
|
||||
"lsoa21",
|
||||
"oa21",
|
||||
"epc_property_type",
|
||||
"pp_property_type",
|
||||
"built_form",
|
||||
)
|
||||
.rename(
|
||||
{
|
||||
"date_of_transfer": "Previous transaction date",
|
||||
"date_of_transfer": "Date of last transaction",
|
||||
"construction_age_band": "Construction age",
|
||||
"is_construction_date_approximate": "Is construction date approximate",
|
||||
"pp_address": "Address per Property Register",
|
||||
|
|
@ -206,11 +212,11 @@ def _build_wide(
|
|||
"current_energy_rating": "Current energy rating",
|
||||
"potential_energy_rating": "Potential energy rating",
|
||||
"total_floor_area": "Total floor area (sqm)",
|
||||
"epc_property_type": "Property type",
|
||||
"restaurants_2km": "Restaurants within 2km",
|
||||
"groceries_2km": "Groceries within 2km",
|
||||
"parks_2km": "Parks within 2km",
|
||||
"public_transport_2km": "Public transport within 2km",
|
||||
"property_type": "Property type",
|
||||
"restaurants_2km": "Number of restaurants within 2km",
|
||||
"groceries_2km": "Number of grocery shops and supermarkets within 2km",
|
||||
"parks_2km": "Number of parks within 2km",
|
||||
"public_transport_2km": "Number of public transport stations within 2km",
|
||||
"latest_price": "Last known price",
|
||||
"number_habitable_rooms": "Number of bedrooms & living rooms",
|
||||
"noise_lden_db": "Noise (dB)",
|
||||
|
|
@ -219,7 +225,6 @@ def _build_wide(
|
|||
"max_download_speed": "Max available download speed (Mbps)",
|
||||
"serious_crime_avg_yr": "Serious crime (avg/yr)",
|
||||
"minor_crime_avg_yr": "Minor crime (avg/yr)",
|
||||
"transaction_year": "Transaction year",
|
||||
"environmental_risk": "Environmental risk",
|
||||
"collapsible_deposits_risk": "Collapsible deposits risk",
|
||||
"compressible_ground_risk": "Compressible ground risk",
|
||||
|
|
|
|||
|
|
@ -42,7 +42,10 @@ def process_oa(
|
|||
|
||||
for pc, polys in pc_inspire_polys.items():
|
||||
merged = unary_union(polys)
|
||||
clipped = merged.intersection(oa_geom)
|
||||
if not merged.is_valid:
|
||||
merged = make_valid(merged)
|
||||
valid_oa = oa_geom if oa_geom.is_valid else make_valid(oa_geom)
|
||||
clipped = merged.intersection(valid_oa)
|
||||
if not clipped.is_empty:
|
||||
if not clipped.is_valid:
|
||||
clipped = make_valid(clipped)
|
||||
|
|
@ -58,11 +61,13 @@ def process_oa(
|
|||
used = None
|
||||
for pc, geom in claimed.items():
|
||||
if used is not None:
|
||||
if not geom.is_valid:
|
||||
geom = make_valid(geom)
|
||||
if not used.is_valid:
|
||||
used = make_valid(used)
|
||||
geom = geom.difference(used)
|
||||
if geom.is_empty:
|
||||
continue
|
||||
if not geom.is_valid:
|
||||
geom = make_valid(geom)
|
||||
geom = _extract_polygonal(geom)
|
||||
if geom is None:
|
||||
continue
|
||||
|
|
@ -75,11 +80,12 @@ def process_oa(
|
|||
all_claimed = unary_union(list(claimed.values()))
|
||||
if not all_claimed.is_valid:
|
||||
all_claimed = make_valid(all_claimed)
|
||||
remaining = oa_geom.difference(all_claimed)
|
||||
valid_oa = oa_geom if oa_geom.is_valid else make_valid(oa_geom)
|
||||
remaining = valid_oa.difference(all_claimed)
|
||||
if not remaining.is_valid:
|
||||
remaining = make_valid(remaining)
|
||||
else:
|
||||
remaining = oa_geom
|
||||
remaining = oa_geom if oa_geom.is_valid else make_valid(oa_geom)
|
||||
|
||||
# Distribute remaining area via Voronoi
|
||||
if not remaining.is_empty and remaining.area > 0.01:
|
||||
|
|
|
|||
|
|
@ -75,6 +75,9 @@ def compute_voronoi_regions(
|
|||
n_real = len(pts)
|
||||
pc_polys: dict[str, list[Polygon]] = defaultdict(list)
|
||||
|
||||
if not boundary.is_valid:
|
||||
boundary = make_valid(boundary)
|
||||
|
||||
for i in range(n_real):
|
||||
region_idx = vor.point_region[i]
|
||||
region = vor.regions[region_idx]
|
||||
|
|
@ -100,6 +103,8 @@ def _equal_split_fallback(
|
|||
postcodes: list[str], boundary: Polygon | MultiPolygon
|
||||
) -> dict[str, Polygon | MultiPolygon]:
|
||||
"""Split boundary into roughly equal horizontal strips, one per postcode."""
|
||||
if not boundary.is_valid:
|
||||
boundary = make_valid(boundary)
|
||||
min_x, min_y, max_x, max_y = boundary.bounds
|
||||
n = len(postcodes)
|
||||
result = {}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
"""Count POIs within a radius of properties, optimized via postcode deduplication."""
|
||||
|
||||
import tempfile
|
||||
|
||||
import numpy as np
|
||||
import polars as pl
|
||||
|
||||
from .haversine import haversine_km
|
||||
|
||||
|
||||
def _count_pois_per_postcode(
|
||||
def count_pois_per_postcode(
|
||||
postcodes_df: pl.DataFrame,
|
||||
pois: pl.DataFrame,
|
||||
groups: dict[str, list[str]],
|
||||
|
|
@ -64,9 +62,7 @@ def _count_pois_per_postcode(
|
|||
pc_codes = postcodes_df["postcode"].to_list()
|
||||
|
||||
# Initialize result arrays
|
||||
result_counts = {
|
||||
group: np.zeros(n_postcodes, dtype=np.int32) for group in groups
|
||||
}
|
||||
result_counts = {group: np.zeros(n_postcodes, dtype=np.int32) for group in groups}
|
||||
|
||||
# Process in batches with progress
|
||||
batch_size = 50000
|
||||
|
|
@ -128,47 +124,3 @@ def _count_pois_per_postcode(
|
|||
result = pl.DataFrame(result_data)
|
||||
print(" Completed POI counting")
|
||||
return result
|
||||
|
||||
|
||||
def count_pois_within_radius(
|
||||
properties: pl.DataFrame, pois: pl.DataFrame, radius_km: float = 2.0
|
||||
) -> dict[str, pl.Series]:
|
||||
"""
|
||||
Count POIs within radius for properties, optimized by deduplicating postcodes.
|
||||
|
||||
Returns dict of {column_name: count_series} aligned to properties dataframe.
|
||||
"""
|
||||
# Get unique postcodes with coordinates
|
||||
print("Deduplicating postcodes...")
|
||||
unique_postcodes = properties.select(["postcode", "lat", "lon"]).unique(
|
||||
subset=["postcode"]
|
||||
)
|
||||
|
||||
print(
|
||||
f" {len(properties):,} properties → {len(unique_postcodes):,} unique postcodes"
|
||||
)
|
||||
|
||||
# Count POIs per postcode
|
||||
postcode_counts = _count_pois_per_postcode(unique_postcodes, pois, radius_km)
|
||||
|
||||
print(" Writing postcode counts to temp file...")
|
||||
with tempfile.NamedTemporaryFile(suffix=".parquet") as tmp:
|
||||
tmp_path = tmp.name
|
||||
postcode_counts.write_parquet(tmp_path)
|
||||
|
||||
# Join using lazy evaluation
|
||||
print(" Joining counts back to properties (lazy)...")
|
||||
count_cols = [f"{group}_{int(radius_km)}km" for group in POI_GROUPS]
|
||||
|
||||
# Convert properties to lazy frame, join, then collect
|
||||
result_lazy = (
|
||||
properties.lazy()
|
||||
.select("postcode")
|
||||
.join(pl.scan_parquet(tmp_path), on="postcode", how="left")
|
||||
.select(count_cols)
|
||||
.fill_null(0)
|
||||
)
|
||||
|
||||
result_df = result_lazy.collect(engine="streaming")
|
||||
|
||||
return {col: result_df[col] for col in count_cols}
|
||||
|
|
|
|||
1
property-data
Symbolic link
1
property-data
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/bulk/property-data
|
||||
29
server-rs/Cargo.lock
generated
29
server-rs/Cargo.lock
generated
|
|
@ -2367,7 +2367,6 @@ dependencies = [
|
|||
"anyhow",
|
||||
"axum",
|
||||
"clap",
|
||||
"futures",
|
||||
"h3o",
|
||||
"lasso",
|
||||
"metrics",
|
||||
|
|
@ -2383,7 +2382,6 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
|
|
@ -2660,7 +2658,6 @@ dependencies = [
|
|||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
|
|
@ -2685,14 +2682,12 @@ dependencies = [
|
|||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
|
@ -3328,17 +3323,6 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
|
|
@ -3673,19 +3657,6 @@ dependencies = [
|
|||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.85"
|
||||
|
|
|
|||
|
|
@ -21,9 +21,7 @@ tracing = "0.1"
|
|||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
metrics = "0.24"
|
||||
metrics-exporter-prometheus = "0.16"
|
||||
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream"] }
|
||||
futures = "0.3"
|
||||
tokio-stream = "0.1"
|
||||
reqwest = { version = "0.12", features = ["rustls-tls", "json"] }
|
||||
regex = "1"
|
||||
urlencoding = "2"
|
||||
rust_xlsxwriter = "0.79"
|
||||
|
|
|
|||
|
|
@ -18,10 +18,6 @@ pub struct PocketBaseUser {
|
|||
pub id: String,
|
||||
pub email: String,
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub avatar: String,
|
||||
#[serde(default)]
|
||||
pub verified: bool,
|
||||
}
|
||||
|
||||
|
|
@ -94,21 +90,10 @@ async fn validate_token(
|
|||
}
|
||||
|
||||
pub async fn auth_middleware(req: Request, next: Next) -> Response {
|
||||
let pocketbase_url = req
|
||||
let state = req
|
||||
.extensions()
|
||||
.get::<Arc<crate::state::AppState>>()
|
||||
.and_then(|st| st.pocketbase_url.as_deref())
|
||||
.map(String::from);
|
||||
|
||||
let token_cache = req
|
||||
.extensions()
|
||||
.get::<Arc<crate::state::AppState>>()
|
||||
.map(|st| st.token_cache.clone());
|
||||
|
||||
let http_client = req
|
||||
.extensions()
|
||||
.get::<Arc<crate::state::AppState>>()
|
||||
.map(|st| st.http_client.clone());
|
||||
.cloned();
|
||||
|
||||
let token = req
|
||||
.headers()
|
||||
|
|
@ -117,14 +102,14 @@ pub async fn auth_middleware(req: Request, next: Next) -> Response {
|
|||
.and_then(|hv| hv.strip_prefix("Bearer "))
|
||||
.map(String::from);
|
||||
|
||||
let user = match (&pocketbase_url, &token, &token_cache, &http_client) {
|
||||
(Some(pb_url), Some(tk), Some(cache), Some(client)) => {
|
||||
if let Some(cached) = cache.get(tk) {
|
||||
let user = match (&state, &token) {
|
||||
(Some(st), Some(tk)) => {
|
||||
if let Some(cached) = st.token_cache.get(tk) {
|
||||
Some(cached)
|
||||
} else {
|
||||
match validate_token(client, pb_url, tk).await {
|
||||
match validate_token(&st.http_client, &st.pocketbase_url, tk).await {
|
||||
Some(user) => {
|
||||
cache.insert(tk.clone(), user.clone());
|
||||
st.token_cache.insert(tk.clone(), user.clone());
|
||||
Some(user)
|
||||
}
|
||||
None => {
|
||||
|
|
|
|||
|
|
@ -10,3 +10,7 @@ pub const GRID_CELL_SIZE: f32 = 0.01;
|
|||
pub const MAX_POIS_PER_REQUEST: usize = 2500;
|
||||
pub const DEFAULT_PROPERTIES_LIMIT: usize = 100;
|
||||
pub const MAX_PROPERTIES_LIMIT: usize = 500;
|
||||
|
||||
pub const AREA_SUMMARY_SYSTEM_PROMPT: &str = "You are an experienced estate agent with an expertise in area analysis. Help the user find his/her dream area or perfect postcode to settle in. The user is looking to buy a property based on the filters they provide. Given area statistics, write at most a single concise sentences summarising the key characteristics of the area. Be factual and highlight notable values. Do not use bullet points or headers — just flowing prose. Do not use markdown formatting. Highlight unusual facts that stand out from the average, but do not exaggerate. If there are no notable characteristics, say so. Always write at most a single sentence! Reason about the relation of different statistics to each other.";
|
||||
pub const AREA_SUMMARY_MAX_TOKENS: usize = 300;
|
||||
pub const AREA_SUMMARY_TEMPERATURE: f32 = 0.3;
|
||||
|
|
|
|||
|
|
@ -14,12 +14,13 @@ use std::sync::Arc;
|
|||
|
||||
use anyhow::{bail, Context};
|
||||
use axum::middleware;
|
||||
use axum::routing::{any, get};
|
||||
use axum::routing::{any, get, post};
|
||||
use axum::Router;
|
||||
use clap::Parser;
|
||||
use tower_http::compression::CompressionLayer;
|
||||
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tower_http::services::ServeDir;
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing::{info, warn};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
|
@ -49,9 +50,9 @@ struct Cli {
|
|||
#[arg(long)]
|
||||
dist: Option<PathBuf>,
|
||||
|
||||
/// URL of the OG screenshot sidecar (e.g. http://og-screenshot:8002)
|
||||
#[arg(long, env = "OG_SIDECAR_URL")]
|
||||
og_sidecar_url: Option<String>,
|
||||
/// URL of the screenshot service (e.g. http://screenshot:8002)
|
||||
#[arg(long, env = "SCREENSHOT_URL")]
|
||||
screenshot_url: String,
|
||||
|
||||
/// Public-facing URL for absolute og:image URLs
|
||||
#[arg(
|
||||
|
|
@ -63,7 +64,15 @@ struct Cli {
|
|||
|
||||
/// PocketBase server URL for authentication (e.g. http://localhost:8090)
|
||||
#[arg(long, env = "POCKETBASE_URL")]
|
||||
pocketbase_url: Option<String>,
|
||||
pocketbase_url: String,
|
||||
|
||||
/// Ollama server URL for AI area summaries (e.g. http://ollama:11434)
|
||||
#[arg(long, env = "OLLAMA_URL")]
|
||||
ollama_url: String,
|
||||
|
||||
/// Ollama model name for area summaries
|
||||
#[arg(long, env = "OLLAMA_MODEL", default_value = "gemma3:12b")]
|
||||
ollama_model: String,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
|
|
@ -168,6 +177,11 @@ async fn main() -> anyhow::Result<()> {
|
|||
.iter()
|
||||
.map(|name| format!("max_{}", name))
|
||||
.collect();
|
||||
let avg_keys: Vec<String> = property_data
|
||||
.feature_names
|
||||
.iter()
|
||||
.map(|name| format!("avg_{}", name))
|
||||
.collect();
|
||||
|
||||
let poi_category_groups = poi_data.category_groups()?;
|
||||
|
||||
|
|
@ -203,12 +217,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
|
||||
let http_client = reqwest::Client::new();
|
||||
|
||||
if cli.og_sidecar_url.is_some() {
|
||||
info!(
|
||||
"OG sidecar configured: {}",
|
||||
cli.og_sidecar_url.as_deref().unwrap()
|
||||
);
|
||||
}
|
||||
info!("Screenshot service configured: {}", cli.screenshot_url);
|
||||
|
||||
let features_response = routes::build_features_response(&property_data);
|
||||
info!(
|
||||
|
|
@ -223,9 +232,8 @@ async fn main() -> anyhow::Result<()> {
|
|||
postcode_data.postcodes.len(),
|
||||
);
|
||||
|
||||
if let Some(ref pb_url) = cli.pocketbase_url {
|
||||
info!("PocketBase configured: {}", pb_url);
|
||||
}
|
||||
info!("PocketBase configured: {}", cli.pocketbase_url);
|
||||
info!("Ollama configured: {} (model: {})", cli.ollama_url, cli.ollama_model);
|
||||
|
||||
let token_cache = Arc::new(auth::TokenCache::new());
|
||||
|
||||
|
|
@ -239,13 +247,16 @@ async fn main() -> anyhow::Result<()> {
|
|||
feature_name_to_index,
|
||||
min_keys,
|
||||
max_keys,
|
||||
avg_keys,
|
||||
poi_category_groups,
|
||||
features_response,
|
||||
og_sidecar_url: cli.og_sidecar_url,
|
||||
screenshot_url: cli.screenshot_url,
|
||||
public_url: cli.public_url,
|
||||
index_html,
|
||||
http_client,
|
||||
pocketbase_url: cli.pocketbase_url,
|
||||
ollama_url: cli.ollama_url,
|
||||
ollama_model: cli.ollama_model,
|
||||
token_cache,
|
||||
});
|
||||
|
||||
|
|
@ -266,6 +277,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
let state_export = state.clone();
|
||||
let state_crawler = state.clone();
|
||||
let state_pb = state.clone();
|
||||
let state_area_summary = state.clone();
|
||||
|
||||
let api = Router::new()
|
||||
.route(
|
||||
|
|
@ -310,7 +322,11 @@ async fn main() -> anyhow::Result<()> {
|
|||
"/api/export",
|
||||
get(move |query| routes::get_export(state_export.clone(), query)),
|
||||
)
|
||||
.route("/api/me", get(routes::get_me));
|
||||
.route("/api/me", get(routes::get_me))
|
||||
.route(
|
||||
"/api/area-summary",
|
||||
post(move |body| routes::post_area_summary(state_area_summary.clone(), body)),
|
||||
);
|
||||
|
||||
// Add tile routes
|
||||
let reader_tile = tile_reader.clone();
|
||||
|
|
@ -336,7 +352,10 @@ async fn main() -> anyhow::Result<()> {
|
|||
);
|
||||
|
||||
let app = if frontend_dist.exists() {
|
||||
api.fallback_service(ServeDir::new(&frontend_dist))
|
||||
api.fallback_service(
|
||||
ServeDir::new(&frontend_dist)
|
||||
.not_found_service(ServeFile::new(frontend_dist.join("index.html"))),
|
||||
)
|
||||
} else {
|
||||
api
|
||||
};
|
||||
|
|
|
|||
|
|
@ -39,11 +39,11 @@ pub async fn og_middleware(request: Request, next: Next) -> Response {
|
|||
None => return response,
|
||||
};
|
||||
|
||||
// Build OG-injected HTML
|
||||
// Build OG-injected HTML (og=1 triggers heading overlay on screenshot)
|
||||
let og_image_url = if query_string.is_empty() {
|
||||
format!("{}/api/og-image", state.public_url)
|
||||
format!("{}/api/og-image?og=1", state.public_url)
|
||||
} else {
|
||||
format!("{}/api/og-image?{}", state.public_url, query_string)
|
||||
format!("{}/api/og-image?og=1&{}", state.public_url, query_string)
|
||||
};
|
||||
|
||||
let og_tags = format!(
|
||||
|
|
|
|||
|
|
@ -45,12 +45,12 @@ fn build_prompt(req: &AreaSummaryRequest) -> String {
|
|||
|
||||
let area_type = if req.is_postcode { "postcode" } else { "area" };
|
||||
parts.push(format!(
|
||||
"Summarise this {} of England ({}) which contain {} properties matching the filters.",
|
||||
area_type, req.location, req.count
|
||||
"Summarise this {} of England which contains {} properties matching my requirements.\n",
|
||||
area_type, req.count
|
||||
));
|
||||
|
||||
if !req.filters.is_empty() {
|
||||
parts.push(format!("Active filters: {}.", req.filters.join(", ")));
|
||||
parts.push(format!("Active filters: {}.\n", req.filters.join(", ")));
|
||||
}
|
||||
|
||||
if !req.numeric_stats.is_empty() {
|
||||
|
|
@ -59,7 +59,11 @@ fn build_prompt(req: &AreaSummaryRequest) -> String {
|
|||
.iter()
|
||||
.map(|stat| format!("{}: {:.1}", stat.name, stat.mean))
|
||||
.collect();
|
||||
parts.push(format!("Average values: {}.", stats.join(", ")));
|
||||
parts.push(format!(
|
||||
"Average values of the {}: {}.",
|
||||
if req.is_postcode { "postcode" } else { "area" },
|
||||
stats.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
for es in &req.enum_stats {
|
||||
|
|
|
|||
|
|
@ -93,13 +93,13 @@ fn extract_filter_feature_names(filters_str: Option<&str>) -> Vec<String> {
|
|||
names
|
||||
}
|
||||
|
||||
/// Fetch the OG screenshot image from the sidecar service.
|
||||
async fn fetch_og_image(
|
||||
/// Fetch a screenshot image from the screenshot service for Excel export.
|
||||
async fn fetch_screenshot(
|
||||
state: &AppState,
|
||||
view_param: &str,
|
||||
filters_str: Option<&str>,
|
||||
) -> Option<Vec<u8>> {
|
||||
let sidecar_url = state.og_sidecar_url.as_deref()?;
|
||||
let screenshot_base = &state.screenshot_url;
|
||||
|
||||
let mut params = vec![format!("v={}", urlencoding::encode(view_param))];
|
||||
if let Some(fs) = filters_str {
|
||||
|
|
@ -107,25 +107,25 @@ async fn fetch_og_image(
|
|||
params.push(format!("f={}", urlencoding::encode(fs)));
|
||||
}
|
||||
}
|
||||
let url = format!("{}/screenshot?{}", sidecar_url, params.join("&"));
|
||||
let url = format!("{}/screenshot?{}", screenshot_base, params.join("&"));
|
||||
|
||||
match state.http_client.get(&url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => match resp.bytes().await {
|
||||
Ok(bytes) => {
|
||||
info!(bytes = bytes.len(), "Fetched OG image for export");
|
||||
info!(bytes = bytes.len(), "Fetched screenshot for export");
|
||||
Some(bytes.to_vec())
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Failed to read OG sidecar response for export: {err}");
|
||||
warn!("Failed to read screenshot response for export: {err}");
|
||||
None
|
||||
}
|
||||
},
|
||||
Ok(resp) => {
|
||||
warn!(status = %resp.status(), "OG sidecar returned error for export");
|
||||
warn!(status = %resp.status(), "Screenshot service returned error for export");
|
||||
None
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Failed to reach OG sidecar for export: {err}");
|
||||
warn!("Failed to reach screenshot service for export: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
|
|
@ -163,8 +163,8 @@ pub async fn get_export(
|
|||
};
|
||||
let view_param = format!("{:.4},{:.4},{:.1}", center_lat, center_lon, zoom);
|
||||
|
||||
// Fetch OG image from sidecar (async, before spawn_blocking)
|
||||
let og_image_bytes = fetch_og_image(&state, &view_param, filters_str.as_deref()).await;
|
||||
// Fetch screenshot (async, before spawn_blocking)
|
||||
let og_image_bytes = fetch_screenshot(&state, &view_param, filters_str.as_deref()).await;
|
||||
|
||||
// Build feature name → description map from the precomputed features response
|
||||
let feature_descriptions: FxHashMap<String, String> = state
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ struct CellAgg {
|
|||
count: u32,
|
||||
mins: Box<[f32]>,
|
||||
maxs: Box<[f32]>,
|
||||
sums: Box<[f64]>,
|
||||
feat_counts: Box<[u32]>,
|
||||
}
|
||||
|
||||
impl CellAgg {
|
||||
|
|
@ -46,6 +48,8 @@ impl CellAgg {
|
|||
count: 0,
|
||||
mins: vec![f32::INFINITY; num_features].into_boxed_slice(),
|
||||
maxs: vec![f32::NEG_INFINITY; num_features].into_boxed_slice(),
|
||||
sums: vec![0.0f64; num_features].into_boxed_slice(),
|
||||
feat_counts: vec![0u32; num_features].into_boxed_slice(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -65,6 +69,8 @@ impl CellAgg {
|
|||
if value > self.maxs[feat_index] {
|
||||
self.maxs[feat_index] = value;
|
||||
}
|
||||
self.sums[feat_index] += value as f64;
|
||||
self.feat_counts[feat_index] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -89,6 +95,8 @@ impl CellAgg {
|
|||
if value > self.maxs[feat_index] {
|
||||
self.maxs[feat_index] = value;
|
||||
}
|
||||
self.sums[feat_index] += value as f64;
|
||||
self.feat_counts[feat_index] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -99,6 +107,7 @@ fn build_feature_maps(
|
|||
groups: &FxHashMap<u64, CellAgg>,
|
||||
min_keys: &[String],
|
||||
max_keys: &[String],
|
||||
avg_keys: &[String],
|
||||
num_features: usize,
|
||||
indices: Option<&[usize]>,
|
||||
query_bounds: (f64, f64, f64, f64), // (south, west, north, east)
|
||||
|
|
@ -122,6 +131,14 @@ fn build_feature_maps(
|
|||
let mut map = Map::new();
|
||||
map.insert("h3".into(), Value::String(cell.to_string()));
|
||||
map.insert("count".into(), Value::Number(aggregation.count.into()));
|
||||
let center: h3o::LatLng = cell.into();
|
||||
if let (Some(lat), Some(lon)) = (
|
||||
serde_json::Number::from_f64(center.lat()),
|
||||
serde_json::Number::from_f64(center.lng()),
|
||||
) {
|
||||
map.insert("lat".into(), Value::Number(lat));
|
||||
map.insert("lon".into(), Value::Number(lon));
|
||||
}
|
||||
|
||||
let iter: Box<dyn Iterator<Item = usize>> = if let Some(idx) = indices {
|
||||
Box::new(idx.iter().copied())
|
||||
|
|
@ -130,14 +147,16 @@ fn build_feature_maps(
|
|||
};
|
||||
|
||||
for feat_index in iter {
|
||||
if aggregation.mins[feat_index].is_finite() && aggregation.maxs[feat_index].is_finite()
|
||||
{
|
||||
if let (Some(min_num), Some(max_num)) = (
|
||||
if aggregation.feat_counts[feat_index] > 0 {
|
||||
let avg = aggregation.sums[feat_index] / aggregation.feat_counts[feat_index] as f64;
|
||||
if let (Some(min_num), Some(max_num), Some(avg_num)) = (
|
||||
serde_json::Number::from_f64(aggregation.mins[feat_index] as f64),
|
||||
serde_json::Number::from_f64(aggregation.maxs[feat_index] as f64),
|
||||
serde_json::Number::from_f64(avg),
|
||||
) {
|
||||
map.insert(min_keys[feat_index].clone(), Value::Number(min_num));
|
||||
map.insert(max_keys[feat_index].clone(), Value::Number(max_num));
|
||||
map.insert(avg_keys[feat_index].clone(), Value::Number(avg_num));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -208,6 +227,7 @@ pub async fn get_hexagons(
|
|||
let feature_data = &state.data.feature_data;
|
||||
let min_keys = &state.min_keys;
|
||||
let max_keys = &state.max_keys;
|
||||
let avg_keys = &state.avg_keys;
|
||||
|
||||
let h3_res = h3o::Resolution::try_from(resolution)
|
||||
.map_err(|error| format!("Invalid H3 resolution {}: {}", resolution, error))?;
|
||||
|
|
@ -277,6 +297,7 @@ pub async fn get_hexagons(
|
|||
&groups,
|
||||
min_keys,
|
||||
max_keys,
|
||||
avg_keys,
|
||||
num_features,
|
||||
field_indices.as_deref(),
|
||||
(south, west, north, east),
|
||||
|
|
|
|||
|
|
@ -15,20 +15,20 @@ pub struct OgImageQuery {
|
|||
filters: Option<String>,
|
||||
poi: Option<String>,
|
||||
tab: Option<String>,
|
||||
/// When "1", renders the OG heading overlay on the screenshot
|
||||
og: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_og_image(
|
||||
state: Arc<AppState>,
|
||||
Query(query): Query<OgImageQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let sidecar_url = match &state.og_sidecar_url {
|
||||
Some(url) => url,
|
||||
None => {
|
||||
return (StatusCode::SERVICE_UNAVAILABLE, "OG sidecar not configured").into_response();
|
||||
}
|
||||
};
|
||||
let screenshot_base = &state.screenshot_url;
|
||||
|
||||
let mut params = Vec::new();
|
||||
if query.og.as_deref() == Some("1") {
|
||||
params.push("og=1".to_string());
|
||||
}
|
||||
if let Some(ref val) = query.view {
|
||||
params.push(format!("v={}", urlencoding::encode(val)));
|
||||
}
|
||||
|
|
@ -47,9 +47,8 @@ pub async fn get_og_image(
|
|||
} else {
|
||||
format!("?{}", params.join("&"))
|
||||
};
|
||||
|
||||
let url = format!("{}/screenshot{}", sidecar_url, qs);
|
||||
info!("Proxying OG screenshot request to: {}", url);
|
||||
let url = format!("{}/screenshot{}", screenshot_base, qs);
|
||||
info!("Proxying screenshot request to: {}", url);
|
||||
|
||||
match state.http_client.get(&url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => match resp.bytes().await {
|
||||
|
|
@ -63,19 +62,19 @@ pub async fn get_og_image(
|
|||
)
|
||||
.into_response(),
|
||||
Err(err) => {
|
||||
warn!("Failed to read sidecar response: {}", err);
|
||||
warn!("Failed to read screenshot response: {}", err);
|
||||
(StatusCode::BAD_GATEWAY, "Failed to read screenshot").into_response()
|
||||
}
|
||||
},
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
warn!("Sidecar returned status {}: {}", status, body);
|
||||
(StatusCode::BAD_GATEWAY, "Screenshot sidecar error").into_response()
|
||||
warn!("Screenshot service returned status {}: {}", status, body);
|
||||
(StatusCode::BAD_GATEWAY, "Screenshot service error").into_response()
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Failed to reach sidecar: {}", err);
|
||||
(StatusCode::BAD_GATEWAY, "Screenshot sidecar unavailable").into_response()
|
||||
warn!("Failed to reach screenshot service: {}", err);
|
||||
(StatusCode::BAD_GATEWAY, "Screenshot service unavailable").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ struct PostcodeAgg {
|
|||
count: u32,
|
||||
mins: Box<[f32]>,
|
||||
maxs: Box<[f32]>,
|
||||
sums: Box<[f64]>,
|
||||
feat_counts: Box<[u32]>,
|
||||
}
|
||||
|
||||
impl PostcodeAgg {
|
||||
|
|
@ -39,6 +41,8 @@ impl PostcodeAgg {
|
|||
count: 0,
|
||||
mins: vec![f32::INFINITY; num_features].into_boxed_slice(),
|
||||
maxs: vec![f32::NEG_INFINITY; num_features].into_boxed_slice(),
|
||||
sums: vec![0.0f64; num_features].into_boxed_slice(),
|
||||
feat_counts: vec![0u32; num_features].into_boxed_slice(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -55,6 +59,8 @@ impl PostcodeAgg {
|
|||
if value > self.maxs[feat_index] {
|
||||
self.maxs[feat_index] = value;
|
||||
}
|
||||
self.sums[feat_index] += value as f64;
|
||||
self.feat_counts[feat_index] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -78,6 +84,8 @@ impl PostcodeAgg {
|
|||
if value > self.maxs[feat_index] {
|
||||
self.maxs[feat_index] = value;
|
||||
}
|
||||
self.sums[feat_index] += value as f64;
|
||||
self.feat_counts[feat_index] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -127,6 +135,7 @@ pub async fn get_postcodes(
|
|||
let feature_data = &state.data.feature_data;
|
||||
let min_keys = &state.min_keys;
|
||||
let max_keys = &state.max_keys;
|
||||
let avg_keys = &state.avg_keys;
|
||||
|
||||
let has_selective = field_indices.is_some();
|
||||
let sel_indices = field_indices.as_deref().unwrap_or(&[]);
|
||||
|
|
@ -272,15 +281,16 @@ pub async fn get_postcodes(
|
|||
};
|
||||
|
||||
for feat_index in iter {
|
||||
if aggregation.mins[feat_index].is_finite()
|
||||
&& aggregation.maxs[feat_index].is_finite()
|
||||
{
|
||||
if let (Some(min_num), Some(max_num)) = (
|
||||
if aggregation.feat_counts[feat_index] > 0 {
|
||||
let avg = aggregation.sums[feat_index] / aggregation.feat_counts[feat_index] as f64;
|
||||
if let (Some(min_num), Some(max_num), Some(avg_num)) = (
|
||||
serde_json::Number::from_f64(aggregation.mins[feat_index] as f64),
|
||||
serde_json::Number::from_f64(aggregation.maxs[feat_index] as f64),
|
||||
serde_json::Number::from_f64(avg),
|
||||
) {
|
||||
props.insert(min_keys[feat_index].clone(), Value::Number(min_num));
|
||||
props.insert(max_keys[feat_index].clone(), Value::Number(max_num));
|
||||
props.insert(avg_keys[feat_index].clone(), Value::Number(avg_num));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,20 +23,26 @@ pub struct AppState {
|
|||
pub min_keys: Vec<String>,
|
||||
/// Precomputed JSON key names: "max_{feature_name}" for each feature
|
||||
pub max_keys: Vec<String>,
|
||||
/// Precomputed JSON key names: "avg_{feature_name}" for each feature
|
||||
pub avg_keys: Vec<String>,
|
||||
/// Precomputed POI category groups (sorted)
|
||||
pub poi_category_groups: Vec<POICategoryGroup>,
|
||||
/// Precomputed features response for /api/features endpoint
|
||||
pub features_response: FeaturesResponse,
|
||||
/// URL of the OG screenshot sidecar service (e.g. http://og-screenshot:8002)
|
||||
pub og_sidecar_url: Option<String>,
|
||||
/// URL of the screenshot service (e.g. http://screenshot:8002)
|
||||
pub screenshot_url: String,
|
||||
/// Public-facing URL for absolute og:image URLs (e.g. https://narrowit.schmelczer.dev)
|
||||
pub public_url: String,
|
||||
/// Contents of index.html read at startup, used for crawler OG injection
|
||||
pub index_html: Option<String>,
|
||||
/// Shared HTTP client for proxying to the screenshot sidecar and PocketBase
|
||||
/// Shared HTTP client for proxying to the screenshot service and PocketBase
|
||||
pub http_client: reqwest::Client,
|
||||
/// PocketBase server URL for authentication (e.g. http://localhost:8090)
|
||||
pub pocketbase_url: Option<String>,
|
||||
pub pocketbase_url: String,
|
||||
/// Ollama server URL for AI area summaries (e.g. http://ollama:11434)
|
||||
pub ollama_url: String,
|
||||
/// Ollama model name for area summaries (e.g. gemma3:12b)
|
||||
pub ollama_model: String,
|
||||
/// Token validation cache (60s TTL)
|
||||
pub token_cache: Arc<TokenCache>,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue