Codex changes
This commit is contained in:
parent
0bae902e08
commit
d4dde21ad2
46 changed files with 4953 additions and 966 deletions
94
.github/workflows/ci.yml
vendored
94
.github/workflows/ci.yml
vendored
|
|
@ -1,94 +0,0 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
python:
|
||||
name: Python (lint + test)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync
|
||||
|
||||
- name: Ruff check
|
||||
run: uv run ruff check .
|
||||
|
||||
- name: Deptry (unused dependencies)
|
||||
run: uv run deptry .
|
||||
|
||||
- name: Tests
|
||||
run: |
|
||||
uv run pytest pipeline/utils/test_haversine.py
|
||||
uv run pytest pipeline/utils/test_poi_counts.py
|
||||
uv run pytest pipeline/transform/postcode_boundaries/test_postcode_boundaries.py
|
||||
|
||||
frontend:
|
||||
name: Frontend (lint + typecheck)
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: ESLint
|
||||
run: npm run lint
|
||||
|
||||
- name: Prettier check
|
||||
run: npm run format:check
|
||||
|
||||
- name: TypeScript typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
rust:
|
||||
name: Rust (lint + test)
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: server-rs
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: server-rs
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
|
||||
- name: Format check
|
||||
run: cargo fmt --check
|
||||
|
||||
- name: Install cargo-machete
|
||||
run: cargo install cargo-machete
|
||||
|
||||
- name: Unused dependencies check
|
||||
run: cargo machete
|
||||
|
||||
- name: Tests
|
||||
run: cargo test
|
||||
72
.github/workflows/docker-publish.yml
vendored
72
.github/workflows/docker-publish.yml
vendored
|
|
@ -1,72 +0,0 @@
|
|||
name: Build and publish Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
|
||||
- name: Download map assets (fonts, sprites, twemoji)
|
||||
run: uv run python -m pipeline.download.map_assets --output frontend/public/assets
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Build and push screenshot service
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./screenshot
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-screenshot:latest
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-screenshot:sha-${{ github.sha }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=screenshot
|
||||
cache-to: type=gha,mode=max,scope=screenshot
|
||||
|
||||
|
|
@ -22,6 +22,7 @@ POIS_RAW := $(DATA_DIR)/uk_pois.parquet
|
|||
POIS_FILTERED := $(DATA_DIR)/filtered_uk_pois.parquet
|
||||
POI_PROXIMITY := $(DATA_DIR)/poi_proximity.parquet
|
||||
EPC_PP := $(DATA_DIR)/epc_pp.parquet
|
||||
POSTCODES_RAW := $(DATA_DIR)/gb-postcodes-v5
|
||||
POSTCODES_PQ := $(DATA_DIR)/postcode.parquet
|
||||
PROPERTIES_PQ := $(DATA_DIR)/properties.parquet
|
||||
MERGE_STAMP := $(DATA_DIR)/.merge_done
|
||||
|
|
@ -80,7 +81,7 @@ download-naptan: $(NAPTAN)
|
|||
download-pois: $(POIS_RAW)
|
||||
download-ofsted: $(OFSTED)
|
||||
download-broadband: $(BROADBAND)
|
||||
download-postcodes: $(POSTCODES)
|
||||
download-postcodes: $(POSTCODES_RAW)
|
||||
download-rental-prices: $(RENTAL)
|
||||
download-noise: $(NOISE)
|
||||
download-inspire: $(INSPIRE_STAMP)
|
||||
|
|
@ -154,7 +155,7 @@ $(OFSTED):
|
|||
$(BROADBAND):
|
||||
uv run python -m pipeline.download.broadband --output $@
|
||||
|
||||
$(POSTCODES):
|
||||
$(POSTCODES_RAW):
|
||||
uv run python -m pipeline.download.postcodes --output $@
|
||||
|
||||
$(NOISE): $(ARCGIS)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ tasks:
|
|||
cmds:
|
||||
- uv run pytest pipeline/utils/test_haversine.py
|
||||
- uv run pytest pipeline/utils/test_poi_counts.py
|
||||
- uv run pytest pipeline/download/test_naptan.py
|
||||
- uv run pytest pipeline/transform/postcode_boundaries/test_postcode_boundaries.py
|
||||
|
||||
test:python:fuzzy-join:
|
||||
|
|
|
|||
1833
frontend/package-lock.json
generated
1833
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,6 +6,7 @@
|
|||
"build": "webpack --mode production && node scripts/prerender.mjs",
|
||||
"build:no-prerender": "webpack --mode production",
|
||||
"prerender": "node scripts/prerender.mjs",
|
||||
"test": "vitest run --environment jsdom",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"lint:fix": "eslint src --ext .ts,.tsx --fix",
|
||||
|
|
@ -39,6 +40,7 @@
|
|||
"@babel/preset-react": "^7.28.5",
|
||||
"@babel/preset-typescript": "^7.28.5",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
|
|
@ -53,6 +55,7 @@
|
|||
"favicons": "^7.2.0",
|
||||
"favicons-webpack-plugin": "^6.0.1",
|
||||
"html-webpack-plugin": "^5.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"mini-css-extract-plugin": "^2.9.0",
|
||||
"postcss": "^8.4.0",
|
||||
"postcss-loader": "^8.0.0",
|
||||
|
|
@ -63,6 +66,7 @@
|
|||
"tailwindcss": "^3.4.0",
|
||||
"ts-loader": "^9.5.0",
|
||||
"typescript": "^5.4.0",
|
||||
"vitest": "^4.1.5",
|
||||
"webpack": "^5.90.0",
|
||||
"webpack-cli": "^5.1.0",
|
||||
"webpack-dev-server": "^5.0.0"
|
||||
|
|
|
|||
|
|
@ -9,16 +9,21 @@ import type {
|
|||
} from '../../types';
|
||||
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
import type { HexagonLocation } from '../../lib/external-search';
|
||||
import { formatValue, formatFilterValue, calculateHistogramMean } from '../../lib/format';
|
||||
import {
|
||||
formatValue,
|
||||
formatFilterValue,
|
||||
calculateHistogramMean,
|
||||
roundedPercentages,
|
||||
} from '../../lib/format';
|
||||
import { groupFeaturesByCategory } from '../../lib/features';
|
||||
import { STACKED_GROUPS, STACKED_ENUM_GROUPS } from '../../lib/consts';
|
||||
import { PARTY_FEATURE_COLORS, STACKED_GROUPS, STACKED_ENUM_GROUPS } from '../../lib/consts';
|
||||
import { DualHistogram, LoadingSkeleton } from './DualHistogram';
|
||||
import EnumBarChart from './EnumBarChart';
|
||||
import StackedBarChart from './StackedBarChart';
|
||||
import StackedEnumChart from './StackedEnumChart';
|
||||
import PriceHistoryChart from './PriceHistoryChart';
|
||||
import ExternalSearchLinks from './ExternalSearchLinks';
|
||||
import { InfoIcon } from '../ui/icons';
|
||||
import { FilterIcon, InfoIcon } from '../ui/icons';
|
||||
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
||||
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
||||
import { EmptyState } from '../ui/EmptyState';
|
||||
|
|
@ -35,14 +40,26 @@ interface AreaPaneProps {
|
|||
isPostcode?: boolean;
|
||||
postcodeData?: PostcodeFeature | null;
|
||||
onViewProperties: () => void;
|
||||
onClearFilters?: () => void;
|
||||
hexagonLocation: HexagonLocation | null;
|
||||
filters: FeatureFilters;
|
||||
unfilteredCount?: number | null;
|
||||
onNavigateToSource?: (slug: string, featureName: string) => void;
|
||||
travelTimeEntries?: TravelTimeEntry[];
|
||||
isGroupExpanded: (name: string) => boolean;
|
||||
onToggleGroup: (name: string) => void;
|
||||
}
|
||||
|
||||
function normalizePercentageSegments<T extends { value: number }>(segments: T[]): T[] {
|
||||
const total = segments.reduce((sum, segment) => sum + segment.value, 0);
|
||||
const normalizedValues = roundedPercentages(
|
||||
segments.map((segment) => segment.value),
|
||||
total,
|
||||
1
|
||||
);
|
||||
return segments.map((segment, index) => ({ ...segment, value: normalizedValues[index] }));
|
||||
}
|
||||
|
||||
export default function AreaPane({
|
||||
stats,
|
||||
globalFeatures,
|
||||
|
|
@ -51,8 +68,10 @@ export default function AreaPane({
|
|||
isPostcode = false,
|
||||
postcodeData,
|
||||
onViewProperties,
|
||||
onClearFilters,
|
||||
hexagonLocation,
|
||||
filters,
|
||||
unfilteredCount,
|
||||
onNavigateToSource,
|
||||
travelTimeEntries,
|
||||
isGroupExpanded,
|
||||
|
|
@ -60,6 +79,8 @@ export default function AreaPane({
|
|||
}: AreaPaneProps) {
|
||||
const { t } = useTranslation();
|
||||
const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count;
|
||||
const activeFilterCount = Object.keys(filters).length + (travelTimeEntries?.length ?? 0);
|
||||
const hasFilteredOutArea = activeFilterCount > 0 && stats?.count === 0;
|
||||
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
|
||||
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
|
||||
|
|
@ -119,8 +140,36 @@ export default function AreaPane({
|
|||
? t('common.postcode').toLowerCase()
|
||||
: t('common.area').toLowerCase(),
|
||||
})}
|
||||
{Object.keys(filters).length > 0 ? t('areaPane.matchingFilters') : ''}
|
||||
</p>
|
||||
<div className="mt-2 flex gap-2 rounded border border-teal-200 bg-teal-50 px-2.5 py-2 text-xs leading-snug text-teal-800 dark:border-teal-800/70 dark:bg-teal-950/40 dark:text-teal-200">
|
||||
<FilterIcon className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<p>
|
||||
{activeFilterCount > 0
|
||||
? t('areaPane.filtersAffectStats', { count: activeFilterCount })
|
||||
: t('areaPane.noFiltersAffectStats')}
|
||||
</p>
|
||||
</div>
|
||||
{hasFilteredOutArea && (
|
||||
<div className="mt-2 rounded border border-amber-200 bg-amber-50 px-2.5 py-2 text-xs leading-snug text-amber-900 dark:border-amber-800/70 dark:bg-amber-950/40 dark:text-amber-100">
|
||||
<p className="font-semibold">{t('areaPane.noFilteredMatches')}</p>
|
||||
<p className="mt-1">
|
||||
{unfilteredCount != null && unfilteredCount > 0
|
||||
? t('areaPane.unfilteredAreaCount', { count: unfilteredCount })
|
||||
: unfilteredCount === 0
|
||||
? t('areaPane.noUnfilteredAreaProperties')
|
||||
: t('areaPane.relaxFiltersHint')}
|
||||
</p>
|
||||
{onClearFilters && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearFilters}
|
||||
className="mt-2 rounded bg-amber-600 px-2 py-1 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
|
||||
>
|
||||
{t('filters.clearAll')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{stats && stats.count > 0 && (
|
||||
<button
|
||||
onClick={onViewProperties}
|
||||
|
|
@ -149,7 +198,7 @@ export default function AreaPane({
|
|||
) : stats ? (
|
||||
<div>
|
||||
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
|
||||
<HistogramLegend />
|
||||
{stats.count > 0 && <HistogramLegend />}
|
||||
{stats.price_history &&
|
||||
(() => {
|
||||
const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year)));
|
||||
|
|
@ -190,148 +239,170 @@ export default function AreaPane({
|
|||
{expanded && (
|
||||
<div className="px-3 py-2 space-y-3">
|
||||
{stackedCharts?.map((chart) => {
|
||||
const segments = chart.components
|
||||
.map((name) => ({
|
||||
name,
|
||||
value: numericByName.get(name)?.mean ?? 0,
|
||||
}))
|
||||
.filter((s) => s.value > 0);
|
||||
const segments = chart.components
|
||||
.map((name) => ({
|
||||
name,
|
||||
value: numericByName.get(name)?.mean ?? 0,
|
||||
}))
|
||||
.filter((s) => s.value > 0);
|
||||
|
||||
const aggregateStats = chart.feature
|
||||
? numericByName.get(chart.feature)
|
||||
: undefined;
|
||||
const total = aggregateStats
|
||||
? aggregateStats.mean
|
||||
: segments.reduce((sum, s) => sum + s.value, 0);
|
||||
const isPercentageComposition = chart.unit === '%' && !chart.feature;
|
||||
const displaySegments = isPercentageComposition
|
||||
? normalizePercentageSegments(segments)
|
||||
: segments;
|
||||
|
||||
// Use rateFeature (e.g. per-1k) for display if available
|
||||
const rateStats = chart.rateFeature
|
||||
? numericByName.get(chart.rateFeature)
|
||||
: undefined;
|
||||
const displayValue = rateStats ? rateStats.mean : total;
|
||||
const aggregateStats = chart.feature
|
||||
? numericByName.get(chart.feature)
|
||||
: undefined;
|
||||
const total = aggregateStats
|
||||
? aggregateStats.mean
|
||||
: displaySegments.reduce((sum, s) => sum + s.value, 0);
|
||||
|
||||
// Use rateFeature for info popup and national average when available
|
||||
const infoFeatureName = chart.rateFeature ?? chart.feature;
|
||||
const featureMeta = infoFeatureName
|
||||
? globalFeatureByName.get(infoFeatureName)
|
||||
: undefined;
|
||||
// Use rateFeature (e.g. per-1k) for display if available
|
||||
const rateStats = chart.rateFeature
|
||||
? numericByName.get(chart.rateFeature)
|
||||
: undefined;
|
||||
const displayValue = isPercentageComposition
|
||||
? 100
|
||||
: rateStats
|
||||
? rateStats.mean
|
||||
: total;
|
||||
|
||||
const globalMean =
|
||||
featureMeta?.histogram
|
||||
? calculateHistogramMean(featureMeta.histogram)
|
||||
: undefined;
|
||||
// Use rateFeature for info popup and national average when available
|
||||
const infoFeatureName = chart.rateFeature ?? chart.feature;
|
||||
const featureMeta = infoFeatureName
|
||||
? globalFeatureByName.get(infoFeatureName)
|
||||
: undefined;
|
||||
|
||||
if (total === 0) return null;
|
||||
const globalMean = featureMeta?.histogram
|
||||
? calculateHistogramMean(featureMeta.histogram)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ts(chart.label)}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline mb-1.5">
|
||||
{featureMeta ? (
|
||||
<FeatureLabel
|
||||
feature={{ ...featureMeta, name: ts(chart.label) }}
|
||||
onShowInfo={setInfoFeature}
|
||||
className="mr-2"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
|
||||
{ts(chart.label)}
|
||||
</span>
|
||||
)}
|
||||
<div className="text-right shrink-0">
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(displayValue)}
|
||||
{chart.unit ? ` ${chart.unit}` : ''}
|
||||
</span>
|
||||
{globalMean != null && (
|
||||
<div className="text-[10px] text-warm-400 dark:text-warm-500 whitespace-nowrap">
|
||||
{t('areaPane.nationalAvg')}: {formatValue(globalMean)}
|
||||
</div>
|
||||
)}
|
||||
if (total === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ts(chart.label)}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline mb-1.5">
|
||||
{featureMeta ? (
|
||||
<FeatureLabel
|
||||
feature={{ ...featureMeta, name: ts(chart.label) }}
|
||||
onShowInfo={setInfoFeature}
|
||||
className="mr-2"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
|
||||
{ts(chart.label)}
|
||||
</span>
|
||||
)}
|
||||
<div className="text-right shrink-0">
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(displayValue)}
|
||||
{chart.unit ? ` ${chart.unit}` : ''}
|
||||
</span>
|
||||
{globalMean != null && (
|
||||
<div className="text-[10px] text-warm-400 dark:text-warm-500 whitespace-nowrap">
|
||||
{t('areaPane.nationalAvg')}: {formatValue(globalMean)}
|
||||
</div>
|
||||
</div>
|
||||
<StackedBarChart segments={segments} total={total} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<StackedBarChart
|
||||
segments={displaySegments}
|
||||
total={total}
|
||||
colorMap={
|
||||
chart.label === 'Political vote share'
|
||||
? PARTY_FEATURE_COLORS
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(() => {
|
||||
const stackedFeatureNames = new Set<string>(
|
||||
stackedCharts?.flatMap((c) =>
|
||||
[c.feature, c.rateFeature, ...c.components].filter((s): s is string => Boolean(s))
|
||||
[c.feature, c.rateFeature, ...c.components].filter((s): s is string =>
|
||||
Boolean(s)
|
||||
)
|
||||
) ?? []
|
||||
);
|
||||
return group.features
|
||||
.filter((f) => !stackedFeatureNames.has(f.name) && !stackedEnumFeatureNames.has(f.name))
|
||||
.map((feature) => {
|
||||
const numericStats = numericByName.get(feature.name);
|
||||
const enumStats = enumByName.get(feature.name);
|
||||
.filter(
|
||||
(f) =>
|
||||
!stackedFeatureNames.has(f.name) &&
|
||||
!stackedEnumFeatureNames.has(f.name)
|
||||
)
|
||||
.map((feature) => {
|
||||
const numericStats = numericByName.get(feature.name);
|
||||
const enumStats = enumByName.get(feature.name);
|
||||
|
||||
if (numericStats) {
|
||||
const globalFeature = globalFeatureByName.get(feature.name);
|
||||
const globalHistogram = globalFeature?.histogram;
|
||||
const globalMean = globalHistogram
|
||||
? calculateHistogramMean(globalHistogram)
|
||||
: undefined;
|
||||
if (numericStats) {
|
||||
const globalFeature = globalFeatureByName.get(feature.name);
|
||||
const globalHistogram = globalFeature?.histogram;
|
||||
const globalMean = globalHistogram
|
||||
? calculateHistogramMean(globalHistogram)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline">
|
||||
<FeatureLabel
|
||||
feature={feature}
|
||||
onShowInfo={setInfoFeature}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(numericStats.mean, feature)}
|
||||
</span>
|
||||
</div>
|
||||
{numericStats.histogram &&
|
||||
(globalHistogram ? (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={globalHistogram.counts}
|
||||
p1={numericStats.histogram.p1}
|
||||
p99={numericStats.histogram.p99}
|
||||
globalMean={globalMean}
|
||||
formatLabel={(v) => formatFilterValue(v, feature.raw)}
|
||||
/>
|
||||
) : (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={numericStats.histogram.counts}
|
||||
p1={numericStats.histogram.p1}
|
||||
p99={numericStats.histogram.p99}
|
||||
formatLabel={(v) => formatFilterValue(v, feature.raw)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (enumStats) {
|
||||
const globalFeature = globalFeatureByName.get(feature.name);
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
|
||||
<EnumBarChart
|
||||
counts={enumStats.counts}
|
||||
globalCounts={globalFeature?.counts}
|
||||
featureName={feature.name}
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline">
|
||||
<FeatureLabel
|
||||
feature={feature}
|
||||
onShowInfo={setInfoFeature}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(numericStats.mean, feature)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{numericStats.histogram &&
|
||||
(globalHistogram ? (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={globalHistogram.counts}
|
||||
p1={numericStats.histogram.p1}
|
||||
p99={numericStats.histogram.p99}
|
||||
globalMean={globalMean}
|
||||
formatLabel={(v) => formatFilterValue(v, feature.raw)}
|
||||
/>
|
||||
) : (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={numericStats.histogram.counts}
|
||||
p1={numericStats.histogram.p1}
|
||||
p99={numericStats.histogram.p99}
|
||||
formatLabel={(v) => formatFilterValue(v, feature.raw)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
if (enumStats) {
|
||||
const globalFeature = globalFeatureByName.get(feature.name);
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
|
||||
<EnumBarChart
|
||||
counts={enumStats.counts}
|
||||
globalCounts={globalFeature?.counts}
|
||||
featureName={feature.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
})()}
|
||||
{stackedEnumCharts?.map((chart) => {
|
||||
const featureMeta = chart.feature
|
||||
|
|
|
|||
|
|
@ -604,6 +604,7 @@ export default memo(function Filters({
|
|||
<FeatureActions
|
||||
feature={feature}
|
||||
isPinned={isPinned}
|
||||
isPreviewing={isActive}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={setActiveInfoFeature}
|
||||
onRemove={onRemoveFilter}
|
||||
|
|
|
|||
|
|
@ -34,10 +34,12 @@ const ZOOM_FOR_TYPE: Record<string, number> = {
|
|||
export default function LocationSearch({
|
||||
onFlyTo,
|
||||
onLocationSearched,
|
||||
onCurrentLocationFound,
|
||||
onMouseEnter,
|
||||
}: {
|
||||
onFlyTo: (lat: number, lng: number, zoom: number) => void;
|
||||
onLocationSearched?: (postcode: SearchedLocation | null) => void;
|
||||
onCurrentLocationFound?: (lat: number, lng: number) => void;
|
||||
onMouseEnter?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -131,27 +133,8 @@ export default function LocationSearch({
|
|||
});
|
||||
});
|
||||
const { latitude, longitude } = position.coords;
|
||||
const res = await fetch(
|
||||
`/api/nearest-postcode?lat=${latitude}&lng=${longitude}`,
|
||||
authHeaders()
|
||||
);
|
||||
if (!res.ok) {
|
||||
setError(t('locationSearch.lookupFailed'));
|
||||
return;
|
||||
}
|
||||
const json: {
|
||||
postcode: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
geometry: PostcodeGeometry;
|
||||
} = await res.json();
|
||||
onFlyTo(json.latitude, json.longitude, 16);
|
||||
onLocationSearched?.({
|
||||
postcode: json.postcode,
|
||||
geometry: json.geometry,
|
||||
latitude: json.latitude,
|
||||
longitude: json.longitude,
|
||||
});
|
||||
onFlyTo(latitude, longitude, 17);
|
||||
onCurrentLocationFound?.(latitude, longitude);
|
||||
search.clear();
|
||||
if (isMobile) setExpanded(false);
|
||||
} catch {
|
||||
|
|
@ -159,7 +142,7 @@ export default function LocationSearch({
|
|||
} finally {
|
||||
setLocating(false);
|
||||
}
|
||||
}, [onFlyTo, onLocationSearched, isMobile, search, t]);
|
||||
}, [onFlyTo, onCurrentLocationFound, isMobile, search, t]);
|
||||
|
||||
// Mobile collapsed state: search icon + locate button
|
||||
if (isMobile && !expanded) {
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ interface MapProps {
|
|||
filters?: FeatureFilters;
|
||||
selectedPostcodeGeometry?: PostcodeGeometry | null;
|
||||
onLocationSearched?: (location: SearchedLocation | null) => void;
|
||||
onCurrentLocationFound?: (lat: number, lng: number) => void;
|
||||
currentLocation?: { lat: number; lng: number } | null;
|
||||
bounds?: Bounds | null;
|
||||
hideLegend?: boolean;
|
||||
travelTimeEntries?: TravelTimeEntry[];
|
||||
|
|
@ -114,6 +116,8 @@ export default memo(function Map({
|
|||
filters = {},
|
||||
selectedPostcodeGeometry,
|
||||
onLocationSearched,
|
||||
onCurrentLocationFound,
|
||||
currentLocation,
|
||||
bounds: viewportBounds,
|
||||
hideLegend = false,
|
||||
travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
|
||||
|
|
@ -225,6 +229,7 @@ export default memo(function Map({
|
|||
onHexagonHover,
|
||||
theme,
|
||||
selectedPostcodeGeometry,
|
||||
currentLocation,
|
||||
bounds: viewportBounds,
|
||||
travelTimeEntries,
|
||||
});
|
||||
|
|
@ -307,6 +312,7 @@ export default memo(function Map({
|
|||
<LocationSearch
|
||||
onFlyTo={handleFlyTo}
|
||||
onLocationSearched={onLocationSearched}
|
||||
onCurrentLocationFound={onCurrentLocationFound}
|
||||
onMouseEnter={handleMouseLeave}
|
||||
/>
|
||||
{!hideLegend &&
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import { useTranslation } from 'react-i18next';
|
|||
import { formatValue } from '../../lib/format';
|
||||
import { ts } from '../../i18n/server';
|
||||
import {
|
||||
FEATURE_GRADIENT,
|
||||
DENSITY_GRADIENT,
|
||||
DENSITY_GRADIENT_DARK,
|
||||
getEnumPaletteForFeature,
|
||||
getFeatureGradient,
|
||||
} from '../../lib/consts';
|
||||
import { gradientToCss } from '../../lib/utils';
|
||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
|
|
@ -95,7 +95,9 @@ export default function MapLegend({
|
|||
const enumPalette = getEnumPaletteForFeature(featureName ?? null, enumValues);
|
||||
const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
|
||||
const gradientStyle =
|
||||
mode === 'density' ? gradientToCss(densityGradient) : gradientToCss(FEATURE_GRADIENT);
|
||||
mode === 'density'
|
||||
? gradientToCss(densityGradient)
|
||||
: gradientToCss(getFeatureGradient(featureName));
|
||||
|
||||
const fmt = raw ? { raw: true } : undefined;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cellToLatLng } from 'h3-js';
|
||||
import type {
|
||||
FeatureMeta,
|
||||
FeatureFilters,
|
||||
|
|
@ -17,7 +18,7 @@ import { PropertiesPane } from './PropertiesPane';
|
|||
import AreaPane from './AreaPane';
|
||||
import MobileDrawer from './MobileDrawer';
|
||||
import MapLegend from './MapLegend';
|
||||
import { TabButton } from '../ui/TabButton';
|
||||
import { MapPageSelectionPane } from './MapPageSelectionPane';
|
||||
import { useMapData } from '../../hooks/useMapData';
|
||||
import { usePOIData } from '../../hooks/usePOIData';
|
||||
import { useFilters } from '../../hooks/useFilters';
|
||||
|
|
@ -42,7 +43,6 @@ import { trackEvent } from '../../lib/analytics';
|
|||
import { INITIAL_VIEW_STATE } from '../../lib/consts';
|
||||
import { useLicense } from '../../hooks/useLicense';
|
||||
import UpgradeModal from '../ui/UpgradeModal';
|
||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
import { MapPinIcon } from '../ui/icons/MapPinIcon';
|
||||
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
|
||||
|
|
@ -125,6 +125,7 @@ export default function MapPage({
|
|||
|
||||
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
||||
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
|
||||
const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null);
|
||||
|
||||
const [showBookmarkToast, setShowBookmarkToast] = useState(false);
|
||||
const bookmarkToastDismissed = useRef(localStorage.getItem('bookmark_toast_dismissed') === '1');
|
||||
|
|
@ -249,7 +250,14 @@ export default function MapPage({
|
|||
}
|
||||
}
|
||||
},
|
||||
[fetchAiFilters, handleSetFilters, handleSetEntries, activeEntries, filters, mapData.currentView?.zoom]
|
||||
[
|
||||
fetchAiFilters,
|
||||
handleSetFilters,
|
||||
handleSetEntries,
|
||||
activeEntries,
|
||||
filters,
|
||||
mapData.currentView?.zoom,
|
||||
]
|
||||
);
|
||||
|
||||
const handleClearAll = useCallback(() => {
|
||||
|
|
@ -304,6 +312,7 @@ export default function MapPage({
|
|||
loadingProperties,
|
||||
areaStats,
|
||||
loadingAreaStats,
|
||||
unfilteredAreaCount,
|
||||
hoveredHexagon,
|
||||
rightPaneTab,
|
||||
setRightPaneTab,
|
||||
|
|
@ -315,25 +324,38 @@ export default function MapPage({
|
|||
handleCloseSelection,
|
||||
selectedPostcodeGeometry,
|
||||
handleLocationSearch,
|
||||
handleCurrentLocationSearch,
|
||||
} = useHexagonSelection({
|
||||
filters,
|
||||
features,
|
||||
resolution: mapData.resolution,
|
||||
usePostcodeView: mapData.usePostcodeView,
|
||||
journeyDest,
|
||||
});
|
||||
|
||||
const handleLocationSearchResult = useCallback(
|
||||
(result: SearchedLocation | null) => {
|
||||
if (result) {
|
||||
setCurrentLocation(null);
|
||||
handleLocationSearch(result.postcode, result.geometry, result.latitude, result.longitude);
|
||||
if (isMobile) setMobileDrawerOpen(true);
|
||||
} else {
|
||||
setCurrentLocation(null);
|
||||
handleCloseSelection();
|
||||
}
|
||||
},
|
||||
[handleLocationSearch, handleCloseSelection, isMobile]
|
||||
);
|
||||
|
||||
const handleCurrentLocationFound = useCallback(
|
||||
(lat: number, lng: number) => {
|
||||
setCurrentLocation({ lat, lng });
|
||||
handleCurrentLocationSearch(lat, lng);
|
||||
if (isMobile) setMobileDrawerOpen(true);
|
||||
},
|
||||
[handleCurrentLocationSearch, isMobile]
|
||||
);
|
||||
|
||||
const handleZoomToFreeZone = useCallback(() => {
|
||||
mapFlyToRef.current?.(
|
||||
INITIAL_VIEW_STATE.latitude,
|
||||
|
|
@ -428,20 +450,19 @@ export default function MapPage({
|
|||
const [lon, lat] = postcodeFeature.properties.centroid;
|
||||
return { lat, lon, resolution: mapData.resolution, postcode: hexId, isPostcode: true };
|
||||
} else {
|
||||
// For hexagons, get lat/lon from hexagon data; central postcode comes from stats
|
||||
const hex = hexId ? mapData.data.find((d) => d.h3 === hexId) : null;
|
||||
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null;
|
||||
if (!hexId) return null;
|
||||
const [lat, lon] = cellToLatLng(hexId);
|
||||
return {
|
||||
lat: hex.lat as number,
|
||||
lon: hex.lon as number,
|
||||
resolution: mapData.resolution,
|
||||
lat,
|
||||
lon,
|
||||
resolution: selectedHexagon?.resolution ?? mapData.resolution,
|
||||
postcode: areaStats?.central_postcode,
|
||||
};
|
||||
}
|
||||
}, [
|
||||
selectedHexagon?.id,
|
||||
selectedHexagon?.resolution,
|
||||
selectedHexagon?.type,
|
||||
mapData.data,
|
||||
mapData.postcodeData,
|
||||
mapData.resolution,
|
||||
areaStats?.central_postcode,
|
||||
|
|
@ -487,6 +508,7 @@ export default function MapPage({
|
|||
}, [mapData.licenseRequired]);
|
||||
|
||||
const densityLabel = t('mapLegend.historicalMatches');
|
||||
const hasActiveFilters = Object.keys(filters).length > 0 || activeEntries.length > 0;
|
||||
|
||||
const mobileLegendMeta = useMemo(
|
||||
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
|
||||
|
|
@ -607,8 +629,10 @@ export default function MapPage({
|
|||
: null
|
||||
}
|
||||
onViewProperties={handleViewPropertiesFromArea}
|
||||
onClearFilters={hasActiveFilters ? handleClearAll : undefined}
|
||||
hexagonLocation={hexagonLocation}
|
||||
filters={filters}
|
||||
unfilteredCount={unfilteredAreaCount}
|
||||
travelTimeEntries={activeEntries}
|
||||
isGroupExpanded={isAreaGroupExpanded}
|
||||
onToggleGroup={toggleAreaGroup}
|
||||
|
|
@ -695,10 +719,7 @@ export default function MapPage({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={mobileMapRef}
|
||||
className="relative overflow-hidden"
|
||||
>
|
||||
<div ref={mobileMapRef} className="relative overflow-hidden">
|
||||
<Map
|
||||
data={mapData.data}
|
||||
postcodeData={mapData.postcodeData}
|
||||
|
|
@ -721,6 +742,8 @@ export default function MapPage({
|
|||
filters={filters}
|
||||
selectedPostcodeGeometry={selectedPostcodeGeometry}
|
||||
onLocationSearched={handleLocationSearchResult}
|
||||
onCurrentLocationFound={handleCurrentLocationFound}
|
||||
currentLocation={currentLocation}
|
||||
bounds={mapData.bounds}
|
||||
hideLegend
|
||||
travelTimeEntries={entries}
|
||||
|
|
@ -907,10 +930,12 @@ export default function MapPage({
|
|||
filters={filters}
|
||||
selectedPostcodeGeometry={selectedPostcodeGeometry}
|
||||
onLocationSearched={handleLocationSearchResult}
|
||||
onCurrentLocationFound={handleCurrentLocationFound}
|
||||
currentLocation={currentLocation}
|
||||
bounds={mapData.bounds}
|
||||
travelTimeEntries={entries}
|
||||
densityLabel={densityLabel}
|
||||
totalCount={filterCounts.total || undefined}
|
||||
totalCount={hasActiveFilters ? filterCounts.total : undefined}
|
||||
/>
|
||||
{mapData.loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
|
|
@ -940,47 +965,16 @@ export default function MapPage({
|
|||
</div>
|
||||
|
||||
{selectedHexagon && (
|
||||
<div
|
||||
data-tutorial="right-pane"
|
||||
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
|
||||
style={{ width: rightPaneWidth }}
|
||||
>
|
||||
<div
|
||||
className="w-3 cursor-col-resize flex items-center justify-center group 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"
|
||||
{...rightPaneHandlers}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
|
||||
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
|
||||
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
|
||||
<TabButton
|
||||
label="Area"
|
||||
isActive={rightPaneTab === 'area'}
|
||||
onClick={() => setRightPaneTab('area')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Properties"
|
||||
isActive={rightPaneTab === 'properties'}
|
||||
onClick={handlePropertiesTabClick}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCloseSelection}
|
||||
className="px-2 flex items-center text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||
title="Close pane"
|
||||
>
|
||||
<CloseIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{rightPaneTab === 'properties' ? renderPropertiesPane() : renderAreaPane()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MapPageSelectionPane
|
||||
width={rightPaneWidth}
|
||||
resizeHandlers={rightPaneHandlers}
|
||||
tab={rightPaneTab}
|
||||
onAreaTabClick={() => setRightPaneTab('area')}
|
||||
onPropertiesTabClick={handlePropertiesTabClick}
|
||||
onClose={handleCloseSelection}
|
||||
renderAreaPane={renderAreaPane}
|
||||
renderPropertiesPane={renderPropertiesPane}
|
||||
/>
|
||||
)}
|
||||
|
||||
{bookmarkToast}
|
||||
|
|
|
|||
|
|
@ -90,10 +90,10 @@ export function TravelTimeCard({
|
|||
{slug && (
|
||||
<IconButton
|
||||
onClick={onTogglePin}
|
||||
active={isPinned}
|
||||
active={isPinned || isActive}
|
||||
title={isPinned ? t('travel.stopPreviewing') : t('travel.previewOnMap')}
|
||||
>
|
||||
<EyeIcon className="w-3.5 h-3.5" filled={isPinned} />
|
||||
<EyeIcon className="w-3.5 h-3.5" filled={isPinned || isActive} />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton onClick={() => onRemove()} title={t('travel.removeTravelTime')}>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { IconButton } from './IconButton';
|
|||
interface FeatureActionsProps {
|
||||
feature: FeatureMeta;
|
||||
isPinned: boolean;
|
||||
isPreviewing?: boolean;
|
||||
onTogglePin: (name: string) => void;
|
||||
onShowInfo?: (feature: FeatureMeta) => void;
|
||||
onRemove?: (name: string) => void;
|
||||
|
|
@ -14,11 +15,14 @@ interface FeatureActionsProps {
|
|||
export function FeatureActions({
|
||||
feature,
|
||||
isPinned,
|
||||
isPreviewing = false,
|
||||
onTogglePin,
|
||||
onShowInfo,
|
||||
onRemove,
|
||||
onAdd,
|
||||
}: FeatureActionsProps) {
|
||||
const isEyeActive = isPinned || isPreviewing;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
{feature.detail && onShowInfo && (
|
||||
|
|
@ -29,10 +33,10 @@ export function FeatureActions({
|
|||
<IconButton
|
||||
onClick={() => onTogglePin(feature.name)}
|
||||
title={isPinned ? 'Unpin colour view' : 'Colour map by this feature'}
|
||||
active={isPinned}
|
||||
active={isEyeActive}
|
||||
size="md"
|
||||
>
|
||||
<EyeIcon filled={isPinned} className="w-5 h-5 md:w-3.5 md:h-3.5" />
|
||||
<EyeIcon filled={isEyeActive} className="w-5 h-5 md:w-3.5 md:h-3.5" />
|
||||
</IconButton>
|
||||
{onAdd && (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { useCallback, useRef, useState, useMemo, useEffect } from 'react';
|
||||
import { H3HexagonLayer } from '@deck.gl/geo-layers';
|
||||
import { GeoJsonLayer, IconLayer, TextLayer, ScatterplotLayer } from '@deck.gl/layers';
|
||||
import { GeoJsonLayer, TextLayer, ScatterplotLayer } from '@deck.gl/layers';
|
||||
import { cellToBoundary } from 'h3-js';
|
||||
import Supercluster from 'supercluster';
|
||||
import type { PickingInfo } from '@deck.gl/core';
|
||||
import type {
|
||||
HexagonData,
|
||||
|
|
@ -16,16 +15,12 @@ import type {
|
|||
import {
|
||||
DENSITY_GRADIENT,
|
||||
DENSITY_GRADIENT_DARK,
|
||||
POI_GROUP_COLORS,
|
||||
POI_DEFAULT_COLOR,
|
||||
MINOR_POI_CATEGORIES,
|
||||
MINOR_POI_ZOOM_THRESHOLD,
|
||||
POI_CLUSTER_RADIUS,
|
||||
POI_CLUSTER_MAX_ZOOM,
|
||||
getEnumPaletteForFeature,
|
||||
getFeatureGradient,
|
||||
} from '../lib/consts';
|
||||
import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils';
|
||||
import { getFeatureFillColor } from '../lib/map-utils';
|
||||
import type { TravelTimeEntry } from './useTravelTime';
|
||||
import { usePoiLayers } from './usePoiLayers';
|
||||
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
|
||||
import { PieHexExtension } from '../lib/PieHexExtension';
|
||||
|
||||
|
|
@ -45,29 +40,11 @@ interface UseDeckLayersProps {
|
|||
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
|
||||
theme: 'light' | 'dark';
|
||||
selectedPostcodeGeometry?: PostcodeGeometry | null;
|
||||
currentLocation?: { lat: number; lng: number } | null;
|
||||
bounds?: Bounds | null;
|
||||
travelTimeEntries?: TravelTimeEntry[];
|
||||
}
|
||||
|
||||
interface PopupInfo {
|
||||
x: number;
|
||||
y: number;
|
||||
name: string;
|
||||
category: string;
|
||||
group: string;
|
||||
emoji: string;
|
||||
id: string;
|
||||
isCluster?: boolean;
|
||||
clusterCount?: number;
|
||||
}
|
||||
|
||||
interface ClusterPoint {
|
||||
lng: number;
|
||||
lat: number;
|
||||
count: number;
|
||||
clusterId: number;
|
||||
}
|
||||
|
||||
/** Normalize a distribution count array to [0..1] ratios, padded to 10 values. */
|
||||
function distToRatios(dist: unknown): number[] {
|
||||
if (!Array.isArray(dist) || dist.length === 0) return [1, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
|
|
@ -95,10 +72,10 @@ export function useDeckLayers({
|
|||
onHexagonHover,
|
||||
theme,
|
||||
selectedPostcodeGeometry,
|
||||
currentLocation,
|
||||
bounds: viewportBounds,
|
||||
travelTimeEntries = [],
|
||||
}: UseDeckLayersProps) {
|
||||
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
|
||||
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const [hoveredPostcode, setHoveredPostcode] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -114,6 +91,7 @@ export function useDeckLayers({
|
|||
|
||||
const isDark = theme === 'dark';
|
||||
const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
|
||||
const { poiLayers, popupInfo, clearPopupInfo } = usePoiLayers({ pois, zoom, isDark });
|
||||
|
||||
// --- Refs for deck.gl accessors ---
|
||||
const viewFeatureRef = useRef(viewFeature);
|
||||
|
|
@ -126,6 +104,8 @@ export function useDeckLayers({
|
|||
isDarkRef.current = isDark;
|
||||
const densityGradientRef = useRef(densityGradient);
|
||||
densityGradientRef.current = densityGradient;
|
||||
const featureGradientRef = useRef(getFeatureGradient(viewFeature));
|
||||
featureGradientRef.current = getFeatureGradient(viewFeature);
|
||||
const selectedHexagonIdRef = useRef(selectedHexagonId);
|
||||
selectedHexagonIdRef.current = selectedHexagonId;
|
||||
const hoveredHexagonIdRef = useRef(hoveredHexagonId);
|
||||
|
|
@ -148,9 +128,7 @@ export function useDeckLayers({
|
|||
: 0;
|
||||
|
||||
// Per-feature color palette (uses overrides when defined)
|
||||
const enumPaletteRef = useRef(
|
||||
getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values)
|
||||
);
|
||||
const enumPaletteRef = useRef(getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values));
|
||||
enumPaletteRef.current = getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values);
|
||||
|
||||
const countRange = useMemo(() => {
|
||||
|
|
@ -231,52 +209,6 @@ export function useDeckLayers({
|
|||
}
|
||||
}, []);
|
||||
|
||||
const handlePoiHover = useCallback((info: PickingInfo<POI>) => {
|
||||
if (info.object && info.x !== undefined && info.y !== undefined) {
|
||||
setPopupInfo({
|
||||
x: info.x,
|
||||
y: info.y,
|
||||
name: info.object.name,
|
||||
category: info.object.category,
|
||||
group: info.object.group,
|
||||
emoji: info.object.emoji,
|
||||
id: info.object.id,
|
||||
});
|
||||
} else {
|
||||
setPopupInfo(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePoiHoverRef = useRef(handlePoiHover);
|
||||
handlePoiHoverRef.current = handlePoiHover;
|
||||
const stablePoiHover = useCallback((info: PickingInfo<POI>) => {
|
||||
handlePoiHoverRef.current(info);
|
||||
}, []);
|
||||
|
||||
const handleClusterHover = useCallback((info: PickingInfo<ClusterPoint>) => {
|
||||
if (info.object && info.x !== undefined && info.y !== undefined) {
|
||||
setPopupInfo({
|
||||
x: info.x,
|
||||
y: info.y,
|
||||
name: `${info.object.count} places`,
|
||||
category: 'Zoom in to see details',
|
||||
group: '',
|
||||
emoji: '',
|
||||
id: '',
|
||||
isCluster: true,
|
||||
clusterCount: info.object.count,
|
||||
});
|
||||
} else {
|
||||
setPopupInfo(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClusterHoverRef = useRef(handleClusterHover);
|
||||
handleClusterHoverRef.current = handleClusterHover;
|
||||
const stableClusterHover = useCallback((info: PickingInfo<ClusterPoint>) => {
|
||||
handleClusterHoverRef.current(info);
|
||||
}, []);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handlePostcodeClick = useCallback((info: PickingInfo<any>) => {
|
||||
const pc = info.object?.properties?.postcode;
|
||||
|
|
@ -380,7 +312,10 @@ export function useDeckLayers({
|
|||
0,
|
||||
densityGradientRef.current,
|
||||
dark,
|
||||
255
|
||||
255,
|
||||
0,
|
||||
undefined,
|
||||
featureGradientRef.current
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -399,7 +334,8 @@ export function useDeckLayers({
|
|||
dark,
|
||||
255,
|
||||
enumCountRef.current,
|
||||
enumPaletteRef.current
|
||||
enumPaletteRef.current,
|
||||
featureGradientRef.current
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -481,7 +417,10 @@ export function useDeckLayers({
|
|||
0,
|
||||
densityGradientRef.current,
|
||||
dark,
|
||||
180
|
||||
180,
|
||||
0,
|
||||
undefined,
|
||||
featureGradientRef.current
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -501,7 +440,8 @@ export function useDeckLayers({
|
|||
dark,
|
||||
180,
|
||||
enumCountRef.current,
|
||||
enumPaletteRef.current
|
||||
enumPaletteRef.current,
|
||||
featureGradientRef.current
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -576,148 +516,6 @@ export function useDeckLayers({
|
|||
[postcodeData, theme]
|
||||
);
|
||||
|
||||
// --- POI clustering ---
|
||||
const clusterIndex = useMemo(() => {
|
||||
if (pois.length === 0) return null;
|
||||
const index = new Supercluster<POI>({
|
||||
radius: POI_CLUSTER_RADIUS,
|
||||
maxZoom: POI_CLUSTER_MAX_ZOOM,
|
||||
});
|
||||
const features: Supercluster.PointFeature<POI>[] = pois.map((poi) => ({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [poi.lng, poi.lat] },
|
||||
properties: poi,
|
||||
}));
|
||||
index.load(features);
|
||||
return index;
|
||||
}, [pois]);
|
||||
|
||||
const clusterZoom = Math.floor(zoom);
|
||||
const showMinorPois = zoom >= MINOR_POI_ZOOM_THRESHOLD;
|
||||
|
||||
const { visiblePois, clusters } = useMemo(() => {
|
||||
if (!clusterIndex || pois.length === 0) {
|
||||
return { visiblePois: [] as POI[], clusters: [] as ClusterPoint[] };
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const allFeatures = clusterIndex.getClusters([-180, -85, 180, 85], clusterZoom) as any[];
|
||||
const individual: POI[] = [];
|
||||
const clusterPoints: ClusterPoint[] = [];
|
||||
for (const feature of allFeatures) {
|
||||
if (feature.properties.cluster) {
|
||||
clusterPoints.push({
|
||||
lng: feature.geometry.coordinates[0],
|
||||
lat: feature.geometry.coordinates[1],
|
||||
count: feature.properties.point_count,
|
||||
clusterId: feature.properties.cluster_id,
|
||||
});
|
||||
} else {
|
||||
const poi = feature.properties as POI;
|
||||
if (!showMinorPois && MINOR_POI_CATEGORIES.has(poi.category)) continue;
|
||||
individual.push(poi);
|
||||
}
|
||||
}
|
||||
return { visiblePois: individual, clusters: clusterPoints };
|
||||
}, [clusterIndex, clusterZoom, showMinorPois, pois]);
|
||||
|
||||
// --- Individual POI layers (shadow → background → emoji) ---
|
||||
const poiShadowLayer = useMemo(
|
||||
() =>
|
||||
new ScatterplotLayer<POI>({
|
||||
id: 'poi-shadow',
|
||||
data: visiblePois,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getRadius: 16,
|
||||
radiusUnits: 'pixels',
|
||||
getFillColor: isDark ? [0, 0, 0, 50] : [0, 0, 0, 25],
|
||||
pickable: false,
|
||||
transitions: { getRadius: { duration: 300, enter: () => [0] } },
|
||||
}),
|
||||
[visiblePois, isDark]
|
||||
);
|
||||
|
||||
const poiBackgroundLayer = useMemo(
|
||||
() =>
|
||||
new ScatterplotLayer<POI>({
|
||||
id: 'poi-background',
|
||||
data: visiblePois,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getRadius: 14,
|
||||
radiusUnits: 'pixels',
|
||||
getFillColor: isDark ? [41, 37, 36, 255] : [255, 255, 255, 255],
|
||||
getLineColor: (d) => {
|
||||
const c = POI_GROUP_COLORS[d.group] || POI_DEFAULT_COLOR;
|
||||
return [c[0], c[1], c[2], 255] as [number, number, number, number];
|
||||
},
|
||||
getLineWidth: 2.5,
|
||||
lineWidthUnits: 'pixels',
|
||||
stroked: true,
|
||||
pickable: true,
|
||||
onHover: stablePoiHover,
|
||||
transitions: { getRadius: { duration: 300, enter: () => [0] } },
|
||||
}),
|
||||
[visiblePois, isDark, stablePoiHover]
|
||||
);
|
||||
|
||||
const poiIconLayer = useMemo(
|
||||
() =>
|
||||
new IconLayer<POI>({
|
||||
id: 'poi-icons',
|
||||
data: visiblePois,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: (d) => ({
|
||||
url: emojiToTwemojiUrl(d.emoji),
|
||||
width: 72,
|
||||
height: 72,
|
||||
}),
|
||||
getSize: 18,
|
||||
sizeUnits: 'pixels',
|
||||
pickable: false,
|
||||
transitions: { getSize: { duration: 300, enter: () => [0] } },
|
||||
}),
|
||||
[visiblePois]
|
||||
);
|
||||
|
||||
// --- Cluster layers ---
|
||||
const clusterCircleLayer = useMemo(
|
||||
() =>
|
||||
new ScatterplotLayer<ClusterPoint>({
|
||||
id: 'poi-clusters',
|
||||
data: clusters,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getRadius: (d) => Math.min(30, 14 + Math.sqrt(d.count) * 2),
|
||||
radiusUnits: 'pixels',
|
||||
getFillColor: isDark ? [5, 129, 114, 220] : [20, 184, 166, 220],
|
||||
getLineColor: [255, 255, 255, isDark ? 60 : 120],
|
||||
getLineWidth: 2,
|
||||
lineWidthUnits: 'pixels',
|
||||
stroked: true,
|
||||
pickable: true,
|
||||
onHover: stableClusterHover,
|
||||
transitions: { getRadius: { duration: 300, enter: () => [0] } },
|
||||
}),
|
||||
[clusters, isDark, stableClusterHover]
|
||||
);
|
||||
|
||||
const clusterTextLayer = useMemo(
|
||||
() =>
|
||||
new TextLayer<ClusterPoint>({
|
||||
id: 'poi-cluster-text',
|
||||
data: clusters,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => (d.count >= 1000 ? `${(d.count / 1000).toFixed(1)}k` : String(d.count)),
|
||||
getSize: 12,
|
||||
getColor: [255, 255, 255, 255],
|
||||
fontWeight: 700,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
sizeUnits: 'pixels',
|
||||
pickable: false,
|
||||
}),
|
||||
[clusters]
|
||||
);
|
||||
|
||||
// Marching ants highlight layer for selected hexagon or postcode
|
||||
const marchingAntsLayer = useMemo(() => {
|
||||
let geometry: PostcodeGeometry | null = null;
|
||||
|
|
@ -748,10 +546,25 @@ export function useDeckLayers({
|
|||
});
|
||||
}, [selectedPostcodeGeometry, selectedHexagonId, marchTime]);
|
||||
|
||||
const poiLayers = useMemo(
|
||||
() => [poiShadowLayer, poiBackgroundLayer, poiIconLayer, clusterCircleLayer, clusterTextLayer],
|
||||
[poiShadowLayer, poiBackgroundLayer, poiIconLayer, clusterCircleLayer, clusterTextLayer]
|
||||
);
|
||||
const currentLocationLayer = useMemo(() => {
|
||||
if (!currentLocation) return null;
|
||||
return new ScatterplotLayer<{ lat: number; lng: number; kind: 'ring' | 'dot' }>({
|
||||
id: 'current-location-dot',
|
||||
data: [
|
||||
{ ...currentLocation, kind: 'ring' },
|
||||
{ ...currentLocation, kind: 'dot' },
|
||||
],
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getRadius: (d) => (d.kind === 'ring' ? 16 : 5),
|
||||
radiusUnits: 'pixels',
|
||||
getFillColor: (d) => (d.kind === 'ring' ? [20, 184, 166, 45] : [220, 38, 38, 255]),
|
||||
getLineColor: (d) => (d.kind === 'ring' ? [20, 184, 166, 240] : [255, 255, 255, 240]),
|
||||
getLineWidth: 2,
|
||||
lineWidthUnits: 'pixels',
|
||||
stroked: true,
|
||||
pickable: false,
|
||||
});
|
||||
}, [currentLocation]);
|
||||
|
||||
const layers = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
|
@ -761,6 +574,7 @@ export function useDeckLayers({
|
|||
: [postcodeLayer, ...poiLayers]
|
||||
: [hexLayer, ...poiLayers];
|
||||
if (marchingAntsLayer) baseLayers.push(marchingAntsLayer);
|
||||
if (currentLocationLayer) baseLayers.push(currentLocationLayer);
|
||||
return baseLayers;
|
||||
}, [
|
||||
usePostcodeView,
|
||||
|
|
@ -770,16 +584,15 @@ export function useDeckLayers({
|
|||
postcodeLabelsLayer,
|
||||
poiLayers,
|
||||
marchingAntsLayer,
|
||||
currentLocationLayer,
|
||||
]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setHoverPosition(null);
|
||||
setHoveredPostcode(null);
|
||||
setPopupInfo(null);
|
||||
clearPopupInfo();
|
||||
onHexagonHoverRef.current(null);
|
||||
}, []);
|
||||
|
||||
const clearPopupInfo = useCallback(() => setPopupInfo(null), []);
|
||||
}, [clearPopupInfo]);
|
||||
|
||||
return {
|
||||
layers,
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
const meta = features.find((f) => f.name === name);
|
||||
if (meta?.type === 'enum') return;
|
||||
pendingDragRef.current = name;
|
||||
setActiveFeature(name);
|
||||
},
|
||||
[features]
|
||||
);
|
||||
|
|
@ -112,8 +113,9 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
if (pendingDragRef.current) {
|
||||
// Click without drag — no state was changed, just clear the ref
|
||||
// Click without drag — no filter value was changed, just clear preview state.
|
||||
pendingDragRef.current = null;
|
||||
setActiveFeature(null);
|
||||
return;
|
||||
}
|
||||
const af = dragActiveRef.current;
|
||||
|
|
@ -131,6 +133,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
const handleDragEndNoCommit = useCallback((): [number, number] | null => {
|
||||
if (pendingDragRef.current) {
|
||||
pendingDragRef.current = null;
|
||||
setActiveFeature(null);
|
||||
return null;
|
||||
}
|
||||
const dv = dragValueRef.current;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { latLngToCell } from 'h3-js';
|
||||
import { cellToLatLng, cellToParent, latLngToCell } from 'h3-js';
|
||||
import { trackEvent } from '../lib/analytics';
|
||||
import type {
|
||||
FeatureMeta,
|
||||
|
|
@ -11,10 +11,13 @@ import type {
|
|||
} from '../types';
|
||||
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders } from '../lib/api';
|
||||
|
||||
const CURRENT_LOCATION_HEX_RESOLUTION = 12;
|
||||
|
||||
interface SelectedHexagon {
|
||||
id: string;
|
||||
type: 'hexagon' | 'postcode';
|
||||
resolution: number;
|
||||
lockedResolution?: boolean;
|
||||
}
|
||||
|
||||
interface JourneyDest {
|
||||
|
|
@ -22,10 +25,18 @@ interface JourneyDest {
|
|||
slug: string;
|
||||
}
|
||||
|
||||
interface PostcodeLookupResponse {
|
||||
postcode: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
geometry: PostcodeGeometry;
|
||||
}
|
||||
|
||||
interface UseHexagonSelectionOptions {
|
||||
filters: FeatureFilters;
|
||||
features: FeatureMeta[];
|
||||
resolution: number;
|
||||
usePostcodeView: boolean;
|
||||
/** First transit destination — used to pick the best central_postcode for journey display. */
|
||||
journeyDest?: JourneyDest | null;
|
||||
}
|
||||
|
|
@ -34,6 +45,7 @@ export function useHexagonSelection({
|
|||
filters,
|
||||
features,
|
||||
resolution,
|
||||
usePostcodeView,
|
||||
journeyDest,
|
||||
}: UseHexagonSelectionOptions) {
|
||||
const [selectedHexagon, setSelectedHexagon] = useState<SelectedHexagon | null>(null);
|
||||
|
|
@ -42,6 +54,7 @@ export function useHexagonSelection({
|
|||
const [propertiesOffset, setPropertiesOffset] = useState(0);
|
||||
const [loadingProperties, setLoadingProperties] = useState(false);
|
||||
const [areaStats, setAreaStats] = useState<HexagonStatsResponse | null>(null);
|
||||
const [unfilteredAreaCount, setUnfilteredAreaCount] = useState<number | null>(null);
|
||||
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
|
||||
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
|
||||
const [rightPaneTab, setRightPaneTab] = useState<'properties' | 'area'>('area');
|
||||
|
|
@ -50,12 +63,18 @@ export function useHexagonSelection({
|
|||
);
|
||||
|
||||
const fetchHexagonStats = useCallback(
|
||||
async (h3: string, res: number, signal?: AbortSignal, fields?: string[]) => {
|
||||
async (
|
||||
h3: string,
|
||||
res: number,
|
||||
signal?: AbortSignal,
|
||||
fields?: string[],
|
||||
includeFilters = true
|
||||
) => {
|
||||
const params = new URLSearchParams({
|
||||
h3,
|
||||
resolution: res.toString(),
|
||||
});
|
||||
const filterStr = buildFilterString(filters, features);
|
||||
const filterStr = includeFilters ? buildFilterString(filters, features) : '';
|
||||
if (filterStr) params.append('filters', filterStr);
|
||||
if (fields) {
|
||||
params.set('fields', fields.join(';;'));
|
||||
|
|
@ -72,9 +91,9 @@ export function useHexagonSelection({
|
|||
);
|
||||
|
||||
const fetchPostcodeStats = useCallback(
|
||||
async (postcode: string, signal?: AbortSignal) => {
|
||||
async (postcode: string, signal?: AbortSignal, includeFilters = true) => {
|
||||
const params = new URLSearchParams({ postcode });
|
||||
const filterStr = buildFilterString(filters, features);
|
||||
const filterStr = includeFilters ? buildFilterString(filters, features) : '';
|
||||
if (filterStr) params.append('filters', filterStr);
|
||||
const response = await fetch(apiUrl('postcode-stats', params), authHeaders({ signal }));
|
||||
assertOk(response, 'postcode-stats');
|
||||
|
|
@ -83,6 +102,47 @@ export function useHexagonSelection({
|
|||
[filters, features]
|
||||
);
|
||||
|
||||
const filterStr = useMemo(() => buildFilterString(filters, features), [filters, features]);
|
||||
|
||||
const fetchUnfilteredAreaCount = useCallback(
|
||||
async (selection: SelectedHexagon, signal?: AbortSignal) => {
|
||||
if (!filterStr) {
|
||||
setUnfilteredAreaCount(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const stats =
|
||||
selection.type === 'postcode'
|
||||
? await fetchPostcodeStats(selection.id, signal, false)
|
||||
: await fetchHexagonStats(selection.id, selection.resolution, signal, undefined, false);
|
||||
setUnfilteredAreaCount(stats.count);
|
||||
},
|
||||
[filterStr, fetchHexagonStats, fetchPostcodeStats]
|
||||
);
|
||||
|
||||
const refreshUnfilteredAreaCount = useCallback(
|
||||
(selection: SelectedHexagon, filteredCount: number, signal?: AbortSignal) => {
|
||||
if (!filterStr || filteredCount > 0) {
|
||||
setUnfilteredAreaCount(null);
|
||||
return;
|
||||
}
|
||||
|
||||
fetchUnfilteredAreaCount(selection, signal).catch((error) =>
|
||||
logNonAbortError('Failed to fetch unfiltered area count', error)
|
||||
);
|
||||
},
|
||||
[filterStr, fetchUnfilteredAreaCount]
|
||||
);
|
||||
|
||||
const fetchPostcodeLookup = useCallback(async (postcode: string, signal?: AbortSignal) => {
|
||||
const response = await fetch(
|
||||
`/api/postcode/${encodeURIComponent(postcode)}`,
|
||||
authHeaders({ signal })
|
||||
);
|
||||
assertOk(response, 'postcode lookup');
|
||||
return (await response.json()) as PostcodeLookupResponse;
|
||||
}, []);
|
||||
|
||||
const fetchHexagonProperties = useCallback(
|
||||
async (h3: string, res: number, offset = 0) => {
|
||||
setLoadingProperties(true);
|
||||
|
|
@ -156,33 +216,42 @@ export function useHexagonSelection({
|
|||
setSelectedHexagon(null);
|
||||
setProperties([]);
|
||||
setAreaStats(null);
|
||||
setUnfilteredAreaCount(null);
|
||||
setSelectedPostcodeGeometry(null);
|
||||
} else {
|
||||
const type = isPostcode ? 'postcode' : 'hexagon';
|
||||
const type: SelectedHexagon['type'] = isPostcode ? 'postcode' : 'hexagon';
|
||||
const selection = { id, type, resolution };
|
||||
trackEvent('Hexagon Click', { type });
|
||||
setSelectedHexagon({ id, type, resolution });
|
||||
setSelectedHexagon(selection);
|
||||
setSelectedPostcodeGeometry(isPostcode && geometry ? geometry : null);
|
||||
setProperties([]);
|
||||
setPropertiesTotal(0);
|
||||
setPropertiesOffset(0);
|
||||
setUnfilteredAreaCount(null);
|
||||
setRightPaneTab('area');
|
||||
|
||||
if (isPostcode) {
|
||||
setLoadingAreaStats(true);
|
||||
fetchPostcodeStats(id)
|
||||
.then((stats) => setAreaStats(stats))
|
||||
.then((stats) => {
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count);
|
||||
})
|
||||
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
|
||||
.finally(() => setLoadingAreaStats(false));
|
||||
} else {
|
||||
setLoadingAreaStats(true);
|
||||
fetchHexagonStats(id, resolution)
|
||||
.then((stats) => setAreaStats(stats))
|
||||
.then((stats) => {
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count);
|
||||
})
|
||||
.catch((error) => logNonAbortError('Failed to fetch area stats', error))
|
||||
.finally(() => setLoadingAreaStats(false));
|
||||
}
|
||||
}
|
||||
},
|
||||
[selectedHexagon, resolution, fetchHexagonStats, fetchPostcodeStats]
|
||||
[selectedHexagon, resolution, fetchHexagonStats, fetchPostcodeStats, refreshUnfilteredAreaCount]
|
||||
);
|
||||
|
||||
const handleHexagonHover = useCallback((h3: string | null) => {
|
||||
|
|
@ -232,11 +301,111 @@ export function useHexagonSelection({
|
|||
setSelectedHexagon(null);
|
||||
setProperties([]);
|
||||
setAreaStats(null);
|
||||
setUnfilteredAreaCount(null);
|
||||
setSelectedPostcodeGeometry(null);
|
||||
}, []);
|
||||
|
||||
// Keep the selected area aligned with the active map view as zoom changes.
|
||||
useEffect(() => {
|
||||
if (!selectedHexagon) return;
|
||||
const selection = selectedHexagon;
|
||||
const shouldSync =
|
||||
(usePostcodeView &&
|
||||
selection.type === 'hexagon' &&
|
||||
!selection.lockedResolution &&
|
||||
areaStats?.central_postcode != null) ||
|
||||
(!usePostcodeView && selection.type === 'postcode') ||
|
||||
(!usePostcodeView &&
|
||||
selection.type === 'hexagon' &&
|
||||
!selection.lockedResolution &&
|
||||
selection.resolution !== resolution);
|
||||
if (!shouldSync) return;
|
||||
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
|
||||
const refreshProperties = (selection: SelectedHexagon) => {
|
||||
if (rightPaneTab !== 'properties') return;
|
||||
if (selection.type === 'postcode') {
|
||||
fetchPostcodeProperties(selection.id, 0);
|
||||
} else {
|
||||
fetchHexagonProperties(selection.id, selection.resolution, 0);
|
||||
}
|
||||
};
|
||||
|
||||
async function syncSelection() {
|
||||
let nextSelection: SelectedHexagon | null = null;
|
||||
let nextGeometry: PostcodeGeometry | null = null;
|
||||
let nextStats: HexagonStatsResponse | null = null;
|
||||
|
||||
if (usePostcodeView && selection.type === 'hexagon' && !selection.lockedResolution) {
|
||||
if (!areaStats?.central_postcode) return;
|
||||
const lookup = await fetchPostcodeLookup(areaStats.central_postcode, controller.signal);
|
||||
nextSelection = { id: lookup.postcode, type: 'postcode', resolution };
|
||||
nextGeometry = lookup.geometry;
|
||||
nextStats = await fetchPostcodeStats(lookup.postcode, controller.signal);
|
||||
} else if (!usePostcodeView && selection.type === 'postcode') {
|
||||
const lookup = await fetchPostcodeLookup(selection.id, controller.signal);
|
||||
const nextId = latLngToCell(lookup.latitude, lookup.longitude, resolution);
|
||||
nextSelection = { id: nextId, type: 'hexagon', resolution };
|
||||
nextStats = await fetchHexagonStats(nextId, resolution, controller.signal);
|
||||
} else if (
|
||||
!usePostcodeView &&
|
||||
selection.type === 'hexagon' &&
|
||||
!selection.lockedResolution &&
|
||||
selection.resolution !== resolution
|
||||
) {
|
||||
const nextId =
|
||||
resolution < selection.resolution
|
||||
? cellToParent(selection.id, resolution)
|
||||
: latLngToCell(...cellToLatLng(selection.id), resolution);
|
||||
nextSelection = { id: nextId, type: 'hexagon', resolution };
|
||||
nextStats = await fetchHexagonStats(nextId, resolution, controller.signal);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cancelled || !nextSelection || !nextStats) return;
|
||||
setSelectedHexagon(nextSelection);
|
||||
setSelectedPostcodeGeometry(nextGeometry);
|
||||
setAreaStats(nextStats);
|
||||
refreshUnfilteredAreaCount(nextSelection, nextStats.count, controller.signal);
|
||||
refreshProperties(nextSelection);
|
||||
}
|
||||
|
||||
setProperties([]);
|
||||
setPropertiesTotal(0);
|
||||
setPropertiesOffset(0);
|
||||
setUnfilteredAreaCount(null);
|
||||
setLoadingAreaStats(true);
|
||||
|
||||
syncSelection()
|
||||
.catch((error) => {
|
||||
if (!cancelled) logNonAbortError('Failed to sync selected area with map view', error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingAreaStats(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [
|
||||
selectedHexagon,
|
||||
resolution,
|
||||
usePostcodeView,
|
||||
areaStats?.central_postcode,
|
||||
fetchHexagonStats,
|
||||
fetchPostcodeStats,
|
||||
fetchPostcodeLookup,
|
||||
fetchHexagonProperties,
|
||||
fetchPostcodeProperties,
|
||||
refreshUnfilteredAreaCount,
|
||||
rightPaneTab,
|
||||
]);
|
||||
|
||||
// Re-fetch stats when filters change while a hexagon is selected
|
||||
const filterStr = useMemo(() => buildFilterString(filters, features), [filters, features]);
|
||||
const prevFilterStr = useRef(filterStr);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -261,19 +430,14 @@ export function useHexagonSelection({
|
|||
fetchStats
|
||||
.then((stats) => {
|
||||
if (cancelled) return;
|
||||
if (stats.count === 0) {
|
||||
setSelectedHexagon(null);
|
||||
setAreaStats(null);
|
||||
setSelectedPostcodeGeometry(null);
|
||||
} else {
|
||||
setAreaStats(stats);
|
||||
// Re-fetch properties if the properties tab is active
|
||||
if (rightPaneTab === 'properties') {
|
||||
if (selectedHexagon.type === 'postcode') {
|
||||
fetchPostcodeProperties(selectedHexagon.id, 0);
|
||||
} else {
|
||||
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
|
||||
}
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selectedHexagon, stats.count);
|
||||
// Re-fetch properties if the properties tab is active and the filtered area still has matches.
|
||||
if (rightPaneTab === 'properties' && stats.count > 0) {
|
||||
if (selectedHexagon.type === 'postcode') {
|
||||
fetchPostcodeProperties(selectedHexagon.id, 0);
|
||||
} else {
|
||||
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -296,6 +460,7 @@ export function useHexagonSelection({
|
|||
rightPaneTab,
|
||||
fetchHexagonProperties,
|
||||
fetchPostcodeProperties,
|
||||
refreshUnfilteredAreaCount,
|
||||
]);
|
||||
|
||||
const handleLocationSearch = useCallback(
|
||||
|
|
@ -304,6 +469,7 @@ export function useHexagonSelection({
|
|||
setProperties([]);
|
||||
setPropertiesTotal(0);
|
||||
setPropertiesOffset(0);
|
||||
setUnfilteredAreaCount(null);
|
||||
setRightPaneTab('area');
|
||||
setLoadingAreaStats(true);
|
||||
|
||||
|
|
@ -311,18 +477,22 @@ export function useHexagonSelection({
|
|||
fetchPostcodeStats(postcode)
|
||||
.then(async (stats) => {
|
||||
if (stats.count > 0) {
|
||||
setSelectedHexagon({ id: postcode, type: 'postcode', resolution });
|
||||
const selection = { id: postcode, type: 'postcode' as const, resolution };
|
||||
setSelectedHexagon(selection);
|
||||
setSelectedPostcodeGeometry(geometry);
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count);
|
||||
return;
|
||||
}
|
||||
|
||||
// No properties in this postcode — fall back to hexagons
|
||||
if (lat == null || lng == null) {
|
||||
// No coordinates available, show empty postcode anyway
|
||||
setSelectedHexagon({ id: postcode, type: 'postcode', resolution });
|
||||
const selection = { id: postcode, type: 'postcode' as const, resolution };
|
||||
setSelectedHexagon(selection);
|
||||
setSelectedPostcodeGeometry(geometry);
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -332,9 +502,11 @@ export function useHexagonSelection({
|
|||
const h3 = latLngToCell(lat, lng, res);
|
||||
const hexStats = await fetchHexagonStats(h3, res);
|
||||
if (hexStats.count > 1) {
|
||||
setSelectedHexagon({ id: h3, type: 'hexagon', resolution: res });
|
||||
const selection = { id: h3, type: 'hexagon' as const, resolution: res };
|
||||
setSelectedHexagon(selection);
|
||||
setSelectedPostcodeGeometry(null);
|
||||
setAreaStats(hexStats);
|
||||
refreshUnfilteredAreaCount(selection, hexStats.count);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -342,14 +514,47 @@ export function useHexagonSelection({
|
|||
// Even the coarsest hexagon has ≤1 property — show whatever the finest has
|
||||
const h3 = latLngToCell(lat, lng, 9);
|
||||
const fallbackStats = await fetchHexagonStats(h3, 9);
|
||||
setSelectedHexagon({ id: h3, type: 'hexagon', resolution: 9 });
|
||||
const selection = { id: h3, type: 'hexagon' as const, resolution: 9 };
|
||||
setSelectedHexagon(selection);
|
||||
setSelectedPostcodeGeometry(null);
|
||||
setAreaStats(fallbackStats);
|
||||
refreshUnfilteredAreaCount(selection, fallbackStats.count);
|
||||
})
|
||||
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
|
||||
.finally(() => setLoadingAreaStats(false));
|
||||
},
|
||||
[resolution, fetchPostcodeStats, fetchHexagonStats]
|
||||
[resolution, fetchPostcodeStats, fetchHexagonStats, refreshUnfilteredAreaCount]
|
||||
);
|
||||
|
||||
const handleCurrentLocationSearch = useCallback(
|
||||
(lat: number, lng: number) => {
|
||||
const h3 = latLngToCell(lat, lng, CURRENT_LOCATION_HEX_RESOLUTION);
|
||||
const selection = {
|
||||
id: h3,
|
||||
type: 'hexagon' as const,
|
||||
resolution: CURRENT_LOCATION_HEX_RESOLUTION,
|
||||
lockedResolution: true,
|
||||
};
|
||||
|
||||
trackEvent('Current Location Search');
|
||||
setSelectedHexagon(selection);
|
||||
setSelectedPostcodeGeometry(null);
|
||||
setProperties([]);
|
||||
setPropertiesTotal(0);
|
||||
setPropertiesOffset(0);
|
||||
setUnfilteredAreaCount(null);
|
||||
setRightPaneTab('area');
|
||||
setLoadingAreaStats(true);
|
||||
|
||||
fetchHexagonStats(h3, CURRENT_LOCATION_HEX_RESOLUTION)
|
||||
.then((stats) => {
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count);
|
||||
})
|
||||
.catch((error) => logNonAbortError('Failed to fetch current location hex stats', error))
|
||||
.finally(() => setLoadingAreaStats(false));
|
||||
},
|
||||
[fetchHexagonStats, refreshUnfilteredAreaCount]
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
@ -359,6 +564,7 @@ export function useHexagonSelection({
|
|||
loadingProperties,
|
||||
areaStats,
|
||||
loadingAreaStats,
|
||||
unfilteredAreaCount,
|
||||
hoveredHexagon,
|
||||
rightPaneTab,
|
||||
setRightPaneTab,
|
||||
|
|
@ -370,5 +576,6 @@ export function useHexagonSelection({
|
|||
handleCloseSelection,
|
||||
selectedPostcodeGeometry,
|
||||
handleLocationSearch,
|
||||
handleCurrentLocationSearch,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,14 @@ const descriptions: Record<string, Record<string, string>> = {
|
|||
'Écoles primaires notées Bien ou Excellent par Ofsted dans un rayon de 5 km',
|
||||
'Good+ secondary schools within 5km':
|
||||
'Collèges/lycées notés Bien ou Excellent par Ofsted dans un rayon de 5 km',
|
||||
'Outstanding primary schools within 2km':
|
||||
'Écoles primaires notées Excellent par Ofsted dans un rayon de 2 km',
|
||||
'Outstanding secondary schools within 2km':
|
||||
'Collèges/lycées notés Excellent par Ofsted dans un rayon de 2 km',
|
||||
'Outstanding primary schools within 5km':
|
||||
'Écoles primaires notées Excellent par Ofsted dans un rayon de 5 km',
|
||||
'Outstanding secondary schools within 5km':
|
||||
'Collèges/lycées notés Excellent par Ofsted dans un rayon de 5 km',
|
||||
'Education, Skills and Training Score':
|
||||
'Score de qualité éducative du secteur (plus élevé = meilleur)',
|
||||
'Income Score (rate)': 'Taux de précarité de revenu, inversé (plus élevé = moins précaire)',
|
||||
|
|
@ -121,6 +129,14 @@ const descriptions: Record<string, Record<string, string>> = {
|
|||
'Von Ofsted mit Gut oder Hervorragend bewertete Grundschulen im Umkreis von 5 km',
|
||||
'Good+ secondary schools within 5km':
|
||||
'Von Ofsted mit Gut oder Hervorragend bewertete weiterführende Schulen im Umkreis von 5 km',
|
||||
'Outstanding primary schools within 2km':
|
||||
'Von Ofsted mit Hervorragend bewertete Grundschulen im Umkreis von 2 km',
|
||||
'Outstanding secondary schools within 2km':
|
||||
'Von Ofsted mit Hervorragend bewertete weiterführende Schulen im Umkreis von 2 km',
|
||||
'Outstanding primary schools within 5km':
|
||||
'Von Ofsted mit Hervorragend bewertete Grundschulen im Umkreis von 5 km',
|
||||
'Outstanding secondary schools within 5km':
|
||||
'Von Ofsted mit Hervorragend bewertete weiterführende Schulen im Umkreis von 5 km',
|
||||
'Education, Skills and Training Score': 'Bildungsqualitätsscore der Gegend (höher = besser)',
|
||||
'Income Score (rate)':
|
||||
'Einkommensbenachteiligungsrate, invertiert (höher = weniger benachteiligt)',
|
||||
|
|
@ -202,6 +218,10 @@ const descriptions: Record<string, Record<string, string>> = {
|
|||
'Good+ secondary schools within 2km': 'Ofsted评为良好或优秀的2公里内中学',
|
||||
'Good+ primary schools within 5km': 'Ofsted评为良好或优秀的5公里内小学',
|
||||
'Good+ secondary schools within 5km': 'Ofsted评为良好或优秀的5公里内中学',
|
||||
'Outstanding primary schools within 2km': 'Ofsted评为优秀的2公里内小学',
|
||||
'Outstanding secondary schools within 2km': 'Ofsted评为优秀的2公里内中学',
|
||||
'Outstanding primary schools within 5km': 'Ofsted评为优秀的5公里内小学',
|
||||
'Outstanding secondary schools within 5km': 'Ofsted评为优秀的5公里内中学',
|
||||
'Education, Skills and Training Score': '当地教育质量得分(越高越好)',
|
||||
'Income Score (rate)': '收入贫困率,反向指标(越高越不贫困)',
|
||||
'Employment Score (rate)': '就业贫困率,反向指标(越高越不贫困)',
|
||||
|
|
@ -275,6 +295,14 @@ const descriptions: Record<string, Record<string, string>> = {
|
|||
'Ofsted által Jó vagy Kiváló minősítésű általános iskolák 5 km-en belül',
|
||||
'Good+ secondary schools within 5km':
|
||||
'Ofsted által Jó vagy Kiváló minősítésű középiskolák 5 km-en belül',
|
||||
'Outstanding primary schools within 2km':
|
||||
'Ofsted által Kiváló minősítésű általános iskolák 2 km-en belül',
|
||||
'Outstanding secondary schools within 2km':
|
||||
'Ofsted által Kiváló minősítésű középiskolák 2 km-en belül',
|
||||
'Outstanding primary schools within 5km':
|
||||
'Ofsted által Kiváló minősítésű általános iskolák 5 km-en belül',
|
||||
'Outstanding secondary schools within 5km':
|
||||
'Ofsted által Kiváló minősítésű középiskolák 5 km-en belül',
|
||||
'Education, Skills and Training Score':
|
||||
'A környék oktatási minőségi pontszáma (magasabb = jobb)',
|
||||
'Income Score (rate)': 'Jövedelmi deprivációs ráta, invertálva (magasabb = kevésbé hátrányos)',
|
||||
|
|
|
|||
|
|
@ -45,6 +45,14 @@ export const details: Record<string, Record<string, string>> = {
|
|||
"Écoles primaires financées par l'État dans un rayon de 5km ayant une note Ofsted actuelle de Bon ou Exceptionnel. Les écoles n'ayant pas encore été inspectées sont exclues.",
|
||||
'Good+ secondary schools within 5km':
|
||||
"Lycées et collèges financés par l'État dans un rayon de 5km ayant une note Ofsted actuelle de Bon ou Exceptionnel. Les établissements n'ayant pas encore été inspectés sont exclus.",
|
||||
'Outstanding primary schools within 2km':
|
||||
"Écoles primaires financées par l'État dans un rayon de 2km ayant une note Ofsted actuelle d'Exceptionnel. Les écoles n'ayant pas encore été inspectées sont exclues.",
|
||||
'Outstanding secondary schools within 2km':
|
||||
"Lycées et collèges financés par l'État dans un rayon de 2km ayant une note Ofsted actuelle d'Exceptionnel. Les établissements n'ayant pas encore été inspectés sont exclus.",
|
||||
'Outstanding primary schools within 5km':
|
||||
"Écoles primaires financées par l'État dans un rayon de 5km ayant une note Ofsted actuelle d'Exceptionnel. Les écoles n'ayant pas encore été inspectées sont exclues.",
|
||||
'Outstanding secondary schools within 5km':
|
||||
"Lycées et collèges financés par l'État dans un rayon de 5km ayant une note Ofsted actuelle d'Exceptionnel. Les établissements n'ayant pas encore été inspectés sont exclus.",
|
||||
'Education, Skills and Training Score':
|
||||
"Provient des Indices de Déprivation anglais (inversé afin que plus le score est élevé, meilleur est le résultat). Couvre les résultats scolaires, l'accès à l'enseignement supérieur, les qualifications des adultes et la maîtrise de la langue anglaise. Des scores plus élevés indiquent moins de déprivation.",
|
||||
'Income Score (rate)':
|
||||
|
|
@ -177,6 +185,14 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'Staatlich geförderte Grundschulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von „Good" oder „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
|
||||
'Good+ secondary schools within 5km':
|
||||
'Staatlich geförderte weiterführende Schulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von „Good" oder „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
|
||||
'Outstanding primary schools within 2km':
|
||||
'Staatlich geförderte Grundschulen innerhalb von 2 km mit einer aktuellen Ofsted-Bewertung von „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
|
||||
'Outstanding secondary schools within 2km':
|
||||
'Staatlich geförderte weiterführende Schulen innerhalb von 2 km mit einer aktuellen Ofsted-Bewertung von „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
|
||||
'Outstanding primary schools within 5km':
|
||||
'Staatlich geförderte Grundschulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
|
||||
'Outstanding secondary schools within 5km':
|
||||
'Staatlich geförderte weiterführende Schulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
|
||||
'Education, Skills and Training Score':
|
||||
'Aus den englischen Deprivationsindizes (invertiert, sodass höher = besser bedeutet). Umfasst Schulleistungen, Hochschulzugang, Qualifikationen Erwachsener und Englischsprachkenntnisse. Höhere Werte weisen auf geringere Benachteiligung hin.',
|
||||
'Income Score (rate)':
|
||||
|
|
@ -309,6 +325,14 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'5km范围内Ofsted评级为"Good"或"Outstanding"的公立小学数量。尚未接受评估的学校不计入。',
|
||||
'Good+ secondary schools within 5km':
|
||||
'5km范围内Ofsted评级为"Good"或"Outstanding"的公立中学数量。尚未接受评估的学校不计入。',
|
||||
'Outstanding primary schools within 2km':
|
||||
'2km范围内Ofsted评级为"Outstanding"的公立小学数量。尚未接受评估的学校不计入。',
|
||||
'Outstanding secondary schools within 2km':
|
||||
'2km范围内Ofsted评级为"Outstanding"的公立中学数量。尚未接受评估的学校不计入。',
|
||||
'Outstanding primary schools within 5km':
|
||||
'5km范围内Ofsted评级为"Outstanding"的公立小学数量。尚未接受评估的学校不计入。',
|
||||
'Outstanding secondary schools within 5km':
|
||||
'5km范围内Ofsted评级为"Outstanding"的公立中学数量。尚未接受评估的学校不计入。',
|
||||
'Education, Skills and Training Score':
|
||||
'来自英格兰剥夺指数(取反后越高越好)。涵盖学校成绩、高等教育入学率、成人学历和英语水平。分数越高表示剥夺程度越低。',
|
||||
'Income Score (rate)':
|
||||
|
|
@ -439,6 +463,14 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'5 km-en belüli állami fenntartású általános iskolák, amelyek aktuális Ofsted besorolása Jó vagy Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
|
||||
'Good+ secondary schools within 5km':
|
||||
'5 km-en belüli állami fenntartású középiskolák, amelyek aktuális Ofsted besorolása Jó vagy Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
|
||||
'Outstanding primary schools within 2km':
|
||||
'2 km-en belüli állami fenntartású általános iskolák, amelyek aktuális Ofsted besorolása Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
|
||||
'Outstanding secondary schools within 2km':
|
||||
'2 km-en belüli állami fenntartású középiskolák, amelyek aktuális Ofsted besorolása Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
|
||||
'Outstanding primary schools within 5km':
|
||||
'5 km-en belüli állami fenntartású általános iskolák, amelyek aktuális Ofsted besorolása Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
|
||||
'Outstanding secondary schools within 5km':
|
||||
'5 km-en belüli állami fenntartású középiskolák, amelyek aktuális Ofsted besorolása Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
|
||||
'Education, Skills and Training Score':
|
||||
'Az Angol Nélkülözési Indexekből (megfordítva, így magasabb = jobb). Az iskolai teljesítményt, a felsőoktatásba való bejutást, a felnőttkori képesítéseket és az angol nyelvi jártasságot foglalja magában. A magasabb pontszámok kisebb mértékű nélkülözést jeleznek.',
|
||||
'Income Score (rate)':
|
||||
|
|
|
|||
|
|
@ -259,6 +259,16 @@ const de: Translations = {
|
|||
areaStatistics: 'Gebietsstatistiken',
|
||||
statsFor: 'Statistiken für alle Immobilien in diesem {{type}}',
|
||||
matchingFilters: ', die allen aktiven Filtern entsprechen',
|
||||
filtersAffectStats:
|
||||
'Filter im linken Bereich werden hier angewendet: Werte, Diagramme und Immobilienzahlen nutzen die {{count}} aktiven Filter.',
|
||||
noFiltersAffectStats:
|
||||
'Filter im linken Bereich aktualisieren diesen Bereich: Fügen Sie Filter hinzu, um diese Werte für passende Immobilien neu zu berechnen.',
|
||||
noFilteredMatches: 'Keine Immobilien in diesem Gebiet entsprechen Ihren Filtern.',
|
||||
unfilteredAreaCount:
|
||||
'{{count}} Immobilien gibt es hier vor den Filtern; der Ort ist gültig, wird aber herausgefiltert.',
|
||||
noUnfilteredAreaProperties:
|
||||
'In diesem ausgewählten Gebiet wurden auch vor den Filtern keine Immobilien gefunden.',
|
||||
relaxFiltersHint: 'Lockern oder löschen Sie Filter, um Immobilien in diesem Gebiet zu sehen.',
|
||||
viewProperties: '{{count}} Immobilien ansehen',
|
||||
priceHistory: 'Preisentwicklung',
|
||||
journeysFrom: 'Verbindungen ab {{label}}',
|
||||
|
|
@ -742,6 +752,12 @@ const de: Translations = {
|
|||
'Good+ secondary schools within 2km': 'Gute+ weiterführende Schulen im Umkreis von 2 km',
|
||||
'Good+ primary schools within 5km': 'Gute+ Grundschulen im Umkreis von 5 km',
|
||||
'Good+ secondary schools within 5km': 'Gute+ weiterführende Schulen im Umkreis von 5 km',
|
||||
'Outstanding primary schools within 2km': 'Hervorragende Grundschulen im Umkreis von 2 km',
|
||||
'Outstanding secondary schools within 2km':
|
||||
'Hervorragende weiterführende Schulen im Umkreis von 2 km',
|
||||
'Outstanding primary schools within 5km': 'Hervorragende Grundschulen im Umkreis von 5 km',
|
||||
'Outstanding secondary schools within 5km':
|
||||
'Hervorragende weiterführende Schulen im Umkreis von 5 km',
|
||||
'Education, Skills and Training Score': 'Score für Bildung, Kompetenzen und Ausbildung',
|
||||
|
||||
// ─ Feature names (Deprivation) ─
|
||||
|
|
|
|||
|
|
@ -256,6 +256,15 @@ const en = {
|
|||
areaStatistics: 'Area Statistics',
|
||||
statsFor: 'Stats for all properties in this {{type}}',
|
||||
matchingFilters: ' matching all active filters',
|
||||
filtersAffectStats:
|
||||
'Left-pane filters are applied here: values, charts, and property counts use the {{count}} active filters.',
|
||||
noFiltersAffectStats:
|
||||
'Left-pane filters update this pane: add filters to recalculate these values for matching properties.',
|
||||
noFilteredMatches: 'No properties match your filters in this area.',
|
||||
unfilteredAreaCount:
|
||||
'{{count}} properties exist here before filters, so the location is valid but filtered out.',
|
||||
noUnfilteredAreaProperties: 'No properties were found in this selected area before filters.',
|
||||
relaxFiltersHint: 'Relax or clear filters to see properties in this area.',
|
||||
viewProperties: 'View {{count}} Properties',
|
||||
priceHistory: 'Price History',
|
||||
journeysFrom: 'Journeys from {{label}}',
|
||||
|
|
@ -729,6 +738,10 @@ const en = {
|
|||
'Good+ secondary schools within 2km': 'Good+ secondary schools within 2km',
|
||||
'Good+ primary schools within 5km': 'Good+ primary schools within 5km',
|
||||
'Good+ secondary schools within 5km': 'Good+ secondary schools within 5km',
|
||||
'Outstanding primary schools within 2km': 'Outstanding primary schools within 2km',
|
||||
'Outstanding secondary schools within 2km': 'Outstanding secondary schools within 2km',
|
||||
'Outstanding primary schools within 5km': 'Outstanding primary schools within 5km',
|
||||
'Outstanding secondary schools within 5km': 'Outstanding secondary schools within 5km',
|
||||
'Education, Skills and Training Score': 'Education, Skills and Training Score',
|
||||
|
||||
// ─ Feature names (Deprivation) ─
|
||||
|
|
|
|||
|
|
@ -263,6 +263,16 @@ const fr: Translations = {
|
|||
areaStatistics: 'Statistiques de la zone',
|
||||
statsFor: 'Statistiques pour toutes les propriétés de ce/cette {{type}}',
|
||||
matchingFilters: ' correspondant à tous les filtres actifs',
|
||||
filtersAffectStats:
|
||||
'Les filtres du panneau de gauche sont appliqués ici : valeurs, graphiques et nombres de propriétés utilisent les {{count}} filtres actifs.',
|
||||
noFiltersAffectStats:
|
||||
'Les filtres du panneau de gauche mettent ce panneau à jour : ajoutez des filtres pour recalculer ces valeurs pour les propriétés correspondantes.',
|
||||
noFilteredMatches: 'Aucune propriété de cette zone ne correspond à vos filtres.',
|
||||
unfilteredAreaCount:
|
||||
'{{count}} propriétés existent ici avant les filtres ; le lieu est valide, mais filtré.',
|
||||
noUnfilteredAreaProperties:
|
||||
'Aucune propriété n’a été trouvée dans cette zone sélectionnée avant les filtres.',
|
||||
relaxFiltersHint: 'Assouplissez ou effacez les filtres pour voir les propriétés de cette zone.',
|
||||
viewProperties: 'Voir {{count}} propriétés',
|
||||
priceHistory: 'Historique des prix',
|
||||
journeysFrom: 'Trajets depuis {{label}}',
|
||||
|
|
@ -745,6 +755,10 @@ const fr: Translations = {
|
|||
'Good+ secondary schools within 2km': 'Collèges/lycées Bien+ dans un rayon de 2 km',
|
||||
'Good+ primary schools within 5km': 'Écoles primaires Bien+ dans un rayon de 5 km',
|
||||
'Good+ secondary schools within 5km': 'Collèges/lycées Bien+ dans un rayon de 5 km',
|
||||
'Outstanding primary schools within 2km': 'Écoles primaires Excellent dans un rayon de 2 km',
|
||||
'Outstanding secondary schools within 2km': 'Collèges/lycées Excellent dans un rayon de 2 km',
|
||||
'Outstanding primary schools within 5km': 'Écoles primaires Excellent dans un rayon de 5 km',
|
||||
'Outstanding secondary schools within 5km': 'Collèges/lycées Excellent dans un rayon de 5 km',
|
||||
'Education, Skills and Training Score': 'Score éducation, compétences et formation',
|
||||
|
||||
// ─ Feature names (Deprivation) ─
|
||||
|
|
|
|||
|
|
@ -257,6 +257,16 @@ const hu: Translations = {
|
|||
areaStatistics: 'Területi statisztikák',
|
||||
statsFor: 'Statisztikák a(z) {{type}} összes ingatlanáról',
|
||||
matchingFilters: ' az összes aktív szűrőnek megfelelően',
|
||||
filtersAffectStats:
|
||||
'A bal oldali panel szűrői itt is érvényesek: az értékek, diagramok és ingatlanszámok a(z) {{count}} aktív szűrőt használják.',
|
||||
noFiltersAffectStats:
|
||||
'A bal oldali panel szűrői frissítik ezt a panelt: adjon hozzá szűrőket, hogy ezek az értékek az illeszkedő ingatlanokra számolódjanak újra.',
|
||||
noFilteredMatches: 'Ezen a területen egyetlen ingatlan sem felel meg a szűrőknek.',
|
||||
unfilteredAreaCount:
|
||||
'{{count}} ingatlan található itt szűrők nélkül, tehát a hely érvényes, csak a szűrők kizárják.',
|
||||
noUnfilteredAreaProperties:
|
||||
'A kiválasztott területen szűrők nélkül sem található ingatlan.',
|
||||
relaxFiltersHint: 'Lazítson vagy törölje a szűrőket, hogy lássa a terület ingatlanjait.',
|
||||
viewProperties: '{{count}} ingatlan megtekintése',
|
||||
priceHistory: 'Ártörténet',
|
||||
journeysFrom: 'Utazások innen: {{label}}',
|
||||
|
|
@ -737,6 +747,10 @@ const hu: Translations = {
|
|||
'Good+ secondary schools within 2km': 'Jó+ középiskolák 2 km-en belül',
|
||||
'Good+ primary schools within 5km': 'Jó+ általános iskolák 5 km-en belül',
|
||||
'Good+ secondary schools within 5km': 'Jó+ középiskolák 5 km-en belül',
|
||||
'Outstanding primary schools within 2km': 'Kiemelkedő általános iskolák 2 km-en belül',
|
||||
'Outstanding secondary schools within 2km': 'Kiemelkedő középiskolák 2 km-en belül',
|
||||
'Outstanding primary schools within 5km': 'Kiemelkedő általános iskolák 5 km-en belül',
|
||||
'Outstanding secondary schools within 5km': 'Kiemelkedő középiskolák 5 km-en belül',
|
||||
'Education, Skills and Training Score': 'Oktatás, készségek és képzés pontszám',
|
||||
|
||||
// ─ Feature names (Deprivation) ─
|
||||
|
|
|
|||
|
|
@ -254,6 +254,14 @@ const zh: Translations = {
|
|||
areaStatistics: '区域统计',
|
||||
statsFor: '该{{type}}内所有房产的统计数据',
|
||||
matchingFilters: ',满足所有当前筛选条件',
|
||||
filtersAffectStats:
|
||||
'左侧面板的筛选条件会应用到这里:数值、图表和房产数量都会使用 {{count}} 个当前筛选条件。',
|
||||
noFiltersAffectStats:
|
||||
'左侧面板的筛选条件会更新此面板:添加筛选条件后,这些值会按匹配的房产重新计算。',
|
||||
noFilteredMatches: '该区域没有房产符合当前筛选条件。',
|
||||
unfilteredAreaCount: '筛选前这里有 {{count}} 处房产;位置有效,但被筛选条件排除了。',
|
||||
noUnfilteredAreaProperties: '筛选前该选定区域内也没有找到房产。',
|
||||
relaxFiltersHint: '放宽或清除筛选条件即可查看该区域的房产。',
|
||||
viewProperties: '查看 {{count}} 处房产',
|
||||
priceHistory: '价格历史',
|
||||
journeysFrom: '从 {{label}} 出发的路线',
|
||||
|
|
@ -661,7 +669,7 @@ const zh: Translations = {
|
|||
'设置预算、通勤上限、学校质量、犯罪门槛。您关心的一切。只有符合条件的区域会保持高亮。使用眼睛图标按任意特征着色。',
|
||||
step2Title: '或者直接描述',
|
||||
step2Content:
|
||||
'用中文输入您的需求,例如“安静的地区,靠近好学校,£400k 以下”,我们会为您设置筛选。',
|
||||
'用中文输入您的需求,例如“安静的地区,靠近好学校,£40万 以下”,我们会为您设置筛选。',
|
||||
step3Title: '探索现有住宅',
|
||||
step3Content:
|
||||
'在英格兰各地平移和缩放。点击任何彩色区域查看犯罪、学校、价格、宽带、噪音等信息。',
|
||||
|
|
@ -712,6 +720,10 @@ const zh: Translations = {
|
|||
'Good+ secondary schools within 2km': '2公里内良好+中学数量',
|
||||
'Good+ primary schools within 5km': '5公里内良好+小学数量',
|
||||
'Good+ secondary schools within 5km': '5公里内良好+中学数量',
|
||||
'Outstanding primary schools within 2km': '2公里内优秀小学数量',
|
||||
'Outstanding secondary schools within 2km': '2公里内优秀中学数量',
|
||||
'Outstanding primary schools within 5km': '5公里内优秀小学数量',
|
||||
'Outstanding secondary schools within 5km': '5公里内优秀中学数量',
|
||||
'Education, Skills and Training Score': '教育、技能和培训得分',
|
||||
|
||||
// ─ Feature names (Deprivation) ─
|
||||
|
|
|
|||
|
|
@ -44,6 +44,46 @@ export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[]
|
|||
{ t: 1, color: [142, 68, 173] },
|
||||
];
|
||||
|
||||
export type GradientStop = { t: number; color: [number, number, number] };
|
||||
|
||||
function partyGradient(color: [number, number, number]): GradientStop[] {
|
||||
return [
|
||||
{ t: 0, color: [255, 255, 255] },
|
||||
{
|
||||
t: 0.5,
|
||||
color: [
|
||||
Math.round(255 + (color[0] - 255) * 0.45),
|
||||
Math.round(255 + (color[1] - 255) * 0.45),
|
||||
Math.round(255 + (color[2] - 255) * 0.45),
|
||||
],
|
||||
},
|
||||
{ t: 1, color },
|
||||
];
|
||||
}
|
||||
|
||||
/** UK party colours for the 2024 General Election vote-share map layers. */
|
||||
export const PARTY_FEATURE_GRADIENTS: Record<string, GradientStop[]> = {
|
||||
'% Labour': partyGradient([228, 0, 59]), // Labour red
|
||||
'% Conservative': partyGradient([0, 135, 220]), // Conservative blue
|
||||
'% Liberal Democrat': partyGradient([255, 100, 0]), // Liberal Democrat orange
|
||||
'% Reform UK': partyGradient([18, 182, 207]), // Reform UK cyan
|
||||
'% Green': partyGradient([106, 176, 35]), // Green Party green
|
||||
'% Other parties': partyGradient([107, 114, 128]), // neutral fallback for grouped parties
|
||||
};
|
||||
|
||||
export const PARTY_FEATURE_COLORS: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(PARTY_FEATURE_GRADIENTS).map(([featureName, gradient]) => {
|
||||
const color = gradient[gradient.length - 1].color;
|
||||
return [featureName, `rgb(${color[0]}, ${color[1]}, ${color[2]})`];
|
||||
})
|
||||
);
|
||||
|
||||
export function getFeatureGradient(featureName: string | null | undefined): GradientStop[] {
|
||||
return featureName
|
||||
? (PARTY_FEATURE_GRADIENTS[featureName] ?? FEATURE_GRADIENT)
|
||||
: FEATURE_GRADIENT;
|
||||
}
|
||||
|
||||
/** Number of properties gradient — light mode (cream → orange) */
|
||||
export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||
{ t: 0, color: [255, 255, 255] },
|
||||
|
|
|
|||
|
|
@ -122,6 +122,16 @@ export function buildPropertySearchUrls({
|
|||
? (tenureFilter as string[])
|
||||
: [];
|
||||
|
||||
const habitableRoomsFilter = filters['Number of bedrooms & living rooms'];
|
||||
const minBedrooms =
|
||||
Array.isArray(habitableRoomsFilter) && typeof habitableRoomsFilter[0] === 'number'
|
||||
? Math.max(0, habitableRoomsFilter[0] - 1)
|
||||
: undefined;
|
||||
const maxBedrooms =
|
||||
Array.isArray(habitableRoomsFilter) && typeof habitableRoomsFilter[1] === 'number'
|
||||
? Math.max(0, habitableRoomsFilter[1] - 1)
|
||||
: undefined;
|
||||
|
||||
// Rightmove — requires locationIdentifier from typeahead API
|
||||
let rightmove: string | null = null;
|
||||
if (rightmoveLocationId) {
|
||||
|
|
@ -134,6 +144,8 @@ export function buildPropertySearchUrls({
|
|||
rmParams.set('minPrice', String(snapToAllowed(minPrice, RIGHTMOVE_PRICES, 'floor')));
|
||||
if (maxPrice !== undefined)
|
||||
rmParams.set('maxPrice', String(snapToAllowed(maxPrice, RIGHTMOVE_PRICES, 'ceil')));
|
||||
if (minBedrooms !== undefined) rmParams.set('minBedrooms', String(minBedrooms));
|
||||
if (maxBedrooms !== undefined) rmParams.set('maxBedrooms', String(maxBedrooms));
|
||||
if (selectedTypes.length > 0) {
|
||||
const rmTypes = [
|
||||
...new Set(
|
||||
|
|
@ -161,6 +173,8 @@ export function buildPropertySearchUrls({
|
|||
otmParams.set('min-price', String(snapToAllowed(minPrice, OTM_PRICES, 'floor')));
|
||||
if (maxPrice !== undefined)
|
||||
otmParams.set('max-price', String(snapToAllowed(maxPrice, OTM_PRICES, 'ceil')));
|
||||
if (minBedrooms !== undefined) otmParams.set('min-bedrooms', String(minBedrooms));
|
||||
if (maxBedrooms !== undefined) otmParams.set('max-bedrooms', String(maxBedrooms));
|
||||
if (selectedTypes.length > 0) {
|
||||
const otmTypes = [
|
||||
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean)),
|
||||
|
|
@ -181,6 +195,8 @@ export function buildPropertySearchUrls({
|
|||
zParams.set('price_min', String(snapToAllowed(minPrice, ZOOPLA_PRICES, 'floor')));
|
||||
if (maxPrice !== undefined)
|
||||
zParams.set('price_max', String(snapToAllowed(maxPrice, ZOOPLA_PRICES, 'ceil')));
|
||||
if (minBedrooms !== undefined) zParams.set('beds_min', String(minBedrooms));
|
||||
if (maxBedrooms !== undefined) zParams.set('beds_max', String(maxBedrooms));
|
||||
if (selectedTypes.length > 0) {
|
||||
const zTypes = [
|
||||
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.zoopla).filter(Boolean)),
|
||||
|
|
|
|||
|
|
@ -129,6 +129,19 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
|
|||
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
|
||||
</>
|
||||
),
|
||||
'Outstanding primary schools within 5km': (
|
||||
<>
|
||||
<path d="M4 19V9l8-6 8 6v10" />
|
||||
<path d="M9 19v-6h6v6" />
|
||||
<line x1="4" y1="19" x2="20" y2="19" />
|
||||
</>
|
||||
),
|
||||
'Outstanding secondary schools within 5km': (
|
||||
<>
|
||||
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
|
||||
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
|
||||
</>
|
||||
),
|
||||
'Good+ primary schools within 2km': (
|
||||
<>
|
||||
<path d="M4 19V9l8-6 8 6v10" />
|
||||
|
|
@ -142,6 +155,19 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
|
|||
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
|
||||
</>
|
||||
),
|
||||
'Outstanding primary schools within 2km': (
|
||||
<>
|
||||
<path d="M4 19V9l8-6 8 6v10" />
|
||||
<path d="M9 19v-6h6v6" />
|
||||
<line x1="4" y1="19" x2="20" y2="19" />
|
||||
</>
|
||||
),
|
||||
'Outstanding secondary schools within 2km': (
|
||||
<>
|
||||
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
|
||||
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
|
||||
</>
|
||||
),
|
||||
|
||||
// ── Deprivation ──────────────────────────────
|
||||
'Income Score (rate)': (
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import i18n from 'i18next';
|
||||
|
||||
interface ValueFormat {
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
|
|
@ -5,10 +7,31 @@ interface ValueFormat {
|
|||
raw?: boolean;
|
||||
}
|
||||
|
||||
function usesChineseNumberUnits(): boolean {
|
||||
return i18n.language?.toLowerCase().startsWith('zh') ?? false;
|
||||
}
|
||||
|
||||
function formatChineseCompactNumber(value: number): string | null {
|
||||
const abs = Math.abs(value);
|
||||
if (abs >= 100_000_000) return `${trimFixed(value / 100_000_000)}亿`;
|
||||
if (abs >= 10_000) return `${trimFixed(value / 10_000)}万`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function trimFixed(value: number): string {
|
||||
return value.toFixed(1).replace(/\.0$/, '');
|
||||
}
|
||||
|
||||
export function formatValue(value: number, fmt?: ValueFormat): string {
|
||||
const p = fmt?.prefix ?? '';
|
||||
const s = fmt?.suffix ?? '';
|
||||
if (fmt?.raw) return `${p}${Math.round(value)}${s}`;
|
||||
if (usesChineseNumberUnits()) {
|
||||
const chineseCompactValue = formatChineseCompactNumber(value);
|
||||
if (chineseCompactValue) return `${p}${chineseCompactValue}${s}`;
|
||||
if (Number.isInteger(value)) return `${p}${value.toLocaleString()}${s}`;
|
||||
return `${p}${value.toFixed(1)}${s}`;
|
||||
}
|
||||
if (Math.abs(value) >= 1_000_000) return `${p}${(value / 1_000_000).toFixed(1)}M${s}`;
|
||||
if (Math.abs(value) >= 1_000) return `${p}${(value / 1_000).toFixed(1)}k${s}`;
|
||||
if (Number.isInteger(value)) return `${p}${value.toLocaleString()}${s}`;
|
||||
|
|
@ -17,6 +40,12 @@ export function formatValue(value: number, fmt?: ValueFormat): string {
|
|||
|
||||
export function formatFilterValue(value: number, raw?: boolean): string {
|
||||
if (raw) return Math.round(value).toString();
|
||||
if (usesChineseNumberUnits()) {
|
||||
const chineseCompactValue = formatChineseCompactNumber(value);
|
||||
if (chineseCompactValue) return chineseCompactValue;
|
||||
if (Number.isInteger(value)) return value.toString();
|
||||
return value.toFixed(2);
|
||||
}
|
||||
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
|
||||
if (Number.isInteger(value)) return value.toString();
|
||||
|
|
@ -31,14 +60,17 @@ export function parseInputValue(
|
|||
let s = text.trim();
|
||||
if (opts?.prefix) s = s.replace(new RegExp(`^\\${opts.prefix}`), '');
|
||||
if (opts?.suffix) s = s.replace(new RegExp(`${opts.suffix.trim()}$`), '');
|
||||
s = s.trim().replace(/,/g, '');
|
||||
const m = s.match(/^(-?\d+\.?\d*)\s*([kKmM]?)$/);
|
||||
s = s.trim().replace(/[,,]/g, '');
|
||||
const m = s.match(/^(-?\d+\.?\d*)\s*([kKmM万亿億]?)$/);
|
||||
if (!m) return null;
|
||||
let val = parseFloat(m[1]);
|
||||
if (isNaN(val)) return null;
|
||||
const unit = m[2].toLowerCase();
|
||||
const unit = m[2];
|
||||
if (unit === 'k') val *= 1_000;
|
||||
else if (unit === 'm') val *= 1_000_000;
|
||||
else if (unit === 'K') val *= 1_000;
|
||||
else if (unit === 'm' || unit === 'M') val *= 1_000_000;
|
||||
else if (unit === '万') val *= 10_000;
|
||||
else if (unit === '亿' || unit === '億') val *= 100_000_000;
|
||||
if (opts?.step) val = Math.round(val / opts.step) * opts.step;
|
||||
return val;
|
||||
}
|
||||
|
|
@ -102,9 +134,7 @@ export function roundedPercentages(values: number[], total: number, decimals = 0
|
|||
const floors = raw.map((r) => Math.floor(r));
|
||||
const result = floors.slice();
|
||||
let diff = targetSum - floors.reduce((a, b) => a + b, 0);
|
||||
const order = raw
|
||||
.map((r, i) => ({ i, frac: r - floors[i] }))
|
||||
.sort((a, b) => b.frac - a.frac);
|
||||
const order = raw.map((r, i) => ({ i, frac: r - floors[i] })).sort((a, b) => b.frac - a.frac);
|
||||
for (let k = 0; k < order.length && diff > 0; k++) {
|
||||
result[order[k].i] += 1;
|
||||
diff -= 1;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
TWEMOJI_BASE,
|
||||
BUFFER_MULTIPLIER,
|
||||
ENUM_PALETTE,
|
||||
type GradientStop,
|
||||
} from './consts';
|
||||
const ROAD_OPACITY = 0.4;
|
||||
|
||||
|
|
@ -64,8 +65,6 @@ export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
|
|||
} as StyleSpecification;
|
||||
}
|
||||
|
||||
type GradientStop = { t: number; color: [number, number, number] };
|
||||
|
||||
// Oklab color space for perceptually uniform interpolation
|
||||
function srgbToLinear(c: number): number {
|
||||
const v = c / 255;
|
||||
|
|
@ -131,8 +130,11 @@ function interpolateGradient(t: number, gradient: GradientStop[]): [number, numb
|
|||
return gradient[gradient.length - 1].color;
|
||||
}
|
||||
|
||||
function normalizedToColor(t: number): [number, number, number] {
|
||||
return interpolateGradient(t, FEATURE_GRADIENT);
|
||||
function normalizedToColor(
|
||||
t: number,
|
||||
gradient: GradientStop[] = FEATURE_GRADIENT
|
||||
): [number, number, number] {
|
||||
return interpolateGradient(t, gradient);
|
||||
}
|
||||
|
||||
function countToColor(
|
||||
|
|
@ -220,7 +222,8 @@ export function getFeatureFillColor(
|
|||
isDark: boolean,
|
||||
alpha: number,
|
||||
enumCount: number = 0,
|
||||
enumPalette?: [number, number, number][]
|
||||
enumPalette?: [number, number, number][],
|
||||
featureGradient: GradientStop[] = FEATURE_GRADIENT
|
||||
): [number, number, number, number] {
|
||||
if (colorRange) {
|
||||
if (value == null)
|
||||
|
|
@ -244,9 +247,9 @@ export function getFeatureFillColor(
|
|||
|
||||
const range = colorRange[1] - colorRange[0];
|
||||
if (range === 0)
|
||||
return [...FEATURE_GRADIENT[0].color, alpha] as [number, number, number, number];
|
||||
return [...featureGradient[0].color, alpha] as [number, number, number, number];
|
||||
const t = ((value as number) - colorRange[0]) / range;
|
||||
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)));
|
||||
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)), featureGradient);
|
||||
return [...rgb, alpha] as [number, number, number, number];
|
||||
}
|
||||
return [...countToColor(Math.max(0, Math.min(1, countNormalized)), densityGradient), alpha] as [
|
||||
|
|
|
|||
|
|
@ -22,6 +22,92 @@ STOP_TYPES = {
|
|||
}
|
||||
|
||||
|
||||
OUTPUT_COLUMNS = ["id", "name", "category", "lat", "lng"]
|
||||
|
||||
|
||||
def canonical_station_name_expr(name_col: str = "name") -> pl.Expr:
|
||||
"""Normalize station names so entrances/transport-mode variants collapse."""
|
||||
expr = pl.col(name_col).str.to_lowercase()
|
||||
expr = expr.str.replace_all(r"\([^)]*\)", " ")
|
||||
expr = expr.str.replace_all(r"['’`]", "")
|
||||
expr = expr.str.replace_all(r"&", " and ")
|
||||
expr = expr.str.replace_all(r"[^a-z0-9]+", " ")
|
||||
expr = expr.str.replace_all(r"\s+", " ").str.strip_chars()
|
||||
expr = expr.str.replace_all(
|
||||
r"\s+(underground|tube|dlr|metro|rail|railway)\s+station$", ""
|
||||
)
|
||||
expr = expr.str.replace_all(r"\s+tram\s+stop$", "")
|
||||
expr = expr.str.replace_all(r"\s+(station|stop)$", "")
|
||||
return expr.str.strip_chars()
|
||||
|
||||
|
||||
def _has_locality() -> pl.Expr:
|
||||
return pl.col("locality").is_not_null() & (pl.col("locality") != "")
|
||||
|
||||
|
||||
def _deduplicate_tube_partition(
|
||||
df: pl.DataFrame, group_cols: list[str]
|
||||
) -> pl.DataFrame:
|
||||
if len(df) == 0:
|
||||
return pl.DataFrame(
|
||||
{
|
||||
"id": pl.Series([], dtype=pl.String),
|
||||
"name": pl.Series([], dtype=pl.String),
|
||||
"category": pl.Series([], dtype=pl.String),
|
||||
"lat": pl.Series([], dtype=pl.Float64),
|
||||
"lng": pl.Series([], dtype=pl.Float64),
|
||||
}
|
||||
)
|
||||
|
||||
name_len = pl.col("name").str.len_chars()
|
||||
return (
|
||||
df.group_by(group_cols)
|
||||
.agg(
|
||||
pl.col("id").sort_by(name_len).first(),
|
||||
pl.col("name").sort_by(name_len).first(),
|
||||
pl.col("category").first(),
|
||||
pl.col("lat").mean(),
|
||||
pl.col("lng").mean(),
|
||||
)
|
||||
.select(OUTPUT_COLUMNS)
|
||||
)
|
||||
|
||||
|
||||
def deduplicate_naptan(df: pl.DataFrame) -> pl.DataFrame:
|
||||
"""Deduplicate NaPTAN stops, with stricter station-level merging for Tube POIs."""
|
||||
has_loc = df.filter(_has_locality())
|
||||
no_loc = df.filter(~_has_locality())
|
||||
cols_with_locality = [*OUTPUT_COLUMNS, "locality"]
|
||||
|
||||
# First pass: one record per exact stop name/category/locality.
|
||||
deduped_has_loc = (
|
||||
has_loc.group_by("name", "category", "locality")
|
||||
.agg(
|
||||
pl.col("id").first(),
|
||||
pl.col("lat").mean(),
|
||||
pl.col("lng").mean(),
|
||||
)
|
||||
.select(cols_with_locality)
|
||||
)
|
||||
df = pl.concat([deduped_has_loc, no_loc.select(cols_with_locality)])
|
||||
|
||||
tube = df.filter(pl.col("category") == "Tube station").with_columns(
|
||||
canonical_station_name_expr().alias("_station_key")
|
||||
)
|
||||
other = df.filter(pl.col("category") != "Tube station")
|
||||
|
||||
tube_with_loc = tube.filter(_has_locality())
|
||||
tube_no_loc = tube.filter(~_has_locality())
|
||||
deduped_tube = pl.concat(
|
||||
[
|
||||
_deduplicate_tube_partition(tube_with_loc, ["_station_key", "locality"]),
|
||||
_deduplicate_tube_partition(tube_no_loc, ["_station_key"]),
|
||||
]
|
||||
)
|
||||
|
||||
return pl.concat([other.select(OUTPUT_COLUMNS), deduped_tube])
|
||||
|
||||
|
||||
def download_naptan(output: Path) -> None:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
|
@ -50,24 +136,12 @@ def download_naptan(output: Path) -> None:
|
|||
)
|
||||
|
||||
before = len(df)
|
||||
df = deduplicate_naptan(df)
|
||||
|
||||
# Deduplicate: one record per name+category+locality
|
||||
# (merges entrances, bus stop pairs on opposite sides of the road, etc.)
|
||||
has_loc = df.filter(
|
||||
pl.col("locality").is_not_null() & (pl.col("locality") != "")
|
||||
print(
|
||||
f"Deduplicated {before:,} → {len(df):,} stops "
|
||||
"(by name+category+locality; tube stations by normalized station name)"
|
||||
)
|
||||
no_loc = df.filter(
|
||||
pl.col("locality").is_null() | (pl.col("locality") == "")
|
||||
)
|
||||
cols = ["id", "name", "category", "lat", "lng"]
|
||||
deduped = has_loc.group_by("name", "category", "locality").agg(
|
||||
pl.col("id").first(),
|
||||
pl.col("lat").mean(),
|
||||
pl.col("lng").mean(),
|
||||
)
|
||||
df = pl.concat([deduped.select(cols), no_loc.select(cols)])
|
||||
|
||||
print(f"Deduplicated {before:,} → {len(df):,} stops (by name+category+locality)")
|
||||
|
||||
df.write_parquet(output)
|
||||
size_mb = output.stat().st_size / (1024 * 1024)
|
||||
|
|
|
|||
71
pipeline/download/test_naptan.py
Normal file
71
pipeline/download/test_naptan.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import polars as pl
|
||||
import pytest
|
||||
|
||||
from pipeline.download.naptan import canonical_station_name_expr, deduplicate_naptan
|
||||
|
||||
|
||||
def test_canonical_station_name_expr_normalizes_transport_suffixes():
|
||||
df = pl.DataFrame(
|
||||
{
|
||||
"name": [
|
||||
"Bank",
|
||||
"Bank Underground Station",
|
||||
"Bank DLR Station",
|
||||
"Pleasure Beach (Blackpool Tramway)",
|
||||
"Earl's Court Tube Station",
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
result = df.select(canonical_station_name_expr().alias("key"))["key"].to_list()
|
||||
|
||||
assert result == [
|
||||
"bank",
|
||||
"bank",
|
||||
"bank",
|
||||
"pleasure beach",
|
||||
"earls court",
|
||||
]
|
||||
|
||||
|
||||
def test_deduplicate_naptan_merges_tube_station_variants_by_locality():
|
||||
df = pl.DataFrame(
|
||||
{
|
||||
"id": ["bank", "bank-lu", "bank-dlr", "other-bank"],
|
||||
"name": [
|
||||
"Bank",
|
||||
"Bank Underground Station",
|
||||
"Bank DLR Station",
|
||||
"Bank Underground Station",
|
||||
],
|
||||
"category": ["Tube station"] * 4,
|
||||
"lat": [51.5129, 51.5134, 51.5132, 55.0140],
|
||||
"lng": [-0.0889, -0.0890, -0.0885, -1.6781],
|
||||
"locality": ["LOC1", "LOC1", "LOC1", "LOC2"],
|
||||
}
|
||||
)
|
||||
|
||||
result = deduplicate_naptan(df).sort("lat")
|
||||
|
||||
assert len(result) == 2
|
||||
assert result["name"].to_list() == ["Bank", "Bank Underground Station"]
|
||||
assert result["lat"].to_list()[0] == pytest.approx(
|
||||
(51.5129 + 51.5134 + 51.5132) / 3
|
||||
)
|
||||
|
||||
|
||||
def test_deduplicate_naptan_does_not_merge_missing_locality_bus_stops():
|
||||
df = pl.DataFrame(
|
||||
{
|
||||
"id": ["a", "b"],
|
||||
"name": ["High Street", "High Street"],
|
||||
"category": ["Bus stop", "Bus stop"],
|
||||
"lat": [51.5, 52.5],
|
||||
"lng": [-0.1, -1.1],
|
||||
"locality": [None, None],
|
||||
}
|
||||
)
|
||||
|
||||
result = deduplicate_naptan(df)
|
||||
|
||||
assert len(result) == 2
|
||||
|
|
@ -19,6 +19,8 @@ Output directory: property-data/transit/
|
|||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
|
|
@ -108,6 +110,30 @@ def download_bods_gtfs(output_dir: Path) -> Path:
|
|||
return dest
|
||||
|
||||
|
||||
def _parse_csv_line(line: bytes | str) -> list[str]:
|
||||
"""Parse a single GTFS CSV record."""
|
||||
if isinstance(line, bytes):
|
||||
line = line.decode("utf-8", errors="replace")
|
||||
line = line.rstrip("\r\n")
|
||||
if not line:
|
||||
return []
|
||||
return next(csv.reader([line]))
|
||||
|
||||
|
||||
def _format_csv_row(parts: list[str]) -> bytes:
|
||||
"""Serialize one GTFS CSV row with stable LF line endings."""
|
||||
output = io.StringIO()
|
||||
csv.writer(output, lineterminator="\n").writerow(parts)
|
||||
return output.getvalue().encode("utf-8")
|
||||
|
||||
|
||||
def _format_csv_rows(rows: list[list[str]]) -> str:
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output, lineterminator="\n")
|
||||
writer.writerows(rows)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def clean_gtfs(src: Path, dst: Path) -> None:
|
||||
"""Fix R5-incompatible entries in GTFS.
|
||||
|
||||
|
|
@ -128,8 +154,7 @@ def clean_gtfs(src: Path, dst: Path) -> None:
|
|||
dropped = 0
|
||||
with zin.open(info) as f:
|
||||
header = f.readline()
|
||||
header_str = header.decode("utf-8").strip()
|
||||
cols = header_str.split(",")
|
||||
cols = _parse_csv_line(header)
|
||||
arr_idx = (
|
||||
cols.index("arrival_time") if "arrival_time" in cols else -1
|
||||
)
|
||||
|
|
@ -143,10 +168,9 @@ def clean_gtfs(src: Path, dst: Path) -> None:
|
|||
tmp.write(header)
|
||||
|
||||
for line in f:
|
||||
line_str = line.decode("utf-8", errors="replace").strip()
|
||||
if not line_str:
|
||||
parts = _parse_csv_line(line)
|
||||
if not parts:
|
||||
continue
|
||||
parts = line_str.split(",")
|
||||
skip = False
|
||||
for idx in [arr_idx, dep_idx]:
|
||||
if 0 <= idx < len(parts):
|
||||
|
|
@ -171,12 +195,13 @@ def clean_gtfs(src: Path, dst: Path) -> None:
|
|||
|
||||
elif info.filename == "feed_info.txt":
|
||||
data = zin.read(info).decode("utf-8")
|
||||
lines = data.strip().split("\n")
|
||||
header_line = lines[0]
|
||||
feed_cols = header_line.split(",")
|
||||
fixed_lines = [header_line]
|
||||
for line in lines[1:]:
|
||||
parts = line.split(",")
|
||||
rows = list(csv.reader(io.StringIO(data)))
|
||||
if not rows:
|
||||
zout.writestr("feed_info.txt", data)
|
||||
continue
|
||||
feed_cols = rows[0]
|
||||
fixed_rows = [feed_cols]
|
||||
for parts in rows[1:]:
|
||||
for i, col_name in enumerate(feed_cols):
|
||||
if "end_date" in col_name.lower() and i < len(parts):
|
||||
date_val = parts[i].strip('"')
|
||||
|
|
@ -187,8 +212,8 @@ def clean_gtfs(src: Path, dst: Path) -> None:
|
|||
print(
|
||||
f" feed_info: capped end_date {date_val} → 20991231"
|
||||
)
|
||||
fixed_lines.append(",".join(parts))
|
||||
zout.writestr("feed_info.txt", "\n".join(fixed_lines) + "\n")
|
||||
fixed_rows.append(parts)
|
||||
zout.writestr("feed_info.txt", _format_csv_rows(fixed_rows))
|
||||
else:
|
||||
zout.writestr(info, zin.read(info))
|
||||
|
||||
|
|
@ -237,12 +262,11 @@ def convert_high_freq_to_frequency_based(
|
|||
# Step 1: Find metro/tram route IDs
|
||||
target_route_ids: set[str] = set()
|
||||
with zin.open("routes.txt") as f:
|
||||
header = f.readline().decode("utf-8").strip()
|
||||
cols = header.split(",")
|
||||
cols = _parse_csv_line(f.readline())
|
||||
route_id_idx = cols.index("route_id")
|
||||
rt_idx = cols.index("route_type")
|
||||
for line in f:
|
||||
parts = line.decode("utf-8", errors="replace").strip().split(",")
|
||||
parts = _parse_csv_line(line)
|
||||
if not parts:
|
||||
continue
|
||||
route_type = parts[rt_idx].strip('"')
|
||||
|
|
@ -259,14 +283,13 @@ def convert_high_freq_to_frequency_based(
|
|||
# Step 2: Map target trips to grouping keys
|
||||
trip_group_key: dict[str, tuple[str, str, str]] = {}
|
||||
with zin.open("trips.txt") as f:
|
||||
header = f.readline().decode("utf-8").strip()
|
||||
cols = header.split(",")
|
||||
cols = _parse_csv_line(f.readline())
|
||||
trip_id_idx = cols.index("trip_id")
|
||||
route_id_idx = cols.index("route_id")
|
||||
dir_idx = cols.index("direction_id") if "direction_id" in cols else -1
|
||||
service_idx = cols.index("service_id")
|
||||
for line in f:
|
||||
parts = line.decode("utf-8", errors="replace").strip().split(",")
|
||||
parts = _parse_csv_line(line)
|
||||
if not parts:
|
||||
continue
|
||||
route_id = parts[route_id_idx].strip('"')
|
||||
|
|
@ -282,14 +305,13 @@ def convert_high_freq_to_frequency_based(
|
|||
trip_first_dep: dict[str, int] = {}
|
||||
trip_first_stop: dict[str, str] = {}
|
||||
with zin.open("stop_times.txt") as f:
|
||||
header = f.readline().decode("utf-8").strip()
|
||||
cols = header.split(",")
|
||||
cols = _parse_csv_line(f.readline())
|
||||
trip_id_idx = cols.index("trip_id")
|
||||
dep_idx = cols.index("departure_time")
|
||||
seq_idx = cols.index("stop_sequence")
|
||||
stop_id_idx = cols.index("stop_id")
|
||||
for line in f:
|
||||
parts = line.decode("utf-8", errors="replace").strip().split(",")
|
||||
parts = _parse_csv_line(line)
|
||||
if not parts:
|
||||
continue
|
||||
trip_id = parts[trip_id_idx].strip('"')
|
||||
|
|
@ -361,8 +383,7 @@ def convert_high_freq_to_frequency_based(
|
|||
if info.filename == "trips.txt":
|
||||
with zin.open(info) as f:
|
||||
header = f.readline()
|
||||
header_str = header.decode("utf-8").strip()
|
||||
cols = header_str.split(",")
|
||||
cols = _parse_csv_line(header)
|
||||
trip_id_idx = cols.index("trip_id")
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
|
|
@ -370,9 +391,7 @@ def convert_high_freq_to_frequency_based(
|
|||
)
|
||||
tmp.write(header)
|
||||
for line in f:
|
||||
parts = (
|
||||
line.decode("utf-8", errors="replace").strip().split(",")
|
||||
)
|
||||
parts = _parse_csv_line(line)
|
||||
if not parts:
|
||||
continue
|
||||
if parts[trip_id_idx].strip('"') not in trips_to_remove:
|
||||
|
|
@ -384,8 +403,7 @@ def convert_high_freq_to_frequency_based(
|
|||
elif info.filename == "stop_times.txt":
|
||||
with zin.open(info) as f:
|
||||
header = f.readline()
|
||||
header_str = header.decode("utf-8").strip()
|
||||
cols = header_str.split(",")
|
||||
cols = _parse_csv_line(header)
|
||||
trip_id_idx = cols.index("trip_id")
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
|
|
@ -393,9 +411,7 @@ def convert_high_freq_to_frequency_based(
|
|||
)
|
||||
tmp.write(header)
|
||||
for line in f:
|
||||
parts = (
|
||||
line.decode("utf-8", errors="replace").strip().split(",")
|
||||
)
|
||||
parts = _parse_csv_line(line)
|
||||
if not parts:
|
||||
continue
|
||||
if parts[trip_id_idx].strip('"') not in trips_to_remove:
|
||||
|
|
@ -535,25 +551,23 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
|
|||
with zipfile.ZipFile(src, "r") as zin:
|
||||
# Load valid stop IDs
|
||||
with zin.open("stops.txt") as f:
|
||||
header = f.readline().decode("utf-8").strip()
|
||||
stop_id_idx = header.split(",").index("stop_id")
|
||||
lat_idx = header.split(",").index("stop_lat")
|
||||
cols = _parse_csv_line(f.readline())
|
||||
stop_id_idx = cols.index("stop_id")
|
||||
for line in f:
|
||||
parts = line.decode("utf-8", errors="replace").strip().split(",")
|
||||
parts = _parse_csv_line(line)
|
||||
if parts:
|
||||
stop_ids.add(parts[stop_id_idx])
|
||||
|
||||
# Find trips with backwards travel times
|
||||
with zin.open("stop_times.txt") as f:
|
||||
st_header = f.readline().decode("utf-8").strip()
|
||||
st_cols = st_header.split(",")
|
||||
st_cols = _parse_csv_line(f.readline())
|
||||
trip_id_idx = st_cols.index("trip_id")
|
||||
dep_idx = st_cols.index("departure_time")
|
||||
|
||||
prev_trip = ""
|
||||
prev_dep_secs = -1
|
||||
for line in f:
|
||||
parts = line.decode("utf-8", errors="replace").strip().split(",")
|
||||
parts = _parse_csv_line(line)
|
||||
if not parts:
|
||||
continue
|
||||
trip_id = parts[trip_id_idx].strip('"')
|
||||
|
|
@ -594,8 +608,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
|
|||
if info.filename == "stop_times.txt":
|
||||
with zin.open(info) as f:
|
||||
header = f.readline()
|
||||
header_str = header.decode("utf-8").strip()
|
||||
cols = header_str.split(",")
|
||||
cols = _parse_csv_line(header)
|
||||
trip_id_idx = cols.index("trip_id")
|
||||
stop_id_idx = cols.index("stop_id")
|
||||
seq_idx = cols.index("stop_sequence")
|
||||
|
|
@ -614,10 +627,9 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
|
|||
prev_trip = ""
|
||||
seq_counter = 0
|
||||
for line in f:
|
||||
line_str = line.decode("utf-8", errors="replace").strip()
|
||||
if not line_str:
|
||||
parts = _parse_csv_line(line)
|
||||
if not parts:
|
||||
continue
|
||||
parts = line_str.split(",")
|
||||
trip_id = parts[trip_id_idx].strip('"')
|
||||
stop_id = parts[stop_id_idx].strip('"')
|
||||
|
||||
|
|
@ -651,7 +663,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
|
|||
if old_seq != str(seq_counter):
|
||||
seqs_renumbered += 1
|
||||
|
||||
tmp.write((",".join(parts) + "\n").encode("utf-8"))
|
||||
tmp.write(_format_csv_row(parts))
|
||||
|
||||
tmp.close()
|
||||
zout.write(tmp.name, "stop_times.txt")
|
||||
|
|
@ -660,8 +672,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
|
|||
elif info.filename == "stops.txt":
|
||||
with zin.open(info) as f:
|
||||
header = f.readline()
|
||||
header_str = header.decode("utf-8").strip()
|
||||
cols = header_str.split(",")
|
||||
cols = _parse_csv_line(header)
|
||||
lat_idx = cols.index("stop_lat")
|
||||
lon_idx = cols.index("stop_lon")
|
||||
|
||||
|
|
@ -671,10 +682,9 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
|
|||
tmp.write(header)
|
||||
|
||||
for line in f:
|
||||
line_str = line.decode("utf-8", errors="replace").strip()
|
||||
if not line_str:
|
||||
parts = _parse_csv_line(line)
|
||||
if not parts:
|
||||
continue
|
||||
parts = line_str.split(",")
|
||||
try:
|
||||
lat = float(parts[lat_idx])
|
||||
# Fix bogus Irish CIE coordinates (South Atlantic)
|
||||
|
|
@ -685,7 +695,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
|
|||
coords_fixed += 1
|
||||
except ValueError:
|
||||
pass
|
||||
tmp.write((",".join(parts) + "\n").encode("utf-8"))
|
||||
tmp.write(_format_csv_row(parts))
|
||||
|
||||
tmp.close()
|
||||
zout.write(tmp.name, "stops.txt")
|
||||
|
|
@ -694,8 +704,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
|
|||
elif info.filename == "routes.txt":
|
||||
with zin.open(info) as f:
|
||||
header = f.readline()
|
||||
header_str = header.decode("utf-8").strip()
|
||||
cols = header_str.split(",")
|
||||
cols = _parse_csv_line(header)
|
||||
rt_idx = cols.index("route_type")
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
|
|
@ -704,14 +713,13 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
|
|||
tmp.write(header)
|
||||
|
||||
for line in f:
|
||||
line_str = line.decode("utf-8", errors="replace").strip()
|
||||
if not line_str:
|
||||
parts = _parse_csv_line(line)
|
||||
if not parts:
|
||||
continue
|
||||
parts = line_str.split(",")
|
||||
if parts[rt_idx].strip('"') == "714":
|
||||
parts[rt_idx] = "3"
|
||||
route_types_fixed += 1
|
||||
tmp.write((",".join(parts) + "\n").encode("utf-8"))
|
||||
tmp.write(_format_csv_row(parts))
|
||||
|
||||
tmp.close()
|
||||
zout.write(tmp.name, "routes.txt")
|
||||
|
|
@ -721,8 +729,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
|
|||
# Remove trips that have backwards travel times
|
||||
with zin.open(info) as f:
|
||||
header = f.readline()
|
||||
header_str = header.decode("utf-8").strip()
|
||||
cols = header_str.split(",")
|
||||
cols = _parse_csv_line(header)
|
||||
trip_id_idx = cols.index("trip_id")
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
|
|
@ -731,10 +738,9 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
|
|||
tmp.write(header)
|
||||
|
||||
for line in f:
|
||||
line_str = line.decode("utf-8", errors="replace").strip()
|
||||
if not line_str:
|
||||
parts = _parse_csv_line(line)
|
||||
if not parts:
|
||||
continue
|
||||
parts = line_str.split(",")
|
||||
if parts[trip_id_idx].strip('"') not in bad_trip_ids:
|
||||
tmp.write(line)
|
||||
|
||||
|
|
@ -746,8 +752,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
|
|||
# Cap end_date year to 2099
|
||||
with zin.open(info) as f:
|
||||
header = f.readline()
|
||||
header_str = header.decode("utf-8").strip()
|
||||
cols = header_str.split(",")
|
||||
cols = _parse_csv_line(header)
|
||||
end_idx = cols.index("end_date")
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
|
|
@ -756,10 +761,9 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
|
|||
tmp.write(header)
|
||||
|
||||
for line in f:
|
||||
line_str = line.decode("utf-8", errors="replace").strip()
|
||||
if not line_str:
|
||||
parts = _parse_csv_line(line)
|
||||
if not parts:
|
||||
continue
|
||||
parts = line_str.split(",")
|
||||
date_val = parts[end_idx].strip('"')
|
||||
if len(date_val) == 8:
|
||||
try:
|
||||
|
|
@ -768,7 +772,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
|
|||
parts[end_idx] = "20991231"
|
||||
except ValueError:
|
||||
pass
|
||||
tmp.write((",".join(parts) + "\n").encode("utf-8"))
|
||||
tmp.write(_format_csv_row(parts))
|
||||
|
||||
tmp.close()
|
||||
zout.write(tmp.name, "calendar.txt")
|
||||
|
|
|
|||
|
|
@ -60,6 +60,10 @@ _AREA_COLUMNS = [
|
|||
"Good+ secondary schools within 5km",
|
||||
"Good+ primary schools within 2km",
|
||||
"Good+ secondary schools within 2km",
|
||||
"Outstanding primary schools within 5km",
|
||||
"Outstanding secondary schools within 5km",
|
||||
"Outstanding primary schools within 2km",
|
||||
"Outstanding secondary schools within 2km",
|
||||
# Demographics
|
||||
"Median age",
|
||||
# Politics
|
||||
|
|
@ -351,6 +355,10 @@ def _build(
|
|||
"good_secondary_5km": "Good+ secondary schools within 5km",
|
||||
"good_primary_2km": "Good+ primary schools within 2km",
|
||||
"good_secondary_2km": "Good+ secondary schools within 2km",
|
||||
"outstanding_primary_5km": "Outstanding primary schools within 5km",
|
||||
"outstanding_secondary_5km": "Outstanding secondary schools within 5km",
|
||||
"outstanding_primary_2km": "Outstanding primary schools within 2km",
|
||||
"outstanding_secondary_2km": "Outstanding secondary schools within 2km",
|
||||
"max_download_speed": "Max available download speed (Mbps)",
|
||||
"serious_crime_avg_yr": "Serious crime (avg/yr)",
|
||||
"minor_crime_avg_yr": "Minor crime (avg/yr)",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Compute good-rated school proximity counts per postcode."""
|
||||
"""Compute Ofsted-rated school proximity counts per postcode."""
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
|
@ -8,14 +8,16 @@ import polars as pl
|
|||
from pipeline.utils.poi_counts import count_pois_per_postcode
|
||||
|
||||
SCHOOL_GROUPS = {
|
||||
"good_primary": ["good_primary"],
|
||||
"good_secondary": ["good_secondary"],
|
||||
"good_primary": ["good_primary", "outstanding_primary"],
|
||||
"good_secondary": ["good_secondary", "outstanding_secondary"],
|
||||
"outstanding_primary": ["outstanding_primary"],
|
||||
"outstanding_secondary": ["outstanding_secondary"],
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Count good+ primary/secondary schools within 2km per postcode"
|
||||
description="Count good+ and outstanding primary/secondary schools near each postcode"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ofsted", type=Path, required=True, help="Ofsted inspection parquet"
|
||||
|
|
@ -39,12 +41,25 @@ def main():
|
|||
)
|
||||
|
||||
print(f"Good+ schools: {len(ofsted):,}")
|
||||
print(
|
||||
"Outstanding schools: "
|
||||
f"{ofsted.filter(pl.col('Latest OEIF overall effectiveness') == '1').height:,}"
|
||||
)
|
||||
|
||||
# Assign category based on phase
|
||||
# Assign category based on phase and rating. Good+ groups include both
|
||||
# category variants; outstanding groups count grade 1 only.
|
||||
ofsted = ofsted.with_columns(
|
||||
pl.when(pl.col("Ofsted phase") == "Primary")
|
||||
.then(pl.lit("good_primary"))
|
||||
.otherwise(pl.lit("good_secondary"))
|
||||
.then(
|
||||
pl.when(pl.col("Latest OEIF overall effectiveness") == "1")
|
||||
.then(pl.lit("outstanding_primary"))
|
||||
.otherwise(pl.lit("good_primary"))
|
||||
)
|
||||
.otherwise(
|
||||
pl.when(pl.col("Latest OEIF overall effectiveness") == "1")
|
||||
.then(pl.lit("outstanding_secondary"))
|
||||
.otherwise(pl.lit("good_secondary"))
|
||||
)
|
||||
.alias("category")
|
||||
).select(
|
||||
pl.col("Postcode").alias("postcode"),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "npm run build && node --test dist/**/*.test.js",
|
||||
"start": "node dist/server.js",
|
||||
"dev": "tsc --watch & node --watch dist/server.js"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import express from 'express';
|
||||
import express, { type Request } from 'express';
|
||||
import { ScreenshotCache } from './cache.js';
|
||||
import { takeScreenshot, checkWebGL, closeBrowser, initialize } from './screenshot.js';
|
||||
import { buildScreenshotRequest, ValidationError } from './validation.js';
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '8002', 10);
|
||||
const APP_URL = process.env.APP_URL;
|
||||
const CACHE_DIR = process.env.CACHE_DIR;
|
||||
const SCREENSHOT_CONCURRENCY = parsePositiveIntEnv('SCREENSHOT_CONCURRENCY', 3);
|
||||
const RATE_LIMIT_WINDOW_MS = parsePositiveIntEnv('SCREENSHOT_RATE_WINDOW_MS', 60_000);
|
||||
const RATE_LIMIT_MAX = parsePositiveIntEnv('SCREENSHOT_RATE_LIMIT', 30);
|
||||
|
||||
if (!APP_URL) {
|
||||
console.error('Error: APP_URL environment variable is required');
|
||||
|
|
@ -18,6 +22,58 @@ if (!CACHE_DIR) {
|
|||
|
||||
const cache = new ScreenshotCache(CACHE_DIR);
|
||||
const app = express();
|
||||
app.set('trust proxy', true);
|
||||
|
||||
let activeScreenshots = 0;
|
||||
let lastRateLimitPrune = 0;
|
||||
const rateLimitBuckets = new Map<string, { count: number; resetAt: number }>();
|
||||
|
||||
function parsePositiveIntEnv(name: string, fallback: number): number {
|
||||
const value = Number.parseInt(process.env[name] || '', 10);
|
||||
return Number.isFinite(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function acquireScreenshotSlot(): (() => void) | null {
|
||||
if (activeScreenshots >= SCREENSHOT_CONCURRENCY) {
|
||||
return null;
|
||||
}
|
||||
activeScreenshots += 1;
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
activeScreenshots = Math.max(0, activeScreenshots - 1);
|
||||
};
|
||||
}
|
||||
|
||||
function rateLimitKey(req: Request): string {
|
||||
const forwardedFor = req.get('x-forwarded-for')?.split(',')[0]?.trim();
|
||||
return forwardedFor || req.ip || req.socket.remoteAddress || 'unknown';
|
||||
}
|
||||
|
||||
function allowScreenshotRequest(req: Request): boolean {
|
||||
const now = Date.now();
|
||||
if (now - lastRateLimitPrune > RATE_LIMIT_WINDOW_MS) {
|
||||
lastRateLimitPrune = now;
|
||||
for (const [key, bucket] of rateLimitBuckets) {
|
||||
if (bucket.resetAt <= now) {
|
||||
rateLimitBuckets.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const key = rateLimitKey(req);
|
||||
let bucket = rateLimitBuckets.get(key);
|
||||
if (!bucket || bucket.resetAt <= now) {
|
||||
bucket = { count: 0, resetAt: now + RATE_LIMIT_WINDOW_MS };
|
||||
rateLimitBuckets.set(key, bucket);
|
||||
}
|
||||
if (bucket.count >= RATE_LIMIT_MAX) {
|
||||
return false;
|
||||
}
|
||||
bucket.count += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.status(200).send('ok');
|
||||
|
|
@ -33,28 +89,9 @@ app.get('/debug', async (_req, res) => {
|
|||
});
|
||||
|
||||
app.get('/screenshot', async (req, res) => {
|
||||
let releaseSlot: (() => void) | null = null;
|
||||
try {
|
||||
const qs = new URLSearchParams();
|
||||
for (const key of ['lat', 'lon', 'zoom', 'tab', 'og']) {
|
||||
const val = req.query[key];
|
||||
if (typeof val === 'string' && val) {
|
||||
qs.set(key, val);
|
||||
}
|
||||
}
|
||||
// Repeated params: filter, poi, tt (travel time)
|
||||
for (const key of ['filter', 'poi', 'tt']) {
|
||||
const val = req.query[key];
|
||||
if (typeof val === 'string' && val) {
|
||||
qs.append(key, val);
|
||||
} else if (Array.isArray(val)) {
|
||||
for (const v of val) {
|
||||
if (typeof v === 'string' && v) qs.append(key, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract path param (used for non-root pages like /invite/CODE)
|
||||
const pagePath = typeof req.query.path === 'string' && req.query.path ? req.query.path : '/';
|
||||
const { pagePath, qs } = buildScreenshotRequest(req.query as Record<string, unknown>);
|
||||
if (pagePath !== '/') qs.set('path', pagePath);
|
||||
|
||||
// Include auth status in cache key so authenticated screenshots
|
||||
|
|
@ -75,6 +112,17 @@ app.get('/screenshot', async (req, res) => {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!allowScreenshotRequest(req)) {
|
||||
res.status(429).json({ error: 'Screenshot rate limit exceeded' });
|
||||
return;
|
||||
}
|
||||
|
||||
releaseSlot = acquireScreenshotSlot();
|
||||
if (!releaseSlot) {
|
||||
res.status(503).json({ error: 'Screenshot service busy' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the URL for the frontend in screenshot mode
|
||||
qs.set('screenshot', '1');
|
||||
const url = `${APP_URL}${pagePath}?${qs}`;
|
||||
|
|
@ -90,8 +138,14 @@ app.get('/screenshot', async (req, res) => {
|
|||
res.setHeader('X-Cache', 'MISS');
|
||||
res.send(jpeg);
|
||||
} catch (err) {
|
||||
if (err instanceof ValidationError) {
|
||||
res.status(err.status).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
console.error('Screenshot failed:', err);
|
||||
res.status(500).json({ error: 'Screenshot failed' });
|
||||
} finally {
|
||||
releaseSlot?.();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -99,6 +153,8 @@ const server = app.listen(PORT, () => {
|
|||
console.log(`Screenshot service listening on port ${PORT}`);
|
||||
console.log(` APP_URL: ${APP_URL}`);
|
||||
console.log(` CACHE_DIR: ${CACHE_DIR}`);
|
||||
console.log(` SCREENSHOT_CONCURRENCY: ${SCREENSHOT_CONCURRENCY}`);
|
||||
console.log(` SCREENSHOT_RATE_LIMIT: ${RATE_LIMIT_MAX}/${RATE_LIMIT_WINDOW_MS}ms`);
|
||||
|
||||
// Pre-warm browser and populate network cache in background.
|
||||
// The health endpoint is available immediately; screenshot requests
|
||||
|
|
|
|||
|
|
@ -1953,3 +1953,6 @@
|
|||
2026-04-04T21:54:19.870055Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
|
||||
2026-04-04T21:54:22.182709Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203393
|
||||
2026-04-04T22:00:48.160218Z INFO property_map_server: Prometheus metrics initialized
|
||||
2026-04-04T22:12:50.835405Z INFO property_map_server: Prometheus metrics initialized
|
||||
2026-04-04T22:13:40.021088Z INFO property_map_server: Prometheus metrics initialized
|
||||
2026-04-04T22:14:09.136995Z INFO property_map_server: Prometheus metrics initialized
|
||||
|
|
|
|||
1315
server-rs/logs/server.log.2026-05-04
Normal file
1315
server-rs/logs/server.log.2026-05-04
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -514,10 +514,7 @@ pub fn precompute_h3(lat: &[f32], lon: &[f32]) -> anyhow::Result<Vec<u64>> {
|
|||
}
|
||||
|
||||
impl PropertyData {
|
||||
pub fn load(
|
||||
properties_path: &Path,
|
||||
postcode_features_path: &Path,
|
||||
) -> anyhow::Result<Self> {
|
||||
pub fn load(properties_path: &Path, postcode_features_path: &Path) -> anyhow::Result<Self> {
|
||||
// Load postcode.parquet
|
||||
tracing::info!(
|
||||
"Loading postcode features from {:?}",
|
||||
|
|
@ -643,11 +640,22 @@ impl PropertyData {
|
|||
}
|
||||
let df = combined
|
||||
.lazy()
|
||||
.filter(col("lat").is_not_null().and(col("lon").is_not_null()))
|
||||
.select(select_exprs)
|
||||
.collect()
|
||||
.context("Failed to select columns from combined data")?;
|
||||
|
||||
let row_count = df.height();
|
||||
if row_count == 0 {
|
||||
bail!("No property rows have usable coordinates after joining postcode data");
|
||||
}
|
||||
let dropped_coordinate_rows = total_rows.saturating_sub(row_count);
|
||||
if dropped_coordinate_rows > 0 {
|
||||
tracing::warn!(
|
||||
rows = dropped_coordinate_rows,
|
||||
"Dropped properties with missing postcode coordinates"
|
||||
);
|
||||
}
|
||||
tracing::info!(rows = row_count, "Combined data selected");
|
||||
|
||||
let lat_series = df
|
||||
|
|
@ -659,8 +667,8 @@ impl PropertyData {
|
|||
.f32()
|
||||
.context("Failed to read 'lat' as f32")?
|
||||
.into_iter()
|
||||
.map(|value| value.unwrap_or(0.0))
|
||||
.collect();
|
||||
.map(|value| value.context("Missing 'lat' value after coordinate filter"))
|
||||
.collect::<anyhow::Result<Vec<_>>>()?;
|
||||
|
||||
let lon_series = df
|
||||
.column("lon")
|
||||
|
|
@ -671,8 +679,14 @@ impl PropertyData {
|
|||
.f32()
|
||||
.context("Failed to read 'lon' as f32")?
|
||||
.into_iter()
|
||||
.map(|value| value.unwrap_or(0.0))
|
||||
.collect();
|
||||
.map(|value| value.context("Missing 'lon' value after coordinate filter"))
|
||||
.collect::<anyhow::Result<Vec<_>>>()?;
|
||||
|
||||
for (row, (&latitude, &longitude)) in lat.iter().zip(&lon).enumerate() {
|
||||
if !(-90.0..=90.0).contains(&latitude) || !(-180.0..=180.0).contains(&longitude) {
|
||||
bail!("Invalid coordinates at row {row}: lat={latitude}, lon={longitude}");
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Extracting numeric feature columns");
|
||||
let numeric_col_major: Vec<Vec<f32>> = numeric_names
|
||||
|
|
|
|||
|
|
@ -32,8 +32,7 @@ pub struct FeatureConfig {
|
|||
|
||||
/// Features whose histogram bins should be exactly 1 unit wide (one per integer).
|
||||
/// p1/p99 are snapped to integer boundaries before binning.
|
||||
pub const INTEGER_BIN_FEATURES: &[&str] =
|
||||
&["Number of bedrooms & living rooms"];
|
||||
pub const INTEGER_BIN_FEATURES: &[&str] = &["Number of bedrooms & living rooms"];
|
||||
|
||||
pub struct EnumFeatureConfig {
|
||||
pub name: &'static str,
|
||||
|
|
@ -302,6 +301,36 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
raw: false,
|
||||
absolute: false,
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Outstanding primary schools within 2km",
|
||||
bounds: Bounds::Fixed {
|
||||
min: 0.0,
|
||||
max: 10.0,
|
||||
},
|
||||
step: 1.0,
|
||||
description: "Primary schools rated Outstanding by Ofsted within 2km",
|
||||
detail: "State-funded primary schools within 2km with a current Ofsted rating of Outstanding. Schools not yet inspected are excluded.",
|
||||
source: "ofsted",
|
||||
prefix: "",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Outstanding secondary schools within 2km",
|
||||
bounds: Bounds::Fixed {
|
||||
min: 0.0,
|
||||
max: 5.0,
|
||||
},
|
||||
step: 1.0,
|
||||
description: "Secondary schools rated Outstanding by Ofsted within 2km",
|
||||
detail: "State-funded secondary schools within 2km with a current Ofsted rating of Outstanding. Schools not yet inspected are excluded.",
|
||||
source: "ofsted",
|
||||
prefix: "",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Good+ primary schools within 5km",
|
||||
bounds: Bounds::Fixed {
|
||||
|
|
@ -332,6 +361,36 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
raw: false,
|
||||
absolute: false,
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Outstanding primary schools within 5km",
|
||||
bounds: Bounds::Fixed {
|
||||
min: 0.0,
|
||||
max: 30.0,
|
||||
},
|
||||
step: 1.0,
|
||||
description: "Primary schools rated Outstanding by Ofsted within 5km",
|
||||
detail: "State-funded primary schools within 5km with a current Ofsted rating of Outstanding. Schools not yet inspected are excluded.",
|
||||
source: "ofsted",
|
||||
prefix: "",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Outstanding secondary schools within 5km",
|
||||
bounds: Bounds::Fixed {
|
||||
min: 0.0,
|
||||
max: 15.0,
|
||||
},
|
||||
step: 1.0,
|
||||
description: "Secondary schools rated Outstanding by Ofsted within 5km",
|
||||
detail: "State-funded secondary schools within 5km with a current Ofsted rating of Outstanding. Schools not yet inspected are excluded.",
|
||||
source: "ofsted",
|
||||
prefix: "",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Education, Skills and Training Score",
|
||||
bounds: Bounds::Percentile {
|
||||
|
|
|
|||
|
|
@ -165,10 +165,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
cli.properties.display(),
|
||||
cli.postcode_features.display(),
|
||||
);
|
||||
let property_data = data::PropertyData::load(
|
||||
&cli.properties,
|
||||
&cli.postcode_features,
|
||||
)?;
|
||||
let property_data = data::PropertyData::load(&cli.properties, &cli.postcode_features)?;
|
||||
info!(
|
||||
rows = property_data.lat.len(),
|
||||
features = property_data.num_features,
|
||||
|
|
@ -450,7 +447,10 @@ async fn main() -> anyhow::Result<()> {
|
|||
"/api/postcode-properties",
|
||||
get(routes::get_postcode_properties),
|
||||
)
|
||||
.route("/api/screenshot", get(routes::get_screenshot))
|
||||
.route(
|
||||
"/api/screenshot",
|
||||
get(routes::get_screenshot).layer(ConcurrencyLimitLayer::new(3)),
|
||||
)
|
||||
.route(
|
||||
"/api/export",
|
||||
get(routes::get_export).layer(ConcurrencyLimitLayer::new(3)),
|
||||
|
|
|
|||
|
|
@ -281,6 +281,7 @@ pub fn build_system_prompt(
|
|||
- \"cheap\" / \"affordable\" = lower price range. \"expensive\" = higher price range.\n\
|
||||
- \"low crime\" / \"safe\" = low values on Serious crime and Minor crime summary features. \
|
||||
\"quiet\" = low Noise (dB). \"green\" / \"near parks\" = high Number of parks within 1km.\n\
|
||||
- \"good schools\" = Good+ school features. \"outstanding schools\" = Outstanding school features.\n\
|
||||
- When the user says a number like \"under 400k\", interpret it as 400000.\n\
|
||||
- When the user says \"3 bed\" or \"3 bedroom\", use Number of bedrooms & living rooms \
|
||||
(note: this counts bedrooms + living rooms combined, so 3 bed ~ min 4).\n\
|
||||
|
|
@ -424,6 +425,16 @@ pub fn build_system_prompt(
|
|||
.to_string(),
|
||||
);
|
||||
|
||||
parts.push(
|
||||
"\nUser: \"quiet area with outstanding schools\"\n\
|
||||
Output: {\"numeric_filters\": [\
|
||||
{\"name\": \"Noise (dB)\", \"bound\": \"max\", \"value\": 55}, \
|
||||
{\"name\": \"Outstanding primary schools within 2km\", \"bound\": \"min\", \"value\": 1}, \
|
||||
{\"name\": \"Outstanding secondary schools within 2km\", \"bound\": \"min\", \"value\": 1}], \
|
||||
\"enum_filters\": [], \"travel_time_filters\": [], \"notes\": \"\"}"
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
parts.push(
|
||||
"\nUser: \"3 bed flat under 300k with fast broadband near the beach\"\n\
|
||||
Output: {\"numeric_filters\": [\
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ use tracing::{info, warn};
|
|||
use crate::auth::OptionalUser;
|
||||
use crate::consts::NAN_U16;
|
||||
use crate::data::QuantRef;
|
||||
use crate::features::INTEGER_BIN_FEATURES;
|
||||
use crate::licensing::check_license_bounds;
|
||||
use crate::parsing::{parse_field_indices, parse_filters, require_bounds, row_passes_filters};
|
||||
use crate::routes::{fetch_screenshot_bytes, FeatureInfo};
|
||||
|
|
@ -315,6 +316,11 @@ pub async fn get_export(
|
|||
})
|
||||
.collect();
|
||||
|
||||
let integer_feature_indices: FxHashSet<usize> = INTEGER_BIN_FEATURES
|
||||
.iter()
|
||||
.filter_map(|name| state.feature_name_to_index.get(*name).copied())
|
||||
.collect();
|
||||
|
||||
// Build Excel number formats per feature index for unit display
|
||||
let mut feat_num_fmts: FxHashMap<usize, Format> = FxHashMap::default();
|
||||
for &feat_idx in &all_feature_indices {
|
||||
|
|
@ -324,6 +330,8 @@ pub async fn get_export(
|
|||
}
|
||||
let num_fmt_str = if !prefix.is_empty() {
|
||||
format!("\"{}\"#,##0", prefix)
|
||||
} else if integer_feature_indices.contains(&feat_idx) {
|
||||
format!("#,##0\"{}\"", suffix)
|
||||
} else {
|
||||
format!("#,##0.0\"{}\"", suffix)
|
||||
};
|
||||
|
|
@ -488,7 +496,11 @@ pub async fn get_export(
|
|||
} else {
|
||||
let fc = agg.finite_counts[feat_idx];
|
||||
if fc > 0 {
|
||||
let mean = (agg.sums[feat_idx] / fc as f64 * 100.0).round() / 100.0;
|
||||
let mean = if integer_feature_indices.contains(&feat_idx) {
|
||||
(agg.sums[feat_idx] / fc as f64).round()
|
||||
} else {
|
||||
(agg.sums[feat_idx] / fc as f64 * 100.0).round() / 100.0
|
||||
};
|
||||
if let Some(fmt) = feat_num_fmts.get(&feat_idx) {
|
||||
sheet
|
||||
.write_number_with_format(row, col, mean, fmt)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use std::sync::Arc;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
|
|
@ -7,9 +8,39 @@ use axum::{Extension, Json};
|
|||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::auth::{OptionalUser, PocketBaseUser};
|
||||
use crate::pocketbase::get_superuser_token;
|
||||
use crate::state::SharedState;
|
||||
use crate::state::{AppState, SharedState};
|
||||
|
||||
static INVITE_REDEMPTIONS_IN_PROGRESS: LazyLock<Mutex<HashSet<String>>> =
|
||||
LazyLock::new(|| Mutex::new(HashSet::new()));
|
||||
|
||||
struct InviteRedemptionGuard {
|
||||
code: String,
|
||||
}
|
||||
|
||||
impl InviteRedemptionGuard {
|
||||
fn acquire(code: &str) -> Option<Self> {
|
||||
let mut in_progress = INVITE_REDEMPTIONS_IN_PROGRESS
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if !in_progress.insert(code.to_string()) {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
code: code.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InviteRedemptionGuard {
|
||||
fn drop(&mut self) {
|
||||
let mut in_progress = INVITE_REDEMPTIONS_IN_PROGRESS
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
in_progress.remove(&self.code);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct InviteResponse {
|
||||
|
|
@ -87,6 +118,207 @@ fn generate_invite_code() -> String {
|
|||
chars.into_iter().collect()
|
||||
}
|
||||
|
||||
fn current_unix_secs_string() -> String {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn lookup_unused_invite(
|
||||
state: &AppState,
|
||||
pb_url: &str,
|
||||
token: &str,
|
||||
code: &str,
|
||||
) -> Result<Option<serde_json::Value>, Response> {
|
||||
let filter = format!("code=\"{}\" && used_by_id=\"\"", code);
|
||||
let lookup_url = format!(
|
||||
"{pb_url}/api/collections/invites/records?filter={}&perPage=1",
|
||||
urlencoding::encode(&filter)
|
||||
);
|
||||
|
||||
let res = match state
|
||||
.http_client
|
||||
.get(&lookup_url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(err) => {
|
||||
warn!("Failed to look up invite: {err}");
|
||||
return Err(StatusCode::BAD_GATEWAY.into_response());
|
||||
}
|
||||
};
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let text = res.text().await.unwrap_or_default();
|
||||
warn!("PocketBase invite lookup failed ({status}): {text}");
|
||||
return Err(StatusCode::BAD_GATEWAY.into_response());
|
||||
}
|
||||
|
||||
let body: serde_json::Value = match res.json().await {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
warn!("Failed to parse invite lookup response: {err}");
|
||||
return Err(StatusCode::BAD_GATEWAY.into_response());
|
||||
}
|
||||
};
|
||||
|
||||
Ok(body["items"]
|
||||
.as_array()
|
||||
.and_then(|arr| arr.first())
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn mark_invite_used(
|
||||
state: &AppState,
|
||||
pb_url: &str,
|
||||
token: &str,
|
||||
invite_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<(), Response> {
|
||||
let resp = match state
|
||||
.http_client
|
||||
.patch(format!(
|
||||
"{pb_url}/api/collections/invites/records/{invite_id}"
|
||||
))
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({
|
||||
"used_by_id": user_id,
|
||||
"used_at": current_unix_secs_string(),
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(err) => {
|
||||
warn!("Failed to mark invite as used: {err}");
|
||||
return Err(StatusCode::BAD_GATEWAY.into_response());
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
warn!("PocketBase invite usage update failed ({status}): {text}");
|
||||
return Err(StatusCode::BAD_GATEWAY.into_response());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn grant_license_for_invite(
|
||||
state: &AppState,
|
||||
pb_url: &str,
|
||||
token: &str,
|
||||
user_id: &str,
|
||||
) -> Result<(), Response> {
|
||||
let update_url = format!("{pb_url}/api/collections/users/records/{user_id}");
|
||||
let resp = match state
|
||||
.http_client
|
||||
.patch(&update_url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({ "subscription": "licensed" }))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(err) => {
|
||||
warn!("Failed to update user subscription for admin invite: {err}");
|
||||
return Err(StatusCode::BAD_GATEWAY.into_response());
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
warn!("PocketBase user subscription update failed ({status}): {text}");
|
||||
return Err(StatusCode::BAD_GATEWAY.into_response());
|
||||
}
|
||||
|
||||
state.token_cache.invalidate_by_user_id(user_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_referral_checkout(
|
||||
state: &AppState,
|
||||
user: &PocketBaseUser,
|
||||
) -> Result<String, Response> {
|
||||
let count = match super::pricing::count_licensed_users(state).await {
|
||||
Ok(count) => count,
|
||||
Err(err) => {
|
||||
warn!("Failed to count licensed users for invite checkout: {err}");
|
||||
return Err(StatusCode::SERVICE_UNAVAILABLE.into_response());
|
||||
}
|
||||
};
|
||||
let price_pence = super::pricing::price_for_count(count);
|
||||
|
||||
let public_url = &state.public_url;
|
||||
let success_url = format!("{public_url}/pricing?license_success=1");
|
||||
let cancel_url = format!("{public_url}/pricing");
|
||||
|
||||
let form_params = vec![
|
||||
("mode", "payment".to_string()),
|
||||
(
|
||||
"line_items[0][price_data][unit_amount]",
|
||||
price_pence.to_string(),
|
||||
),
|
||||
("line_items[0][price_data][currency]", "gbp".to_string()),
|
||||
(
|
||||
"line_items[0][price_data][product_data][name]",
|
||||
"Perfect Postcodes Lifetime License".to_string(),
|
||||
),
|
||||
("line_items[0][quantity]", "1".to_string()),
|
||||
("success_url", success_url),
|
||||
("cancel_url", cancel_url),
|
||||
("client_reference_id", user.id.clone()),
|
||||
("customer_email", user.email.clone()),
|
||||
(
|
||||
"discounts[0][coupon]",
|
||||
state.stripe_referral_coupon_id.clone(),
|
||||
),
|
||||
];
|
||||
|
||||
let stripe_res = state
|
||||
.http_client
|
||||
.post("https://api.stripe.com/v1/checkout/sessions")
|
||||
.basic_auth(&state.stripe_secret_key, None::<&str>)
|
||||
.form(&form_params)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match stripe_res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let stripe_body: serde_json::Value = match resp.json().await {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
warn!("Failed to parse Stripe checkout response: {err}");
|
||||
return Err(StatusCode::BAD_GATEWAY.into_response());
|
||||
}
|
||||
};
|
||||
let checkout_url = stripe_body["url"].as_str().unwrap_or_default().to_string();
|
||||
if checkout_url.is_empty() {
|
||||
warn!("Stripe checkout response did not include a URL");
|
||||
return Err(StatusCode::BAD_GATEWAY.into_response());
|
||||
}
|
||||
Ok(checkout_url)
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
warn!("Failed to create Stripe checkout for referral invite ({status}): {text}");
|
||||
Err(StatusCode::BAD_GATEWAY.into_response())
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Stripe request error for referral invite: {err}");
|
||||
Err(StatusCode::BAD_GATEWAY.into_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an invite. Admins create "admin" invites (free license) by default,
|
||||
/// but can explicitly request "referral" type. Licensed non-admin users always create "referral" invites (30% off).
|
||||
pub async fn post_invites(
|
||||
|
|
@ -319,154 +551,80 @@ pub async fn post_redeem_invite(
|
|||
}
|
||||
};
|
||||
|
||||
// Look up invite
|
||||
let filter = format!("code=\"{}\" && used_by_id=\"\"", req.code);
|
||||
let lookup_url = format!(
|
||||
"{pb_url}/api/collections/invites/records?filter={}&perPage=1",
|
||||
urlencoding::encode(&filter)
|
||||
);
|
||||
let _redemption_guard = match InviteRedemptionGuard::acquire(&req.code) {
|
||||
Some(guard) => guard,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
"Invite redemption is already in progress",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let res = match state
|
||||
.http_client
|
||||
.get(&lookup_url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
warn!("Failed to look up invite: {err}");
|
||||
let invite = match lookup_unused_invite(&state, pb_url, &token, &req.code).await {
|
||||
Ok(Some(invite)) => invite,
|
||||
Ok(None) => {
|
||||
return (StatusCode::NOT_FOUND, "Invalid or already used invite code").into_response()
|
||||
}
|
||||
Err(response) => return response,
|
||||
};
|
||||
|
||||
let invite_id = match invite["id"].as_str().filter(|id| !id.is_empty()) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
warn!(code = %req.code, "Invite lookup returned record without id");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
let invite_type = match invite["invite_type"].as_str() {
|
||||
Some("admin") => "admin",
|
||||
Some("referral") => "referral",
|
||||
Some(other) => {
|
||||
warn!(code = %req.code, invite_type = other, "Invite has unsupported type");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
None => {
|
||||
warn!(code = %req.code, "Invite lookup returned record without invite_type");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let body: serde_json::Value = match res.json().await {
|
||||
Ok(v) => v,
|
||||
Err(_) => return StatusCode::BAD_GATEWAY.into_response(),
|
||||
};
|
||||
|
||||
let invite = match body["items"].as_array().and_then(|arr| arr.first()) {
|
||||
Some(inv) => inv.clone(),
|
||||
None => {
|
||||
return (StatusCode::NOT_FOUND, "Invalid or already used invite code").into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let invite_id = invite["id"].as_str().unwrap_or("");
|
||||
let invite_type = invite["invite_type"].as_str().unwrap_or("");
|
||||
|
||||
// Mark invite as used
|
||||
let now = {
|
||||
let dur = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
dur.as_secs().to_string()
|
||||
};
|
||||
let _ = state
|
||||
.http_client
|
||||
.patch(format!(
|
||||
"{pb_url}/api/collections/invites/records/{invite_id}"
|
||||
))
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({
|
||||
"used_by_id": user.id,
|
||||
"used_at": now,
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
if invite_type == "admin" {
|
||||
// Grant license directly
|
||||
let update_url = format!("{pb_url}/api/collections/users/records/{}", user.id);
|
||||
let res = state
|
||||
.http_client
|
||||
.patch(&update_url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({ "subscription": "licensed" }))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
state.token_cache.invalidate_by_user_id(&user.id);
|
||||
info!(user_id = %user.id, code = %req.code, "Admin invite redeemed — user licensed");
|
||||
Json(RedeemResponse {
|
||||
result: "licensed".to_string(),
|
||||
checkout_url: None,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
_ => {
|
||||
warn!("Failed to update user subscription for admin invite");
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
if let Err(response) = grant_license_for_invite(&state, pb_url, &token, &user.id).await {
|
||||
return response;
|
||||
}
|
||||
} else {
|
||||
// Referral invite — create discounted checkout with dynamic pricing
|
||||
let count = match super::pricing::count_licensed_users(&state).await {
|
||||
Ok(c) => c,
|
||||
Err(err) => {
|
||||
warn!("Failed to count licensed users for invite checkout: {err}");
|
||||
return StatusCode::SERVICE_UNAVAILABLE.into_response();
|
||||
}
|
||||
};
|
||||
let price_pence = super::pricing::price_for_count(count);
|
||||
|
||||
let secret_key = &state.stripe_secret_key;
|
||||
let public_url = &state.public_url;
|
||||
let success_url = format!("{public_url}/pricing?license_success=1");
|
||||
let cancel_url = format!("{public_url}/pricing");
|
||||
|
||||
let form_params = vec![
|
||||
("mode", "payment".to_string()),
|
||||
(
|
||||
"line_items[0][price_data][unit_amount]",
|
||||
price_pence.to_string(),
|
||||
),
|
||||
("line_items[0][price_data][currency]", "gbp".to_string()),
|
||||
(
|
||||
"line_items[0][price_data][product_data][name]",
|
||||
"Perfect Postcodes Lifetime License".to_string(),
|
||||
),
|
||||
("line_items[0][quantity]", "1".to_string()),
|
||||
("success_url", success_url),
|
||||
("cancel_url", cancel_url),
|
||||
("client_reference_id", user.id.clone()),
|
||||
("customer_email", user.email.clone()),
|
||||
(
|
||||
"discounts[0][coupon]",
|
||||
state.stripe_referral_coupon_id.clone(),
|
||||
),
|
||||
];
|
||||
|
||||
let stripe_res = state
|
||||
.http_client
|
||||
.post("https://api.stripe.com/v1/checkout/sessions")
|
||||
.basic_auth(secret_key, None::<&str>)
|
||||
.form(&form_params)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match stripe_res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let stripe_body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
let checkout_url = stripe_body["url"].as_str().unwrap_or_default().to_string();
|
||||
info!(user_id = %user.id, code = %req.code, "Referral invite redeemed — checkout created");
|
||||
Json(RedeemResponse {
|
||||
result: "checkout".to_string(),
|
||||
checkout_url: Some(checkout_url),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
_ => {
|
||||
warn!("Failed to create Stripe checkout for referral invite");
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
if let Err(response) = mark_invite_used(&state, pb_url, &token, invite_id, &user.id).await {
|
||||
return response;
|
||||
}
|
||||
|
||||
info!(user_id = %user.id, code = %req.code, "Admin invite redeemed; user licensed");
|
||||
return Json(RedeemResponse {
|
||||
result: "licensed".to_string(),
|
||||
checkout_url: None,
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let checkout_url = match create_referral_checkout(&state, &user).await {
|
||||
Ok(url) => url,
|
||||
Err(response) => return response,
|
||||
};
|
||||
|
||||
if let Err(response) = mark_invite_used(&state, pb_url, &token, invite_id, &user.id).await {
|
||||
return response;
|
||||
}
|
||||
|
||||
info!(user_id = %user.id, code = %req.code, "Referral invite redeemed; checkout created");
|
||||
Json(RedeemResponse {
|
||||
result: "checkout".to_string(),
|
||||
checkout_url: Some(checkout_url),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// List invites. Admins see all invites; licensed users see only their own.
|
||||
/// List invites. Users only see invites they created, including admins.
|
||||
pub async fn get_invites(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
|
|
@ -487,16 +645,9 @@ pub async fn get_invites(
|
|||
}
|
||||
};
|
||||
|
||||
let filter = if user.is_admin {
|
||||
String::new()
|
||||
} else {
|
||||
format!("created_by=\"{}\"", user.id)
|
||||
};
|
||||
|
||||
let filter = format!("created_by=\"{}\"", user.id);
|
||||
let mut url = format!("{pb_url}/api/collections/invites/records?sort=-created&perPage=200");
|
||||
if !filter.is_empty() {
|
||||
url.push_str(&format!("&filter={}", urlencoding::encode(&filter)));
|
||||
}
|
||||
url.push_str(&format!("&filter={}", urlencoding::encode(&filter)));
|
||||
|
||||
let res = match state
|
||||
.http_client
|
||||
|
|
|
|||
|
|
@ -3,14 +3,18 @@ use std::sync::Arc;
|
|||
use axum::extract::{Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Json;
|
||||
use rustc_hash::FxHashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::info;
|
||||
|
||||
use crate::consts::MAX_POIS_PER_REQUEST;
|
||||
use crate::data::POICategoryGroup;
|
||||
use crate::data::{POICategoryGroup, POIData};
|
||||
use crate::parsing::require_bounds;
|
||||
use crate::state::SharedState;
|
||||
|
||||
const TUBE_STATION_CATEGORY: &str = "Tube station";
|
||||
const TUBE_STATION_MERGE_RADIUS_DEGREES: f32 = 0.01;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub struct POI {
|
||||
|
|
@ -35,6 +39,167 @@ pub struct POIParams {
|
|||
categories: Option<String>,
|
||||
}
|
||||
|
||||
struct SelectedPOIRow {
|
||||
row: usize,
|
||||
id_override: Option<String>,
|
||||
name_override: Option<String>,
|
||||
lat: f32,
|
||||
lng: f32,
|
||||
lat_sum: f32,
|
||||
lng_sum: f32,
|
||||
count: u32,
|
||||
priority: u32,
|
||||
}
|
||||
|
||||
impl SelectedPOIRow {
|
||||
fn new(data: &POIData, row: usize, override_identity: bool) -> Self {
|
||||
Self {
|
||||
row,
|
||||
id_override: override_identity.then(|| data.id(row).to_string()),
|
||||
name_override: override_identity.then(|| data.name[row].clone()),
|
||||
lat: data.lat[row],
|
||||
lng: data.lng[row],
|
||||
lat_sum: data.lat[row],
|
||||
lng_sum: data.lng[row],
|
||||
count: 1,
|
||||
priority: data.priority[row],
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_tube_station(&mut self, data: &POIData, row: usize) {
|
||||
self.lat_sum += data.lat[row];
|
||||
self.lng_sum += data.lng[row];
|
||||
self.count += 1;
|
||||
self.lat = self.lat_sum / self.count as f32;
|
||||
self.lng = self.lng_sum / self.count as f32;
|
||||
self.priority = self.priority.min(data.priority[row]);
|
||||
|
||||
let current_name = self
|
||||
.name_override
|
||||
.as_deref()
|
||||
.unwrap_or(&data.name[self.row]);
|
||||
let candidate_name = &data.name[row];
|
||||
if tube_station_name_score(candidate_name) < tube_station_name_score(current_name) {
|
||||
self.id_override = Some(data.id(row).to_string());
|
||||
self.name_override = Some(candidate_name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn id(&self, data: &POIData) -> String {
|
||||
self.id_override
|
||||
.clone()
|
||||
.unwrap_or_else(|| data.id(self.row).to_string())
|
||||
}
|
||||
|
||||
fn name(&self, data: &POIData) -> String {
|
||||
self.name_override
|
||||
.clone()
|
||||
.unwrap_or_else(|| data.name[self.row].clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn dedupe_tube_stations(data: &POIData, rows: Vec<usize>) -> Vec<SelectedPOIRow> {
|
||||
let mut selected = Vec::with_capacity(rows.len());
|
||||
let mut tube_groups: FxHashMap<String, Vec<usize>> = FxHashMap::default();
|
||||
|
||||
for row in rows {
|
||||
if data.category.get(row) != TUBE_STATION_CATEGORY {
|
||||
selected.push(SelectedPOIRow::new(data, row, false));
|
||||
continue;
|
||||
}
|
||||
|
||||
let station_key = canonical_tube_station_name(&data.name[row]);
|
||||
if station_key.is_empty() {
|
||||
selected.push(SelectedPOIRow::new(data, row, false));
|
||||
continue;
|
||||
}
|
||||
|
||||
let existing = tube_groups.get(&station_key).and_then(|indices| {
|
||||
indices.iter().copied().find(|&index| {
|
||||
same_tube_station_area(&selected[index], data.lat[row], data.lng[row])
|
||||
})
|
||||
});
|
||||
|
||||
if let Some(index) = existing {
|
||||
selected[index].merge_tube_station(data, row);
|
||||
} else {
|
||||
let index = selected.len();
|
||||
selected.push(SelectedPOIRow::new(data, row, true));
|
||||
tube_groups.entry(station_key).or_default().push(index);
|
||||
}
|
||||
}
|
||||
|
||||
selected
|
||||
}
|
||||
|
||||
fn canonical_tube_station_name(name: &str) -> String {
|
||||
let mut normalized = String::with_capacity(name.len());
|
||||
let mut paren_depth = 0u32;
|
||||
|
||||
for ch in name.chars() {
|
||||
match ch {
|
||||
'(' => {
|
||||
paren_depth += 1;
|
||||
normalized.push(' ');
|
||||
}
|
||||
')' => {
|
||||
paren_depth = paren_depth.saturating_sub(1);
|
||||
normalized.push(' ');
|
||||
}
|
||||
_ if paren_depth > 0 => {}
|
||||
'\'' | '’' | '`' => {}
|
||||
'&' => normalized.push_str(" and "),
|
||||
_ if ch.is_ascii_alphanumeric() => normalized.push(ch.to_ascii_lowercase()),
|
||||
_ => normalized.push(' '),
|
||||
}
|
||||
}
|
||||
|
||||
let mut words: Vec<&str> = normalized.split_whitespace().collect();
|
||||
const SUFFIXES: &[&[&str]] = &[
|
||||
&["underground", "station"],
|
||||
&["tube", "station"],
|
||||
&["dlr", "station"],
|
||||
&["metro", "station"],
|
||||
&["tram", "stop"],
|
||||
&["rail", "station"],
|
||||
&["railway", "station"],
|
||||
&["station"],
|
||||
&["stop"],
|
||||
];
|
||||
|
||||
loop {
|
||||
let Some(suffix) = SUFFIXES.iter().find(|suffix| words.ends_with(suffix)) else {
|
||||
break;
|
||||
};
|
||||
words.truncate(words.len() - suffix.len());
|
||||
}
|
||||
|
||||
words.join(" ")
|
||||
}
|
||||
|
||||
fn same_tube_station_area(station: &SelectedPOIRow, lat: f32, lng: f32) -> bool {
|
||||
let dlat = station.lat - lat;
|
||||
let dlng = (station.lng - lng) * station.lat.to_radians().cos();
|
||||
(dlat * dlat + dlng * dlng) <= TUBE_STATION_MERGE_RADIUS_DEGREES.powi(2)
|
||||
}
|
||||
|
||||
fn tube_station_name_score(name: &str) -> (u8, usize) {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let suffix_penalty = if lower.ends_with(" underground station")
|
||||
|| lower.ends_with(" tube station")
|
||||
|| lower.ends_with(" dlr station")
|
||||
|| lower.ends_with(" metro station")
|
||||
|| lower.ends_with(" tram stop")
|
||||
|| lower.ends_with(" station")
|
||||
|| lower.ends_with(" stop")
|
||||
{
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
(suffix_penalty, name.len())
|
||||
}
|
||||
|
||||
pub async fn get_pois(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Query(params): Query<POIParams>,
|
||||
|
|
@ -68,7 +233,7 @@ pub async fn get_pois(
|
|||
let t0 = std::time::Instant::now();
|
||||
let row_indices = state.poi_grid.query(south, west, north, east);
|
||||
|
||||
let mut matching_rows: Vec<usize> = row_indices
|
||||
let matching_rows: Vec<usize> = row_indices
|
||||
.iter()
|
||||
.filter_map(|&row_idx| {
|
||||
let row = row_idx as usize;
|
||||
|
|
@ -81,27 +246,32 @@ pub async fn get_pois(
|
|||
})
|
||||
.collect();
|
||||
|
||||
if matching_rows.len() > MAX_POIS_PER_REQUEST {
|
||||
let ratio = (matching_rows.len() / MAX_POIS_PER_REQUEST) as u32;
|
||||
let mut matching_pois = dedupe_tube_stations(&state.poi_data, matching_rows);
|
||||
|
||||
if matching_pois.len() > MAX_POIS_PER_REQUEST {
|
||||
let ratio = (matching_pois.len() / MAX_POIS_PER_REQUEST) as u32;
|
||||
let step = ratio.next_power_of_two();
|
||||
let mask = step - 1;
|
||||
matching_rows.retain(|&row| state.poi_data.priority[row] & mask == 0);
|
||||
if matching_rows.len() > MAX_POIS_PER_REQUEST {
|
||||
matching_rows.sort_unstable_by_key(|&row| state.poi_data.priority[row]);
|
||||
matching_rows.truncate(MAX_POIS_PER_REQUEST);
|
||||
matching_pois.retain(|poi| poi.priority & mask == 0);
|
||||
if matching_pois.len() > MAX_POIS_PER_REQUEST {
|
||||
matching_pois.sort_unstable_by_key(|poi| poi.priority);
|
||||
matching_pois.truncate(MAX_POIS_PER_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
let pois: Vec<POI> = matching_rows
|
||||
let pois: Vec<POI> = matching_pois
|
||||
.iter()
|
||||
.map(|&row| POI {
|
||||
id: state.poi_data.id(row).to_string(),
|
||||
name: state.poi_data.name[row].clone(),
|
||||
category: state.poi_data.category.get(row).to_string(),
|
||||
group: state.poi_data.group.get(row).to_string(),
|
||||
lat: state.poi_data.lat[row],
|
||||
lng: state.poi_data.lng[row],
|
||||
emoji: state.poi_data.emoji.get(row).to_string(),
|
||||
.map(|poi| {
|
||||
let row = poi.row;
|
||||
POI {
|
||||
id: poi.id(&state.poi_data),
|
||||
name: poi.name(&state.poi_data),
|
||||
category: state.poi_data.category.get(row).to_string(),
|
||||
group: state.poi_data.group.get(row).to_string(),
|
||||
lat: poi.lat,
|
||||
lng: poi.lng,
|
||||
emoji: state.poi_data.emoji.get(row).to_string(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
|
@ -143,3 +313,53 @@ pub async fn get_poi_categories(
|
|||
|
||||
Json(POICategoriesResponse { groups })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn canonical_tube_station_name_strips_transport_suffixes() {
|
||||
assert_eq!(canonical_tube_station_name("Bank"), "bank");
|
||||
assert_eq!(
|
||||
canonical_tube_station_name("Bank Underground Station"),
|
||||
"bank"
|
||||
);
|
||||
assert_eq!(canonical_tube_station_name("Bank DLR Station"), "bank");
|
||||
assert_eq!(
|
||||
canonical_tube_station_name("Pleasure Beach (Blackpool Tramway)"),
|
||||
"pleasure beach"
|
||||
);
|
||||
assert_eq!(
|
||||
canonical_tube_station_name("Earl's Court Tube Station"),
|
||||
"earls court"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_tube_station_area_keeps_distant_names_separate() {
|
||||
let station = SelectedPOIRow {
|
||||
row: 0,
|
||||
id_override: None,
|
||||
name_override: None,
|
||||
lat: 51.5130,
|
||||
lng: -0.0889,
|
||||
lat_sum: 51.5130,
|
||||
lng_sum: -0.0889,
|
||||
count: 1,
|
||||
priority: 0,
|
||||
};
|
||||
|
||||
assert!(same_tube_station_area(&station, 51.5132, -0.0885));
|
||||
assert!(!same_tube_station_area(&station, 55.0140, -1.6781));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tube_station_name_score_prefers_plain_station_names() {
|
||||
assert!(tube_station_name_score("Bank") < tube_station_name_score("Bank DLR Station"));
|
||||
assert!(
|
||||
tube_station_name_score("Acton Town")
|
||||
< tube_station_name_score("Acton Town Underground Station")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,13 +13,13 @@ use tracing::info;
|
|||
use crate::aggregation::{Aggregator, EnumDistConfig};
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::consts::MAX_CELLS_PER_REQUEST;
|
||||
use crate::pocketbase::log_user_location;
|
||||
use crate::data::travel_time::TravelData;
|
||||
use crate::licensing::check_license_bounds;
|
||||
use crate::parsing::{
|
||||
bounds_intersect, parse_enum_dist, parse_field_indices, parse_filters, require_bounds,
|
||||
row_passes_filters,
|
||||
};
|
||||
use crate::pocketbase::log_user_location;
|
||||
use crate::routes::travel_time::{parse_optional_travel, TravelTimeAgg};
|
||||
use crate::state::SharedState;
|
||||
use crate::utils::normalize_postcode;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue