Compare commits
16 commits
1e90acf6a5
...
fe46cb3379
| Author | SHA1 | Date | |
|---|---|---|---|
| fe46cb3379 | |||
| a48eb945e0 | |||
| f114ada255 | |||
| 701c17a703 | |||
| a9bad79339 | |||
| 5c3b87f2d5 | |||
| 94f9c0d594 | |||
| 28323f145e | |||
| 58bb3cb4f8 | |||
| e3e8a4522e | |||
| ea7afd618c | |||
| 7cba369308 | |||
| 7a1696541f | |||
| 48983e3b4b | |||
| 7c36cbfdd4 | |||
| 589de0c5ac |
5
.gitignore
vendored
|
|
@ -5,4 +5,7 @@
|
|||
**/dist
|
||||
server-rs/target
|
||||
.task
|
||||
frontend/public/assets
|
||||
frontend/public/assets/*
|
||||
!frontend/public/assets/poi-icons/
|
||||
!frontend/public/assets/poi-icons/**
|
||||
server-rs/logs
|
||||
|
|
|
|||
|
|
@ -189,8 +189,8 @@ $(GREENSPACE): $(PBF)
|
|||
$(OS_GREENSPACE):
|
||||
uv run python -m pipeline.download.os_greenspace --output $@
|
||||
|
||||
$(PLACES): $(PBF) $(ENGLAND_BOUNDARY)
|
||||
uv run python -m pipeline.download.places --output $@ --pbf $(PBF) --boundary $(ENGLAND_BOUNDARY)
|
||||
$(PLACES): $(PBF) $(ENGLAND_BOUNDARY) $(NAPTAN)
|
||||
uv run python -m pipeline.download.places --output $@ --pbf $(PBF) --boundary $(ENGLAND_BOUNDARY) --naptan $(NAPTAN)
|
||||
|
||||
$(LSOA_POP):
|
||||
uv run python -m pipeline.download.lsoa_population --output $@
|
||||
|
|
|
|||
29
README.md
|
|
@ -8,6 +8,35 @@ a React/deck.gl map.
|
|||
The public product is branded as Perfect Postcodes, while this repository is
|
||||
still named `property-map`.
|
||||
|
||||
## Public SEO Pages
|
||||
|
||||
The indexable public pages are listed in `frontend/public/sitemap.xml` and
|
||||
prerendered by `frontend/scripts/prerender.mjs`:
|
||||
|
||||
- [Home](https://perfect-postcode.co.uk/) - `/`
|
||||
- [Learn](https://perfect-postcode.co.uk/learn) - `/learn`
|
||||
- [Pricing](https://perfect-postcode.co.uk/pricing) - `/pricing`
|
||||
- [Property price map](https://perfect-postcode.co.uk/property-price-map) -
|
||||
`/property-price-map`
|
||||
- [Postcode property search](https://perfect-postcode.co.uk/postcode-property-search) -
|
||||
`/postcode-property-search`
|
||||
- [Commute property search](https://perfect-postcode.co.uk/commute-property-search) -
|
||||
`/commute-property-search`
|
||||
- [School property search](https://perfect-postcode.co.uk/school-property-search) -
|
||||
`/school-property-search`
|
||||
- [Postcode checker](https://perfect-postcode.co.uk/postcode-checker) -
|
||||
`/postcode-checker`
|
||||
- [Birmingham property search](https://perfect-postcode.co.uk/property-search/birmingham) -
|
||||
`/property-search/birmingham`
|
||||
- [Manchester property search](https://perfect-postcode.co.uk/property-search/manchester) -
|
||||
`/property-search/manchester`
|
||||
- [Bristol property search](https://perfect-postcode.co.uk/property-search/bristol) -
|
||||
`/property-search/bristol`
|
||||
- [Data sources](https://perfect-postcode.co.uk/data-sources) - `/data-sources`
|
||||
- [Methodology](https://perfect-postcode.co.uk/methodology) - `/methodology`
|
||||
- [Privacy and security](https://perfect-postcode.co.uk/privacy-security) -
|
||||
`/privacy-security`
|
||||
|
||||
## What Is In Here
|
||||
|
||||
- `frontend/` - React 18, TypeScript, Tailwind, MapLibre, and deck.gl. The app
|
||||
|
|
|
|||
1
check.sh
|
|
@ -23,6 +23,7 @@ step "Python unit tests" uv run pytest \
|
|||
step "Frontend lint: ESLint" npm run lint
|
||||
step "Frontend format check: Prettier" npm run format:check
|
||||
step "Frontend typecheck: TypeScript" npm run typecheck
|
||||
step "Frontend i18n completeness" npm run check:i18n
|
||||
step "Frontend unit tests: Vitest" npm run test
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -54,8 +54,12 @@ services:
|
|||
init: true
|
||||
build: /volumes/syncthing/Projects/property-map/screenshot
|
||||
environment:
|
||||
PORT: "8002"
|
||||
APP_URL: http://frontend:3001
|
||||
CACHE_DIR: /cache
|
||||
SCREENSHOT_CONCURRENCY: "3"
|
||||
SCREENSHOT_RATE_WINDOW_MS: "60000"
|
||||
SCREENSHOT_RATE_LIMIT: "30"
|
||||
volumes:
|
||||
- screenshot-cache:/cache
|
||||
networks:
|
||||
|
|
|
|||
54
frontend/eslint.config.cjs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
const js = require('@eslint/js');
|
||||
const tsParser = require('@typescript-eslint/parser');
|
||||
const tsPlugin = require('@typescript-eslint/eslint-plugin');
|
||||
const globals = require('globals');
|
||||
const reactPlugin = require('eslint-plugin-react');
|
||||
const reactHooksPlugin = require('eslint-plugin-react-hooks');
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**'],
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: false,
|
||||
},
|
||||
},
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.es2021,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': tsPlugin,
|
||||
react: reactPlugin,
|
||||
'react-hooks': reactHooksPlugin,
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
...reactPlugin.configs.recommended.rules,
|
||||
...tsPlugin.configs.recommended.rules,
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/prop-types': 'off',
|
||||
'no-undef': 'off',
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
5948
frontend/package-lock.json
generated
|
|
@ -11,65 +11,72 @@
|
|||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"lint:fix": "eslint src --ext .ts,.tsx --fix",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\""
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"",
|
||||
"check:i18n": "node scripts/check-translations.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deck.gl/core": "^9.0.0",
|
||||
"@deck.gl/geo-layers": "^9.0.0",
|
||||
"@deck.gl/layers": "^9.0.0",
|
||||
"@deck.gl/mapbox": "^9.2.6",
|
||||
"@deck.gl/react": "^9.0.0",
|
||||
"@plausible-analytics/tracker": "^0.4.4",
|
||||
"@protomaps/basemaps": "^5.7.0",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-slider": "^1.1.0",
|
||||
"@deck.gl/core": "^9.3.2",
|
||||
"@deck.gl/extensions": "^9.3.2",
|
||||
"@deck.gl/geo-layers": "^9.3.2",
|
||||
"@deck.gl/layers": "^9.3.2",
|
||||
"@deck.gl/mapbox": "^9.3.2",
|
||||
"@deck.gl/mesh-layers": "^9.3.2",
|
||||
"@deck.gl/react": "^9.3.2",
|
||||
"@deck.gl/widgets": "^9.3.2",
|
||||
"@plausible-analytics/tracker": "^0.4.5",
|
||||
"@protomaps/basemaps": "^5.7.2",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"i18next": "^26.0.3",
|
||||
"maplibre-gl": "^4.0.0",
|
||||
"i18next": "^26.0.10",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"pocketbase": "^0.26.8",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-i18next": "^17.0.2",
|
||||
"react-joyride": "^2.9.3",
|
||||
"react-map-gl": "^7.1.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.7",
|
||||
"react-joyride": "^3.1.0",
|
||||
"react-map-gl": "^8.1.1",
|
||||
"supercluster": "^8.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.0",
|
||||
"@babel/preset-env": "^7.29.0",
|
||||
"@babel/preset-env": "^7.29.5",
|
||||
"@babel/preset-react": "^7.28.5",
|
||||
"@babel/preset-typescript": "^7.28.5",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@tailwindcss/postcss": "^4.2.4",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"babel-loader": "^10.0.0",
|
||||
"copy-webpack-plugin": "^13.0.1",
|
||||
"css-loader": "^7.0.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react": "^7.34.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.2",
|
||||
"@typescript-eslint/parser": "^8.59.2",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"babel-loader": "^10.1.1",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"css-loader": "^7.1.4",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"favicons": "^7.2.0",
|
||||
"favicons-webpack-plugin": "^6.0.1",
|
||||
"html-webpack-plugin": "^5.6.0",
|
||||
"globals": "^17.6.0",
|
||||
"html-webpack-plugin": "^5.6.7",
|
||||
"jsdom": "^29.1.1",
|
||||
"mini-css-extract-plugin": "^2.9.0",
|
||||
"postcss": "^8.4.0",
|
||||
"postcss-loader": "^8.0.0",
|
||||
"prettier": "^3.2.0",
|
||||
"puppeteer": "^24.0.0",
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"postcss": "^8.5.14",
|
||||
"postcss-loader": "^8.2.1",
|
||||
"prettier": "^3.8.3",
|
||||
"puppeteer": "^24.43.0",
|
||||
"react-refresh": "^0.18.0",
|
||||
"sharp": "^0.34.5",
|
||||
"style-loader": "^4.0.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"ts-loader": "^9.5.0",
|
||||
"typescript": "^5.4.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"ts-loader": "^9.5.7",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.5",
|
||||
"webpack": "^5.90.0",
|
||||
"webpack-cli": "^5.1.0",
|
||||
"webpack-dev-server": "^5.0.0"
|
||||
"webpack": "^5.106.2",
|
||||
"webpack-cli": "^7.0.2",
|
||||
"webpack-dev-server": "^5.2.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="1.293 7.314 15.48 3.293"
|
||||
width="512"
|
||||
height="109"
|
||||
version="1.1"
|
||||
id="svg9721"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs9725" />
|
||||
<g
|
||||
id="g10639"
|
||||
style="isolation:isolate"
|
||||
transform="matrix(1.0559228,0,0,1.1142984,-3.8273049,-1.8542288)">
|
||||
<g
|
||||
id="g10637"
|
||||
transform="translate(-0.02928986,-0.38857607)">
|
||||
<path
|
||||
d="M 9.266,9.717 C 9.306,9.65 9.351,9.601 9.415,9.575 9.522,9.533 9.627,9.536 9.726,9.6 9.75,9.616 9.768,9.619 9.796,9.606 10.017,9.507 10.232,9.52 10.439,9.649 10.472,9.67 10.492,9.666 10.523,9.646 10.724,9.52 10.936,9.508 11.15,9.598 c 0.179,0.076 0.272,0.23 0.308,0.427 0.008,0.046 0.013,0.093 0.013,0.141 -10e-4,0.188 -10e-4,0.376 0,0.564 0,0.061 -0.009,0.12 -0.026,0.177 -0.04,0.132 -0.142,0.216 -0.271,0.224 -0.04,0.002 -0.08,0.003 -0.12,-0.007 -0.14,-0.035 -0.226,-0.15 -0.241,-0.32 -0.003,-0.036 -0.003,-0.073 -0.003,-0.109 0,-0.133 -0.001,-0.265 0,-0.398 0,-0.037 -0.004,-0.073 -0.011,-0.108 -0.005,-0.028 -0.02,-0.046 -0.049,-0.047 -0.028,0 -0.05,0.009 -0.061,0.04 -0.01,0.029 -0.01,0.059 -0.01,0.089 0,0.152 0,0.304 -10e-4,0.456 0,0.068 -0.009,0.136 -0.033,0.2 -0.036,0.099 -0.101,0.163 -0.196,0.191 -0.075,0.022 -0.151,0.021 -0.224,-0.005 -0.123,-0.045 -0.182,-0.147 -0.203,-0.278 -0.007,-0.046 -0.011,-0.093 -0.011,-0.14 0.001,-0.144 0.001,-0.287 0,-0.43 0,-0.026 -0.002,-0.052 -0.007,-0.077 -0.005,-0.027 -0.019,-0.046 -0.049,-0.046 -0.029,0 -0.05,0.011 -0.06,0.042 -0.008,0.025 -0.008,0.051 -0.008,0.076 0,0.148 0,0.296 -0.001,0.443 0,0.065 -0.005,0.128 -0.022,0.19 -0.04,0.143 -0.143,0.231 -0.281,0.238 C 9.36,11.143 9.225,11.003 9.221,10.75 9.218,10.566 9.22,10.382 9.22,10.198 c 0,-0.023 0,-0.047 0,-0.069 -0.019,-0.012 -0.028,0.004 -0.039,0.01 -0.07,0.044 -0.148,0.065 -0.222,0.094 -0.135,0.052 -0.11,0.026 -0.112,0.168 -0.002,0.122 0,0.244 0,0.366 0,0.054 -0.008,0.107 -0.023,0.158 C 8.775,11.097 8.647,11.171 8.495,11.161 8.313,11.15 8.203,11.028 8.187,10.833 8.182,10.765 8.18,10.697 8.181,10.628 8.184,10.393 8.175,10.158 8.186,9.922 8.187,9.897 8.191,9.872 8.196,9.846 8.248,9.568 8.527,9.524 8.679,9.624 8.71,9.644 8.733,9.648 8.765,9.627 8.812,9.598 8.864,9.582 8.918,9.574 9.054,9.556 9.171,9.595 9.266,9.717 Z"
|
||||
fill="#ffff00"
|
||||
id="path10597" />
|
||||
<path
|
||||
d="M 8.031,9.957 C 8.031,9.895 8.019,9.834 7.997,9.776 7.961,9.68 7.896,9.618 7.804,9.591 7.709,9.562 7.618,9.572 7.533,9.627 7.508,9.643 7.489,9.647 7.463,9.63 7.432,9.61 7.398,9.598 7.363,9.587 7.086,9.502 6.76,9.623 6.578,9.879 6.566,9.896 6.558,9.919 6.532,9.933 6.527,9.775 6.446,9.683 6.315,9.635 6.33,9.615 6.351,9.611 6.368,9.6 6.446,9.549 6.488,9.477 6.488,9.378 6.489,9.241 6.419,9.128 6.294,9.077 6.162,9.023 6.027,9.024 5.895,9.071 5.684,9.144 5.584,9.316 5.555,9.539 5.547,9.602 5.528,9.632 5.471,9.655 5.32,9.715 5.258,9.885 5.324,10.032 c 0.035,0.08 0.096,0.131 0.174,0.156 0.035,0.011 0.046,0.029 0.045,0.068 -0.002,0.158 0,0.316 -0.001,0.475 0,0.047 0.004,0.094 0.011,0.14 0.028,0.178 0.146,0.29 0.314,0.291 0.162,10e-4 0.281,-0.081 0.323,-0.263 0.016,-0.07 0.016,-0.14 0.016,-0.211 10e-4,-0.139 0,-0.278 10e-4,-0.417 0,-0.055 0,-0.056 0.051,-0.064 0.061,-0.009 0.116,-0.031 0.166,-0.068 0.01,-0.008 0.019,-0.02 0.036,-0.02 -0.005,0.02 -0.008,0.037 -0.012,0.053 -0.033,0.14 -0.032,0.28 0.005,0.419 0.142,0.537 0.677,0.696 1.006,0.508 0.023,-0.013 0.04,-0.01 0.06,0.005 0.047,0.035 0.1,0.054 0.157,0.057 0.151,0.01 0.282,-0.06 0.332,-0.231 0.015,-0.049 0.023,-0.099 0.023,-0.151 10e-4,-0.274 10e-4,-0.548 0,-0.822 z m -0.666,0.392 c 0,0.006 0,0.012 0,0.019 0,0.087 -0.03,0.141 -0.091,0.161 C 7.206,10.552 7.139,10.518 7.115,10.446 7.104,10.41 7.099,10.372 7.105,10.333 c 0.014,-0.091 0.061,-0.135 0.141,-0.131 0.071,0.004 0.115,0.057 0.119,0.147 z"
|
||||
fill="#ffff00"
|
||||
id="path10599" />
|
||||
<path
|
||||
d="M 17.691,9.421 C 17.691,9.395 17.69,9.369 17.688,9.344 17.669,9.124 17.535,8.998 17.331,9.011 17.198,9.02 17.097,9.102 17.055,9.238 17.039,9.291 17.03,9.346 17.031,9.402 V 9.55 C 17.011,9.545 17.001,9.543 16.992,9.541 16.786,9.498 16.594,9.534 16.419,9.657 c -0.425,0.298 -0.45,0.972 -0.045,1.308 0.228,0.189 0.482,0.235 0.752,0.105 0.025,-0.012 0.04,-0.005 0.06,0.008 0.09,0.061 0.187,0.069 0.287,0.038 0.088,-0.028 0.148,-0.089 0.184,-0.18 0.026,-0.068 0.034,-0.14 0.034,-0.213 0,-0.434 0,-0.868 0,-1.302 z m -0.656,0.907 c -0.002,0.024 -0.002,0.045 -0.006,0.066 -0.014,0.073 -0.063,0.117 -0.13,0.118 -0.066,0 -0.117,-0.044 -0.131,-0.116 -0.009,-0.044 -0.009,-0.089 10e-4,-0.133 0.015,-0.069 0.064,-0.111 0.128,-0.111 0.066,-10e-4 0.113,0.038 0.13,0.109 0.005,0.022 0.005,0.046 0.008,0.067 z"
|
||||
fill="#ffff00"
|
||||
id="path10601" />
|
||||
<path
|
||||
d="m 18.021,10.431 c -0.073,-0.062 -0.127,-0.132 -0.152,-0.224 -0.054,-0.194 0.013,-0.4 0.17,-0.53 0.115,-0.094 0.247,-0.139 0.388,-0.149 0.137,-0.011 0.271,0.009 0.399,0.063 0.068,0.029 0.129,0.068 0.18,0.125 0.132,0.147 0.11,0.35 -0.05,0.461 -0.01,0.006 -0.02,0.013 -0.03,0.019 0.005,0.02 0.021,0.027 0.033,0.037 0.193,0.157 0.204,0.414 0.098,0.603 -0.088,0.156 -0.224,0.242 -0.384,0.285 -0.207,0.057 -0.411,0.041 -0.609,-0.045 -0.056,-0.024 -0.105,-0.06 -0.15,-0.104 -0.165,-0.163 -0.113,-0.448 0.088,-0.526 0.005,-0.002 0.009,-0.007 0.019,-0.015 z"
|
||||
fill="#ffff00"
|
||||
id="path10603" />
|
||||
<path
|
||||
d="m 12.468,9.603 c 0.03,0.016 0.056,0.026 0.079,0.041 0.094,0.06 0.143,0.147 0.135,0.266 -0.007,0.114 -0.065,0.189 -0.16,0.235 -0.038,0.019 -0.079,0.033 -0.121,0.035 -0.034,0.002 -0.042,0.019 -0.042,0.053 10e-4,0.167 0,0.333 0,0.5 10e-4,0.065 -0.007,0.128 -0.029,0.189 -0.037,0.105 -0.106,0.174 -0.209,0.2 -0.061,0.015 -0.123,0.016 -0.184,-10e-4 -0.107,-0.029 -0.178,-0.101 -0.213,-0.213 -0.019,-0.059 -0.026,-0.12 -0.026,-0.183 10e-4,-0.165 10e-4,-0.329 0,-0.494 0,-0.052 0,-0.052 -0.047,-0.071 -0.032,-0.012 -0.063,-0.028 -0.09,-0.05 -0.15,-0.119 -0.141,-0.355 0.018,-0.46 0.015,-0.01 0.031,-0.023 0.048,-0.025 0.065,-0.009 0.079,-0.057 0.082,-0.116 0.002,-0.038 0.013,-0.075 0.023,-0.112 0.067,-0.23 0.234,-0.37 0.458,-0.385 0.089,-0.006 0.176,10e-4 0.259,0.036 0.145,0.062 0.221,0.211 0.187,0.365 -0.016,0.071 -0.056,0.121 -0.113,0.158 -0.017,0.01 -0.034,0.02 -0.055,0.032 z"
|
||||
fill="#ffff00"
|
||||
id="path10605" />
|
||||
<path
|
||||
d="M 13.729,9.577 C 13.59,9.528 13.449,9.516 13.304,9.535 c -0.425,0.056 -0.727,0.417 -0.685,0.886 0.025,0.279 0.153,0.487 0.38,0.621 0.185,0.11 0.385,0.135 0.592,0.099 0.404,-0.07 0.656,-0.405 0.652,-0.798 0.006,-0.354 -0.198,-0.655 -0.514,-0.766 z m -0.424,0.845 c -0.017,-0.052 -0.019,-0.104 -0.003,-0.157 0.02,-0.068 0.063,-0.106 0.125,-0.108 0.06,-0.002 0.107,0.035 0.13,0.101 0.01,0.026 0.012,0.053 0.011,0.083 0.001,0.028 -0.003,0.058 -0.013,0.086 -0.024,0.063 -0.066,0.092 -0.13,0.09 -0.061,-0.002 -0.101,-0.032 -0.12,-0.095 z"
|
||||
fill="#ffff00"
|
||||
id="path10607" />
|
||||
<path
|
||||
d="M 15.976,10.298 C 15.963,10.02 15.847,9.803 15.624,9.657 15.446,9.541 15.25,9.509 15.043,9.534 c -0.35,0.043 -0.653,0.305 -0.69,0.721 -0.022,0.237 0.035,0.45 0.188,0.627 0.167,0.194 0.382,0.27 0.623,0.274 0.048,-0.001 0.096,-0.005 0.143,-0.012 0.398,-0.059 0.691,-0.394 0.669,-0.846 z m -0.935,0.133 c -0.011,-0.03 -0.014,-0.062 -0.013,-0.091 -0.001,-0.033 10e-4,-0.062 0.012,-0.091 0.022,-0.058 0.067,-0.092 0.124,-0.092 0.055,0 0.104,0.035 0.124,0.092 0.023,0.062 0.021,0.126 -0.003,0.187 -0.023,0.057 -0.066,0.083 -0.123,0.081 -0.061,-10e-4 -0.099,-0.028 -0.121,-0.086 z"
|
||||
fill="#ffff00"
|
||||
id="path10609" />
|
||||
<path
|
||||
d="m 9.35,10.338 c 0,-0.131 0,-0.261 0,-0.392 C 9.35,9.897 9.355,9.848 9.373,9.802 9.399,9.735 9.446,9.697 9.514,9.689 9.597,9.679 9.669,9.7 9.722,9.773 9.74,9.796 9.752,9.801 9.775,9.78 c 0.16,-0.15 0.511,-0.129 0.669,0.045 0.029,0.032 0.031,0.031 0.066,-10e-4 0.149,-0.138 0.323,-0.17 0.51,-0.127 0.192,0.044 0.309,0.199 0.318,0.418 0.006,0.12 0.003,0.239 0.003,0.359 0,0.086 0,0.171 0,0.257 0,0.036 -0.003,0.072 -0.012,0.108 -0.026,0.105 -0.089,0.158 -0.191,0.157 -0.103,0 -0.166,-0.052 -0.189,-0.158 -0.011,-0.05 -0.013,-0.102 -0.013,-0.153 0,-0.133 0,-0.265 0,-0.398 0,-0.051 -0.002,-0.103 -0.017,-0.152 -0.025,-0.084 -0.076,-0.126 -0.159,-0.13 -0.088,-0.005 -0.155,0.035 -0.185,0.112 -0.019,0.047 -0.026,0.098 -0.026,0.15 0,0.152 -10e-4,0.304 0,0.456 0,0.043 -0.004,0.085 -0.015,0.126 -0.025,0.096 -0.089,0.146 -0.187,0.147 -0.097,0.002 -0.162,-0.047 -0.189,-0.143 -0.011,-0.041 -0.016,-0.083 -0.016,-0.126 0,-0.146 0,-0.291 0,-0.437 0,-0.047 -0.003,-0.094 -0.014,-0.14 -0.022,-0.095 -0.077,-0.143 -0.165,-0.146 -0.096,-0.002 -0.162,0.044 -0.189,0.134 -0.014,0.048 -0.018,0.096 -0.017,0.146 0,0.148 -10e-4,0.295 -10e-4,0.443 0,0.047 -0.005,0.094 -0.02,0.138 -0.03,0.089 -0.092,0.133 -0.185,0.131 C 9.456,10.995 9.396,10.95 9.368,10.862 9.354,10.819 9.35,10.774 9.35,10.729 c 0,-0.13 0,-0.261 0,-0.391 z"
|
||||
fill="#ff0000"
|
||||
id="path10611" />
|
||||
<path
|
||||
d="M 8.714,9.827 C 8.78,9.766 8.854,9.722 8.942,9.71 9.023,9.698 9.093,9.72 9.148,9.785 c 0.065,0.078 0.05,0.182 -0.032,0.236 -0.04,0.026 -0.084,0.04 -0.127,0.055 -0.055,0.02 -0.11,0.038 -0.164,0.062 -0.074,0.033 -0.107,0.092 -0.107,0.175 -10e-4,0.07 -10e-4,0.141 -10e-4,0.211 0,0.079 0,0.159 0,0.238 C 8.716,10.794 8.715,10.825 8.708,10.857 8.674,11.005 8.535,11.072 8.409,10.999 8.358,10.969 8.333,10.918 8.322,10.86 8.314,10.823 8.31,10.785 8.31,10.746 c 0,-0.252 0,-0.505 0,-0.757 C 8.31,9.957 8.314,9.925 8.319,9.894 8.331,9.818 8.36,9.754 8.434,9.729 8.512,9.702 8.587,9.709 8.653,9.768 8.673,9.786 8.691,9.805 8.714,9.827 Z"
|
||||
fill="#ff0000"
|
||||
id="path10613" />
|
||||
<path
|
||||
d="M 7.884,9.842 C 7.868,9.793 7.842,9.752 7.796,9.734 7.71,9.699 7.627,9.708 7.556,9.775 7.539,9.791 7.526,9.809 7.51,9.827 7.505,9.824 7.499,9.822 7.494,9.818 7.373,9.698 7.226,9.684 7.073,9.711 6.774,9.763 6.538,10.044 6.556,10.405 c 0.012,0.227 0.102,0.407 0.282,0.53 0.193,0.133 0.487,0.15 0.663,-0.031 0.002,-0.002 0.007,0 0.011,0 0.017,0.022 0.032,0.048 0.053,0.068 0.109,0.106 0.287,0.051 0.325,-0.101 0.009,-0.035 0.012,-0.071 0.012,-0.108 0,-0.263 0,-0.526 0,-0.789 0,-0.045 -0.004,-0.09 -0.018,-0.132 z M 7.115,10.446 C 7.104,10.41 7.099,10.372 7.105,10.333 c 0.014,-0.091 0.061,-0.134 0.141,-0.131 0.071,0.004 0.115,0.057 0.119,0.147 0,0.006 0,0.012 0,0.019 0,0.087 -0.03,0.141 -0.091,0.161 -0.068,0.023 -0.135,-0.011 -0.159,-0.083 z"
|
||||
fill="#ff0000"
|
||||
id="path10615" />
|
||||
<path
|
||||
d="M 6.098,9.177 C 6.142,9.175 6.191,9.184 6.239,9.202 6.269,9.214 6.296,9.231 6.319,9.256 6.392,9.34 6.366,9.461 6.266,9.5 6.232,9.514 6.196,9.518 6.161,9.525 6.106,9.535 6.093,9.549 6.087,9.607 6.083,9.643 6.084,9.68 6.083,9.716 c 0,0.022 0.01,0.03 0.03,0.031 0.036,10e-4 0.071,0.004 0.107,0.007 0.045,0.005 0.086,0.019 0.123,0.047 0.083,0.063 0.077,0.185 -0.012,0.239 -0.056,0.033 -0.118,0.037 -0.18,0.038 -0.08,0.001 -0.073,-0.012 -0.073,0.08 -0.001,0.19 -0.001,0.381 0,0.571 0,0.043 -0.004,0.086 -0.011,0.128 -0.019,0.106 -0.083,0.164 -0.182,0.168 -0.109,0.003 -0.176,-0.051 -0.201,-0.16 -0.01,-0.042 -0.012,-0.085 -0.012,-0.128 0.001,-0.199 0,-0.398 0,-0.597 0,-0.06 0.004,-0.062 -0.053,-0.063 C 5.575,10.076 5.533,10.062 5.494,10.038 5.405,9.983 5.408,9.839 5.499,9.79 5.537,9.77 5.577,9.758 5.619,9.753 5.671,9.748 5.671,9.747 5.671,9.693 5.671,9.644 5.672,9.595 5.68,9.546 5.723,9.302 5.862,9.177 6.098,9.177 Z"
|
||||
fill="#ff0000"
|
||||
id="path10617" />
|
||||
<path
|
||||
d="M 17.558,9.347 C 17.54,9.181 17.438,9.124 17.294,9.159 17.239,9.171 17.204,9.211 17.183,9.266 17.167,9.311 17.16,9.357 17.16,9.404 c 0.001,0.105 0.001,0.21 0,0.315 0,0.013 0.008,0.03 -0.01,0.04 C 17.145,9.756 17.139,9.755 17.134,9.751 17.017,9.666 16.885,9.648 16.75,9.669 c -0.443,0.067 -0.675,0.588 -0.449,1.002 0.134,0.245 0.406,0.375 0.66,0.318 0.078,-0.018 0.145,-0.057 0.207,-0.111 0.016,0.019 0.03,0.037 0.046,0.053 0.068,0.068 0.148,0.082 0.233,0.049 0.078,-0.03 0.103,-0.103 0.111,-0.183 0.004,-0.036 0.004,-0.073 0.004,-0.109 0,-0.41 0,-0.821 0,-1.232 0,-0.036 0,-0.073 -0.004,-0.109 z m -0.529,1.047 c -0.014,0.073 -0.063,0.117 -0.13,0.118 -0.066,0 -0.117,-0.044 -0.131,-0.116 -0.009,-0.044 -0.009,-0.089 10e-4,-0.133 0.015,-0.069 0.064,-0.111 0.128,-0.111 0.066,-10e-4 0.113,0.038 0.13,0.109 0.005,0.022 0.005,0.046 0.008,0.067 -0.002,0.024 -0.002,0.045 -0.006,0.066 z"
|
||||
fill="#ff0000"
|
||||
id="path10619" />
|
||||
<path
|
||||
d="m 18.447,11.014 c -0.121,0 -0.229,-0.017 -0.331,-0.065 -0.048,-0.022 -0.092,-0.051 -0.127,-0.094 -0.05,-0.062 -0.059,-0.135 -0.026,-0.202 0.032,-0.066 0.095,-0.099 0.171,-0.087 0.048,0.008 0.093,0.027 0.137,0.047 0.059,0.025 0.118,0.053 0.182,0.06 0.035,0.003 0.068,0 0.099,-0.02 0.044,-0.028 0.047,-0.075 0.006,-0.109 -0.027,-0.022 -0.06,-0.034 -0.092,-0.047 -0.082,-0.033 -0.166,-0.062 -0.245,-0.101 -0.038,-0.019 -0.075,-0.04 -0.11,-0.065 -0.132,-0.099 -0.17,-0.266 -0.094,-0.419 0.052,-0.103 0.135,-0.169 0.236,-0.205 0.188,-0.069 0.373,-0.056 0.555,0.027 0.035,0.016 0.066,0.039 0.094,0.067 0.059,0.061 0.076,0.127 0.047,0.19 -0.029,0.066 -0.108,0.107 -0.182,0.094 C 18.728,10.077 18.691,10.062 18.653,10.048 18.616,10.033 18.578,10.02 18.54,10.006 18.509,9.995 18.477,9.995 18.445,9.998 c -0.037,0.004 -0.062,0.025 -0.066,0.054 -0.006,0.036 0.016,0.055 0.042,0.07 0.033,0.02 0.069,0.032 0.105,0.045 0.083,0.03 0.166,0.06 0.245,0.103 0.034,0.018 0.066,0.038 0.097,0.063 0.134,0.107 0.165,0.278 0.079,0.432 -0.059,0.104 -0.148,0.166 -0.251,0.206 -0.084,0.033 -0.17,0.046 -0.249,0.043 z"
|
||||
fill="#ff0000"
|
||||
id="path10621" />
|
||||
<path
|
||||
d="m 11.829,10.437 c 0,-0.11 0,-0.219 0,-0.328 -0.001,-0.05 0.007,-0.059 -0.051,-0.06 -0.038,0 -0.075,-0.011 -0.109,-0.028 -0.053,-0.027 -0.086,-0.071 -0.086,-0.135 0,-0.065 0.034,-0.107 0.087,-0.134 0.039,-0.019 0.081,-0.025 0.122,-0.03 0.028,-0.004 0.037,-0.015 0.035,-0.045 -0.003,-0.066 10e-4,-0.132 0.015,-0.197 0.042,-0.204 0.179,-0.325 0.374,-0.33 0.06,-0.002 0.12,-0.001 0.177,0.023 0.079,0.032 0.12,0.089 0.121,0.165 0.001,0.06 -0.024,0.102 -0.075,0.126 -0.041,0.019 -0.085,0.025 -0.128,0.034 -0.052,0.011 -0.067,0.029 -0.071,0.085 -10e-4,0.006 -10e-4,0.013 -10e-4,0.019 0.002,0.036 -0.013,0.079 0.007,0.106 0.02,0.027 0.06,0.006 0.091,0.011 0.05,0.008 0.099,0.014 0.144,0.042 0.049,0.03 0.076,0.073 0.073,0.136 -0.003,0.064 -0.036,0.102 -0.088,0.125 -0.058,0.025 -0.12,0.029 -0.182,0.029 -0.053,-10e-4 -0.054,0 -0.054,0.055 0,0.201 0,0.402 0,0.604 0,0.051 -0.001,0.102 -0.016,0.152 -0.027,0.087 -0.084,0.131 -0.177,0.134 -0.091,0.004 -0.159,-0.041 -0.189,-0.126 -0.014,-0.04 -0.02,-0.082 -0.019,-0.125 0,-0.007 0,-0.013 0,-0.02 0,-0.096 0,-0.192 0,-0.288 0,0 0,0 0,0 z"
|
||||
fill="#ff0000"
|
||||
id="path10623" />
|
||||
<path
|
||||
d="m 13.841,9.789 c -0.23,-0.158 -0.48,-0.167 -0.727,-0.055 -0.227,0.103 -0.354,0.298 -0.369,0.562 -0.015,0.259 0.08,0.467 0.288,0.607 0.12,0.081 0.253,0.114 0.417,0.113 0.031,10e-4 0.084,-0.003 0.137,-0.014 0.291,-0.058 0.5,-0.286 0.524,-0.591 0.021,-0.262 -0.061,-0.479 -0.27,-0.622 z m -0.536,0.633 c -0.017,-0.052 -0.019,-0.104 -0.003,-0.157 0.02,-0.068 0.063,-0.106 0.125,-0.108 0.06,-0.002 0.107,0.035 0.13,0.101 0.01,0.026 0.012,0.053 0.011,0.083 0.001,0.028 -0.003,0.058 -0.013,0.086 -0.024,0.063 -0.066,0.092 -0.13,0.09 -0.061,-0.002 -0.101,-0.032 -0.12,-0.095 z"
|
||||
fill="#ff0000"
|
||||
id="path10625" />
|
||||
<path
|
||||
d="M 15.847,10.312 C 15.836,10.058 15.728,9.866 15.516,9.752 15.282,9.627 15.04,9.628 14.807,9.755 14.43,9.96 14.36,10.516 14.669,10.824 c 0.139,0.138 0.308,0.192 0.494,0.192 0.054,0 0.108,-0.004 0.161,-0.014 0.32,-0.065 0.539,-0.338 0.523,-0.69 z m -0.562,0.124 c -0.023,0.057 -0.066,0.083 -0.123,0.081 -0.061,-10e-4 -0.099,-0.028 -0.121,-0.086 -0.011,-0.03 -0.014,-0.062 -0.013,-0.091 -0.001,-0.033 10e-4,-0.062 0.012,-0.091 0.022,-0.058 0.067,-0.092 0.124,-0.092 0.055,0 0.104,0.035 0.124,0.092 0.023,0.062 0.021,0.126 -0.003,0.187 z"
|
||||
fill="#ff0000"
|
||||
id="path10627" />
|
||||
<path
|
||||
d="M 7.493,10.316 C 7.48,10.178 7.384,10.073 7.262,10.064 7.123,10.053 7.015,10.135 6.983,10.278 6.971,10.331 6.972,10.385 6.98,10.438 7,10.574 7.095,10.665 7.22,10.672 7.352,10.679 7.449,10.601 7.484,10.463 7.492,10.431 7.495,10.4 7.493,10.368 c 0,-0.017 0.002,-0.035 0,-0.052 z m -0.128,0.052 c 0,0.087 -0.03,0.141 -0.091,0.161 C 7.206,10.552 7.139,10.518 7.115,10.446 7.104,10.41 7.099,10.372 7.105,10.333 c 0.014,-0.091 0.061,-0.135 0.141,-0.131 0.071,0.004 0.115,0.057 0.119,0.147 0,0.006 0,0.012 0,0.019 z"
|
||||
fill="#ffff00"
|
||||
id="path10629" />
|
||||
<path
|
||||
d="m 17.142,10.196 c -0.037,-0.108 -0.121,-0.178 -0.223,-0.183 -0.115,-0.006 -0.203,0.044 -0.253,0.155 -0.048,0.105 -0.048,0.215 -0.003,0.322 0.043,0.104 0.142,0.167 0.245,0.16 0.112,-0.007 0.191,-0.065 0.233,-0.179 0.016,-0.044 0.024,-0.09 0.022,-0.137 0.002,-0.048 -0.005,-0.094 -0.021,-0.138 z m -0.113,0.198 c -0.014,0.073 -0.063,0.117 -0.13,0.118 -0.066,0 -0.117,-0.044 -0.131,-0.116 -0.009,-0.044 -0.009,-0.089 10e-4,-0.133 0.015,-0.069 0.064,-0.111 0.128,-0.111 0.066,-10e-4 0.113,0.038 0.13,0.109 0.005,0.022 0.005,0.046 0.008,0.067 -0.002,0.024 -0.002,0.045 -0.006,0.066 z"
|
||||
fill="#ffff00"
|
||||
id="path10631" />
|
||||
<path
|
||||
d="m 13.666,10.178 c -0.05,-0.105 -0.13,-0.159 -0.241,-0.157 -0.111,0.002 -0.19,0.059 -0.234,0.167 -0.037,0.09 -0.038,0.182 -0.009,0.275 0.036,0.118 0.124,0.19 0.235,0.193 0.123,0.003 0.214,-0.059 0.257,-0.178 0.017,-0.044 0.025,-0.091 0.024,-0.138 0.002,-0.057 -0.009,-0.111 -0.032,-0.162 z m -0.111,0.249 c -0.024,0.063 -0.066,0.092 -0.13,0.09 -0.061,-0.002 -0.101,-0.032 -0.12,-0.095 -0.017,-0.052 -0.019,-0.104 -0.003,-0.157 0.02,-0.068 0.063,-0.106 0.125,-0.108 0.06,-0.002 0.107,0.035 0.13,0.101 0.01,0.026 0.012,0.053 0.011,0.083 0.001,0.028 -0.003,0.058 -0.013,0.086 z"
|
||||
fill="#ffff00"
|
||||
id="path10633" />
|
||||
<path
|
||||
d="m 15.41,10.202 c -0.042,-0.119 -0.14,-0.186 -0.263,-0.181 -0.112,0.005 -0.203,0.083 -0.236,0.202 -0.01,0.037 -0.016,0.075 -0.013,0.112 -0.003,0.052 0.007,0.1 0.023,0.146 0.039,0.109 0.127,0.173 0.236,0.175 0.115,10e-4 0.204,-0.058 0.247,-0.166 0.037,-0.095 0.039,-0.192 0.006,-0.288 z m -0.125,0.234 c -0.023,0.057 -0.066,0.083 -0.123,0.081 -0.061,-10e-4 -0.099,-0.028 -0.121,-0.086 -0.011,-0.03 -0.014,-0.062 -0.013,-0.091 -0.001,-0.033 10e-4,-0.062 0.012,-0.091 0.022,-0.058 0.067,-0.092 0.124,-0.092 0.055,0 0.104,0.035 0.124,0.092 0.023,0.062 0.021,0.126 -0.003,0.187 z"
|
||||
fill="#ffff00"
|
||||
id="path10635" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 18 KiB |
|
|
@ -0,0 +1,120 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="0.062 6.68 17.995 5.325"
|
||||
width="512"
|
||||
height="152"
|
||||
version="1.1"
|
||||
id="svg9721"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs9725" />
|
||||
<g
|
||||
id="g14013"
|
||||
transform="matrix(0.320158,0,0,0.39123983,-30.632326,-67.815147)">
|
||||
<path
|
||||
d="m 145.21454,202.62385 h -42.36967 c -0.64735,0 -1.42663,-0.35066 -1.85314,-0.83361 l -2.929467,-3.31259 c -0.531989,-0.6036 -0.531989,-1.54728 0,-2.15018 l 2.928407,-3.31364 c 0.42722,-0.4826 1.20685,-0.83255 1.8542,-0.83255 h 42.36967 c 0.64805,0 1.42804,0.34995 1.85349,0.83361 l 2.92912,3.31258 c 0.53128,0.6029 0.53128,1.54658 0,2.15018 l -2.92912,3.31259 c -0.4265,0.48295 -1.20544,0.83361 -1.85349,0.83361"
|
||||
style="fill:#808080;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778"
|
||||
id="path12781" />
|
||||
<path
|
||||
d="m 145.21454,202.62385 h -42.36967 c -0.64735,0 -1.42663,-0.35066 -1.85314,-0.83361 l -2.929467,-3.31259 c -0.531989,-0.6036 -0.531989,-1.54728 0,-2.15018 l 2.928407,-3.31364 c 0.42722,-0.4826 1.20685,-0.83255 1.8542,-0.83255 h 42.36967 c 0.64805,0 1.42804,0.34995 1.85349,0.83361 l 2.92912,3.31258 c 0.53128,0.6029 0.53128,1.54658 0,2.15018 l -2.92912,3.31259 c -0.4265,0.48295 -1.20544,0.83361 -1.85349,0.83361 z"
|
||||
style="fill:none;stroke:#808080;stroke-width:0.1651;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path12785" />
|
||||
<path
|
||||
d="m 145.10776,202.24694 h -42.36967 c -0.64735,0 -1.42698,-0.35031 -1.85385,-0.83256 l -2.927698,-3.31364 c -0.533048,-0.60254 -0.533048,-1.54693 0,-2.14912 l 2.927698,-3.31364 c 0.42545,-0.48331 1.2065,-0.83326 1.85385,-0.83326 h 42.36967 c 0.6477,0 1.42769,0.34995 1.85455,0.83326 l 2.92665,3.31258 c 0.53304,0.60325 0.53304,1.54764 0.001,2.15018 l -2.92912,3.31364 c -0.4258,0.48225 -1.20579,0.83256 -1.85349,0.83256"
|
||||
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778"
|
||||
id="path12789" />
|
||||
<path
|
||||
d="m 145.10776,202.24694 h -42.36967 c -0.64735,0 -1.42698,-0.35031 -1.85385,-0.83256 l -2.927698,-3.31364 c -0.533048,-0.60254 -0.533048,-1.54693 0,-2.14912 l 2.927698,-3.31364 c 0.42545,-0.48331 1.2065,-0.83326 1.85385,-0.83326 h 42.36967 c 0.6477,0 1.42769,0.34995 1.85455,0.83326 l 2.92665,3.31258 c 0.53304,0.60325 0.53304,1.54764 0.001,2.15018 l -2.92912,3.31364 c -0.4258,0.48225 -1.20579,0.83256 -1.85349,0.83256 z"
|
||||
style="fill:none;stroke:#808080;stroke-width:0.1651;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path12793" />
|
||||
<path
|
||||
d="m 149.31093,196.45239 c 0.27834,0.31609 0.27834,0.8315 0,1.14688 l -2.92806,3.31329 c -0.27869,0.31468 -0.8516,0.5722 -1.27493,0.5722 h -42.37003 c -0.42262,0 -0.99695,-0.25752 -1.27529,-0.5722 l -2.928053,-3.31329 c -0.2794,-0.31538 -0.2794,-0.83079 0,-1.14688 l 2.928053,-3.31258 c 0.27834,-0.31539 0.85267,-0.57327 1.27529,-0.57327 h 42.37003 c 0.42333,0 0.99624,0.25788 1.27493,0.57327 z"
|
||||
style="fill:#2b388f;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778"
|
||||
id="path12797" />
|
||||
<path
|
||||
d="m 102.73805,192.64338 c -0.39758,0 -0.95497,0.25047 -1.21708,0.54645 l -2.928412,3.31364 c -0.254,0.28822 -0.254,0.75671 0,1.04528 l 2.928412,3.31329 c 0.26211,0.29598 0.8195,0.54681 1.21708,0.54681 h 42.36967 c 0.39828,0 0.95462,-0.25083 1.21708,-0.54681 l 2.92735,-3.31329 c 0.25506,-0.28857 0.25506,-0.75706 0,-1.04528 l -2.92735,-3.31364 c -0.26246,-0.29598 -0.8188,-0.54645 -1.21708,-0.54645 z m 42.36967,8.91893 h -42.36967 c -0.44344,0 -1.0414,-0.26917 -1.33421,-0.59937 l -2.927698,-3.31329 c -0.303742,-0.34431 -0.303742,-0.90382 0,-1.24742 l 2.927698,-3.31364 c 0.29281,-0.3302 0.89077,-0.59867 1.33421,-0.59867 h 42.36967 c 0.44309,0 1.04105,0.26847 1.33385,0.59867 l 2.92806,3.31364 c 0.30374,0.3436 0.30374,0.90311 0,1.24742 l -2.92806,3.31329 c -0.2928,0.3302 -0.89076,0.59937 -1.33385,0.59937"
|
||||
style="fill:#1d1752;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778"
|
||||
id="path12801" />
|
||||
<path
|
||||
d="m 106.90884,200.05453 h -1.391 l 0.52916,-2.34774 h -2.61972 l -0.52035,2.34774 h -1.39136 l 1.31057,-5.85929 h 1.38219 l -0.52988,2.3548 h 2.62079 l 0.52987,-2.3548 h 1.38995 z"
|
||||
style="fill:#1d1752;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778"
|
||||
id="path12805" />
|
||||
<path
|
||||
d="m 110.0317,196.71077 c -0.40322,0 -0.71861,0.31115 -0.83397,0.72884 l 1.30034,-0.0176 c 0.0367,-0.0981 0.054,-0.1778 0.054,-0.24906 0,-0.24942 -0.17921,-0.46214 -0.52035,-0.46214 m 1.57939,1.6002 -2.58375,0.0448 c -0.0279,0.50624 0.36724,0.74613 0.85161,0.74613 0.40463,0 0.72742,-0.0882 1.16663,-0.28399 l 0.30551,0.84455 c -0.58314,0.30268 -1.14829,0.48048 -1.83056,0.48048 -1.18499,0 -1.80376,-0.80962 -1.80376,-1.78717 0,-1.44074 1.03188,-2.60597 2.47615,-2.60597 1.05022,0 1.62454,0.57856 1.62454,1.42311 0,0.32067 -0.0716,0.69356 -0.20637,1.13806"
|
||||
style="fill:#1d1752;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778"
|
||||
id="path12809" />
|
||||
<path
|
||||
d="m 114.84235,197.13784 c -0.17145,-0.10689 -0.37782,-0.15169 -0.49424,-0.15169 -0.34149,0 -0.72672,0.14217 -1.04105,1.59243 l -0.32279,1.47603 h -1.36419 l 0.94191,-4.20617 h 1.03153 v 0.79974 h 0.01 c 0.27764,-0.46178 0.50271,-0.89852 1.09432,-0.89852 0.25153,0 0.54822,0.0723 0.75424,0.26776 z"
|
||||
style="fill:#1d1752;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778"
|
||||
id="path12813" />
|
||||
<path
|
||||
d="m 117.8386,197.00332 c -0.13476,-0.13264 -0.32279,-0.22119 -0.57432,-0.22119 -0.24307,0 -0.48437,0.10689 -0.66428,0.31891 -0.20532,0.24095 -0.34149,0.61454 -0.34149,1.11231 0,0.26705 0.0723,0.47978 0.19755,0.63182 0.13512,0.15911 0.32385,0.24836 0.5835,0.24836 0.23318,0 0.46743,-0.0981 0.64664,-0.27552 0.22472,-0.23918 0.37677,-0.6223 0.37677,-1.15676 0,-0.28363 -0.0808,-0.49671 -0.22437,-0.65793 m 0.72637,2.50825 c -0.43921,0.39123 -1.022,0.63112 -1.67746,0.63112 -0.6731,0 -1.149,-0.2226 -1.46262,-0.55069 -0.3503,-0.37394 -0.5027,-0.88053 -0.5027,-1.36983 0,-0.67557 0.25082,-1.28023 0.6731,-1.72473 0.44838,-0.47131 1.07667,-0.74754 1.79422,-0.74754 0.70062,0 1.1938,0.2226 1.50848,0.57009 0.33126,0.36477 0.47555,0.86148 0.47555,1.33279 0,0.74825 -0.30551,1.40547 -0.80857,1.85879"
|
||||
style="fill:#1d1752;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778"
|
||||
id="path12817" />
|
||||
<path
|
||||
d="m 123.83258,197.51972 -0.57432,2.53471 h -1.38113 l 0.56445,-2.49061 c 0.0367,-0.15064 0.0367,-0.28399 0.0367,-0.33726 0,-0.19544 -0.10795,-0.31997 -0.3235,-0.31997 -0.30586,0 -0.92393,0.22155 -1.24778,1.68064 l -0.32279,1.4672 h -1.36454 l 0.94262,-4.20582 h 1.0414 v 0.7994 h 0.009 c 0.34078,-0.43568 0.87066,-0.89818 1.58856,-0.89818 0.68156,0 1.10384,0.34714 1.12148,1.0855 0,0.18662 -0.0356,0.43603 -0.09,0.68439"
|
||||
style="fill:#1d1752;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778"
|
||||
id="path12821" />
|
||||
<path
|
||||
d="m 129.63884,195.30607 h -1.8669 l -0.30445,1.34197 h 1.83974 l -0.24236,1.10313 h -1.84009 l -0.51153,2.30329 h -1.39135 l 1.29222,-5.85929 h 3.40184 z"
|
||||
style="fill:#1d1752;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778"
|
||||
id="path12825" />
|
||||
<path
|
||||
d="m 132.33784,197.00332 c -0.13441,-0.13264 -0.32279,-0.22119 -0.57397,-0.22119 -0.24306,0 -0.48436,0.10689 -0.66357,0.31891 -0.20603,0.24095 -0.34149,0.61454 -0.34149,1.11231 0,0.26705 0.0716,0.47978 0.1972,0.63182 0.13511,0.15911 0.3235,0.24836 0.58314,0.24836 0.23283,0 0.46637,-0.0981 0.64558,-0.27552 0.22472,-0.23918 0.37748,-0.6223 0.37748,-1.15676 0,-0.28363 -0.0808,-0.49671 -0.22437,-0.65793 m 0.72707,2.50825 c -0.43991,0.39123 -1.02305,0.63112 -1.67781,0.63112 -0.67345,0 -1.14864,-0.2226 -1.46297,-0.55069 -0.34995,-0.37394 -0.50235,-0.88053 -0.50235,-1.36983 0,-0.67557 0.25118,-1.28023 0.67239,-1.72473 0.44874,-0.47131 1.07703,-0.74754 1.79494,-0.74754 0.69991,0 1.19344,0.2226 1.50777,0.57009 0.33196,0.36477 0.47484,0.86148 0.47484,1.33279 0,0.74825 -0.30445,1.40547 -0.80681,1.85879"
|
||||
style="fill:#1d1752;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778"
|
||||
id="path12829" />
|
||||
<path
|
||||
d="m 136.98477,197.00332 c -0.13406,-0.13264 -0.32314,-0.22119 -0.57467,-0.22119 -0.24201,0 -0.48437,0.10689 -0.66323,0.31891 -0.20672,0.24095 -0.34149,0.61454 -0.34149,1.11231 0,0.26705 0.0713,0.47978 0.19756,0.63182 0.13511,0.15911 0.32279,0.24836 0.58349,0.24836 0.23284,0 0.46673,-0.0981 0.64629,-0.27552 0.22437,-0.23918 0.37606,-0.6223 0.37606,-1.15676 0,-0.28363 -0.0801,-0.49671 -0.22401,-0.65793 m 0.72707,2.50825 c -0.43956,0.39123 -1.0234,0.63112 -1.67816,0.63112 -0.6731,0 -1.14829,-0.2226 -1.46262,-0.55069 -0.3503,-0.37394 -0.50235,-0.88053 -0.50235,-1.36983 0,-0.67557 0.25118,-1.28023 0.67345,-1.72473 0.44803,-0.47131 1.07597,-0.74754 1.79458,-0.74754 0.69921,0 1.1938,0.2226 1.50777,0.57009 0.33126,0.36477 0.47484,0.86148 0.47484,1.33279 0,0.74825 -0.30586,1.40547 -0.80751,1.85879"
|
||||
style="fill:#1d1752;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778"
|
||||
id="path12833" />
|
||||
<path
|
||||
d="m 141.42687,196.78199 c -0.6283,0 -1.33703,0.3997 -1.33703,1.29822 0,0.61278 0.40323,0.99519 0.87912,0.99519 0.1704,0 0.30586,-0.0173 0.43075,-0.0713 l 0.48507,-2.14242 c -0.09,-0.0441 -0.24236,-0.0797 -0.45791,-0.0797 m 1.18463,2.99579 c -0.54787,0.24059 -1.07668,0.36512 -1.68663,0.36512 -1.49049,0 -2.16359,-0.88018 -2.16359,-1.94698 0,-1.73425 1.391,-2.44616 2.55799,-2.44616 0.34043,0 0.57397,0.0452 0.78952,0.1076 l 0.47554,-2.09832 h 1.37337 z"
|
||||
style="fill:#1d1752;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778"
|
||||
id="path12837" />
|
||||
<path
|
||||
d="m 146.47918,197.05804 c -0.31432,-0.14323 -0.79869,-0.2854 -1.06751,-0.2854 -0.24306,0 -0.44943,0.0452 -0.44943,0.29387 0,0.45332 1.48096,0.58596 1.48096,1.78717 0,0.82691 -0.77153,1.28905 -1.76848,1.28905 -0.59161,0 -1.17475,-0.133 -1.67781,-0.4445 l 0.42263,-0.95109 c 0.35807,0.21343 0.79833,0.3997 1.15676,0.3997 0.26952,0 0.51188,-0.0716 0.51188,-0.27588 0,-0.4699 -1.48132,-0.55139 -1.48132,-1.80445 0,-0.71085 0.53905,-1.31692 1.75049,-1.31692 0.31432,0 0.82479,0.0451 1.33667,0.25929 z"
|
||||
style="fill:#1d1752;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778"
|
||||
id="path12841" />
|
||||
<path
|
||||
d="m 106.70811,199.79256 h -1.391 l 0.52881,-2.34774 h -2.62043 l -0.52,2.34774 h -1.39065 l 1.30951,-5.85999 h 1.38254 l -0.53022,2.35655 h 2.62184 l 0.52881,-2.35655 h 1.39101 z"
|
||||
style="fill:#ffdd00;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778"
|
||||
id="path12845" />
|
||||
<path
|
||||
d="m 109.83047,196.44925 c -0.40358,0 -0.71755,0.31115 -0.83432,0.72884 l 1.30175,-0.018 c 0.0346,-0.097 0.0529,-0.1771 0.0529,-0.24906 0,-0.24871 -0.17957,-0.46179 -0.52035,-0.46179 m 1.58009,1.59985 -2.5848,0.0448 c -0.0268,0.50624 0.36795,0.74612 0.85266,0.74612 0.40358,0 0.72637,-0.0878 1.16699,-0.28363 l 0.30374,0.8449 c -0.58279,0.30198 -1.14794,0.47908 -1.83021,0.47908 -1.18357,0 -1.8034,-0.80857 -1.8034,-1.78647 0,-1.44039 1.03223,-2.60562 2.47685,-2.60562 1.05022,0 1.62455,0.57821 1.62455,1.42276 0,0.32067 -0.0727,0.69285 -0.20638,1.13806"
|
||||
style="fill:#ffdd00;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778"
|
||||
id="path12849" />
|
||||
<path
|
||||
d="m 114.64018,196.87583 c -0.17004,-0.10654 -0.37712,-0.15099 -0.49318,-0.15099 -0.34149,0 -0.72708,0.14217 -1.04105,1.59173 l -0.32244,1.47603 h -1.36419 l 0.94192,-4.20617 h 1.03187 v 0.79974 h 0.008 c 0.27869,-0.46178 0.50306,-0.89781 1.09502,-0.89781 0.25118,0 0.54786,0.0716 0.75424,0.2674 z"
|
||||
style="fill:#ffdd00;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778"
|
||||
id="path12853" />
|
||||
<path
|
||||
d="m 117.6378,196.7423 c -0.13476,-0.13335 -0.3235,-0.2219 -0.57503,-0.2219 -0.24165,0 -0.48436,0.10725 -0.66357,0.32032 -0.20673,0.23954 -0.34149,0.61278 -0.34149,1.11055 0,0.2674 0.072,0.48048 0.19791,0.63182 0.13476,0.16017 0.32279,0.24836 0.58349,0.24836 0.23319,0 0.46602,-0.0967 0.64594,-0.27481 0.22401,-0.23989 0.37676,-0.62336 0.37676,-1.1557 0,-0.28505 -0.0811,-0.49848 -0.22401,-0.65864 m 0.72637,2.50719 c -0.43956,0.39158 -1.0227,0.63077 -1.67816,0.63077 -0.67346,0 -1.1483,-0.2219 -1.46227,-0.54998 -0.35066,-0.37395 -0.50271,-0.88018 -0.50271,-1.36984 0,-0.67557 0.25118,-1.28023 0.67346,-1.72438 0.44838,-0.47131 1.07597,-0.74788 1.79458,-0.74788 0.6992,0 1.19274,0.22189 1.50671,0.56973 0.33232,0.36407 0.4759,0.86219 0.4759,1.3328 0,0.74788 -0.30445,1.40581 -0.80751,1.85878"
|
||||
style="fill:#ffdd00;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778"
|
||||
id="path12857" />
|
||||
<path
|
||||
d="m 123.63206,197.25768 -0.57538,2.53471 h -1.38113 l 0.56515,-2.48955 c 0.036,-0.15135 0.036,-0.28505 0.036,-0.33832 0,-0.19508 -0.10795,-0.31997 -0.32279,-0.31997 -0.30551,0 -0.92499,0.22225 -1.24743,1.68064 l -0.32385,1.4672 h -1.36313 l 0.94156,-4.20582 h 1.0407 v 0.7994 h 0.009 c 0.34114,-0.43463 0.87101,-0.89782 1.58856,-0.89782 0.68157,0 1.10419,0.34678 1.12219,1.08514 0,0.18733 -0.0363,0.43603 -0.0896,0.68439"
|
||||
style="fill:#ffdd00;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778"
|
||||
id="path12861" />
|
||||
<path
|
||||
d="m 129.43758,195.04368 h -1.86725 l -0.3041,1.34232 h 1.83868 l -0.24165,1.10314 h -1.84009 l -0.51188,2.30328 h -1.38995 l 1.29188,-5.85964 h 3.40113 z"
|
||||
style="fill:#ffdd00;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778"
|
||||
id="path12865" />
|
||||
<path
|
||||
d="m 132.1364,196.7423 c -0.13406,-0.13335 -0.32279,-0.2219 -0.57432,-0.2219 -0.2413,0 -0.48437,0.10725 -0.66358,0.32032 -0.20637,0.23954 -0.34149,0.61278 -0.34149,1.11055 0,0.2674 0.0727,0.48048 0.19791,0.63182 0.13476,0.16017 0.32279,0.24836 0.58314,0.24836 0.23319,0 0.46602,-0.0967 0.64629,-0.27481 0.22402,-0.23989 0.37677,-0.62336 0.37677,-1.1557 0,-0.28505 -0.0804,-0.49848 -0.22472,-0.65864 m 0.72707,2.50719 c -0.43885,0.39158 -1.0234,0.63077 -1.67781,0.63077 -0.67274,0 -1.14935,-0.2219 -1.46332,-0.54998 -0.34995,-0.37395 -0.502,-0.88018 -0.502,-1.36984 0,-0.67557 0.25118,-1.28023 0.67204,-1.72438 0.44909,-0.47131 1.07809,-0.74788 1.79529,-0.74788 0.70061,0 1.19344,0.22189 1.50742,0.56973 0.33231,0.36407 0.47589,0.86219 0.47589,1.3328 0,0.74788 -0.30515,1.40581 -0.80751,1.85878"
|
||||
style="fill:#ffdd00;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778"
|
||||
id="path12869" />
|
||||
<path
|
||||
d="m 136.78404,196.7423 c -0.13476,-0.13335 -0.32279,-0.2219 -0.57432,-0.2219 -0.24236,0 -0.48507,0.10725 -0.66499,0.32032 -0.20531,0.23954 -0.34078,0.61278 -0.34078,1.11055 0,0.2674 0.0723,0.48048 0.1972,0.63182 0.13547,0.16017 0.3235,0.24836 0.5842,0.24836 0.23319,0 0.46637,-0.0967 0.64558,-0.27481 0.22437,-0.23989 0.37677,-0.62336 0.37677,-1.1557 0,-0.28505 -0.0801,-0.49848 -0.22366,-0.65864 m 0.72672,2.50719 c -0.44026,0.39158 -1.02305,0.63077 -1.67816,0.63077 -0.6731,0 -1.14865,-0.2219 -1.46262,-0.54998 -0.35066,-0.37395 -0.50341,-0.88018 -0.50341,-1.36984 0,-0.67557 0.25153,-1.28023 0.6738,-1.72438 0.44874,-0.47131 1.07598,-0.74788 1.79458,-0.74788 0.69991,0 1.1931,0.22189 1.50778,0.56973 0.33231,0.36407 0.47625,0.86219 0.47625,1.3328 0,0.74788 -0.30551,1.40581 -0.80822,1.85878"
|
||||
style="fill:#ffdd00;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778"
|
||||
id="path12873" />
|
||||
<path
|
||||
d="m 141.22614,196.52055 c -0.629,0 -1.33844,0.39934 -1.33844,1.29752 0,0.61348 0.40464,0.99589 0.88053,0.99589 0.17004,0 0.30516,-0.0176 0.4311,-0.0709 l 0.48401,-2.14313 c -0.09,-0.0437 -0.24201,-0.0794 -0.4572,-0.0794 m 1.18392,2.99544 c -0.54716,0.23988 -1.07703,0.36442 -1.68769,0.36442 -1.48907,0 -2.16182,-0.87983 -2.16182,-1.94734 0,-1.73319 1.391,-2.44475 2.55799,-2.44475 0.34079,0 0.57362,0.0455 0.78917,0.10725 l 0.47554,-2.09868 h 1.37266 z"
|
||||
style="fill:#ffdd00;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778"
|
||||
id="path12877" />
|
||||
<path
|
||||
d="m 146.2782,196.7961 c -0.31432,-0.14252 -0.79939,-0.28469 -1.06786,-0.28469 -0.24236,0 -0.44908,0.0448 -0.44908,0.29316 0,0.45296 1.4806,0.58631 1.4806,1.78717 0,0.82762 -0.77152,1.2887 -1.76706,1.2887 -0.59302,0 -1.17616,-0.13265 -1.67816,-0.44415 l 0.42086,-0.95144 c 0.35913,0.21343 0.79869,0.40075 1.15817,0.40075 0.26917,0 0.51153,-0.0716 0.51153,-0.27658 0,-0.4706 -1.48096,-0.55068 -1.48096,-1.80445 0,-0.71156 0.53763,-1.31622 1.74977,-1.31622 0.31433,0 0.82586,0.0455 1.33738,0.25824 z"
|
||||
style="fill:#ffdd00;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778"
|
||||
id="path12881" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 10 KiB |
62
frontend/public/assets/poi-icons/brands_2024/booths.svg
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="1.456 7.143 15.313 3.512"
|
||||
width="512"
|
||||
height="117"
|
||||
version="1.1"
|
||||
id="svg9721"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs9725">
|
||||
<clipPath
|
||||
id="_clipPath_BPUXlzslHcRxD4HZ3WvLhtmAFTUxmZKt">
|
||||
<rect
|
||||
x="2.7690001"
|
||||
y="0"
|
||||
width="18.462"
|
||||
height="24"
|
||||
fill="#ffffff"
|
||||
id="rect14492" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g
|
||||
clip-path="url(#_clipPath_BPUXlzslHcRxD4HZ3WvLhtmAFTUxmZKt)"
|
||||
id="g14513"
|
||||
style="isolation:isolate"
|
||||
transform="translate(-2.8872165,-0.89609282)">
|
||||
<g
|
||||
id="g14511">
|
||||
<g
|
||||
id="g14509">
|
||||
<g
|
||||
id="g14507">
|
||||
<path
|
||||
d="m 18.013,9.043 v 0 c -0.126,0.115 -0.189,0.265 -0.189,0.446 0,0.192 0.092,0.351 0.275,0.471 0.002,0 0.003,0 0.487,0.233 0.155,0.08 0.231,0.172 0.231,0.277 0,0.079 -0.029,0.144 -0.091,0.198 -0.064,0.055 -0.137,0.083 -0.225,0.083 -0.207,0 -0.355,-0.111 -0.448,-0.34 l -0.009,-0.021 -0.336,0.207 0.006,0.016 c 0.08,0.155 0.173,0.273 0.28,0.348 0.136,0.099 0.313,0.151 0.527,0.151 0.186,0 0.352,-0.064 0.49,-0.191 0.14,-0.128 0.211,-0.289 0.211,-0.485 0,-0.205 -0.092,-0.374 -0.274,-0.503 C 18.935,9.923 18.893,9.896 18.46,9.688 18.306,9.614 18.228,9.537 18.228,9.457 c 0,-0.055 0.017,-0.101 0.053,-0.14 0.047,-0.054 0.114,-0.08 0.209,-0.08 0.136,0 0.234,0.06 0.336,0.204 l 0.005,0.01 h 0.015 L 19.148,9.215 19.137,9.199 c -0.16,-0.217 -0.381,-0.326 -0.653,-0.326 -0.187,0 -0.345,0.056 -0.471,0.17 z"
|
||||
fill="#ffffff"
|
||||
id="path14495" />
|
||||
<path
|
||||
d="M 4.807,8.473 H 4.778 v 2.604 h 0.944 c 0.223,0 0.419,-0.066 0.58,-0.193 0.186,-0.144 0.28,-0.342 0.28,-0.591 C 6.582,10.139 6.53,9.997 6.429,9.874 6.348,9.772 6.232,9.692 6.112,9.649 6.291,9.478 6.328,9.338 6.328,9.142 6.328,8.963 6.266,8.809 6.144,8.686 6,8.545 5.794,8.473 5.53,8.473 Z m 0.699,0.382 c 0.127,0 0.228,0.035 0.304,0.102 0.061,0.058 0.093,0.128 0.093,0.209 0,0.236 -0.147,0.35 -0.45,0.35 H 5.196 V 8.855 Z m 0.095,1.043 c 0.364,0 0.54,0.13 0.54,0.396 0,0.127 -0.044,0.226 -0.128,0.294 -0.089,0.07 -0.209,0.105 -0.356,0.105 H 5.196 V 9.898 Z"
|
||||
fill="#ffffff"
|
||||
id="path14497" />
|
||||
<path
|
||||
d="m 14.468,8.884 h -1.71 v 0.38 h 0.656 v 1.813 h 0.409 V 9.264 h 0.661 v -0.38 z"
|
||||
fill="#ffffff"
|
||||
id="path14499" />
|
||||
<path
|
||||
d="m 17.045,8.884 h -0.39 V 9.716 H 15.58 V 8.884 h -0.41 v 2.193 h 0.41 v -0.981 h 1.075 v 0.981 h 0.408 V 8.884 Z"
|
||||
fill="#ffffff"
|
||||
id="path14501" />
|
||||
<path
|
||||
d="m 7.181,9.982 c 0,0.622 0.507,1.131 1.132,1.131 0.625,0 1.132,-0.509 1.132,-1.131 0,-0.625 -0.507,-1.133 -1.132,-1.133 -0.625,0 -1.132,0.508 -1.132,1.133 z m 0.409,0 c 0,-0.399 0.324,-0.724 0.723,-0.724 0.398,0 0.722,0.325 0.722,0.724 0,0.397 -0.324,0.721 -0.722,0.721 -0.399,0 -0.723,-0.324 -0.723,-0.721 z"
|
||||
fill="#ffffff"
|
||||
id="path14503" />
|
||||
<path
|
||||
d="m 10.029,9.982 c 0,0.622 0.507,1.131 1.132,1.131 0.623,0 1.133,-0.509 1.133,-1.131 0,-0.625 -0.51,-1.133 -1.133,-1.133 -0.625,0 -1.132,0.508 -1.132,1.133 z m 0.408,0 c 0,-0.399 0.325,-0.724 0.724,-0.724 0.399,0 0.723,0.325 0.723,0.724 0,0.397 -0.324,0.721 -0.723,0.721 -0.399,0 -0.724,-0.324 -0.724,-0.721 z"
|
||||
fill="#ffffff"
|
||||
id="path14505" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
53
frontend/public/assets/poi-icons/brands_2024/budgens.svg
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="2.251 2.278 13.6 13.612"
|
||||
width="512"
|
||||
height="512"
|
||||
version="1.1"
|
||||
id="svg9721"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs9725">
|
||||
<clipPath
|
||||
id="_clipPath_EKjfOHJMlOQRcibSU1LmqOhMlWQdWjHs">
|
||||
<rect
|
||||
x="2.7690001"
|
||||
y="0"
|
||||
width="18.462"
|
||||
height="24"
|
||||
fill="#ffffff"
|
||||
id="rect9852" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g
|
||||
clip-path="url(#_clipPath_EKjfOHJMlOQRcibSU1LmqOhMlWQdWjHs)"
|
||||
id="g9867"
|
||||
style="isolation:isolate"
|
||||
transform="translate(-2.9490722,-0.66036177)">
|
||||
<g
|
||||
id="g9865">
|
||||
<circle
|
||||
vector-effect="non-scaling-stroke"
|
||||
cx="12"
|
||||
cy="9.742836"
|
||||
r="6.4147997"
|
||||
fill="#ffffff"
|
||||
id="circle9855" />
|
||||
<g
|
||||
id="g9859">
|
||||
<path
|
||||
d="m 15.5,8.319 c -0.075,-0.31 0.123,-0.542 0.386,-0.606 0.359,-0.088 0.746,0.094 0.828,0.429 0,0 0.022,0.105 -0.055,0.115 C 16.621,8.261 16.585,8.234 16.576,8.19 16.567,8.143 16.59,8.108 16.579,8.08 16.569,8.054 16.532,8.046 16.473,8.062 16.187,8.141 16.119,8.505 15.896,8.559 15.748,8.595 15.652,8.522 15.629,8.425 c -0.053,-0.217 0.27,-0.602 0.606,-0.566 0,0 -0.148,-0.038 -0.292,-0.003 -0.144,0.035 -0.388,0.159 -0.443,0.463 z"
|
||||
fill="#abd518"
|
||||
id="path9857" />
|
||||
</g>
|
||||
<g
|
||||
id="g9863">
|
||||
<path
|
||||
d="m 12.446,12.884 c -0.697,0 -1.689,-0.043 -1.921,-0.103 -0.074,-0.018 -0.079,-0.018 -0.079,-0.754 v -2.009 c 0,-0.398 0,-0.398 0.07,-0.405 0.272,-0.033 0.998,-0.033 1.576,-0.033 1.272,0 2.513,0.126 2.513,1.624 0,1.68 -1.457,1.68 -2.159,1.68 z m -2,-6.139 c 0,-0.375 0.005,-0.376 0.063,-0.39 0.179,-0.039 1.395,-0.091 1.918,-0.091 0.914,0 1.846,0.146 1.846,1.245 0,0.703 -0.518,1.02 -0.952,1.159 -0.45,0.157 -1.763,0.157 -2.328,0.157 -0.272,0 -0.468,0 -0.522,-0.032 C 10.449,8.774 10.45,8.759 10.446,8.576 Z m 3.668,2.377 c 0.692,-0.369 1.04,-0.914 1.04,-1.669 0,-0.899 -0.469,-1.973 -2.684,-1.973 -0.279,0 -1.949,0.073 -2.272,0.078 C 10.138,5.559 9.729,5.562 9.524,5.549 9.315,5.536 9.129,5.507 8.992,5.507 8.94,5.507 8.891,5.515 8.846,5.528 8.65,5.586 8.537,5.756 8.537,5.919 c 0,0.037 0.019,0.299 0.313,0.374 0.049,0.012 0.105,0.02 0.171,0.02 0.176,0 0.21,-0.024 0.427,-0.024 0.144,0 0.149,0.075 0.149,1.088 0,0 0.014,2.574 -0.005,4.755 -0.007,0.669 -0.031,0.711 -0.149,0.718 -0.16,0.008 -0.261,-0.015 -0.427,-0.015 -0.065,0 -0.171,0.02 -0.171,0.02 -0.294,0.075 -0.313,0.337 -0.313,0.374 0,0.163 0.112,0.334 0.309,0.391 0.044,0.013 0.093,0.02 0.146,0.02 0.109,0 0.323,-0.028 0.532,-0.041 0.199,-0.013 0.584,-0.009 0.584,-0.009 0.314,0.004 1.241,0.037 1.241,0.037 0.48,0.02 0.971,0.037 1.29,0.037 2.481,0 2.85,-1.505 2.85,-2.403 0,-0.729 -0.243,-1.678 -1.37,-2.139 z"
|
||||
fill="#5a9f23"
|
||||
id="path9861" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
54
frontend/public/assets/poi-icons/brands_2024/cook.svg
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Generator: Gravit.io -->
|
||||
|
||||
<svg
|
||||
style="isolation:isolate"
|
||||
viewBox="5.028 5.493 13.945 9.648"
|
||||
width="512"
|
||||
height="354"
|
||||
version="1.1"
|
||||
id="svg25"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs5">
|
||||
<clipPath
|
||||
id="_clipPath_809F2z0yYIwfJgrzpof7RohP6xJUmu2h">
|
||||
<rect
|
||||
width="24"
|
||||
height="24"
|
||||
id="rect2" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g
|
||||
clip-path="url(#_clipPath_809F2z0yYIwfJgrzpof7RohP6xJUmu2h)"
|
||||
id="g23">
|
||||
<g
|
||||
id="g21">
|
||||
<path
|
||||
d=" M 6.83 5.977 C 5.894 6.34 5.481 6.858 5.432 7.739 C 5.382 8.589 5.647 9.144 6.27 9.507 C 6.504 9.643 6.645 9.68 7.101 9.698 C 7.699 9.729 7.804 9.698 8.241 9.353 C 8.414 9.218 8.5 9.101 8.5 9.002 C 8.5 8.768 8.414 8.774 8.124 9.039 C 7.699 9.421 7.225 9.501 6.83 9.261 C 6.448 9.027 6.307 8.799 6.22 8.306 C 5.986 6.907 6.627 6.051 7.748 6.273 C 8.1 6.347 8.192 6.457 8.192 6.79 C 8.192 6.92 8.217 6.944 8.328 6.926 C 8.469 6.907 8.549 6.741 8.648 6.279 C 8.691 6.094 8.685 6.082 8.457 6.026 C 8.328 5.989 8.18 5.965 8.13 5.965 C 8.081 5.965 7.816 5.946 7.545 5.928 C 7.2 5.903 6.984 5.915 6.83 5.977 Z "
|
||||
fill="rgb(255,255,255)"
|
||||
id="path9" />
|
||||
<path
|
||||
d=" M 9.769 5.977 C 9.252 6.168 8.999 6.47 8.845 7.086 C 8.722 7.585 8.722 8.059 8.839 8.515 C 9.042 9.304 9.511 9.711 10.213 9.711 C 10.86 9.711 11.113 9.52 11.464 8.75 C 11.716 8.214 11.76 7.69 11.618 7.043 C 11.525 6.642 11.482 6.544 11.273 6.359 C 11.143 6.242 10.952 6.1 10.848 6.051 C 10.576 5.909 10.053 5.872 9.769 5.977 Z M 10.675 6.384 C 10.909 6.568 10.977 6.87 10.983 7.733 C 10.983 8.583 10.878 9.058 10.638 9.286 C 10.379 9.526 9.93 9.36 9.634 8.916 C 9.227 8.3 9.449 6.636 9.966 6.353 C 10.201 6.23 10.49 6.242 10.675 6.384 Z "
|
||||
fill="rgb(255,255,255)"
|
||||
id="path11" />
|
||||
<path
|
||||
d=" M 13.097 5.977 C 12.579 6.168 12.326 6.47 12.172 7.086 C 12.049 7.585 12.049 8.059 12.166 8.515 C 12.37 9.304 12.838 9.711 13.54 9.711 C 14.181 9.711 14.44 9.514 14.791 8.756 C 15.044 8.226 15.087 7.69 14.945 7.043 C 14.853 6.642 14.809 6.544 14.6 6.359 C 14.471 6.242 14.28 6.1 14.175 6.051 C 13.904 5.909 13.38 5.872 13.097 5.977 Z M 14.002 6.384 C 14.236 6.568 14.304 6.87 14.31 7.733 C 14.31 8.583 14.206 9.058 13.965 9.286 C 13.707 9.526 13.257 9.36 12.961 8.916 C 12.554 8.3 12.776 6.636 13.294 6.353 C 13.528 6.23 13.817 6.242 14.002 6.384 Z "
|
||||
fill="rgb(255,255,255)"
|
||||
id="path13" />
|
||||
<path
|
||||
d=" M 17.003 6.014 C 17.003 6.119 17.046 6.137 17.249 6.137 L 17.502 6.137 L 17.299 6.396 C 17.188 6.544 17.083 6.673 17.065 6.692 C 17.046 6.71 16.898 6.889 16.744 7.092 C 16.59 7.295 16.405 7.511 16.338 7.573 C 16.22 7.677 16.214 7.677 16.196 7.573 C 16.184 7.511 16.177 7.178 16.19 6.827 C 16.208 6.254 16.22 6.199 16.331 6.199 C 16.473 6.199 16.492 6.026 16.35 5.971 C 16.294 5.952 16.042 5.958 15.783 5.989 C 15.407 6.032 15.302 6.069 15.29 6.149 C 15.272 6.23 15.309 6.26 15.426 6.26 C 15.567 6.26 15.586 6.285 15.592 6.488 C 15.604 6.938 15.604 7.53 15.592 8.423 L 15.586 9.323 L 15.389 9.378 C 15.247 9.421 15.185 9.477 15.185 9.557 C 15.185 9.668 15.235 9.68 15.875 9.68 C 16.251 9.68 16.584 9.655 16.609 9.631 C 16.713 9.532 16.529 9.372 16.331 9.39 L 16.14 9.409 L 16.14 8.86 C 16.14 8.324 16.14 8.312 16.362 8.053 L 16.59 7.788 L 16.775 8.121 C 16.88 8.3 17.083 8.651 17.225 8.897 C 17.373 9.138 17.465 9.341 17.434 9.341 C 17.397 9.341 17.373 9.397 17.373 9.47 C 17.373 9.588 17.404 9.594 17.977 9.575 C 18.519 9.557 18.574 9.544 18.574 9.44 C 18.574 9.353 18.513 9.304 18.377 9.267 C 18.241 9.23 18.124 9.125 17.97 8.891 C 17.397 8.016 17.052 7.474 17.028 7.4 C 17.009 7.357 17.163 7.098 17.367 6.833 C 17.866 6.193 17.915 6.149 18.106 6.125 C 18.198 6.112 18.272 6.063 18.285 5.995 C 18.303 5.903 18.241 5.891 17.656 5.891 C 17.046 5.891 17.003 5.897 17.003 6.014 Z "
|
||||
fill="rgb(255,255,255)"
|
||||
id="path15" />
|
||||
<path
|
||||
d=" M 7.572 13.031 C 5.899 13.232 5.766 13.379 7.238 13.419 L 8.509 13.446 L 7.345 13.526 C 6.716 13.566 6.14 13.646 6.087 13.7 C 6.033 13.767 6.555 13.82 7.251 13.834 L 8.509 13.861 L 7.439 13.901 C 5.873 13.968 5.779 14.128 7.238 14.209 L 8.509 14.289 L 7.278 14.316 C 6.595 14.329 6.033 14.396 6.033 14.463 C 6.033 14.57 7.613 14.744 8.723 14.744 C 9.232 14.744 9.446 14.677 9.741 14.396 L 10.115 14.048 L 12.926 14.128 C 14.479 14.182 16.125 14.249 16.593 14.289 C 17.544 14.383 17.677 14.289 17.677 13.606 C 17.677 12.763 17.477 12.723 14.934 13.191 C 14.345 13.298 13.006 13.432 11.962 13.486 L 10.062 13.58 L 9.647 13.218 C 9.192 12.843 9.205 12.843 7.572 13.031 Z "
|
||||
fill="rgb(255,255,255)"
|
||||
id="path17" />
|
||||
<path
|
||||
d=" M 6.175 11.044 C 6.079 11.261 6.067 11.455 6.151 11.684 C 6.26 11.987 6.321 12.011 6.865 12.011 C 7.203 11.999 8.001 11.938 8.642 11.854 C 9.295 11.769 10.214 11.709 10.71 11.709 C 11.459 11.709 11.641 11.745 11.834 11.963 C 12.052 12.204 12.136 12.216 13.756 12.132 C 15.546 12.047 16.211 11.938 17.057 11.624 C 17.94 11.298 17.722 11.104 16.453 11.104 C 12.414 11.092 7.989 10.971 7.53 10.874 C 6.611 10.669 6.333 10.705 6.175 11.044 Z "
|
||||
fill="rgb(255,255,255)"
|
||||
id="path19" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
79
frontend/public/assets/poi-icons/brands_2024/iceland.svg
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="1.136 6.823 16.067 4.302"
|
||||
width="512"
|
||||
height="137"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
sodipodi:docname="iceland.svg"
|
||||
inkscape:version="1.4 (86a8ad7, 2024-10-11)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="pt"
|
||||
inkscape:zoom="25.05682"
|
||||
inkscape:cx="12.032652"
|
||||
inkscape:cy="14.666665"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="974"
|
||||
inkscape:window-x="-11"
|
||||
inkscape:window-y="1609"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg1" />
|
||||
<defs
|
||||
id="defs1" />
|
||||
<g
|
||||
id="g11494"
|
||||
style="isolation:isolate;fill:#ffffff;fill-opacity:1"
|
||||
transform="matrix(1.4014473,0,0,1.4014473,-7.6485718,-4.7627046)">
|
||||
<g
|
||||
id="g11492"
|
||||
style="fill:#ffffff;fill-opacity:1">
|
||||
<path
|
||||
d="m 12.557,9.096 c -0.3,0 -0.566,0.071 -0.707,0.122 0.006,0.02 0.062,0.303 0.071,0.337 0.175,-0.063 0.407,-0.105 0.56,-0.105 0.246,0 0.353,0.071 0.353,0.246 v 0.071 c -0.772,0 -1.094,0.274 -1.094,0.627 0,0.393 0.359,0.617 0.871,0.617 0.333,0 0.627,-0.057 0.749,-0.094 V 9.719 c 0,-0.422 -0.266,-0.623 -0.803,-0.623 z m 0.277,1.553 c -0.051,0.011 -0.136,0.017 -0.181,0.017 -0.178,0 -0.368,-0.074 -0.368,-0.277 0,-0.204 0.176,-0.3 0.549,-0.297 z"
|
||||
fill="#d11c2e"
|
||||
id="path11478"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
<path
|
||||
d="m 8.382,9.096 c -0.548,0 -0.961,0.413 -0.961,1.001 0,0.546 0.379,0.914 0.948,0.914 0.147,0 0.345,-0.043 0.449,-0.085 L 8.736,10.609 c -0.099,0.034 -0.209,0.048 -0.288,0.048 -0.306,0 -0.495,-0.223 -0.495,-0.591 0,-0.37 0.212,-0.616 0.517,-0.616 0.074,0 0.153,0.02 0.215,0.042 L 8.776,9.167 C 8.668,9.122 8.516,9.096 8.382,9.096 Z"
|
||||
fill="#d11c2e"
|
||||
id="path11480"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
<path
|
||||
d="M 16.88,8.593 V 9.122 C 16.821,9.111 16.736,9.096 16.615,9.096 c -0.45,0 -0.933,0.309 -0.933,0.97 0,0.591 0.384,0.945 1.026,0.945 0.271,0 0.574,-0.043 0.701,-0.08 V 8.593 Z m 0,2.061 c -0.045,0.009 -0.107,0.012 -0.144,0.012 -0.331,0 -0.52,-0.227 -0.52,-0.614 0,-0.294 0.124,-0.602 0.492,-0.602 0.051,0 0.135,0.008 0.172,0.02 z"
|
||||
fill="#d11c2e"
|
||||
id="path11482"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
<path
|
||||
d="m 10.066,10.666 c -0.416,0 -0.546,-0.179 -0.58,-0.345 L 10.614,10.08 C 10.611,9.255 10.119,9.096 9.8,9.096 c -0.416,0 -0.865,0.303 -0.865,0.97 0,0.591 0.395,0.945 1.051,0.945 0.199,0 0.416,-0.043 0.529,-0.085 l -0.07,-0.317 c -0.105,0.034 -0.255,0.057 -0.379,0.057 z M 9.8,9.45 c 0.172,0 0.288,0.124 0.308,0.365 L 9.461,9.953 C 9.455,9.634 9.588,9.45 9.8,9.45 Z"
|
||||
fill="#d11c2e"
|
||||
id="path11484"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
<path
|
||||
d="M 10.914,10.974 H 11.44 V 8.593 h -0.526 z"
|
||||
fill="#d11c2e"
|
||||
id="path11486"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
<path
|
||||
d="M 6.591,10.974 H 7.117 V 8.593 H 6.591 Z"
|
||||
fill="#d11c2e"
|
||||
id="path11488"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
<path
|
||||
d="m 14.259,9.473 c 0.076,-0.018 0.195,-0.032 0.289,-0.032 0.195,0 0.322,0.057 0.322,0.311 v 1.222 h 0.526 v -1.25 c 0,-0.427 -0.26,-0.628 -0.837,-0.628 -0.325,0 -0.696,0.066 -0.829,0.094 v 1.784 h 0.529 z"
|
||||
fill="#d11c2e"
|
||||
id="path11490"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
1
frontend/public/assets/poi-icons/brands_2024/makro.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg viewBox="4.411 6.098 16.397 5.428" xmlns="http://www.w3.org/2000/svg" width="512" height="169"><path fill="#f5e71b" fill-rule="evenodd" d="M12.72 6.72c-.09.08-.12.68-.12 2.04 0 1.87 0 1.93.22 2.08.3.22.62 0 .66-.45.02-.18.1-.41.17-.52.13-.18.15-.17.4.23l.31.54c.1.25.5.36.73.2.3-.21.27-.4-.18-1.14l-.4-.65.35-.37c.38-.42.42-.59.18-.82-.25-.25-.42-.2-1 .35l-.54.52v-.95c0-.99-.07-1.18-.45-1.18a.5.5 0 0 0-.33.12m5.64 1.03c-.21.08-.46.2-.54.29-.13.13-.17.12-.34-.1a.6.6 0 0 0-.77-.15q-.15.08-.36-.01a.5.5 0 0 0-.44.01c-.2.11-.21.2-.21 1.56s.01 1.45.2 1.55c.17.09.27.08.45-.04.22-.15.24-.23.27-1.08l.03-.93.35-.17c.48-.23.5-.22.38.13-.26.76.12 1.77.78 2.05 1.25.52 2.45-.55 2.14-1.9-.22-.98-1.07-1.5-1.94-1.2m-13.3.1c-.13.12-.16.4-.16 1.5 0 1.26.01 1.34.22 1.48.19.13.25.14.43.02.17-.11.2-.27.25-1.11.04-.79.08-1 .23-1.09q.17-.12.34 0c.15.09.19.3.23 1.08.04.8.08 1 .23 1.11.2.14.46.1.6-.1.03-.05.07-.49.07-.96 0-.96.12-1.23.5-1.17.24.03.25.06.3 1.03.03.55.09 1.06.13 1.13s.22.12.38.12c.41 0 .52-.33.47-1.52-.03-.8-.08-1.03-.24-1.25-.3-.4-.65-.5-1.17-.34-.53.15-.56.15-.94 0-.26-.1-.42-.1-.75-.02q-.42.1-.52.02c-.15-.14-.43-.1-.6.07m5.24-.05c-.37.15-.6.4-.6.64 0 .45.52.61.83.25.22-.26.8-.28.75-.02-.02.12-.2.2-.62.29-.84.16-1.1.39-1.14.96-.03.39.01.51.21.71.3.3.88.42 1.3.29q.33-.1.49 0t.42 0c.26-.13.26-.13.26-1.42 0-1.4-.04-1.52-.57-1.72-.31-.12-1-.1-1.33.02m8.1.95c-.3.4-.27.97.07 1.23.48.38.93.06.93-.66 0-.75-.58-1.09-1-.57m-7.7.93c-.29.08-.33.29-.08.44.23.15.68-.06.68-.33 0-.21-.14-.24-.6-.11"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
49
frontend/public/assets/poi-icons/brands_2024/mns.svg
Normal file
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -0,0 +1,53 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="4.58 1.61 14.84 14.849"
|
||||
width="512"
|
||||
height="512"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1">
|
||||
<clipPath
|
||||
id="_clipPath_E9rXdIHY3dkNHvZGWkIDJ3F3emxd4i5R">
|
||||
<rect
|
||||
width="24"
|
||||
height="24"
|
||||
id="rect13775"
|
||||
x="0"
|
||||
y="0" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<circle
|
||||
vector-effect="non-scaling-stroke"
|
||||
cx="12"
|
||||
cy="9.0375166"
|
||||
fill="#b40555"
|
||||
id="circle13689"
|
||||
style="isolation:isolate;stroke-width:0.051852"
|
||||
r="7" />
|
||||
<circle
|
||||
vector-effect="non-scaling-stroke"
|
||||
cx="12"
|
||||
cy="9.0189352"
|
||||
fill="#ffffff"
|
||||
id="circle13691"
|
||||
style="isolation:isolate;stroke-width:0.051852"
|
||||
r="6.5074077" />
|
||||
<g
|
||||
clip-path="url(#_clipPath_E9rXdIHY3dkNHvZGWkIDJ3F3emxd4i5R)"
|
||||
id="g13782"
|
||||
style="isolation:isolate"
|
||||
transform="matrix(0.58665022,0,0,0.57290629,4.7070258,1.9147365)">
|
||||
<path
|
||||
d="m 11.421,15.814 c -1.054,0 -2.148,-0.232 -3.118,-0.697 C 8.114,14.988 8.078,14.911 8.078,14.73 v -0.947 c 0,-0.173 0.059,-0.344 0.379,-0.344 0.171,0 0.284,0.091 0.527,0.266 0.973,0.679 1.9,0.955 2.684,0.955 1.322,0 2.22,-0.8 2.22,-1.791 0.01,-2.589 -5.9,-1.797 -5.9,-5.617 0,-1.768 1.362,-3.314 3.879,-3.314 1.252,0 2.388,0.24 3.028,0.49 0.173,0.078 0.206,0.166 0.206,0.341 v 1.097 c 0,0.168 -0.051,0.3 -0.258,0.3 -0.168,0 -0.302,-0.104 -0.511,-0.259 C 13.576,5.365 12.853,5.03 11.906,5.03 c -1.058,0 -1.89,0.568 -1.89,1.445 0,2.169 5.996,1.936 5.996,5.421 0,2.633 -2.194,3.871 -4.594,3.871 m 69.616,-0.549"
|
||||
fill="#f47320"
|
||||
id="path13780" />
|
||||
</g>
|
||||
<text
|
||||
style="font-style:normal;font-weight:400;font-size:2.59259px;font-family:Sarala;isolation:isolate;fill:#b40555;stroke:none;stroke-width:0.051852"
|
||||
id="text13695"
|
||||
x="8.6738129"
|
||||
y="13.645541">Local</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
47
frontend/public/assets/poi-icons/brands_2024/wholefoods.svg
Normal file
|
After Width: | Height: | Size: 29 KiB |
1
frontend/public/assets/poi-icons/logos/aldi.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!-- Generator: Gravit.io --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="4.813 2.563 14.374 14.383" width="512" height="512"><defs><clipPath id="_clipPath_vbIexxSM2i6O9J69ZX3QZueqZh3MIWMj"><rect width="24" height="24"/></clipPath></defs><g clip-path="url(#_clipPath_vbIexxSM2i6O9J69ZX3QZueqZh3MIWMj)"><g><circle vector-effect="non-scaling-stroke" cx="12" cy="9.752321981424151" r="6.780185758513929" fill="rgb(250,110,10)"/><circle vector-effect="non-scaling-stroke" cx="12.048429898275081" cy="9.752321981424132" r="6.295886775762934" fill="rgb(240,61,20)"/><circle vector-effect="non-scaling-stroke" cx="12.048429898275096" cy="9.761959437594516" r="6.005307386112342" fill="rgb(0,181,219)"/><circle vector-effect="non-scaling-stroke" cx="12.048429898275096" cy="9.757140709509319" r="5.758339166651663" fill="rgb(0,31,120)"/><g><g><path d=" M 16.012 8.207 L 15.117 8.207 L 15.117 10.81 L 16.012 10.81 L 16.012 8.207 Z M 13.894 9.508 C 13.894 10.009 13.881 10.247 13.594 10.247 L 13.52 10.247 L 13.52 8.777 L 13.594 8.777 C 13.881 8.773 13.894 9.015 13.894 9.508 Z M 14.793 9.508 C 14.793 8.244 14.366 8.211 13.458 8.211 L 12.625 8.211 L 12.625 10.814 L 13.458 10.814 C 14.37 10.81 14.793 10.773 14.793 9.508 Z M 12.358 10.81 L 12.272 10.103 L 11.627 10.103 L 11.627 8.207 L 10.732 8.207 L 10.732 10.81 L 12.358 10.81 Z M 10.519 10.81 L 9.767 8.207 L 8.655 8.207 L 7.891 10.81 L 8.786 10.81 L 8.856 10.51 L 9.484 10.51 L 9.558 10.81 L 10.519 10.81 Z M 9.377 9.96 L 8.962 9.96 L 9.168 8.905 L 9.377 9.96 Z " fill="rgb(255,255,255)"/></g></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
1
frontend/public/assets/poi-icons/logos/asda.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!-- Generator: Gravit.io --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="3.91 7.135 16.181 5.442" width="512" height="172"><defs><clipPath id="_clipPath_2ns1Qiv3DwYjULkq3uQylFlYIP1ofCvX"><rect width="24" height="24"/></clipPath></defs><g clip-path="url(#_clipPath_2ns1Qiv3DwYjULkq3uQylFlYIP1ofCvX)"><g><g><path d=" M 16.915 10.429 L 17.373 8.906 L 17.831 10.429 L 16.915 10.429 Z M 13.56 10.93 L 13.278 10.93 L 13.278 8.82 L 13.556 8.82 C 13.979 8.82 14.448 8.997 14.448 9.87 C 14.448 10.644 14.033 10.93 13.56 10.93 Z M 6.118 10.429 L 6.586 8.887 L 7.046 10.429 L 6.118 10.429 Z M 18.207 7.726 L 16.633 7.726 L 15.733 10.339 C 15.776 10.121 15.782 9.949 15.782 9.872 C 15.782 8.778 15.159 7.726 13.693 7.726 L 12.011 7.726 L 12.014 10.325 C 11.87 9.587 11.2 9.388 10.366 9.133 C 10.053 9.038 9.882 8.884 9.944 8.741 C 9.998 8.616 10.188 8.577 10.421 8.61 C 10.774 8.659 11.057 8.779 11.33 8.928 L 11.702 8.002 C 11.617 7.959 11.015 7.597 10.261 7.597 C 9.206 7.597 8.534 8.112 8.534 8.875 C 8.534 9.554 8.951 9.949 9.716 10.172 C 10.538 10.41 10.747 10.507 10.723 10.746 C 10.7 10.949 10.19 11.2 8.943 10.429 L 8.573 11.199 L 7.426 7.726 L 5.853 7.726 L 4.367 12.044 L 5.608 12.044 L 5.8 11.433 L 7.364 11.433 L 7.549 12.044 L 8.852 12.044 L 8.704 11.622 C 9.144 11.877 9.691 12.111 10.343 12.111 C 11.342 12.111 11.883 11.562 12.013 11.035 L 12.013 12.044 L 13.691 12.044 C 14.692 12.044 15.216 11.564 15.488 11.051 L 15.147 12.044 L 16.386 12.044 L 16.586 11.433 L 18.148 11.433 L 18.328 12.044 L 19.633 12.044 L 18.207 7.726 Z " fill="rgb(125,194,66)"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
1
frontend/public/assets/poi-icons/logos/centra.svg
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
1
frontend/public/assets/poi-icons/logos/coop.svg
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
4
frontend/public/assets/poi-icons/logos/costco.svg
Normal file
|
After Width: | Height: | Size: 12 KiB |
1
frontend/public/assets/poi-icons/logos/lidl.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!-- Generator: Gravit.io --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="4.938 2.973 13.985 13.985" width="512" height="512"><defs><clipPath id="_clipPath_BIWaDv3sQ9VuMwpTXVaWaU14cOERcNng"><rect width="24" height="24"/></clipPath></defs><g clip-path="url(#_clipPath_BIWaDv3sQ9VuMwpTXVaWaU14cOERcNng)"><g><path d=" M 11.889 3.415 C 8.268 3.415 5.332 6.352 5.332 9.974 C 5.332 13.592 8.268 16.529 11.889 16.529 C 15.509 16.529 18.446 13.592 18.446 9.974 C 18.446 6.352 15.509 3.415 11.889 3.415 L 11.889 3.415 Z " fill-rule="evenodd" fill="rgb(255,227,0)"/><path d=" M 11.999 10.083 L 11.999 9.636 L 11.481 10.156 L 10.324 8.999 L 8.989 10.336 L 8.989 10.785 L 9.326 10.447 L 10.257 11.381 L 9.913 11.724 L 10.137 11.949 L 11.999 10.083 Z " fill-rule="evenodd" fill="rgb(180,30,10)"/><path d=" M 6.077 10.746 L 6.423 10.746 L 6.423 9.2 L 6.077 9.2 L 6.077 8.755 L 8.139 8.755 L 8.139 9.2 L 7.795 9.2 L 7.795 10.639 L 8.99 9.974 L 8.99 11.193 L 6.077 11.193 L 6.077 10.746 Z " fill-rule="evenodd" fill="rgb(0,50,120)"/><path d=" M 10.154 7.37 C 10.559 7.37 10.888 7.696 10.888 8.102 C 10.888 8.508 10.559 8.836 10.154 8.836 C 9.748 8.836 9.419 8.508 9.419 8.102 C 9.419 7.696 9.748 7.37 10.154 7.37 Z " fill-rule="evenodd" fill="rgb(180,30,10)"/><path d=" M 11.656 8.755 L 11.656 9.201 L 12 9.201 L 12 10.746 L 11.656 10.746 L 11.656 11.193 L 13.622 11.193 C 15.075 11.193 15.09 8.755 13.622 8.755 L 11.656 8.755 Z M 13.248 9.512 L 13.33 9.512 C 13.761 9.512 13.761 10.434 13.346 10.432 L 13.248 10.432 L 13.248 9.512 Z " fill-rule="evenodd" fill="rgb(0,50,120)"/><path d=" M 14.772 10.746 L 15.117 10.746 L 15.117 9.2 L 14.772 9.2 L 14.772 8.755 L 16.834 8.755 L 16.834 9.2 L 16.49 9.2 L 16.49 10.639 L 17.684 9.974 L 17.684 11.193 L 14.772 11.193 L 14.772 10.746 Z " fill="rgb(0,50,120)"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
1
frontend/public/assets/poi-icons/logos/morrisons.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!-- Generator: Gravit.io --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="5.231 2.985 13.538 13.398" width="512" height="507"><defs><clipPath id="_clipPath_BFoySlfvjacKQVUV9UtAlSTDRujMNc5d"><rect width="24" height="24"/></clipPath></defs><g clip-path="url(#_clipPath_BFoySlfvjacKQVUV9UtAlSTDRujMNc5d)"><g><circle vector-effect="non-scaling-stroke" cx="12" cy="9.640866873065017" r="6.260061919504644" fill="rgb(254,225,51)"/><g><path d=" M 11.922 10.813 C 11.922 10.813 11.003 6.857 10.969 6.76 C 10.935 6.663 10.862 6.591 10.758 6.59 C 10.653 6.589 9.026 6.591 9.026 6.591 C 8.866 6.591 8.813 6.772 8.813 6.963 C 8.813 7.151 8.858 7.338 9.018 7.338 L 9.474 7.337 L 9.079 11.647 L 8.667 11.648 C 8.507 11.648 8.463 11.833 8.463 12.024 C 8.463 12.214 8.515 12.392 8.676 12.392 L 10.449 12.392 C 10.61 12.392 10.663 12.209 10.663 12.02 C 10.663 11.829 10.618 11.644 10.457 11.644 L 10.021 11.642 L 10.365 7.601 L 11.597 12.27 L 12.247 12.27 L 13.479 7.601 L 13.824 11.642 L 13.387 11.644 C 13.227 11.644 13.182 11.829 13.182 12.02 C 13.182 12.209 13.235 12.392 13.395 12.392 L 15.169 12.392 C 15.329 12.392 15.382 12.214 15.382 12.024 C 15.382 11.833 15.337 11.648 15.177 11.648 L 14.765 11.647 L 14.371 7.337 L 14.826 7.338 C 14.986 7.338 15.031 7.151 15.031 6.962 C 15.031 6.772 14.978 6.591 14.818 6.591 C 14.818 6.591 13.191 6.589 13.087 6.59 C 12.982 6.591 12.909 6.663 12.875 6.76 C 12.842 6.857 11.922 10.813 11.922 10.813 Z " fill="rgb(0,97,75)"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
1
frontend/public/assets/poi-icons/logos/sainsburys.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!-- Generator: Gravit.io --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="7.502 3.572 8.996 12.646" width="364" height="512"><defs><clipPath id="_clipPath_E9rXdIHY3dkNHvZGWkIDJ3F3emxd4i5R"><rect width="24" height="24"/></clipPath></defs><g clip-path="url(#_clipPath_E9rXdIHY3dkNHvZGWkIDJ3F3emxd4i5R)"><path d=" M 11.421 15.814 C 10.367 15.814 9.273 15.582 8.303 15.117 C 8.114 14.988 8.078 14.911 8.078 14.73 L 8.078 13.783 C 8.078 13.61 8.137 13.439 8.457 13.439 C 8.628 13.439 8.741 13.53 8.984 13.705 C 9.957 14.384 10.884 14.66 11.668 14.66 C 12.99 14.66 13.888 13.86 13.888 12.869 C 13.898 10.28 7.988 11.072 7.988 7.252 C 7.988 5.484 9.35 3.938 11.867 3.938 C 13.119 3.938 14.255 4.178 14.895 4.428 C 15.068 4.506 15.101 4.594 15.101 4.769 L 15.101 5.866 C 15.101 6.034 15.05 6.166 14.843 6.166 C 14.675 6.166 14.541 6.062 14.332 5.907 C 13.576 5.365 12.853 5.03 11.906 5.03 C 10.848 5.03 10.016 5.598 10.016 6.475 C 10.016 8.644 16.012 8.411 16.012 11.896 C 16.012 14.529 13.818 15.767 11.418 15.767 M 81.034 15.218" fill="rgb(244,115,32)"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
1
frontend/public/assets/poi-icons/logos/spar.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!-- Generator: Gravit.io --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="4.933 2.968 14.133 13.853" width="512" height="502"><defs><clipPath id="_clipPath_hDaODHs0EjulYgN5PZ1zbV2fOUh1CPBp"><rect width="24" height="24"/></clipPath></defs><g clip-path="url(#_clipPath_hDaODHs0EjulYgN5PZ1zbV2fOUh1CPBp)"><g><path d=" M 5.401 10.073 C 5.401 12.447 6.596 14.573 8.702 15.613 C 10.681 16.591 13.319 16.591 15.304 15.613 C 17.262 14.649 18.599 12.447 18.599 10.073 C 18.599 6.442 15.694 3.474 12 3.474 C 8.289 3.474 5.401 6.419 5.401 10.073 Z " fill="rgb(21,121,70)"/><path d=" M 12.003 5.185 C 11.339 7.437 9.36 10.737 7.917 12.355 L 11.339 12.355 L 11.347 13.335 C 11.339 13.891 10.897 14.468 10.254 14.46 C 9.36 14.45 8.242 14.037 7.567 12.89 C 7.075 12.054 6.718 11.395 6.718 10.073 C 6.718 7.187 9.043 4.795 12 4.795 C 14.925 4.795 17.283 7.256 17.283 10.073 C 17.283 11.395 16.666 12.537 16.463 12.867 C 15.74 14.037 14.64 14.45 13.789 14.46 C 13.202 14.468 12.661 14.037 12.661 13.332 L 12.661 12.355 L 16.137 12.355 C 14.64 10.737 12.661 7.437 12.003 5.185 Z " fill="rgb(255,255,255)"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
1
frontend/public/assets/poi-icons/logos/tesco.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!-- Generator: Gravit.io --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="4.338 6.865 15.324 5.359" width="512" height="179"><defs><clipPath id="_clipPath_O8x8RjmkfcOwvIJxGHZE2JbxsH3kp6fc"><rect width="24" height="24"/></clipPath></defs><g clip-path="url(#_clipPath_O8x8RjmkfcOwvIJxGHZE2JbxsH3kp6fc)"><g><path d=" M 5.937 11.052 C 5.651 11.052 5.419 11.216 5.307 11.332 C 5.307 11.332 5.035 11.602 4.85 11.696 C 4.842 11.7 4.842 11.709 4.854 11.709 L 6.52 11.709 C 6.85 11.709 7.005 11.597 7.2 11.422 C 7.2 11.422 7.485 11.158 7.68 11.072 C 7.7 11.065 7.695 11.052 7.68 11.052 L 5.937 11.052 Z M 8.801 11.052 C 8.514 11.052 8.283 11.216 8.17 11.332 C 8.17 11.332 7.898 11.602 7.714 11.696 C 7.706 11.7 7.706 11.709 7.717 11.709 L 9.387 11.709 C 9.717 11.709 9.872 11.597 10.067 11.422 C 10.067 11.422 10.349 11.158 10.544 11.072 C 10.564 11.065 10.562 11.052 10.547 11.052 L 8.801 11.052 L 8.801 11.052 Z M 11.667 11.052 C 11.381 11.052 11.149 11.216 11.037 11.332 C 11.037 11.332 10.765 11.602 10.58 11.696 C 10.572 11.7 10.573 11.709 10.584 11.709 L 12.254 11.709 C 12.583 11.709 12.735 11.597 12.931 11.422 C 12.931 11.422 13.215 11.158 13.411 11.072 C 13.43 11.065 13.429 11.052 13.414 11.052 L 11.667 11.052 Z M 14.531 11.052 C 14.245 11.052 14.016 11.216 13.904 11.332 C 13.904 11.332 13.628 11.602 13.444 11.696 C 13.436 11.7 13.436 11.709 13.447 11.709 L 15.117 11.709 C 15.447 11.709 15.602 11.597 15.797 11.422 C 15.797 11.422 16.082 11.158 16.277 11.072 C 16.297 11.065 16.292 11.052 16.277 11.052 L 14.531 11.052 Z M 17.397 11.052 C 17.111 11.052 16.879 11.216 16.767 11.332 C 16.767 11.332 16.495 11.602 16.311 11.696 C 16.302 11.7 16.303 11.709 16.314 11.709 L 17.984 11.709 C 18.313 11.709 18.468 11.597 18.664 11.422 C 18.664 11.422 18.946 11.158 19.141 11.072 C 19.16 11.065 19.159 11.052 19.144 11.052 L 17.397 11.052 L 17.397 11.052 Z " fill="rgb(0,84,164)" vector-effect="non-scaling-stroke" stroke-width="0.079" stroke="rgb(255,255,255)" stroke-linejoin="miter" stroke-linecap="butt" stroke-miterlimit="11.47399998"/><path d=" M 11.771 7.43 C 11.161 7.43 10.567 7.611 10.567 8.217 C 10.567 9.266 12.397 8.712 12.397 9.354 C 12.397 9.563 12.073 9.647 11.811 9.647 C 11.34 9.647 11.015 9.577 10.604 9.33 L 10.604 9.857 C 10.911 9.958 11.328 10.007 11.781 10.007 C 12.413 10.007 13.004 9.844 13.004 9.224 C 13.004 8.128 11.174 8.62 11.174 8.084 C 11.174 7.868 11.468 7.794 11.724 7.794 C 12.156 7.794 12.621 7.92 12.877 8.157 L 12.877 7.597 C 12.546 7.492 12.106 7.43 11.771 7.43 L 11.771 7.43 Z M 15.004 7.437 C 13.905 7.437 13.177 7.965 13.177 8.77 C 13.177 9.509 13.845 10.004 14.844 10.004 C 15.168 10.004 15.456 9.961 15.794 9.88 L 15.794 9.257 C 15.555 9.509 15.272 9.61 14.967 9.61 C 14.377 9.61 13.974 9.243 13.974 8.714 C 13.974 8.192 14.392 7.81 14.977 7.81 C 15.296 7.81 15.551 7.926 15.757 8.127 L 15.757 7.504 C 15.543 7.459 15.275 7.437 15.004 7.437 L 15.004 7.437 Z M 17.567 7.437 C 16.62 7.437 15.951 7.982 15.951 8.75 C 15.951 9.504 16.552 10.004 17.447 10.004 C 18.399 10.004 19.054 9.472 19.054 8.7 C 19.054 7.943 18.458 7.437 17.567 7.437 L 17.567 7.437 Z M 4.944 7.514 L 4.944 8.104 C 5.15 7.944 5.58 7.884 6.05 7.88 L 6.05 9.49 C 6.05 9.724 6.03 9.791 5.93 9.914 L 6.86 9.914 C 6.756 9.791 6.74 9.724 6.74 9.49 L 6.74 7.867 C 7.148 7.87 7.635 7.944 7.84 8.104 L 7.84 7.514 L 4.944 7.514 L 4.944 7.514 Z M 8.09 7.514 C 8.188 7.636 8.207 7.706 8.207 7.94 L 8.207 9.49 C 8.207 9.724 8.19 9.795 8.097 9.914 L 10.344 9.914 L 10.344 9.33 C 10.036 9.55 9.449 9.551 9.211 9.547 L 8.884 9.547 L 8.884 8.877 L 9.124 8.877 C 9.287 8.877 9.58 8.896 9.751 8.947 L 9.751 8.447 C 9.582 8.501 9.286 8.517 9.124 8.517 L 8.884 8.517 L 8.884 7.881 L 9.211 7.881 C 9.502 7.881 10.01 7.941 10.201 8.101 L 10.201 7.514 L 8.09 7.514 L 8.09 7.514 Z M 17.504 7.8 C 18 7.8 18.327 8.211 18.327 8.717 C 18.327 9.223 18 9.634 17.504 9.634 C 17 9.634 16.677 9.223 16.677 8.717 C 16.677 8.211 17 7.8 17.504 7.8 Z " fill="rgb(240,46,37)" vector-effect="non-scaling-stroke" stroke-width="0.03" stroke="rgb(255,255,255)" stroke-linejoin="miter" stroke-linecap="butt" stroke-miterlimit="3.86400008"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
1
frontend/public/assets/poi-icons/logos/tesco_express.svg
Normal file
|
After Width: | Height: | Size: 21 KiB |
1
frontend/public/assets/poi-icons/logos/tesco_extra.svg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/public/assets/poi-icons/logos/the_food_warehouse.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
1
frontend/public/assets/poi-icons/logos/waitrose.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!-- Generator: Gravit.io --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="5.528 6.09 12.943 9.855" width="512" height="390"><defs><clipPath id="_clipPath_7GsZdunOKL1x2LPBh7r6yOcQYl1CItDV"><rect width="24" height="24"/></clipPath></defs><g clip-path="url(#_clipPath_7GsZdunOKL1x2LPBh7r6yOcQYl1CItDV)"><g><path d=" M 14.45 15.605 L 12 9.716 L 9.55 15.605 L 5.912 6.772 L 7.388 6.772 L 9.574 12.469 L 12 6.517 L 14.417 12.469 L 16.612 6.772 L 18.088 6.772 L 14.45 15.605 Z " fill="rgb(123,177,52)"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 637 B |
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!-- Generator: Gravit.io --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 24 24" width="512" height="512"><defs><clipPath id="_clipPath_wt5ZtCPd83P04Y5gLpCTm5PnahVUld6n"><rect width="24" height="24"/></clipPath></defs><g clip-path="url(#_clipPath_wt5ZtCPd83P04Y5gLpCTm5PnahVUld6n)"><circle vector-effect="non-scaling-stroke" cx="12" cy="12" r="9.71875" fill="rgb(214,35,30)"/><circle vector-effect="non-scaling-stroke" cx="12" cy="12" r="6.375" fill="rgb(255,255,255)"/><rect x="0.031" y="10.219" width="23.938" height="3.563" transform="matrix(1,0,0,1,0,0)" fill="rgb(0,24,163)"/></g></svg>
|
||||
|
After Width: | Height: | Size: 722 B |
1
frontend/public/assets/poi-icons/visuals/mns.svg
Normal file
|
After Width: | Height: | Size: 6 KiB |
9
frontend/public/home-hex-pattern-dark.svg
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="138" height="159" viewBox="0 0 138 159">
|
||||
<g fill="none" stroke="#2dd4bf" stroke-width="1.2" stroke-linejoin="round" stroke-opacity="0.58">
|
||||
<path d="M46 0L69 40L46 80H0L-23 40L0 0Z"/>
|
||||
<path d="M46 80L69 120L46 159H0L-23 120L0 80Z"/>
|
||||
<path d="M115 40L138 80L115 120H69L46 80L69 40Z"/>
|
||||
<path d="M184 0L207 40L184 80H138L115 40L138 0Z"/>
|
||||
<path d="M184 80L207 120L184 159H138L115 120L138 80Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 473 B |
9
frontend/public/home-hex-pattern.svg
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="138" height="159" viewBox="0 0 138 159">
|
||||
<g fill="none" stroke="#14b8a6" stroke-width="1.2" stroke-linejoin="round" stroke-opacity="0.5">
|
||||
<path d="M46 0L69 40L46 80H0L-23 40L0 0Z"/>
|
||||
<path d="M46 80L69 120L46 159H0L-23 120L0 80Z"/>
|
||||
<path d="M115 40L138 80L115 120H69L46 80L69 40Z"/>
|
||||
<path d="M184 0L207 40L184 80H138L115 40L138 0Z"/>
|
||||
<path d="M184 80L207 120L184 159H138L115 120L138 80Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 472 B |
|
|
@ -5,11 +5,6 @@
|
|||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://perfect-postcode.co.uk/dashboard</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://perfect-postcode.co.uk/learn</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
|
|
@ -20,4 +15,59 @@
|
|||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://perfect-postcode.co.uk/property-price-map</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.85</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://perfect-postcode.co.uk/postcode-property-search</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.85</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://perfect-postcode.co.uk/commute-property-search</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.85</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://perfect-postcode.co.uk/school-property-search</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://perfect-postcode.co.uk/postcode-checker</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://perfect-postcode.co.uk/property-search/birmingham</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.75</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://perfect-postcode.co.uk/property-search/manchester</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.75</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://perfect-postcode.co.uk/property-search/bristol</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.75</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://perfect-postcode.co.uk/data-sources</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://perfect-postcode.co.uk/methodology</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://perfect-postcode.co.uk/privacy-security</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
|
|
|
|||
BIN
frontend/public/video/poster.jpg
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
frontend/public/video/recording.mp4
Normal file
259
frontend/scripts/check-translations.mjs
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
#!/usr/bin/env node
|
||||
// Validates that every translation file under src/i18n is complete and consistent.
|
||||
//
|
||||
// Checks:
|
||||
// 1. Locales declared in SUPPORTED_LANGUAGES (index.ts) match the files in locales/
|
||||
// and the language records in descriptions.ts / details.ts.
|
||||
// 2. Every leaf key in en.ts is present and non-empty in every other locale.
|
||||
// 3. Every {{placeholder}} and HTML tag in an English string also appears,
|
||||
// with the same multiset, in the translated string.
|
||||
// 4. descriptions.ts and details.ts: the union of feature-name keys across
|
||||
// languages is treated as canonical; every language must cover all of them.
|
||||
//
|
||||
// The script parses the TypeScript source with the compiler API and walks the
|
||||
// AST — no runtime import, no transpilation, no temp files. Run it with:
|
||||
// node frontend/scripts/check-translations.mjs
|
||||
|
||||
import { readFileSync, readdirSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import ts from 'typescript';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const I18N_DIR = join(__dirname, '..', 'src', 'i18n');
|
||||
const LOCALES_DIR = join(I18N_DIR, 'locales');
|
||||
|
||||
const PLACEHOLDER_RE = /\{\{\s*[a-zA-Z_][\w]*\s*\}\}/g;
|
||||
const HTML_TAG_RE = /<\/?[a-zA-Z][\w]*\b[^>]*>/g;
|
||||
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const fail = (msg) => errors.push(msg);
|
||||
const warn = (msg) => warnings.push(msg);
|
||||
|
||||
function parseFile(path) {
|
||||
const src = readFileSync(path, 'utf8');
|
||||
return ts.createSourceFile(path, src, ts.ScriptTarget.Latest, true);
|
||||
}
|
||||
|
||||
// Recursively turn a TS literal expression into a plain JS value.
|
||||
// Returns undefined for nodes we don't understand — callers must check.
|
||||
function literalToJs(node) {
|
||||
if (!node) return undefined;
|
||||
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
|
||||
if (ts.isAsExpression(node) || ts.isParenthesizedExpression(node)) {
|
||||
return literalToJs(node.expression);
|
||||
}
|
||||
if (ts.isObjectLiteralExpression(node)) {
|
||||
const out = {};
|
||||
for (const prop of node.properties) {
|
||||
if (!ts.isPropertyAssignment(prop)) continue;
|
||||
const k = prop.name;
|
||||
let key;
|
||||
if (ts.isIdentifier(k)) key = k.text;
|
||||
else if (ts.isStringLiteral(k) || ts.isNoSubstitutionTemplateLiteral(k)) key = k.text;
|
||||
else continue;
|
||||
out[key] = literalToJs(prop.initializer);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (ts.isArrayLiteralExpression(node)) {
|
||||
return node.elements.map((e) => literalToJs(e));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findVarInitializer(sourceFile, name) {
|
||||
let result;
|
||||
function visit(node) {
|
||||
if (ts.isVariableStatement(node)) {
|
||||
for (const decl of node.declarationList.declarations) {
|
||||
if (ts.isIdentifier(decl.name) && decl.name.text === name) {
|
||||
result = decl.initializer;
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(sourceFile);
|
||||
return result;
|
||||
}
|
||||
|
||||
function readSupportedLanguages() {
|
||||
const sf = parseFile(join(I18N_DIR, 'index.ts'));
|
||||
const init = findVarInitializer(sf, 'SUPPORTED_LANGUAGES');
|
||||
if (!init) throw new Error('Could not find SUPPORTED_LANGUAGES in index.ts');
|
||||
const arr = literalToJs(init);
|
||||
if (!Array.isArray(arr)) throw new Error('SUPPORTED_LANGUAGES is not an array literal');
|
||||
return arr.map((entry) => entry.code);
|
||||
}
|
||||
|
||||
function readLocale(code) {
|
||||
const path = join(LOCALES_DIR, `${code}.ts`);
|
||||
const sf = parseFile(path);
|
||||
const init = findVarInitializer(sf, code);
|
||||
if (!init) throw new Error(`Could not find const ${code} in ${path}`);
|
||||
const obj = literalToJs(init);
|
||||
if (!obj || typeof obj !== 'object') throw new Error(`${code}.ts: not an object literal`);
|
||||
return obj;
|
||||
}
|
||||
|
||||
function readNamedRecord(file, varName) {
|
||||
const sf = parseFile(join(I18N_DIR, file));
|
||||
const init = findVarInitializer(sf, varName);
|
||||
if (!init) throw new Error(`Could not find ${varName} in ${file}`);
|
||||
const obj = literalToJs(init);
|
||||
if (!obj || typeof obj !== 'object') throw new Error(`${file}: ${varName} is not an object`);
|
||||
return obj;
|
||||
}
|
||||
|
||||
function flatten(obj, prefix = '', out = new Map()) {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const path = prefix ? `${prefix}.${k}` : k;
|
||||
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
|
||||
flatten(v, path, out);
|
||||
} else {
|
||||
out.set(path, v);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function tokenMultiset(s, re) {
|
||||
const matches = String(s).match(re) || [];
|
||||
// Normalise whitespace inside placeholders so '{{ count }}' == '{{count}}'.
|
||||
return matches.map((t) => t.replace(/\s+/g, '')).sort();
|
||||
}
|
||||
|
||||
function multisetsEqual(a, b) {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkLeafConsistency(path, enValue, trValue, lang) {
|
||||
if (typeof trValue !== 'string') {
|
||||
fail(`[${lang}] ${path}: missing translation`);
|
||||
return;
|
||||
}
|
||||
if (trValue.trim() === '') {
|
||||
fail(`[${lang}] ${path}: empty translation`);
|
||||
return;
|
||||
}
|
||||
for (const [re, label] of [
|
||||
[PLACEHOLDER_RE, 'placeholder'],
|
||||
[HTML_TAG_RE, 'HTML tag'],
|
||||
]) {
|
||||
const want = tokenMultiset(enValue, re);
|
||||
const got = tokenMultiset(trValue, re);
|
||||
if (!multisetsEqual(want, got)) {
|
||||
fail(
|
||||
`[${lang}] ${path}: ${label} mismatch — en=${JSON.stringify(want)} ` +
|
||||
`${lang}=${JSON.stringify(got)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkLocales(supportedCodes) {
|
||||
const localeFiles = readdirSync(LOCALES_DIR)
|
||||
.filter((f) => f.endsWith('.ts'))
|
||||
.map((f) => f.replace(/\.ts$/, ''));
|
||||
|
||||
for (const code of supportedCodes) {
|
||||
if (!localeFiles.includes(code)) {
|
||||
fail(`SUPPORTED_LANGUAGES lists "${code}" but locales/${code}.ts is missing`);
|
||||
}
|
||||
}
|
||||
for (const code of localeFiles) {
|
||||
if (!supportedCodes.includes(code)) {
|
||||
warn(`locales/${code}.ts exists but is not listed in SUPPORTED_LANGUAGES`);
|
||||
}
|
||||
}
|
||||
|
||||
const en = flatten(readLocale('en'));
|
||||
for (const code of supportedCodes) {
|
||||
if (code === 'en') continue;
|
||||
if (!localeFiles.includes(code)) continue;
|
||||
const tr = flatten(readLocale(code));
|
||||
for (const [path, enValue] of en) {
|
||||
checkLeafConsistency(path, enValue, tr.get(path), code);
|
||||
}
|
||||
for (const path of tr.keys()) {
|
||||
if (!en.has(path)) warn(`[${code}] ${path}: extra key not in en.ts`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkRecordCoverage(file, varName, supportedCodes, serverKeys) {
|
||||
const record = readNamedRecord(file, varName);
|
||||
const expected = supportedCodes.filter((c) => c !== 'en');
|
||||
const present = Object.keys(record);
|
||||
|
||||
for (const code of expected) {
|
||||
if (!present.includes(code)) {
|
||||
fail(`${file}: missing language record "${code}"`);
|
||||
}
|
||||
}
|
||||
for (const code of present) {
|
||||
if (!expected.includes(code)) {
|
||||
warn(`${file}: unexpected language record "${code}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Use the union of feature-name keys across languages as canonical.
|
||||
const union = new Set();
|
||||
for (const code of expected) {
|
||||
if (record[code]) for (const k of Object.keys(record[code])) union.add(k);
|
||||
}
|
||||
|
||||
for (const code of expected) {
|
||||
const langKeys = new Set(Object.keys(record[code] ?? {}));
|
||||
for (const key of union) {
|
||||
if (!langKeys.has(key)) {
|
||||
fail(`${file} [${code}]: missing translation for "${key}"`);
|
||||
} else {
|
||||
const v = record[code][key];
|
||||
if (typeof v !== 'string' || v.trim() === '') {
|
||||
fail(`${file} [${code}]: empty translation for "${key}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every key here must also be a translatable feature name in en.ts > server.
|
||||
// Otherwise the description is unreachable — ts() looks up server.${name}.
|
||||
for (const key of union) {
|
||||
if (!serverKeys.has(key)) {
|
||||
fail(`${file}: key "${key}" has no matching entry in en.ts > server`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
let supportedCodes;
|
||||
try {
|
||||
supportedCodes = readSupportedLanguages();
|
||||
} catch (e) {
|
||||
console.error(`fatal: ${e.message}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
checkLocales(supportedCodes);
|
||||
const en = readLocale('en');
|
||||
const serverKeys = new Set(Object.keys(en.server ?? {}));
|
||||
checkRecordCoverage('descriptions.ts', 'descriptions', supportedCodes, serverKeys);
|
||||
checkRecordCoverage('details.ts', 'details', supportedCodes, serverKeys);
|
||||
|
||||
for (const w of warnings) console.warn(`warn: ${w}`);
|
||||
if (errors.length > 0) {
|
||||
for (const e of errors) console.error(`error: ${e}`);
|
||||
console.error(`\n${errors.length} translation error(s).`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`i18n OK — ${supportedCodes.length} languages, ${warnings.length} warning(s).`
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -1,10 +1,141 @@
|
|||
import { createServer } from 'http';
|
||||
import { readFileSync, writeFileSync, existsSync, statSync } from 'fs';
|
||||
import { join, extname } from 'path';
|
||||
import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync } from 'fs';
|
||||
import { join, extname, dirname } from 'path';
|
||||
import { launch } from 'puppeteer';
|
||||
|
||||
const DIST_DIR = join(import.meta.dirname, '..', 'dist');
|
||||
const INDEX_PATH = join(DIST_DIR, 'index.html');
|
||||
const PUBLIC_URL = 'https://perfect-postcode.co.uk';
|
||||
const OG_PLACEHOLDER = '<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__"/>';
|
||||
|
||||
const ROUTES = [
|
||||
{
|
||||
path: '/',
|
||||
output: 'index.html',
|
||||
title: 'Perfect Postcode - Find where to buy before browsing listings',
|
||||
description:
|
||||
'Search every postcode by budget, commute, schools, safety, noise, broadband, prices and more. Build a better home-buying shortlist before viewings.',
|
||||
},
|
||||
{
|
||||
path: '/learn',
|
||||
output: 'learn/index.html',
|
||||
title: 'How Perfect Postcode works - Data sources, FAQ and support',
|
||||
description:
|
||||
'Learn how Perfect Postcode combines property prices, EPC records, travel times, crime, schools, broadband, noise, amenities and open data for postcode research.',
|
||||
},
|
||||
{
|
||||
path: '/pricing',
|
||||
output: 'pricing/index.html',
|
||||
title: 'Perfect Postcode pricing - Lifetime property search map access',
|
||||
description:
|
||||
'Get lifetime access to the postcode property search map for England, including filters, saved searches, exports, and future data updates.',
|
||||
},
|
||||
{
|
||||
path: '/property-price-map',
|
||||
output: 'property-price-map/index.html',
|
||||
title: 'Property price map for England - Compare postcodes before viewing',
|
||||
description:
|
||||
'Compare sold prices, estimated current value, price per square metre and local context across English postcodes before searching listings.',
|
||||
},
|
||||
{
|
||||
path: '/postcode-property-search',
|
||||
output: 'postcode-property-search/index.html',
|
||||
title: 'Postcode property search - Find areas that match your criteria',
|
||||
description:
|
||||
'Search every postcode by budget, property type, floor area, tenure, commute, schools, crime, broadband, noise, parks and local amenities.',
|
||||
},
|
||||
{
|
||||
path: '/commute-property-search',
|
||||
output: 'commute-property-search/index.html',
|
||||
title: 'Commute property search - Find places to live by travel time',
|
||||
description:
|
||||
'Filter postcodes by commute time, then compare price, schools, safety, broadband, road noise, parks and property data on one map.',
|
||||
},
|
||||
{
|
||||
path: '/school-property-search',
|
||||
output: 'school-property-search/index.html',
|
||||
title: 'School property search - Compare postcodes for family moves',
|
||||
description:
|
||||
'Compare nearby schools, property size, prices, parks, safety, commute and local amenities before building a viewing shortlist.',
|
||||
},
|
||||
{
|
||||
path: '/postcode-checker',
|
||||
output: 'postcode-checker/index.html',
|
||||
title: 'Postcode checker - Property, crime, broadband, noise and schools',
|
||||
description:
|
||||
'Check postcode-level property prices, EPC data, crime, broadband, road noise, schools, council tax, amenities and travel-time context.',
|
||||
},
|
||||
{
|
||||
path: '/property-search/birmingham',
|
||||
output: 'property-search/birmingham/index.html',
|
||||
title: 'Birmingham property search - Compare postcodes by price and commute',
|
||||
description:
|
||||
'Use postcode-level data to compare Birmingham property prices, commute trade-offs, schools, crime, broadband and local amenities before viewings.',
|
||||
},
|
||||
{
|
||||
path: '/property-search/manchester',
|
||||
output: 'property-search/manchester/index.html',
|
||||
title: 'Manchester property search - Compare postcodes before viewing',
|
||||
description:
|
||||
'Compare Manchester-area postcodes by budget, commute, property type, schools, broadband, crime, noise and amenities before booking viewings.',
|
||||
},
|
||||
{
|
||||
path: '/property-search/bristol',
|
||||
output: 'property-search/bristol/index.html',
|
||||
title: 'Bristol property search - Compare postcodes by commute and price',
|
||||
description:
|
||||
'Compare Bristol postcodes by price, commute, property size, schools, broadband, crime, road noise, parks and amenities before viewings.',
|
||||
},
|
||||
{
|
||||
path: '/data-sources',
|
||||
output: 'data-sources/index.html',
|
||||
title: 'Perfect Postcode data sources - Property, schools, commute and local context',
|
||||
description:
|
||||
'Review the public and official datasets used by Perfect Postcode, including property prices, EPC, schools, crime, broadband, noise and travel-time context.',
|
||||
},
|
||||
{
|
||||
path: '/methodology',
|
||||
output: 'methodology/index.html',
|
||||
title: 'Perfect Postcode methodology - How to interpret postcode property data',
|
||||
description:
|
||||
'Understand how to use postcode filters, property estimates, travel-time data, school context and local signals as a home-buying shortlist tool.',
|
||||
},
|
||||
{
|
||||
path: '/privacy-security',
|
||||
output: 'privacy-security/index.html',
|
||||
title: 'Perfect Postcode privacy and security - Saved searches and account data',
|
||||
description:
|
||||
'Learn how Perfect Postcode treats saved searches, account data and property research workflows with privacy and security in mind.',
|
||||
},
|
||||
];
|
||||
|
||||
const FAQ_SCHEMA_ITEMS = [
|
||||
{
|
||||
question: 'Where should I look once the obvious areas are too expensive?',
|
||||
answer:
|
||||
'Set your budget, property type, floor area, commute, schools, crime, noise, broadband, parks, and other must-haves. The map removes postcodes that fail those tests, so overlooked areas can surface before you start searching listings.',
|
||||
},
|
||||
{
|
||||
question: 'What should I do when my search returns too many or too few areas?',
|
||||
answer:
|
||||
'Start with hard limits, then colour the map by a trade-off such as price per sqm, road noise, school score, or commute time. If the map gets too narrow, relax one slider and you can see exactly which compromise opens up more options.',
|
||||
},
|
||||
{
|
||||
question: 'How are the travel times calculated?',
|
||||
answer:
|
||||
'Travel times are precomputed with Conveyal R5, a routing engine used for transport analysis. For each supported destination we route to reachable postcodes over the street and transit network, then store sparse postcode travel-time files for car, cycling, walking, and public transport.',
|
||||
},
|
||||
{
|
||||
question: 'How does the estimated current price algorithm work?',
|
||||
answer:
|
||||
'The estimate starts with the last HM Land Registry sale price, adjusts it to current-market terms using repeat-sales modelling and fallback models, then blends that result with a nearest-neighbour estimate from nearby, recently sold, same-type homes.',
|
||||
},
|
||||
{
|
||||
question: 'Should I use this before or after checking Rightmove?',
|
||||
answer:
|
||||
'Use Perfect Postcode before and alongside listing portals. Rightmove, Zoopla, and OnTheMarket are still where you check live availability, photos, agent contact, viewings, and alerts.',
|
||||
},
|
||||
];
|
||||
|
||||
const MIME_TYPES = {
|
||||
'.html': 'text/html',
|
||||
|
|
@ -13,9 +144,151 @@ const MIME_TYPES = {
|
|||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
};
|
||||
|
||||
function escapeAttr(value) {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
}
|
||||
|
||||
function routeUrl(pathname) {
|
||||
return `${PUBLIC_URL}${pathname === '/' ? '/' : pathname}`;
|
||||
}
|
||||
|
||||
function jsonLd(data) {
|
||||
return `<script type="application/ld+json">${JSON.stringify(data).replaceAll(
|
||||
'<',
|
||||
'\\u003c'
|
||||
)}</script>`;
|
||||
}
|
||||
|
||||
function structuredDataForRoute(route) {
|
||||
const url = routeUrl(route.path);
|
||||
const base = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
'@id': `${url}#webpage`,
|
||||
url,
|
||||
name: route.title,
|
||||
description: route.description,
|
||||
isPartOf: { '@id': `${PUBLIC_URL}/#website` },
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: 'Perfect Postcode',
|
||||
item: `${PUBLIC_URL}/`,
|
||||
},
|
||||
...(route.path === '/'
|
||||
? []
|
||||
: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: route.title.split(' - ')[0],
|
||||
item: url,
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
if (route.path === '/') {
|
||||
base.push(
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
'@id': `${PUBLIC_URL}/#organization`,
|
||||
name: 'Perfect Postcode',
|
||||
url: `${PUBLIC_URL}/`,
|
||||
logo: `${PUBLIC_URL}/favicon.svg`,
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
'@id': `${PUBLIC_URL}/#website`,
|
||||
name: 'Perfect Postcode',
|
||||
url: `${PUBLIC_URL}/`,
|
||||
publisher: { '@id': `${PUBLIC_URL}/#organization` },
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: 'Perfect Postcode',
|
||||
applicationCategory: 'BusinessApplication',
|
||||
operatingSystem: 'Web',
|
||||
url: `${PUBLIC_URL}/`,
|
||||
description: route.description,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (route.path === '/learn') {
|
||||
base.push({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: FAQ_SCHEMA_ITEMS.map((item) => ({
|
||||
'@type': 'Question',
|
||||
name: item.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: item.answer,
|
||||
},
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (route.path === '/pricing') {
|
||||
base.push({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Product',
|
||||
name: 'Perfect Postcode lifetime access',
|
||||
description: route.description,
|
||||
brand: { '@type': 'Brand', name: 'Perfect Postcode' },
|
||||
});
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
function updateHead(indexHtml, route) {
|
||||
const structuredData = structuredDataForRoute(route).map(jsonLd).join('\n ');
|
||||
return indexHtml
|
||||
.replace(/<title>.*?<\/title>/, `<title>${escapeAttr(route.title)}</title>`)
|
||||
.replace(
|
||||
/<meta name="description" content="[^"]*" ?\/?>/,
|
||||
`<meta name="description" content="${escapeAttr(route.description)}" />`
|
||||
)
|
||||
.replace(OG_PLACEHOLDER, `${OG_PLACEHOLDER}\n ${structuredData}`);
|
||||
}
|
||||
|
||||
function cleanBaseIndexHtml(indexHtml) {
|
||||
const withoutStructuredData = indexHtml.replace(
|
||||
/<script type="application\/ld\+json">.*?<\/script>\s*/gs,
|
||||
''
|
||||
);
|
||||
const rootStart = withoutStructuredData.indexOf('<div id="root">');
|
||||
const bodyEnd = withoutStructuredData.indexOf('</body>', rootStart);
|
||||
|
||||
if (rootStart === -1 || bodyEnd === -1) {
|
||||
return withoutStructuredData;
|
||||
}
|
||||
|
||||
return `${withoutStructuredData.slice(0, rootStart)}<div id="root"></div>${withoutStructuredData.slice(bodyEnd)}`;
|
||||
}
|
||||
|
||||
function startServer() {
|
||||
return new Promise((resolve) => {
|
||||
const server = createServer((req, res) => {
|
||||
|
|
@ -53,81 +326,101 @@ async function prerender() {
|
|||
});
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
const baseIndexHtml = cleanBaseIndexHtml(readFileSync(INDEX_PATH, 'utf-8'));
|
||||
|
||||
// Intercept API requests to prevent real fetches and retry loops
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (req) => {
|
||||
const url = req.url();
|
||||
if (url.includes('/api/features')) {
|
||||
req.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ groups: [] }),
|
||||
for (const route of ROUTES) {
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Intercept API requests to prevent real fetches and retry loops.
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (req) => {
|
||||
const url = req.url();
|
||||
if (url.includes('/api/features')) {
|
||||
req.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ groups: [] }),
|
||||
});
|
||||
} else if (url.includes('/api/poi-categories')) {
|
||||
req.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ groups: [] }),
|
||||
});
|
||||
} else if (url.includes('/api/pricing')) {
|
||||
req.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
licensed_count: 120,
|
||||
current_price_pence: 999,
|
||||
tiers: [
|
||||
{ up_to: 50, price_pence: 99, slots: 50 },
|
||||
{ up_to: 150, price_pence: 999, slots: 100 },
|
||||
{ up_to: 250, price_pence: 2999, slots: 100 },
|
||||
{ up_to: 350, price_pence: 4999, slots: 100 },
|
||||
{ up_to: null, price_pence: 9999, slots: 0 },
|
||||
],
|
||||
}),
|
||||
});
|
||||
} else if (url.includes('/api/')) {
|
||||
req.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: '{}',
|
||||
});
|
||||
} else {
|
||||
req.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(`http://127.0.0.1:${port}${route.path}`, {
|
||||
waitUntil: 'networkidle0',
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await page.waitForSelector('h1', { timeout: 10000 });
|
||||
|
||||
// Extract and clean the rendered HTML.
|
||||
const html = await page.evaluate(() => {
|
||||
const root = document.getElementById('root');
|
||||
if (!root) return '';
|
||||
|
||||
// Strip fade-in-visible classes (added by IntersectionObserver effects).
|
||||
root.querySelectorAll('.fade-in-visible').forEach((el) => {
|
||||
el.classList.remove('fade-in-visible');
|
||||
});
|
||||
} else if (url.includes('/api/poi-categories')) {
|
||||
req.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ groups: [] }),
|
||||
|
||||
// Clean canvas elements (dimensions set by ResizeObserver effect).
|
||||
root.querySelectorAll('canvas').forEach((canvas) => {
|
||||
canvas.removeAttribute('width');
|
||||
canvas.removeAttribute('height');
|
||||
canvas.style.removeProperty('width');
|
||||
canvas.style.removeProperty('height');
|
||||
});
|
||||
} else if (url.includes('/api/')) {
|
||||
req.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: '{}',
|
||||
});
|
||||
} else {
|
||||
req.continue();
|
||||
|
||||
return root.innerHTML;
|
||||
});
|
||||
|
||||
if (!html || html.length < 100) {
|
||||
throw new Error(`Prerender produced too little HTML for ${route.path}`);
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(`http://127.0.0.1:${port}/`, {
|
||||
waitUntil: 'networkidle0',
|
||||
timeout: 30000,
|
||||
});
|
||||
const updated = updateHead(baseIndexHtml, route).replace(
|
||||
'<div id="root"></div>',
|
||||
`<div id="root" data-prerender-path="${escapeAttr(route.path)}">${html}</div>`
|
||||
);
|
||||
|
||||
// Wait for the home page heading to render
|
||||
await page.waitForSelector('h1', { timeout: 10000 });
|
||||
if (updated === baseIndexHtml) {
|
||||
throw new Error('Could not find <div id="root"></div> in index.html');
|
||||
}
|
||||
|
||||
// Extract and clean the rendered HTML
|
||||
const html = await page.evaluate(() => {
|
||||
const root = document.getElementById('root');
|
||||
if (!root) return '';
|
||||
|
||||
// Strip fade-in-visible classes (added by IntersectionObserver effects)
|
||||
root.querySelectorAll('.fade-in-visible').forEach((el) => {
|
||||
el.classList.remove('fade-in-visible');
|
||||
});
|
||||
|
||||
// Clean canvas elements (dimensions set by ResizeObserver effect)
|
||||
root.querySelectorAll('canvas').forEach((canvas) => {
|
||||
canvas.removeAttribute('width');
|
||||
canvas.removeAttribute('height');
|
||||
canvas.style.removeProperty('width');
|
||||
canvas.style.removeProperty('height');
|
||||
});
|
||||
|
||||
return root.innerHTML;
|
||||
});
|
||||
|
||||
if (!html || html.length < 100) {
|
||||
throw new Error('Prerender produced too little HTML — something went wrong');
|
||||
const outputPath = join(DIST_DIR, route.output);
|
||||
mkdirSync(dirname(outputPath), { recursive: true });
|
||||
writeFileSync(outputPath, updated);
|
||||
await page.close();
|
||||
console.log(`Prerendered ${route.path} (${html.length} chars) into ${route.output}`);
|
||||
}
|
||||
|
||||
// Inject into dist/index.html
|
||||
const indexHtml = readFileSync(INDEX_PATH, 'utf-8');
|
||||
const updated = indexHtml.replace(
|
||||
'<div id="root"></div>',
|
||||
`<div id="root">${html}</div>`
|
||||
);
|
||||
|
||||
if (updated === indexHtml) {
|
||||
throw new Error('Could not find <div id="root"></div> in index.html');
|
||||
}
|
||||
|
||||
writeFileSync(INDEX_PATH, updated);
|
||||
console.log(`Prerendered ${html.length} chars into dist/index.html`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
server.close();
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import MapPage, { type ExportState } from './components/map/MapPage';
|
||||
import PricingPage from './components/pricing/PricingPage';
|
||||
import HomePage from './components/home/HomePage';
|
||||
import LearnPage from './components/learn/LearnPage';
|
||||
import AccountPage, { SavedPage, InvitesPage } from './components/account/AccountPage';
|
||||
import InvitePage from './components/invite/InvitePage';
|
||||
import { lazy, Suspense, useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import type { ExportState } from './components/map/MapPage';
|
||||
import {
|
||||
getSeoContentPage,
|
||||
getSeoLandingPage,
|
||||
isSeoContentKey,
|
||||
isSeoLandingKey,
|
||||
SEO_CONTENT_PATHS,
|
||||
SEO_LANDING_PATHS,
|
||||
type SeoContentKey,
|
||||
type SeoLandingKey,
|
||||
} from './lib/seoRoutes';
|
||||
import Header, { type Page } from './components/ui/Header';
|
||||
import AuthModal from './components/ui/AuthModal';
|
||||
import SaveSearchModal from './components/ui/SaveSearchModal';
|
||||
import LicenseSuccessModal from './components/ui/LicenseSuccessModal';
|
||||
import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
|
||||
import { fetchWithRetry, apiUrl } from './lib/api';
|
||||
import { trackEvent } from './lib/analytics';
|
||||
|
|
@ -28,6 +30,32 @@ declare global {
|
|||
}
|
||||
}
|
||||
|
||||
const HomePage = lazy(() => import('./components/home/HomePage'));
|
||||
const PricingPage = lazy(() => import('./components/pricing/PricingPage'));
|
||||
const LearnPage = lazy(() => import('./components/learn/LearnPage'));
|
||||
const SeoLandingPage = lazy(() => import('./components/landing/SeoLandingPage'));
|
||||
const SeoContentPage = lazy(() => import('./components/landing/SeoContentPage'));
|
||||
const AccountPage = lazy(() => import('./components/account/AccountPage'));
|
||||
const SavedPage = lazy(() =>
|
||||
import('./components/account/AccountPage').then((module) => ({ default: module.SavedPage }))
|
||||
);
|
||||
const InvitesPage = lazy(() =>
|
||||
import('./components/account/AccountPage').then((module) => ({ default: module.InvitesPage }))
|
||||
);
|
||||
const InvitePage = lazy(() => import('./components/invite/InvitePage'));
|
||||
const MapPage = lazy(() => import('./components/map/MapPage'));
|
||||
const AuthModal = lazy(() => import('./components/ui/AuthModal'));
|
||||
const SaveSearchModal = lazy(() => import('./components/ui/SaveSearchModal'));
|
||||
const LicenseSuccessModal = lazy(() => import('./components/ui/LicenseSuccessModal'));
|
||||
|
||||
function PageFallback() {
|
||||
return <div className="flex-1 bg-warm-50 dark:bg-navy-950" />;
|
||||
}
|
||||
|
||||
function unavailableAuthAction(): never {
|
||||
throw new Error('Authentication actions are not available in this render mode');
|
||||
}
|
||||
|
||||
function pageToPath(page: Page, inviteCode?: string): string {
|
||||
switch (page) {
|
||||
case 'dashboard':
|
||||
|
|
@ -36,6 +64,19 @@ function pageToPath(page: Page, inviteCode?: string): string {
|
|||
return '/learn';
|
||||
case 'pricing':
|
||||
return '/pricing';
|
||||
case 'property-price-map':
|
||||
case 'postcode-property-search':
|
||||
case 'commute-property-search':
|
||||
case 'school-property-search':
|
||||
case 'postcode-checker':
|
||||
return SEO_LANDING_PATHS[page];
|
||||
case 'birmingham-property-search':
|
||||
case 'manchester-property-search':
|
||||
case 'bristol-property-search':
|
||||
case 'data-sources':
|
||||
case 'methodology':
|
||||
case 'privacy-security':
|
||||
return SEO_CONTENT_PATHS[page];
|
||||
case 'saved':
|
||||
return '/saved';
|
||||
case 'invites':
|
||||
|
|
@ -43,7 +84,10 @@ function pageToPath(page: Page, inviteCode?: string): string {
|
|||
case 'account':
|
||||
return '/account';
|
||||
case 'invite':
|
||||
return `/invite/${inviteCode || ''}`;
|
||||
if (!inviteCode) {
|
||||
throw new Error('Cannot build invite path without an invite code');
|
||||
}
|
||||
return `/invite/${inviteCode}`;
|
||||
default:
|
||||
return '/';
|
||||
}
|
||||
|
|
@ -55,6 +99,10 @@ function pathToPage(pathname: string): { page: Page; inviteCode?: string } | nul
|
|||
if (pathname === '/invites') return { page: 'invites' };
|
||||
if (pathname === '/learn') return { page: 'learn' };
|
||||
if (pathname === '/pricing') return { page: 'pricing' };
|
||||
const seoLandingPage = getSeoLandingPage(pathname);
|
||||
if (seoLandingPage) return { page: seoLandingPage };
|
||||
const seoContentPage = getSeoContentPage(pathname);
|
||||
if (seoContentPage) return { page: seoContentPage };
|
||||
if (pathname === '/account') return { page: 'account' };
|
||||
if (pathname === '/support') return { page: 'learn' };
|
||||
if (pathname.startsWith('/invite/')) {
|
||||
|
|
@ -65,6 +113,14 @@ function pathToPage(pathname: string): { page: Page; inviteCode?: string } | nul
|
|||
return null;
|
||||
}
|
||||
|
||||
function isSeoLandingPage(page: Page): page is SeoLandingKey {
|
||||
return isSeoLandingKey(page);
|
||||
}
|
||||
|
||||
function isSeoContentPage(page: Page): page is SeoContentKey {
|
||||
return isSeoContentKey(page);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const urlState = useMemo(() => parseUrlState(), []);
|
||||
const [mapUrlState, setMapUrlState] = useState(urlState);
|
||||
|
|
@ -141,7 +197,7 @@ export default function App() {
|
|||
setShowLicenseSuccess(true);
|
||||
}
|
||||
// Always refresh auth on startup to pick up server-side subscription changes
|
||||
refreshAuth().catch(() => {});
|
||||
refreshAuth().catch(() => { });
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const savedSearches = useSavedSearches(user?.id ?? null);
|
||||
|
|
@ -214,7 +270,9 @@ export default function App() {
|
|||
window.history.replaceState(
|
||||
{ page: activePage },
|
||||
'',
|
||||
pageToPath(activePage) + window.location.search + window.location.hash
|
||||
pageToPath(activePage, inviteCode ?? undefined) +
|
||||
window.location.search +
|
||||
window.location.hash
|
||||
);
|
||||
}
|
||||
const handlePopState = (e: PopStateEvent) => {
|
||||
|
|
@ -226,7 +284,6 @@ export default function App() {
|
|||
setPendingInfoFeature(e.state.infoFeature);
|
||||
}
|
||||
} else {
|
||||
// Fall back to deriving page from pathname
|
||||
const parsed = pathToPage(window.location.pathname);
|
||||
page = parsed?.page || 'home';
|
||||
setActivePage(page);
|
||||
|
|
@ -271,37 +328,43 @@ export default function App() {
|
|||
|
||||
if ((isScreenshotMode || isOgMode) && inviteCode) {
|
||||
return (
|
||||
<InvitePage
|
||||
code={inviteCode}
|
||||
user={null}
|
||||
theme={theme}
|
||||
screenshotMode
|
||||
onLoginClick={() => {}}
|
||||
onRegisterClick={() => {}}
|
||||
onLicenseGranted={() => {}}
|
||||
/>
|
||||
<Suspense fallback={<PageFallback />}>
|
||||
<InvitePage
|
||||
code={inviteCode}
|
||||
user={null}
|
||||
theme={theme}
|
||||
screenshotMode
|
||||
onLoginClick={unavailableAuthAction}
|
||||
onRegisterClick={unavailableAuthAction}
|
||||
onLicenseGranted={unavailableAuthAction}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
if (isScreenshotMode) {
|
||||
return (
|
||||
<MapPage
|
||||
features={features}
|
||||
poiCategoryGroups={poiCategoryGroups}
|
||||
initialFilters={urlState.filters || {}}
|
||||
initialViewState={initialViewState}
|
||||
initialPOICategories={urlState.poiCategories || new Set()}
|
||||
initialTab={urlState.tab || 'area'}
|
||||
initialLoading={initialLoading}
|
||||
theme={theme}
|
||||
pendingInfoFeature={null}
|
||||
onClearPendingInfoFeature={() => {}}
|
||||
onNavigateTo={() => {}}
|
||||
screenshotMode
|
||||
ogMode={isOgMode}
|
||||
initialTravelTime={urlState.travelTime}
|
||||
shareCode={urlState.share}
|
||||
/>
|
||||
<Suspense fallback={<PageFallback />}>
|
||||
<MapPage
|
||||
features={features}
|
||||
poiCategoryGroups={poiCategoryGroups}
|
||||
initialFilters={urlState.filters}
|
||||
initialViewState={initialViewState}
|
||||
initialPOICategories={urlState.poiCategories}
|
||||
initialTab={urlState.tab}
|
||||
initialLoading={initialLoading}
|
||||
theme={theme}
|
||||
pendingInfoFeature={null}
|
||||
onClearPendingInfoFeature={() => { }}
|
||||
onNavigateTo={() => { }}
|
||||
screenshotMode
|
||||
ogMode={isOgMode}
|
||||
initialTravelTime={urlState.travelTime}
|
||||
shareCode={urlState.share}
|
||||
onLoginClick={unavailableAuthAction}
|
||||
onRegisterClick={unavailableAuthAction}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -312,8 +375,7 @@ export default function App() {
|
|||
onPageChange={navigateTo}
|
||||
theme={theme}
|
||||
onToggleTheme={toggleTheme}
|
||||
onExport={exportState?.onExport ?? null}
|
||||
exporting={exportState?.exporting ?? false}
|
||||
exportState={activePage === 'dashboard' ? exportState : null}
|
||||
onSaveSearch={activePage === 'dashboard' && user ? () => setShowSaveModal(true) : null}
|
||||
savingSearch={savedSearches.saving}
|
||||
user={user}
|
||||
|
|
@ -328,137 +390,145 @@ export default function App() {
|
|||
onLogout={logout}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
{activePage === 'home' ? (
|
||||
<HomePage
|
||||
onOpenDashboard={() => navigateTo('dashboard')}
|
||||
onOpenPricing={() => navigateTo('pricing')}
|
||||
theme={theme}
|
||||
hidePricing={user?.subscription === 'licensed' || user?.isAdmin}
|
||||
/>
|
||||
) : activePage === 'pricing' && !(user?.subscription === 'licensed' || user?.isAdmin) ? (
|
||||
<PricingPage
|
||||
onOpenDashboard={() => navigateTo('dashboard')}
|
||||
user={user}
|
||||
onLoginClick={() => {
|
||||
setAuthModalTab('login');
|
||||
setShowAuthModal(true);
|
||||
}}
|
||||
onRegisterClick={() => {
|
||||
setAuthModalTab('register');
|
||||
setShowAuthModal(true);
|
||||
}}
|
||||
/>
|
||||
) : activePage === 'learn' ? (
|
||||
<LearnPage />
|
||||
) : activePage === 'saved' && user ? (
|
||||
<SavedPage
|
||||
searches={savedSearches.searches}
|
||||
searchesLoading={savedSearches.loading}
|
||||
onDeleteSearch={savedSearches.deleteSearch}
|
||||
onUpdateSearchNotes={savedSearches.updateSearchNotes}
|
||||
onUpdateSearchName={savedSearches.updateSearchName}
|
||||
onOpenSearch={(params) => {
|
||||
window.location.href = `/dashboard?${params}`;
|
||||
}}
|
||||
savedProperties={savedProperties.properties}
|
||||
propertiesLoading={savedProperties.loading}
|
||||
onDeleteProperty={savedProperties.deleteProperty}
|
||||
onUpdatePropertyNotes={savedProperties.updatePropertyNotes}
|
||||
onOpenProperty={(postcode) => {
|
||||
window.location.href = `/dashboard?pc=${encodeURIComponent(postcode)}`;
|
||||
}}
|
||||
/>
|
||||
) : activePage === 'invites' && user ? (
|
||||
<InvitesPage user={user} />
|
||||
) : activePage === 'account' && user ? (
|
||||
<AccountPage user={user} onRefreshAuth={refreshAuth} />
|
||||
) : activePage === 'invite' && inviteCode ? (
|
||||
<InvitePage
|
||||
code={inviteCode}
|
||||
user={user}
|
||||
theme={theme}
|
||||
onLoginClick={() => {
|
||||
setAuthModalTab('login');
|
||||
setShowAuthModal(true);
|
||||
}}
|
||||
onRegisterClick={() => {
|
||||
setAuthModalTab('register');
|
||||
setShowAuthModal(true);
|
||||
}}
|
||||
onLicenseGranted={() => {
|
||||
setShowLicenseSuccess(true);
|
||||
refreshAuth();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<MapPage
|
||||
features={features}
|
||||
poiCategoryGroups={poiCategoryGroups}
|
||||
initialFilters={mapUrlState.filters || {}}
|
||||
initialViewState={initialViewState}
|
||||
initialPOICategories={mapUrlState.poiCategories || new Set()}
|
||||
initialTab={mapUrlState.tab || 'area'}
|
||||
initialLoading={initialLoading}
|
||||
theme={theme}
|
||||
pendingInfoFeature={pendingInfoFeature}
|
||||
onClearPendingInfoFeature={() => setPendingInfoFeature(null)}
|
||||
onNavigateTo={navigateTo}
|
||||
onExportStateChange={setExportState}
|
||||
isMobile={isMobile}
|
||||
initialTravelTime={mapUrlState.travelTime}
|
||||
initialPostcode={mapUrlState.postcode}
|
||||
shareCode={mapUrlState.share}
|
||||
user={user}
|
||||
onLoginClick={() => {
|
||||
setAuthModalTab('login');
|
||||
setShowAuthModal(true);
|
||||
}}
|
||||
onRegisterClick={() => {
|
||||
setAuthModalTab('register');
|
||||
setShowAuthModal(true);
|
||||
}}
|
||||
onSaveProperty={user ? savedProperties.saveProperty : undefined}
|
||||
onUnsaveProperty={user ? savedProperties.deleteProperty : undefined}
|
||||
isPropertySaved={user ? savedProperties.isPropertySaved : undefined}
|
||||
getSavedPropertyId={user ? savedProperties.getSavedPropertyId : undefined}
|
||||
deferTutorial={showLicenseSuccess}
|
||||
onSaveSearch={user ? savedSearches.saveSearch : undefined}
|
||||
savingSearch={savedSearches.saving}
|
||||
/>
|
||||
)}
|
||||
{showAuthModal && (
|
||||
<AuthModal
|
||||
onClose={() => setShowAuthModal(false)}
|
||||
onLogin={login}
|
||||
onRegister={register}
|
||||
onOAuthLogin={loginWithOAuth}
|
||||
onForgotPassword={requestPasswordReset}
|
||||
loading={authLoading}
|
||||
error={authError}
|
||||
onClearError={clearError}
|
||||
initialTab={authModalTab}
|
||||
/>
|
||||
)}
|
||||
{showSaveModal && (
|
||||
<SaveSearchModal
|
||||
onClose={() => setShowSaveModal(false)}
|
||||
onSave={savedSearches.saveSearch}
|
||||
onViewSearches={() => {
|
||||
setShowSaveModal(false);
|
||||
navigateTo('saved');
|
||||
}}
|
||||
saving={savedSearches.saving}
|
||||
error={savedSearches.error}
|
||||
/>
|
||||
)}
|
||||
{showLicenseSuccess && (
|
||||
<LicenseSuccessModal
|
||||
onClose={() => {
|
||||
setShowLicenseSuccess(false);
|
||||
navigateTo('dashboard');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Suspense fallback={<PageFallback />}>
|
||||
{activePage === 'home' ? (
|
||||
<HomePage
|
||||
onOpenDashboard={() => navigateTo('dashboard')}
|
||||
onOpenPricing={() => navigateTo('pricing')}
|
||||
theme={theme}
|
||||
hidePricing={user?.subscription === 'licensed' || user?.isAdmin}
|
||||
/>
|
||||
) : activePage === 'pricing' && !(user?.subscription === 'licensed' || user?.isAdmin) ? (
|
||||
<PricingPage
|
||||
onOpenDashboard={() => navigateTo('dashboard')}
|
||||
user={user}
|
||||
onLoginClick={() => {
|
||||
setAuthModalTab('login');
|
||||
setShowAuthModal(true);
|
||||
}}
|
||||
onRegisterClick={() => {
|
||||
setAuthModalTab('register');
|
||||
setShowAuthModal(true);
|
||||
}}
|
||||
/>
|
||||
) : activePage === 'learn' ? (
|
||||
<LearnPage />
|
||||
) : isSeoLandingPage(activePage) ? (
|
||||
<SeoLandingPage pageKey={activePage} onOpenDashboard={() => navigateTo('dashboard')} />
|
||||
) : isSeoContentPage(activePage) ? (
|
||||
<SeoContentPage pageKey={activePage} onOpenDashboard={() => navigateTo('dashboard')} />
|
||||
) : activePage === 'saved' && user ? (
|
||||
<SavedPage
|
||||
searches={savedSearches.searches}
|
||||
searchesLoading={savedSearches.loading}
|
||||
onDeleteSearch={savedSearches.deleteSearch}
|
||||
onUpdateSearchNotes={savedSearches.updateSearchNotes}
|
||||
onUpdateSearchName={savedSearches.updateSearchName}
|
||||
onOpenSearch={(params) => {
|
||||
window.location.href = `/dashboard?${params}`;
|
||||
}}
|
||||
savedProperties={savedProperties.properties}
|
||||
propertiesLoading={savedProperties.loading}
|
||||
onDeleteProperty={savedProperties.deleteProperty}
|
||||
onUpdatePropertyNotes={savedProperties.updatePropertyNotes}
|
||||
onOpenProperty={(postcode) => {
|
||||
window.location.href = `/dashboard?pc=${encodeURIComponent(postcode)}`;
|
||||
}}
|
||||
/>
|
||||
) : activePage === 'invites' && user ? (
|
||||
<InvitesPage user={user} />
|
||||
) : activePage === 'account' && user ? (
|
||||
<AccountPage user={user} onRefreshAuth={refreshAuth} />
|
||||
) : activePage === 'invite' && inviteCode ? (
|
||||
<InvitePage
|
||||
code={inviteCode}
|
||||
user={user}
|
||||
theme={theme}
|
||||
onLoginClick={() => {
|
||||
setAuthModalTab('login');
|
||||
setShowAuthModal(true);
|
||||
}}
|
||||
onRegisterClick={() => {
|
||||
setAuthModalTab('register');
|
||||
setShowAuthModal(true);
|
||||
}}
|
||||
onLicenseGranted={() => {
|
||||
setShowLicenseSuccess(true);
|
||||
refreshAuth();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<MapPage
|
||||
features={features}
|
||||
poiCategoryGroups={poiCategoryGroups}
|
||||
initialFilters={mapUrlState.filters}
|
||||
initialViewState={initialViewState}
|
||||
initialPOICategories={mapUrlState.poiCategories}
|
||||
initialTab={mapUrlState.tab}
|
||||
initialLoading={initialLoading}
|
||||
theme={theme}
|
||||
pendingInfoFeature={pendingInfoFeature}
|
||||
onClearPendingInfoFeature={() => setPendingInfoFeature(null)}
|
||||
onNavigateTo={navigateTo}
|
||||
onExportStateChange={setExportState}
|
||||
isMobile={isMobile}
|
||||
initialTravelTime={mapUrlState.travelTime}
|
||||
initialPostcode={mapUrlState.postcode}
|
||||
shareCode={mapUrlState.share}
|
||||
user={user}
|
||||
onLoginClick={() => {
|
||||
setAuthModalTab('login');
|
||||
setShowAuthModal(true);
|
||||
}}
|
||||
onRegisterClick={() => {
|
||||
setAuthModalTab('register');
|
||||
setShowAuthModal(true);
|
||||
}}
|
||||
onSaveProperty={user ? savedProperties.saveProperty : undefined}
|
||||
onUnsaveProperty={user ? savedProperties.deleteProperty : undefined}
|
||||
isPropertySaved={user ? savedProperties.isPropertySaved : undefined}
|
||||
getSavedPropertyId={user ? savedProperties.getSavedPropertyId : undefined}
|
||||
deferTutorial={showLicenseSuccess}
|
||||
onSaveSearch={user ? savedSearches.saveSearch : undefined}
|
||||
savingSearch={savedSearches.saving}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
{showAuthModal && (
|
||||
<AuthModal
|
||||
onClose={() => setShowAuthModal(false)}
|
||||
onLogin={login}
|
||||
onRegister={register}
|
||||
onOAuthLogin={loginWithOAuth}
|
||||
onForgotPassword={requestPasswordReset}
|
||||
loading={authLoading}
|
||||
error={authError}
|
||||
onClearError={clearError}
|
||||
initialTab={authModalTab}
|
||||
/>
|
||||
)}
|
||||
{showSaveModal && (
|
||||
<SaveSearchModal
|
||||
onClose={() => setShowSaveModal(false)}
|
||||
onSave={savedSearches.saveSearch}
|
||||
onViewSearches={() => {
|
||||
setShowSaveModal(false);
|
||||
navigateTo('saved');
|
||||
}}
|
||||
saving={savedSearches.saving}
|
||||
error={savedSearches.error}
|
||||
/>
|
||||
)}
|
||||
{showLicenseSuccess && (
|
||||
<LicenseSuccessModal
|
||||
onClose={() => {
|
||||
setShowLicenseSuccess(false);
|
||||
navigateTo('dashboard');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ interface HexConfig {
|
|||
size: number;
|
||||
opacity: number;
|
||||
top: number;
|
||||
left: number;
|
||||
driftDuration: number;
|
||||
bobDuration: number;
|
||||
bobAmount: number;
|
||||
|
|
@ -21,6 +22,7 @@ function generateHexes(): HexConfig[] {
|
|||
size: 10 + Math.random() * 32,
|
||||
opacity: 0.06 + Math.random() * 0.18,
|
||||
top: Math.random() * 100,
|
||||
left: Math.random() * 100,
|
||||
driftDuration,
|
||||
bobDuration: 6 + Math.random() * 8,
|
||||
bobAmount: 8 + Math.random() * 30,
|
||||
|
|
@ -31,18 +33,36 @@ function generateHexes(): HexConfig[] {
|
|||
return hexes;
|
||||
}
|
||||
|
||||
export default function HexCanvas({ isDark = false }: { isDark?: boolean }) {
|
||||
export default function HexCanvas({
|
||||
isDark = false,
|
||||
animated = true,
|
||||
className = '',
|
||||
}: {
|
||||
isDark?: boolean;
|
||||
animated?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const hexes = useMemo(generateHexes, []);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none" style={{ zIndex: 0 }}>
|
||||
<div
|
||||
className={`absolute inset-0 overflow-hidden pointer-events-none ${className}`.trim()}
|
||||
style={{ zIndex: 0 }}
|
||||
>
|
||||
{hexes.map((hex, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute"
|
||||
style={{
|
||||
top: `${hex.top}%`,
|
||||
animation: `hex-drift ${hex.driftDuration}s linear ${hex.delay}s infinite${hex.reverse ? ' reverse' : ''}`,
|
||||
...(animated
|
||||
? {
|
||||
animation: `hex-drift ${hex.driftDuration}s linear ${hex.delay}s infinite${hex.reverse ? ' reverse' : ''}`,
|
||||
}
|
||||
: {
|
||||
left: `${hex.left}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${hex.delay * 12}deg)`,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
|
@ -51,9 +71,11 @@ export default function HexCanvas({ isDark = false }: { isDark?: boolean }) {
|
|||
{
|
||||
width: hex.size,
|
||||
height: (hex.size * 2) / Math.sqrt(3),
|
||||
opacity: hex.opacity * (isDark ? 0.6 : 1),
|
||||
opacity: hex.opacity * (isDark ? 0.45 : 0.6),
|
||||
clipPath: 'polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)',
|
||||
animation: `hex-bob ${hex.bobDuration}s ease-in-out infinite`,
|
||||
animation: animated
|
||||
? `hex-bob ${hex.bobDuration}s ease-in-out infinite`
|
||||
: undefined,
|
||||
'--bob': `${hex.bobAmount}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
|
|
|
|||
1143
frontend/src/components/home/ProductShowcase.tsx
Normal file
|
|
@ -203,7 +203,7 @@ export default function InvitePage({
|
|||
{`\u00A3${(Math.round(pricePence * 0.7) / 100).toFixed(2)}`}
|
||||
</span>
|
||||
<span className="text-warm-500 dark:text-warm-400 ml-2 text-3xl">
|
||||
{t('upgrade.once')}
|
||||
{t('pricingPage.lifetime')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -301,7 +301,9 @@ export default function InvitePage({
|
|||
<span className="text-3xl font-extrabold text-teal-600 dark:text-teal-400">
|
||||
{`\u00A3${(Math.round(pricePence * 0.7) / 100).toFixed(2)}`}
|
||||
</span>
|
||||
<span className="text-warm-500 dark:text-warm-400 ml-1">{t('upgrade.once')}</span>
|
||||
<span className="text-warm-500 dark:text-warm-400 ml-1">
|
||||
{t('pricingPage.lifetime')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
182
frontend/src/components/landing/SeoContentPage.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import { CheckIcon } from '../ui/icons/CheckIcon';
|
||||
import {
|
||||
SEO_CONTENT_PAGES,
|
||||
type SeoContentKey,
|
||||
type SeoFaq,
|
||||
type SeoLink,
|
||||
type SeoSection,
|
||||
} from '../../lib/seoLandingPages';
|
||||
|
||||
const PUBLIC_URL = 'https://perfect-postcode.co.uk';
|
||||
|
||||
function JsonLd({ data }: { data: unknown }) {
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, '\\u003c') }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FaqJsonLd({ faq }: { faq: SeoFaq[] }) {
|
||||
if (faq.length === 0) return null;
|
||||
return (
|
||||
<JsonLd
|
||||
data={{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faq.map((item) => ({
|
||||
'@type': 'Question',
|
||||
name: item.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: item.answer,
|
||||
},
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionList({ sections }: { sections: SeoSection[] }) {
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
{sections.map((section) => (
|
||||
<article
|
||||
key={section.title}
|
||||
className="rounded-lg border border-warm-200 bg-white p-6 dark:border-warm-700 dark:bg-warm-800"
|
||||
>
|
||||
<h2 className="text-xl font-bold">{section.title}</h2>
|
||||
<p className="mt-3 leading-relaxed text-warm-700 dark:text-warm-300">{section.body}</p>
|
||||
{section.bullets && (
|
||||
<ul className="mt-4 space-y-2">
|
||||
{section.bullets.map((bullet) => (
|
||||
<li key={bullet} className="flex gap-2 text-sm text-warm-700 dark:text-warm-300">
|
||||
<CheckIcon className="mt-0.5 h-4 w-4 shrink-0 text-teal-600 dark:text-teal-400" />
|
||||
<span>{bullet}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RelatedLinks({ links }: { links: SeoLink[] }) {
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{links.map((link) => (
|
||||
<a
|
||||
key={link.path}
|
||||
href={link.path}
|
||||
className="rounded-lg border border-warm-200 bg-white p-4 transition-colors hover:border-teal-300 hover:bg-teal-50 dark:border-warm-700 dark:bg-warm-800 dark:hover:border-teal-500/60 dark:hover:bg-teal-950/30"
|
||||
>
|
||||
<h3 className="font-bold text-navy-950 dark:text-warm-100">{link.label}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-warm-600 dark:text-warm-300">
|
||||
{link.description}
|
||||
</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SeoContentPage({
|
||||
pageKey,
|
||||
onOpenDashboard,
|
||||
}: {
|
||||
pageKey: SeoContentKey;
|
||||
onOpenDashboard: () => void;
|
||||
}) {
|
||||
const page = SEO_CONTENT_PAGES[pageKey];
|
||||
const url = `${PUBLIC_URL}${page.path}`;
|
||||
|
||||
return (
|
||||
<main className="flex-1 overflow-y-auto bg-warm-50 text-navy-950 dark:bg-navy-950 dark:text-warm-100">
|
||||
<FaqJsonLd faq={page.faq} />
|
||||
<JsonLd
|
||||
data={{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: 'Perfect Postcode',
|
||||
item: `${PUBLIC_URL}/`,
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: page.title,
|
||||
item: url,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
||||
<section className="bg-navy-950 text-white">
|
||||
<div className="mx-auto max-w-5xl px-6 py-16 md:px-10 md:py-20">
|
||||
<nav className="mb-6 text-sm font-medium text-warm-400" aria-label="Breadcrumb">
|
||||
<a href="/" className="hover:text-teal-200">
|
||||
Home
|
||||
</a>
|
||||
<span className="mx-2">/</span>
|
||||
<span className="text-warm-200">{page.eyebrow}</span>
|
||||
</nav>
|
||||
<p className="mb-4 text-sm font-semibold uppercase tracking-wide text-teal-300">
|
||||
{page.eyebrow}
|
||||
</p>
|
||||
<h1 className="text-3xl font-extrabold leading-tight md:text-5xl">{page.title}</h1>
|
||||
<p className="mt-6 max-w-3xl text-lg leading-relaxed text-warm-300">{page.intro}</p>
|
||||
{page.cta && (
|
||||
<button
|
||||
onClick={onOpenDashboard}
|
||||
className="mt-8 rounded-lg bg-coral-500 px-6 py-3 font-semibold text-white shadow-lg shadow-coral-500/25 transition-colors hover:bg-coral-600"
|
||||
>
|
||||
{page.cta}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto max-w-5xl px-6 py-14 md:px-10">
|
||||
<SectionList sections={page.sections} />
|
||||
</section>
|
||||
|
||||
{page.faq.length > 0 && (
|
||||
<section className="bg-white py-14 dark:bg-navy-900">
|
||||
<div className="mx-auto max-w-5xl px-6 md:px-10">
|
||||
<h2 className="text-2xl font-bold md:text-3xl">Frequently asked questions</h2>
|
||||
<div className="mt-7 grid gap-4">
|
||||
{page.faq.map((item) => (
|
||||
<details
|
||||
key={item.question}
|
||||
className="rounded-lg border border-warm-200 bg-warm-50 p-5 dark:border-warm-700 dark:bg-warm-800"
|
||||
>
|
||||
<summary className="cursor-pointer font-bold">{item.question}</summary>
|
||||
<p className="mt-3 leading-relaxed text-warm-700 dark:text-warm-300">
|
||||
{item.answer}
|
||||
</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="mx-auto max-w-5xl px-6 py-14 md:px-10">
|
||||
<h2 className="text-2xl font-bold md:text-3xl">Related pages</h2>
|
||||
<p className="mt-3 max-w-3xl leading-relaxed text-warm-600 dark:text-warm-300">
|
||||
Follow these internal links to compare the same property-search workflow from another
|
||||
angle.
|
||||
</p>
|
||||
<div className="mt-7">
|
||||
<RelatedLinks links={page.relatedLinks} />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
234
frontend/src/components/landing/SeoLandingPage.tsx
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { CheckIcon } from '../ui/icons/CheckIcon';
|
||||
import { LogoIcon } from '../ui/icons/LogoIcon';
|
||||
import {
|
||||
SEO_LANDING_PAGES,
|
||||
type SeoFaq,
|
||||
type SeoLandingKey,
|
||||
type SeoLink,
|
||||
type SeoSection,
|
||||
} from '../../lib/seoLandingPages';
|
||||
|
||||
const PUBLIC_URL = 'https://perfect-postcode.co.uk';
|
||||
|
||||
function JsonLd({ data }: { data: unknown }) {
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, '\\u003c') }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FaqJsonLd({ faq }: { faq: SeoFaq[] }) {
|
||||
return (
|
||||
<JsonLd
|
||||
data={{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faq.map((item) => ({
|
||||
'@type': 'Question',
|
||||
name: item.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: item.answer,
|
||||
},
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkGrid({ links }: { links: SeoLink[] }) {
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{links.map((link) => (
|
||||
<a
|
||||
key={link.path}
|
||||
href={link.path}
|
||||
className="rounded-lg border border-warm-200 bg-white p-4 transition-colors hover:border-teal-300 hover:bg-teal-50 dark:border-warm-700 dark:bg-warm-800 dark:hover:border-teal-500/60 dark:hover:bg-teal-950/30"
|
||||
>
|
||||
<h3 className="font-bold text-navy-950 dark:text-warm-100">{link.label}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-warm-600 dark:text-warm-300">
|
||||
{link.description}
|
||||
</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionGrid({ sections }: { sections: SeoSection[] }) {
|
||||
return (
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{sections.map((section) => (
|
||||
<article
|
||||
key={section.title}
|
||||
className="rounded-lg border border-warm-200 bg-white p-6 dark:border-warm-700 dark:bg-warm-800"
|
||||
>
|
||||
<h2 className="text-xl font-bold">{section.title}</h2>
|
||||
<p className="mt-3 leading-relaxed text-warm-700 dark:text-warm-300">{section.body}</p>
|
||||
{section.bullets && (
|
||||
<ul className="mt-4 space-y-2">
|
||||
{section.bullets.map((bullet) => (
|
||||
<li key={bullet} className="flex gap-2 text-sm text-warm-700 dark:text-warm-300">
|
||||
<CheckIcon className="mt-0.5 h-4 w-4 shrink-0 text-teal-600 dark:text-teal-400" />
|
||||
<span>{bullet}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SeoLandingPage({
|
||||
pageKey,
|
||||
onOpenDashboard,
|
||||
}: {
|
||||
pageKey: SeoLandingKey;
|
||||
onOpenDashboard: () => void;
|
||||
}) {
|
||||
const page = SEO_LANDING_PAGES[pageKey];
|
||||
const url = `${PUBLIC_URL}${page.path}`;
|
||||
|
||||
return (
|
||||
<main className="flex-1 overflow-y-auto bg-warm-50 text-navy-950 dark:bg-navy-950 dark:text-warm-100">
|
||||
<FaqJsonLd faq={page.faq} />
|
||||
<JsonLd
|
||||
data={{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: 'Perfect Postcode',
|
||||
item: `${PUBLIC_URL}/`,
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: page.title,
|
||||
item: url,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
||||
<section className="bg-navy-950 text-white">
|
||||
<div className="mx-auto max-w-6xl px-6 py-16 md:px-10 md:py-20">
|
||||
<nav className="mb-6 text-sm font-medium text-warm-400" aria-label="Breadcrumb">
|
||||
<a href="/" className="hover:text-teal-200">
|
||||
Home
|
||||
</a>
|
||||
<span className="mx-2">/</span>
|
||||
<span className="text-warm-200">{page.eyebrow}</span>
|
||||
</nav>
|
||||
<p className="mb-4 text-sm font-semibold uppercase tracking-wide text-teal-300">
|
||||
{page.eyebrow}
|
||||
</p>
|
||||
<h1 className="max-w-4xl text-3xl font-extrabold leading-tight md:text-5xl">
|
||||
{page.title}
|
||||
</h1>
|
||||
<p className="mt-6 max-w-3xl text-lg leading-relaxed text-warm-300">{page.intro}</p>
|
||||
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||
<button
|
||||
onClick={onOpenDashboard}
|
||||
className="rounded-lg bg-coral-500 px-6 py-3 font-semibold text-white shadow-lg shadow-coral-500/25 transition-colors hover:bg-coral-600"
|
||||
>
|
||||
{page.cta}
|
||||
</button>
|
||||
<a
|
||||
href="/data-sources"
|
||||
className="rounded-lg border-2 border-teal-400 px-6 py-[10px] text-center font-semibold text-teal-300 transition-colors hover:bg-teal-400/10"
|
||||
>
|
||||
Review the data sources
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto grid max-w-6xl gap-8 px-6 py-12 md:grid-cols-[0.85fr_1.15fr] md:px-10 md:py-16">
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 rounded-md border border-teal-200 bg-teal-50 px-3 py-1.5 text-sm font-semibold text-teal-700 dark:border-teal-400/30 dark:bg-teal-400/10 dark:text-teal-200">
|
||||
<LogoIcon className="h-4 w-4" />
|
||||
Perfect Postcode
|
||||
</div>
|
||||
<h2 className="mt-5 text-2xl font-bold md:text-3xl">What you can compare</h2>
|
||||
<p className="mt-3 leading-relaxed text-warm-600 dark:text-warm-300">
|
||||
Each page is built around real shortlisting work: removing impossible places, comparing
|
||||
the remaining postcodes, and deciding what to validate next.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{page.points.map((point) => (
|
||||
<div
|
||||
key={point}
|
||||
className="flex gap-3 rounded-lg border border-warm-200 bg-white p-4 dark:border-warm-700 dark:bg-warm-800"
|
||||
>
|
||||
<CheckIcon className="mt-0.5 h-5 w-5 shrink-0 text-teal-600 dark:text-teal-400" />
|
||||
<p className="leading-relaxed text-warm-700 dark:text-warm-300">{point}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto max-w-6xl px-6 pb-16 md:px-10">
|
||||
<div className="mb-7 max-w-3xl">
|
||||
<h2 className="text-2xl font-bold md:text-3xl">How to use it</h2>
|
||||
<p className="mt-3 leading-relaxed text-warm-600 dark:text-warm-300">
|
||||
Use these workflows to make the page useful before you open a listing portal or book a
|
||||
viewing.
|
||||
</p>
|
||||
</div>
|
||||
<SectionGrid sections={page.workflows} />
|
||||
</section>
|
||||
|
||||
<section className="mx-auto max-w-6xl px-6 pb-16 md:px-10">
|
||||
<SectionGrid sections={page.sections} />
|
||||
</section>
|
||||
|
||||
<section className="bg-white py-14 dark:bg-navy-900">
|
||||
<div className="mx-auto max-w-6xl px-6 md:px-10">
|
||||
<div className="mb-7 max-w-3xl">
|
||||
<h2 className="text-2xl font-bold md:text-3xl">Method and limitations</h2>
|
||||
<p className="mt-3 leading-relaxed text-warm-600 dark:text-warm-300">
|
||||
The data is designed for comparison and shortlisting. Important decisions still need
|
||||
current listings, professional checks, and direct local validation.
|
||||
</p>
|
||||
</div>
|
||||
<SectionGrid sections={page.methodology} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto max-w-6xl px-6 py-14 md:px-10">
|
||||
<div className="mb-7 max-w-3xl">
|
||||
<h2 className="text-2xl font-bold md:text-3xl">Questions buyers ask</h2>
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
{page.faq.map((item) => (
|
||||
<details
|
||||
key={item.question}
|
||||
className="rounded-lg border border-warm-200 bg-white p-5 dark:border-warm-700 dark:bg-warm-800"
|
||||
>
|
||||
<summary className="cursor-pointer font-bold">{item.question}</summary>
|
||||
<p className="mt-3 leading-relaxed text-warm-700 dark:text-warm-300">{item.answer}</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto max-w-6xl px-6 pb-16 md:px-10">
|
||||
<div className="mb-7 max-w-3xl">
|
||||
<h2 className="text-2xl font-bold md:text-3xl">Related guides</h2>
|
||||
<p className="mt-3 leading-relaxed text-warm-600 dark:text-warm-300">
|
||||
Continue through the indexed public pages using canonical internal links.
|
||||
</p>
|
||||
</div>
|
||||
<LinkGrid links={page.relatedLinks} />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -192,6 +192,7 @@ export default function LearnPage() {
|
|||
items: [
|
||||
{ question: t('learnPage.faqCommute1Q'), answer: t('learnPage.faqCommute1A') },
|
||||
{ question: t('learnPage.faqCommute2Q'), answer: t('learnPage.faqCommute2A') },
|
||||
{ question: t('learnPage.faqCommute3Q'), answer: t('learnPage.faqCommute3A') },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -201,6 +202,14 @@ export default function LearnPage() {
|
|||
{ question: t('learnPage.faqBudget2Q'), answer: t('learnPage.faqBudget2A') },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('learnPage.faqTipsTitle'),
|
||||
items: [
|
||||
{ question: t('learnPage.faqTips1Q'), answer: t('learnPage.faqTips1A') },
|
||||
{ question: t('learnPage.faqTips2Q'), answer: t('learnPage.faqTips2A') },
|
||||
{ question: t('learnPage.faqTips3Q'), answer: t('learnPage.faqTips3A') },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('learnPage.faqDueDiligenceTitle'),
|
||||
items: [
|
||||
|
|
@ -260,6 +269,9 @@ export default function LearnPage() {
|
|||
<>
|
||||
<div className="flex-1">
|
||||
<div className="max-w-5xl mx-auto px-6 py-6">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
|
||||
{t('learnPage.dataSources')}
|
||||
</h1>
|
||||
<p className="text-warm-600 dark:text-warm-400 mb-6">
|
||||
{t('learnPage.dataSourcesIntro', { count: DATA_SOURCE_DEFS.length })}
|
||||
</p>
|
||||
|
|
@ -284,7 +296,7 @@ export default function LearnPage() {
|
|||
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
|
||||
{tDynamic(nameKey)}
|
||||
</h2>
|
||||
<span className="text-xs bg-warm-100 dark:bg-navy-700 text-warm-600 dark:text-warm-300 px-2 py-1 rounded text-right">
|
||||
<span className="max-w-44 text-left text-xs leading-snug bg-warm-100 dark:bg-navy-700 text-warm-600 dark:text-warm-300 px-2 py-1 rounded">
|
||||
{source.license}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -369,6 +381,9 @@ export default function LearnPage() {
|
|||
</>
|
||||
) : tab === 'faq' ? (
|
||||
<div className="max-w-3xl mx-auto px-6 py-6 w-full">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
|
||||
{t('learnPage.faq')}
|
||||
</h1>
|
||||
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.faqIntro')}</p>
|
||||
<div className="space-y-8">
|
||||
{FAQ_SECTIONS.map((section) => (
|
||||
|
|
@ -387,6 +402,9 @@ export default function LearnPage() {
|
|||
</div>
|
||||
) : (
|
||||
<div className="max-w-2xl mx-auto px-6 py-6 w-full">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
|
||||
{t('learnPage.support')}
|
||||
</h1>
|
||||
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.supportIntro')}</p>
|
||||
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 text-center">
|
||||
<p className="text-warm-600 dark:text-warm-300 mb-2">{t('accountPage.needHelp')}</p>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import type { AiFilterErrorType } from '../../hooks/useAiFilters';
|
|||
/** Cycle through loading messages to show progress. */
|
||||
function useLoadingMessage(loading: boolean, messages: string[]): string {
|
||||
const [index, setIndex] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
|
|
@ -20,7 +20,7 @@ function useLoadingMessage(loading: boolean, messages: string[]): string {
|
|||
const t2 = setTimeout(() => setIndex(2), 3500);
|
||||
const t3 = setTimeout(() => setIndex(3), 5500);
|
||||
return () => {
|
||||
clearTimeout(timerRef.current);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
clearTimeout(t2);
|
||||
clearTimeout(t3);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,7 +16,12 @@ import {
|
|||
roundedPercentages,
|
||||
} from '../../lib/format';
|
||||
import { groupFeaturesByCategory } from '../../lib/features';
|
||||
import { PARTY_FEATURE_COLORS, STACKED_GROUPS, STACKED_ENUM_GROUPS } from '../../lib/consts';
|
||||
import {
|
||||
PARTY_FEATURE_COLORS,
|
||||
STACKED_GROUPS,
|
||||
STACKED_ENUM_GROUPS,
|
||||
STACKED_SEGMENT_COLORS,
|
||||
} from '../../lib/consts';
|
||||
import { DualHistogram, LoadingSkeleton } from './DualHistogram';
|
||||
import EnumBarChart from './EnumBarChart';
|
||||
import StackedBarChart from './StackedBarChart';
|
||||
|
|
@ -113,71 +118,75 @@ export default function AreaPane({
|
|||
return (
|
||||
<>
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold dark:text-warm-100">
|
||||
{isPostcode ? hexagonId : t('areaPane.areaStatistics')}
|
||||
</h2>
|
||||
{isPostcode && (
|
||||
<span className="text-xs text-warm-500 dark:text-warm-400">
|
||||
{t('common.postcode')}
|
||||
</span>
|
||||
)}
|
||||
<div className="border-b border-warm-200 bg-white dark:border-navy-700 dark:bg-navy-950">
|
||||
<div className="space-y-3 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h2 className="truncate text-base font-semibold text-warm-900 dark:text-warm-100">
|
||||
{isPostcode ? hexagonId : t('areaPane.areaOverview')}
|
||||
</h2>
|
||||
{loading && (
|
||||
<span className="h-3 w-3 shrink-0 rounded-full border-2 border-teal-600 border-t-transparent dark:border-teal-400 dark:border-t-transparent animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs leading-snug text-warm-500 dark:text-warm-400">
|
||||
{t('areaPane.statsFor', {
|
||||
type: isPostcode
|
||||
? t('common.postcode').toLowerCase()
|
||||
: t('common.area').toLowerCase(),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-lg font-semibold tabular-nums leading-none text-navy-950 dark:text-warm-50">
|
||||
{propertyCount == null ? '...' : propertyCount.toLocaleString()}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs font-medium text-warm-500 dark:text-warm-400">
|
||||
{t('common.propertiesPlural')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{loading && stats && (
|
||||
<div className="w-3 h-3 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
|
||||
|
||||
<div className="flex gap-2 border-l-2 border-teal-500 bg-warm-50 px-2.5 py-2 text-xs leading-snug text-warm-700 dark:bg-navy-900 dark:text-warm-300">
|
||||
<FilterIcon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-teal-700 dark:text-teal-300" />
|
||||
<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}
|
||||
className="w-full text-sm py-1.5 rounded bg-teal-600 hover:bg-teal-700 text-white font-medium"
|
||||
>
|
||||
{t('areaPane.viewPropertiesShort')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{propertyCount != null && (
|
||||
<p className="text-sm text-warm-600 dark:text-warm-400 mt-1">
|
||||
{propertyCount.toLocaleString()} {t('common.propertiesPlural')}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-warm-500 dark:text-warm-400 mt-1">
|
||||
{t('areaPane.statsFor', {
|
||||
type: isPostcode
|
||||
? t('common.postcode').toLowerCase()
|
||||
: t('common.area').toLowerCase(),
|
||||
})}
|
||||
</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}
|
||||
className="mt-2 w-full text-sm py-1.5 rounded bg-teal-600 hover:bg-teal-700 text-white font-medium"
|
||||
>
|
||||
{t('areaPane.viewProperties', { count: stats.count })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hexagonLocation && stats && (
|
||||
|
|
@ -315,7 +324,7 @@ export default function AreaPane({
|
|||
colorMap={
|
||||
chart.label === 'Political vote share'
|
||||
? PARTY_FEATURE_COLORS
|
||||
: undefined
|
||||
: STACKED_SEGMENT_COLORS
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -369,7 +378,15 @@ export default function AreaPane({
|
|||
p1={numericStats.histogram.p1}
|
||||
p99={numericStats.histogram.p99}
|
||||
globalMean={globalMean}
|
||||
formatLabel={(v) => formatFilterValue(v, feature.raw)}
|
||||
meanLabel={t('areaPane.nationalAvg')}
|
||||
formatLabel={(v) =>
|
||||
formatFilterValue(
|
||||
v,
|
||||
feature.suffix === '%'
|
||||
? { raw: feature.raw, suffix: feature.suffix }
|
||||
: feature.raw
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<DualHistogram
|
||||
|
|
@ -377,7 +394,14 @@ export default function AreaPane({
|
|||
globalCounts={numericStats.histogram.counts}
|
||||
p1={numericStats.histogram.p1}
|
||||
p99={numericStats.histogram.p99}
|
||||
formatLabel={(v) => formatFilterValue(v, feature.raw)}
|
||||
formatLabel={(v) =>
|
||||
formatFilterValue(
|
||||
v,
|
||||
feature.suffix === '%'
|
||||
? { raw: feature.raw, suffix: feature.suffix }
|
||||
: feature.raw
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export function DualHistogram({
|
|||
p1,
|
||||
p99,
|
||||
globalMean,
|
||||
meanLabel = 'National avg',
|
||||
formatLabel,
|
||||
}: {
|
||||
localCounts: number[];
|
||||
|
|
@ -41,6 +42,7 @@ export function DualHistogram({
|
|||
p1: number;
|
||||
p99: number;
|
||||
globalMean?: number;
|
||||
meanLabel?: string;
|
||||
formatLabel?: (value: number) => string;
|
||||
}) {
|
||||
const targetBars = 25;
|
||||
|
|
@ -84,34 +86,51 @@ export function DualHistogram({
|
|||
const meanFrac = globalMean != null && p99 > p1 ? (globalMean - p1) / (p99 - p1) : null;
|
||||
// Account for outlier bins: middle region spans bars 1..n-2
|
||||
const meanPct = meanFrac != null ? ((1 + meanFrac * middleBins) / barCount) * 100 : null;
|
||||
const showMeanMarker = meanPct != null && meanPct >= 0 && meanPct <= 100;
|
||||
const meanLabelStyle =
|
||||
showMeanMarker && meanPct < 12
|
||||
? { left: 0 }
|
||||
: showMeanMarker && meanPct > 88
|
||||
? { right: 0 }
|
||||
: { left: '50%', transform: 'translateX(-50%)' };
|
||||
|
||||
return (
|
||||
<div className="mt-1">
|
||||
<div className="relative flex items-end gap-px h-10">
|
||||
{Array.from({ length: barCount }).map((_, index) => {
|
||||
const globalHeight = (globalBars[index] / globalMax) * 100;
|
||||
const localHeight = (localBars[index] / localMax) * 100;
|
||||
return (
|
||||
<div key={index} className="flex-1 relative min-w-[2px] h-full flex items-end">
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 bg-warm-300/40 dark:bg-warm-600/40 rounded-t-sm"
|
||||
style={{ height: `${globalHeight}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 bg-teal-500 dark:bg-teal-400 rounded-t-sm"
|
||||
style={{
|
||||
height: `${localHeight}%`,
|
||||
opacity: localBars[index] > 0 ? 1 : 0.1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{meanPct != null && meanPct >= 0 && meanPct <= 100 && (
|
||||
<div className={showMeanMarker ? 'relative pt-5' : 'relative'}>
|
||||
<div className="relative flex items-end gap-px h-10">
|
||||
{Array.from({ length: barCount }).map((_, index) => {
|
||||
const globalHeight = (globalBars[index] / globalMax) * 100;
|
||||
const localHeight = (localBars[index] / localMax) * 100;
|
||||
return (
|
||||
<div key={index} className="flex-1 relative min-w-[2px] h-full flex items-end">
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 bg-warm-300/40 dark:bg-warm-600/40 rounded-t-sm"
|
||||
style={{ height: `${globalHeight}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 bg-teal-500 dark:bg-teal-400 rounded-t-sm"
|
||||
style={{
|
||||
height: `${localHeight}%`,
|
||||
opacity: localBars[index] > 0 ? 1 : 0.1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{showMeanMarker && (
|
||||
<div
|
||||
className="absolute bottom-0 top-0 w-px border-l border-dashed border-warm-400 dark:border-warm-500"
|
||||
className="pointer-events-none absolute inset-y-0"
|
||||
style={{ left: `${meanPct}%` }}
|
||||
/>
|
||||
>
|
||||
<div
|
||||
className="absolute top-0 max-w-[7rem] truncate rounded-sm border border-warm-300 bg-white px-1 py-0.5 text-[9px] font-medium leading-none text-warm-600 shadow-sm dark:border-warm-600 dark:bg-navy-900 dark:text-warm-300"
|
||||
style={meanLabelStyle}
|
||||
>
|
||||
{meanLabel}
|
||||
</div>
|
||||
<div className="absolute bottom-0 top-5 w-px border-l border-dashed border-warm-400 dark:border-warm-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{tickBars.size > 0 && (
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export default function EnumBarChart({
|
|||
}: {
|
||||
counts: Record<string, number>;
|
||||
globalCounts?: Record<string, number>;
|
||||
featureName?: string;
|
||||
featureName: string;
|
||||
}) {
|
||||
const entries = Object.entries(counts).sort(([, countA], [, countB]) => countB - countA);
|
||||
const localTotal = entries.reduce((sum, [, c]) => sum + c, 0);
|
||||
|
|
@ -40,10 +40,8 @@ export default function EnumBarChart({
|
|||
: (count / maxCount) * 100;
|
||||
const globalWidth = hasGlobal && maxPct > 0 ? (globalPct / maxPct) * 100 : 0;
|
||||
|
||||
const overrideColor = featureName ? getEnumValueColor(featureName, label) : null;
|
||||
const barStyle = overrideColor
|
||||
? `rgb(${overrideColor[0]},${overrideColor[1]},${overrideColor[2]})`
|
||||
: undefined;
|
||||
const color = getEnumValueColor(featureName, label);
|
||||
const barStyle = `rgb(${color[0]},${color[1]},${color[2]})`;
|
||||
|
||||
return (
|
||||
<div key={label} className="flex items-center gap-2 text-xs">
|
||||
|
|
@ -58,14 +56,10 @@ export default function EnumBarChart({
|
|||
/>
|
||||
)}
|
||||
<div
|
||||
className={
|
||||
barStyle
|
||||
? 'h-full rounded relative'
|
||||
: 'h-full bg-teal-500 dark:bg-teal-400 rounded relative'
|
||||
}
|
||||
className="h-full rounded relative"
|
||||
style={{
|
||||
width: `${localWidth}%`,
|
||||
...(barStyle ? { backgroundColor: barStyle } : {}),
|
||||
backgroundColor: barStyle,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { SearchInput } from '../ui/SearchInput';
|
|||
import { FilterIcon } from '../ui/icons';
|
||||
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
||||
import { EmptyState } from '../ui/EmptyState';
|
||||
import type { FeatureMeta } from '../../types';
|
||||
import type { FeatureGroup, FeatureMeta } from '../../types';
|
||||
import { groupFeaturesByCategory } from '../../lib/features';
|
||||
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
||||
import { FeatureActions } from '../ui/FeatureIcons';
|
||||
|
|
@ -33,7 +33,16 @@ interface FeatureBrowserProps {
|
|||
onClearOpenInfoFeature?: () => void;
|
||||
travelTimeEntries: TravelTimeEntry[];
|
||||
onAddTravelTimeEntry: (mode: TransportMode) => void;
|
||||
isLicensed: boolean;
|
||||
}
|
||||
|
||||
function moveTransportFirst(groups: FeatureGroup[]): FeatureGroup[] {
|
||||
const transportIdx = groups.findIndex((group) => group.name === 'Transport');
|
||||
if (transportIdx <= 0) return groups;
|
||||
return [
|
||||
groups[transportIdx],
|
||||
...groups.slice(0, transportIdx),
|
||||
...groups.slice(transportIdx + 1),
|
||||
];
|
||||
}
|
||||
|
||||
export default function FeatureBrowser({
|
||||
|
|
@ -47,7 +56,6 @@ export default function FeatureBrowser({
|
|||
onClearOpenInfoFeature,
|
||||
travelTimeEntries: _travelTimeEntries,
|
||||
onAddTravelTimeEntry,
|
||||
isLicensed,
|
||||
}: FeatureBrowserProps) {
|
||||
const { t } = useTranslation();
|
||||
const modes = useTranslatedModes();
|
||||
|
|
@ -68,10 +76,14 @@ export default function FeatureBrowser({
|
|||
const filtered = useMemo(() => {
|
||||
if (!search) return availableFeatures;
|
||||
const lower = search.toLowerCase();
|
||||
return availableFeatures.filter((f) => f.name.toLowerCase().includes(lower));
|
||||
return availableFeatures.filter((f) =>
|
||||
[f.name, f.description, f.detail, f.group]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.some((value) => value.toLowerCase().includes(lower))
|
||||
);
|
||||
}, [availableFeatures, search]);
|
||||
|
||||
const grouped = useMemo(() => groupFeaturesByCategory(filtered), [filtered]);
|
||||
const grouped = useMemo(() => moveTransportFirst(groupFeaturesByCategory(filtered)), [filtered]);
|
||||
|
||||
// When searching, expand all groups so results are visible
|
||||
const isSearching = search.length > 0;
|
||||
|
|
@ -89,14 +101,11 @@ export default function FeatureBrowser({
|
|||
search.toLowerCase()
|
||||
));
|
||||
|
||||
// Ensure "Transport" group exists when travel modes should be shown
|
||||
// Ensure "Transport" group exists first when travel modes should be shown.
|
||||
const mergedGrouped = useMemo(() => {
|
||||
if (!showTravelModes) return grouped;
|
||||
if (grouped.some((g) => g.name === 'Transport')) return grouped;
|
||||
const groups = [...grouped];
|
||||
const propsIdx = groups.findIndex((g) => g.name === 'Properties in the area');
|
||||
groups.splice(propsIdx === -1 ? 0 : propsIdx + 1, 0, { name: 'Transport', features: [] });
|
||||
return groups;
|
||||
return [{ name: 'Transport', features: [] }, ...grouped];
|
||||
}, [grouped, showTravelModes]);
|
||||
|
||||
return (
|
||||
|
|
@ -107,6 +116,11 @@ export default function FeatureBrowser({
|
|||
onChange={setSearch}
|
||||
placeholder={t('filters.searchFeatures')}
|
||||
/>
|
||||
{!search && (
|
||||
<p className="mt-2 px-1 text-xs leading-relaxed text-warm-500 dark:text-warm-400">
|
||||
{t('filters.chooseFilters')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{mergedGrouped.map((group) => {
|
||||
|
|
@ -126,26 +140,6 @@ export default function FeatureBrowser({
|
|||
</CollapsibleGroupHeader>
|
||||
{isExpanded && (
|
||||
<>
|
||||
{group.features.map((f) => {
|
||||
const isPinned = pinnedFeature === f.name;
|
||||
return (
|
||||
<div
|
||||
key={f.name}
|
||||
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
|
||||
>
|
||||
<div className="min-w-0 mr-2">
|
||||
<FeatureLabel feature={f} size="sm" description={f.description} />
|
||||
</div>
|
||||
<FeatureActions
|
||||
feature={f}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={setInfoFeature}
|
||||
onAdd={onAddFilter}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{group.name === 'Transport' &&
|
||||
showTravelModes &&
|
||||
visibleModes.map((mode) => {
|
||||
|
|
@ -172,22 +166,46 @@ export default function FeatureBrowser({
|
|||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<IconButton
|
||||
onClick={() => setTravelInfoMode(mode)}
|
||||
title={t('filters.featureInfo')}
|
||||
title={t('filters.aboutData')}
|
||||
size="md"
|
||||
>
|
||||
<InfoIcon className="w-5 h-5 md:w-3.5 md:h-3.5" />
|
||||
</IconButton>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAddTravelTimeEntry(mode)}
|
||||
title={t('travel.addTravelTime', { mode: modes.label(mode) })}
|
||||
className="p-1 rounded-md text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30 hover:bg-teal-100 dark:hover:bg-teal-800/40"
|
||||
aria-label={t('travel.addTravelTime', { mode: modes.label(mode) })}
|
||||
className="inline-flex items-center gap-1 rounded-md bg-teal-50 px-2 py-1 text-xs font-semibold text-teal-700 hover:bg-teal-100 dark:bg-teal-900/30 dark:text-teal-300 dark:hover:bg-teal-800/40"
|
||||
>
|
||||
<PlusIcon className="w-5 h-5 md:w-5 md:h-5" strokeWidth={2.5} />
|
||||
<PlusIcon className="w-4 h-4" strokeWidth={2.5} />
|
||||
<span>{t('filters.addFilterAction')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{group.features.map((f) => {
|
||||
const isPinned = pinnedFeature === f.name;
|
||||
return (
|
||||
<div
|
||||
key={f.name}
|
||||
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
|
||||
>
|
||||
<div className="min-w-0 mr-2">
|
||||
<FeatureLabel feature={f} size="sm" description={f.description} />
|
||||
</div>
|
||||
<FeatureActions
|
||||
feature={f}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={setInfoFeature}
|
||||
onAdd={onAddFilter}
|
||||
showText
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -200,10 +218,6 @@ export default function FeatureBrowser({
|
|||
description={search ? t('filters.tryDifferentSearch') : t('filters.removeFilterHint')}
|
||||
className="px-3 py-4"
|
||||
/>
|
||||
) : isLicensed ? (
|
||||
<p className="flex-1 flex items-center justify-center px-4 py-6 text-xs text-center text-warm-400 dark:text-warm-500">
|
||||
{t('filters.chooseFilters')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{infoFeature && (
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import type { FeatureFilters, FeatureMeta } from '../../types';
|
|||
import { formatValue } from '../../lib/format';
|
||||
import { ts } from '../../i18n/server';
|
||||
import { SCHOOL_FILTER_NAME, getSchoolBackendFeatureName } from '../../lib/school-filter';
|
||||
import { getSpecificCrimeFeatureName } from '../../lib/crime-filter';
|
||||
import { getEthnicityFeatureName } from '../../lib/ethnicity-filter';
|
||||
import { POI_DISTANCE_FILTER_NAME, getPoiDistanceFeatureName } from '../../lib/poi-distance-filter';
|
||||
|
||||
interface HoverCardData {
|
||||
count: number;
|
||||
|
|
@ -42,7 +45,16 @@ export default memo(function HoverCard({
|
|||
|
||||
// Show stats for active filters (up to 4)
|
||||
for (const name of activeFilterNames.slice(0, 4)) {
|
||||
const backendName = getSchoolBackendFeatureName(name) ?? name;
|
||||
const schoolBackendName = getSchoolBackendFeatureName(name);
|
||||
const specificCrimeFeatureName = getSpecificCrimeFeatureName(name);
|
||||
const ethnicityFeatureName = getEthnicityFeatureName(name);
|
||||
const poiDistanceFeatureName = getPoiDistanceFeatureName(name);
|
||||
const backendName =
|
||||
schoolBackendName ??
|
||||
specificCrimeFeatureName ??
|
||||
ethnicityFeatureName ??
|
||||
poiDistanceFeatureName ??
|
||||
name;
|
||||
const val = data[`avg_${backendName}`] ?? data[`min_${backendName}`];
|
||||
if (val == null || typeof val !== 'number') continue;
|
||||
const meta = featureMap.get(backendName);
|
||||
|
|
@ -51,7 +63,11 @@ export default memo(function HoverCard({
|
|||
if (label) results.push({ name: backendName, value: ts(label) });
|
||||
} else {
|
||||
results.push({
|
||||
name: backendName === name ? name : SCHOOL_FILTER_NAME,
|
||||
name: schoolBackendName
|
||||
? SCHOOL_FILTER_NAME
|
||||
: poiDistanceFeatureName
|
||||
? POI_DISTANCE_FILTER_NAME
|
||||
: backendName,
|
||||
value: formatValue(val, meta),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ interface JourneyInstructionsProps {
|
|||
entries: TravelTimeEntry[];
|
||||
/** When set, shown as a subtitle (e.g. the central postcode for a hexagon) */
|
||||
label?: string;
|
||||
/** Preloaded journey rows, useful for static demos that should not call the API. */
|
||||
presetJourneys?: JourneyInstructionPreset[];
|
||||
className?: string;
|
||||
showGoogleMapsLink?: boolean;
|
||||
}
|
||||
|
||||
interface JourneyData {
|
||||
|
|
@ -24,6 +28,16 @@ interface JourneyData {
|
|||
loading: boolean;
|
||||
}
|
||||
|
||||
export interface JourneyInstructionPreset {
|
||||
slug: string;
|
||||
label: string;
|
||||
legs: JourneyLeg[] | null;
|
||||
/** Median (50th percentile) total travel time, including waiting. */
|
||||
minutes: number | null;
|
||||
/** Best-case (5th percentile) total travel time. */
|
||||
bestMinutes?: number | null;
|
||||
}
|
||||
|
||||
// Official TfL line colors + other known London transit
|
||||
const ROUTE_COLORS: Record<string, { color: string; darkText?: boolean }> = {
|
||||
Bakerloo: { color: '#B36305' },
|
||||
|
|
@ -164,14 +178,23 @@ export default function JourneyInstructions({
|
|||
postcode,
|
||||
entries,
|
||||
label,
|
||||
presetJourneys,
|
||||
className,
|
||||
showGoogleMapsLink = true,
|
||||
}: JourneyInstructionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [journeys, setJourneys] = useState<JourneyData[]>([]);
|
||||
|
||||
// Only transit entries with a destination set
|
||||
const transitEntries = entries.filter((e) => e.mode === 'transit' && e.slug !== '');
|
||||
const hasPresetJourneys = Boolean(presetJourneys?.length);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasPresetJourneys) {
|
||||
setJourneys([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (transitEntries.length === 0) {
|
||||
setJourneys([]);
|
||||
return;
|
||||
|
|
@ -227,18 +250,29 @@ export default function JourneyInstructions({
|
|||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [postcode, transitEntries.map((e) => e.slug).join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [postcode, hasPresetJourneys, transitEntries.map((e) => e.slug).join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (transitEntries.length === 0) return null;
|
||||
if (transitEntries.length === 0 && !hasPresetJourneys) return null;
|
||||
|
||||
const displayedJourneys: JourneyData[] = hasPresetJourneys
|
||||
? (presetJourneys ?? []).map((journey) => ({
|
||||
slug: journey.slug,
|
||||
label: journey.label,
|
||||
legs: journey.legs,
|
||||
minutes: journey.minutes,
|
||||
bestMinutes: journey.bestMinutes ?? null,
|
||||
loading: false,
|
||||
}))
|
||||
: journeys;
|
||||
|
||||
return (
|
||||
<div className="mx-3 mt-2 space-y-2">
|
||||
<div className={className ?? 'mx-3 mt-2 space-y-2'}>
|
||||
{label && (
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400">
|
||||
{t('areaPane.journeysFrom', { label })}
|
||||
</div>
|
||||
)}
|
||||
{journeys.map((j) => {
|
||||
{displayedJourneys.map((j) => {
|
||||
const displayLegs = j.legs ? invertLegs(j.legs) : null;
|
||||
const legSum = j.legs ? j.legs.reduce((sum, l) => sum + l.minutes, 0) : 0;
|
||||
const totalMin = j.minutes ?? legSum;
|
||||
|
|
@ -267,27 +301,29 @@ export default function JourneyInstructions({
|
|||
{displayLegs.map((leg, i) => (
|
||||
<TimelineLeg key={i} leg={leg} isLast={i === displayLegs.length - 1} />
|
||||
))}
|
||||
<a
|
||||
href={googleMapsUrl(postcode, j.label || j.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
|
||||
>
|
||||
{t('areaPane.viewOnGoogleMaps')}
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
{showGoogleMapsLink && (
|
||||
<a
|
||||
href={googleMapsUrl(postcode, j.label || j.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
|
||||
>
|
||||
<path
|
||||
d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5M7.5 1.5H10.5V4.5M10.5 1.5L5.5 6.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{t('areaPane.viewOnGoogleMaps')}
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path
|
||||
d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5M7.5 1.5H10.5V4.5M10.5 1.5L5.5 6.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
) : j.minutes != null ? (
|
||||
<div>
|
||||
|
|
@ -297,27 +333,29 @@ export default function JourneyInstructions({
|
|||
{t('areaPane.walk')} · {j.minutes} {t('common.min')}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href={googleMapsUrl(postcode, j.label || j.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
|
||||
>
|
||||
{t('areaPane.viewOnGoogleMaps')}
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
{showGoogleMapsLink && (
|
||||
<a
|
||||
href={googleMapsUrl(postcode, j.label || j.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
|
||||
>
|
||||
<path
|
||||
d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5M7.5 1.5H10.5V4.5M10.5 1.5L5.5 6.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{t('areaPane.viewOnGoogleMaps')}
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path
|
||||
d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5M7.5 1.5H10.5V4.5M10.5 1.5L5.5 6.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-warm-500 dark:text-warm-400">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { PostcodeGeometry } from '../../types';
|
||||
import type { MapFlyToOptions, PostcodeGeometry } from '../../types';
|
||||
import { authHeaders } from '../../lib/api';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch';
|
||||
|
|
@ -8,11 +8,17 @@ import { PlaceSearchInput } from '../ui/PlaceSearchInput';
|
|||
import { LocateIcon } from '../ui/icons/LocateIcon';
|
||||
import { SearchIcon } from '../ui/icons/SearchIcon';
|
||||
|
||||
declare const __DEV__: boolean;
|
||||
|
||||
export interface SearchedLocation {
|
||||
postcode: string;
|
||||
geometry: PostcodeGeometry;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
markerLatitude?: number;
|
||||
markerLongitude?: number;
|
||||
openProperties?: boolean;
|
||||
focusAddress?: string;
|
||||
}
|
||||
|
||||
const ZOOM_FOR_TYPE: Record<string, number> = {
|
||||
|
|
@ -31,13 +37,18 @@ const ZOOM_FOR_TYPE: Record<string, number> = {
|
|||
isolated_dwelling: 16,
|
||||
};
|
||||
|
||||
const DEV_CURRENT_LOCATION = {
|
||||
latitude: 51.5033635,
|
||||
longitude: -0.1276248,
|
||||
};
|
||||
|
||||
export default function LocationSearch({
|
||||
onFlyTo,
|
||||
onLocationSearched,
|
||||
onCurrentLocationFound,
|
||||
onMouseEnter,
|
||||
}: {
|
||||
onFlyTo: (lat: number, lng: number, zoom: number) => void;
|
||||
onFlyTo: (lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void;
|
||||
onLocationSearched?: (postcode: SearchedLocation | null) => void;
|
||||
onCurrentLocationFound?: (lat: number, lng: number) => void;
|
||||
onMouseEnter?: () => void;
|
||||
|
|
@ -81,6 +92,46 @@ export default function LocationSearch({
|
|||
return;
|
||||
}
|
||||
|
||||
if (result.type === 'address') {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
search.close();
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/postcode/${encodeURIComponent(result.postcode)}`,
|
||||
authHeaders()
|
||||
);
|
||||
if (!res.ok) {
|
||||
setError(t('locationSearch.postcodeNotFound'));
|
||||
return;
|
||||
}
|
||||
const json: {
|
||||
postcode: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
geometry: PostcodeGeometry;
|
||||
} = await res.json();
|
||||
onFlyTo(result.lat, result.lon, 17);
|
||||
onLocationSearched?.({
|
||||
postcode: json.postcode,
|
||||
geometry: json.geometry,
|
||||
latitude: result.lat,
|
||||
longitude: result.lon,
|
||||
markerLatitude: result.lat,
|
||||
markerLongitude: result.lon,
|
||||
openProperties: true,
|
||||
focusAddress: result.address,
|
||||
});
|
||||
search.clear();
|
||||
if (isMobile) setExpanded(false);
|
||||
} catch {
|
||||
setError(t('locationSearch.lookupFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Postcode — fetch geometry
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
|
@ -118,7 +169,7 @@ export default function LocationSearch({
|
|||
const [locating, setLocating] = useState(false);
|
||||
|
||||
const locateUser = useCallback(async () => {
|
||||
if (!navigator.geolocation) {
|
||||
if (!__DEV__ && !navigator.geolocation) {
|
||||
setError(t('locationSearch.geolocationUnsupported'));
|
||||
return;
|
||||
}
|
||||
|
|
@ -126,15 +177,27 @@ export default function LocationSearch({
|
|||
setLocating(true);
|
||||
search.close();
|
||||
try {
|
||||
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
const { latitude, longitude } = position.coords;
|
||||
onFlyTo(latitude, longitude, 17);
|
||||
onCurrentLocationFound?.(latitude, longitude);
|
||||
const { latitude, longitude } = __DEV__
|
||||
? DEV_CURRENT_LOCATION
|
||||
: await new Promise<GeolocationCoordinates>((resolve, reject) => {
|
||||
if (!navigator.geolocation) {
|
||||
reject(new Error('Geolocation unsupported'));
|
||||
return;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => resolve(position.coords),
|
||||
reject,
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
}
|
||||
);
|
||||
});
|
||||
if (onCurrentLocationFound) {
|
||||
onCurrentLocationFound(latitude, longitude);
|
||||
} else {
|
||||
onFlyTo(latitude, longitude, 17);
|
||||
}
|
||||
search.clear();
|
||||
if (isMobile) setExpanded(false);
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Map as MapGL, useControl, ScaleControl } from 'react-map-gl/maplibre';
|
||||
import type { MapRef } from 'react-map-gl/maplibre';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import type {
|
||||
|
|
@ -12,21 +14,18 @@ import type {
|
|||
POI,
|
||||
FeatureMeta,
|
||||
Bounds,
|
||||
MapFlyToOptions,
|
||||
} from '../../types';
|
||||
|
||||
import {
|
||||
zoomToResolution,
|
||||
getBoundsFromViewState,
|
||||
getBoundsWithBottomScreenInset,
|
||||
getMapStyle,
|
||||
getPoiIconUrl,
|
||||
getMapCenterForTargetScreenPoint,
|
||||
} from '../../lib/map-utils';
|
||||
import {
|
||||
INITIAL_VIEW_STATE,
|
||||
MAP_MIN_ZOOM,
|
||||
MAP_BOUNDS,
|
||||
POI_GROUP_COLORS,
|
||||
POI_DEFAULT_COLOR,
|
||||
} from '../../lib/consts';
|
||||
import { MAP_MIN_ZOOM, MAP_BOUNDS, POI_GROUP_COLORS } from '../../lib/consts';
|
||||
import LocationSearch, { type SearchedLocation } from './LocationSearch';
|
||||
import MapLegend from './MapLegend';
|
||||
import HoverCard from './HoverCard';
|
||||
|
|
@ -48,13 +47,17 @@ interface MapProps {
|
|||
filterRange: [number, number] | null;
|
||||
viewSource: 'drag' | 'eye' | null;
|
||||
onCancelPin: () => void;
|
||||
onResetPreviewScale?: () => void;
|
||||
canResetPreviewScale?: boolean;
|
||||
features: FeatureMeta[];
|
||||
selectedHexagonId: string | null;
|
||||
hoveredHexagonId: string | null;
|
||||
onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void;
|
||||
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
|
||||
initialViewState?: ViewState;
|
||||
flyToRef?: React.MutableRefObject<((lat: number, lng: number, zoom: number) => void) | null>;
|
||||
initialViewState: ViewState;
|
||||
flyToRef?: React.MutableRefObject<
|
||||
((lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void) | null
|
||||
>;
|
||||
theme?: 'light' | 'dark';
|
||||
screenshotMode?: boolean;
|
||||
ogMode?: boolean;
|
||||
|
|
@ -65,9 +68,11 @@ interface MapProps {
|
|||
currentLocation?: { lat: number; lng: number } | null;
|
||||
bounds?: Bounds | null;
|
||||
hideLegend?: boolean;
|
||||
hideLocationSearch?: boolean;
|
||||
travelTimeEntries?: TravelTimeEntry[];
|
||||
densityLabel?: string;
|
||||
totalCount?: number;
|
||||
bottomScreenInset?: number;
|
||||
}
|
||||
|
||||
const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = [];
|
||||
|
|
@ -77,6 +82,132 @@ interface Dimensions {
|
|||
height: number;
|
||||
}
|
||||
|
||||
type MapContainerStyle = CSSProperties & {
|
||||
'--map-mobile-bottom-inset'?: string;
|
||||
};
|
||||
|
||||
function resolveInset(
|
||||
pixelValue: number | undefined,
|
||||
ratioValue: number | undefined,
|
||||
size: number
|
||||
) {
|
||||
return Math.max(0, (pixelValue ?? 0) + (ratioValue ?? 0) * size);
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function getMapRelativeVisibleAreaCenter(dimensions: Dimensions, options?: MapFlyToOptions) {
|
||||
const area = options?.visibleArea;
|
||||
const leftInset = resolveInset(area?.left, area?.leftRatio, dimensions.width);
|
||||
const rightInset = resolveInset(area?.right, area?.rightRatio, dimensions.width);
|
||||
const topInset = resolveInset(area?.top, area?.topRatio, dimensions.height);
|
||||
const bottomInset = resolveInset(area?.bottom, area?.bottomRatio, dimensions.height);
|
||||
|
||||
const left = Math.min(dimensions.width, leftInset);
|
||||
const right = Math.max(left, dimensions.width - Math.min(dimensions.width, rightInset));
|
||||
const top = Math.min(dimensions.height, topInset);
|
||||
const bottom = Math.max(top, dimensions.height - Math.min(dimensions.height, bottomInset));
|
||||
|
||||
return {
|
||||
x: (left + right) / 2,
|
||||
y: (top + bottom) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function getViewportRelativeVisibleAreaCenter(
|
||||
dimensions: Dimensions,
|
||||
container: HTMLDivElement | null,
|
||||
options?: MapFlyToOptions
|
||||
) {
|
||||
const area = options?.visibleViewportArea;
|
||||
if (!area || !container) return null;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const viewportLeft = resolveInset(area.left, area.leftRatio, viewportWidth);
|
||||
const viewportRight = viewportWidth - resolveInset(area.right, area.rightRatio, viewportWidth);
|
||||
const viewportTop = resolveInset(area.top, area.topRatio, viewportHeight);
|
||||
const viewportBottom =
|
||||
viewportHeight - resolveInset(area.bottom, area.bottomRatio, viewportHeight);
|
||||
|
||||
const left = clamp(viewportLeft - rect.left, 0, dimensions.width);
|
||||
const right = clamp(viewportRight - rect.left, left, dimensions.width);
|
||||
const top = clamp(viewportTop - rect.top, 0, dimensions.height);
|
||||
const bottom = clamp(viewportBottom - rect.top, top, dimensions.height);
|
||||
|
||||
return {
|
||||
x: (left + right) / 2,
|
||||
y: (top + bottom) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
interface DeckWithPrivateDraw {
|
||||
_drawLayers?: (
|
||||
redrawReason: string,
|
||||
renderOptions?: { viewports?: unknown[]; [key: string]: unknown }
|
||||
) => unknown;
|
||||
__propertyMapNullViewportPatch?: boolean;
|
||||
}
|
||||
|
||||
function patchNullViewportDraw(overlay: MapboxOverlay) {
|
||||
const deck = (overlay as unknown as { _deck?: DeckWithPrivateDraw })._deck;
|
||||
if (!deck || deck.__propertyMapNullViewportPatch || typeof deck._drawLayers !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const drawLayers = deck._drawLayers.bind(deck);
|
||||
deck._drawLayers = (redrawReason, renderOptions) => {
|
||||
const viewports = renderOptions?.viewports;
|
||||
if (viewports) {
|
||||
// Split-route startup can hand deck.gl a transient null viewport before MapLibre has sized the map.
|
||||
const nonNullViewports = viewports.filter(Boolean);
|
||||
if (nonNullViewports.length === 0) return;
|
||||
if (nonNullViewports.length !== viewports.length) {
|
||||
return drawLayers(redrawReason, { ...renderOptions, viewports: nonNullViewports });
|
||||
}
|
||||
}
|
||||
return drawLayers(redrawReason, renderOptions);
|
||||
};
|
||||
deck.__propertyMapNullViewportPatch = true;
|
||||
}
|
||||
|
||||
class SafeMapboxOverlay extends MapboxOverlay {
|
||||
onAdd(map: unknown) {
|
||||
const element = super.onAdd(map);
|
||||
patchNullViewportDraw(this);
|
||||
return element;
|
||||
}
|
||||
|
||||
setProps(props: Parameters<MapboxOverlay['setProps']>[0]) {
|
||||
super.setProps(props);
|
||||
patchNullViewportDraw(this);
|
||||
}
|
||||
}
|
||||
|
||||
function getPoiGroupColor(group: string): [number, number, number] {
|
||||
const color = POI_GROUP_COLORS[group];
|
||||
if (!color) {
|
||||
throw new Error(`Missing POI group color for '${group}'`);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
function getRenderedViewState(map: MapRef | null): ViewState | null {
|
||||
if (!map) return null;
|
||||
|
||||
const center = map.getCenter();
|
||||
return {
|
||||
longitude: center.lng,
|
||||
latitude: center.lat,
|
||||
zoom: map.getZoom(),
|
||||
pitch: map.getPitch(),
|
||||
bearing: map.getBearing(),
|
||||
};
|
||||
}
|
||||
|
||||
function DeckOverlay({
|
||||
layers,
|
||||
getTooltip,
|
||||
|
|
@ -86,10 +217,13 @@ function DeckOverlay({
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getTooltip: any;
|
||||
}) {
|
||||
const overlay = useControl(() => new MapboxOverlay({ interleaved: true }));
|
||||
const overlay = useControl(() => new SafeMapboxOverlay({ interleaved: true }));
|
||||
|
||||
useEffect(() => {
|
||||
overlay.setProps({ layers: layers.filter(Boolean), getTooltip });
|
||||
overlay.setProps({
|
||||
layers: layers.filter(Boolean),
|
||||
getTooltip,
|
||||
});
|
||||
}, [overlay, layers, getTooltip]);
|
||||
|
||||
return null;
|
||||
|
|
@ -106,6 +240,8 @@ export default memo(function Map({
|
|||
filterRange,
|
||||
viewSource,
|
||||
onCancelPin,
|
||||
onResetPreviewScale,
|
||||
canResetPreviewScale = false,
|
||||
features,
|
||||
selectedHexagonId,
|
||||
hoveredHexagonId,
|
||||
|
|
@ -123,21 +259,22 @@ export default memo(function Map({
|
|||
currentLocation,
|
||||
bounds: viewportBounds,
|
||||
hideLegend = false,
|
||||
hideLocationSearch = false,
|
||||
travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
|
||||
densityLabel: densityLabelProp,
|
||||
totalCount: totalCountProp,
|
||||
bottomScreenInset = 0,
|
||||
}: MapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<MapRef | null>(null);
|
||||
const { t } = useTranslation();
|
||||
const modes = useTranslatedModes();
|
||||
const densityLabel = densityLabelProp ?? t('mapLegend.numberOfProperties');
|
||||
const [internalViewState, setInternalViewState] = useState<ViewState>(
|
||||
initialViewState || INITIAL_VIEW_STATE
|
||||
);
|
||||
const [internalViewState, setInternalViewState] = useState<ViewState>(initialViewState);
|
||||
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
|
||||
|
||||
// In screenshot mode, use the prop directly for instant updates (no async lag)
|
||||
const viewState = screenshotMode && initialViewState ? initialViewState : internalViewState;
|
||||
const viewState = screenshotMode ? initialViewState : internalViewState;
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
|
|
@ -168,17 +305,33 @@ export default memo(function Map({
|
|||
useEffect(() => {
|
||||
if (dimensions.width === 0 || dimensions.height === 0) return;
|
||||
|
||||
const bounds = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
|
||||
const resolution = zoomToResolution(viewState.zoom);
|
||||
let frame = 0;
|
||||
const emit = () => {
|
||||
const renderedViewState = getRenderedViewState(mapRef.current);
|
||||
// mapRef can be null on the very first effect run if MapLibre hasn't
|
||||
// finished mounting; retry next frame so the initial bounds always reach
|
||||
// the data hook.
|
||||
if (!renderedViewState) {
|
||||
frame = window.requestAnimationFrame(emit);
|
||||
return;
|
||||
}
|
||||
// The bottom sheet can reveal covered map area without a pan/zoom event.
|
||||
const dataBoundsHeight = dimensions.height + Math.max(0, bottomScreenInset);
|
||||
const bounds = getBoundsFromViewState(renderedViewState, dimensions.width, dataBoundsHeight);
|
||||
const resolution = zoomToResolution(renderedViewState.zoom);
|
||||
|
||||
onViewChange({
|
||||
resolution,
|
||||
bounds,
|
||||
zoom: viewState.zoom,
|
||||
latitude: viewState.latitude,
|
||||
longitude: viewState.longitude,
|
||||
});
|
||||
}, [viewState, dimensions, onViewChange]);
|
||||
onViewChange({
|
||||
resolution,
|
||||
bounds,
|
||||
zoom: renderedViewState.zoom,
|
||||
latitude: renderedViewState.latitude,
|
||||
longitude: renderedViewState.longitude,
|
||||
});
|
||||
};
|
||||
frame = window.requestAnimationFrame(emit);
|
||||
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [viewState, dimensions, bottomScreenInset, onViewChange]);
|
||||
|
||||
const handleMove = useCallback((evt: { viewState: ViewState }) => {
|
||||
setInternalViewState((prev) => {
|
||||
|
|
@ -199,13 +352,43 @@ export default memo(function Map({
|
|||
});
|
||||
}, []);
|
||||
|
||||
const handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => {
|
||||
setInternalViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom }));
|
||||
}, []);
|
||||
const handleIdle = useCallback(() => {
|
||||
if (screenshotMode) window.__map_idle = true;
|
||||
}, [screenshotMode]);
|
||||
|
||||
const handleFlyTo = useCallback(
|
||||
(lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => {
|
||||
setInternalViewState((prev) => {
|
||||
const targetPoint =
|
||||
getViewportRelativeVisibleAreaCenter(dimensions, containerRef.current, options) ??
|
||||
getMapRelativeVisibleAreaCenter(dimensions, options);
|
||||
const center = getMapCenterForTargetScreenPoint(
|
||||
lat,
|
||||
lng,
|
||||
zoom,
|
||||
dimensions.width,
|
||||
dimensions.height,
|
||||
targetPoint.x,
|
||||
targetPoint.y
|
||||
);
|
||||
|
||||
return { ...prev, ...center, zoom };
|
||||
});
|
||||
},
|
||||
[dimensions]
|
||||
);
|
||||
|
||||
if (flyToRef) flyToRef.current = handleFlyTo;
|
||||
|
||||
const mapStyle = useMemo(() => getMapStyle(theme), [theme]);
|
||||
const maxBounds = useMemo(
|
||||
() => getBoundsWithBottomScreenInset(MAP_BOUNDS, MAP_MIN_ZOOM, bottomScreenInset),
|
||||
[bottomScreenInset]
|
||||
);
|
||||
const mapContainerStyle = useMemo<MapContainerStyle>(
|
||||
() => (bottomScreenInset > 0 ? { '--map-mobile-bottom-inset': `${bottomScreenInset}px` } : {}),
|
||||
[bottomScreenInset]
|
||||
);
|
||||
|
||||
const {
|
||||
layers,
|
||||
|
|
@ -238,18 +421,18 @@ export default memo(function Map({
|
|||
});
|
||||
|
||||
return (
|
||||
<div className="flex-1 h-full relative" ref={containerRef} onMouseLeave={handleMouseLeave}>
|
||||
<div
|
||||
className={`flex-1 h-full relative ${bottomScreenInset > 0 ? 'map-has-mobile-bottom-sheet' : ''}`}
|
||||
ref={containerRef}
|
||||
style={mapContainerStyle}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<MapGL
|
||||
ref={mapRef}
|
||||
{...viewState}
|
||||
onMove={handleMove}
|
||||
onLoad={undefined}
|
||||
onIdle={
|
||||
screenshotMode
|
||||
? () => {
|
||||
window.__map_idle = true;
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onIdle={handleIdle}
|
||||
mapStyle={mapStyle}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
attributionControl={false}
|
||||
|
|
@ -259,7 +442,7 @@ export default memo(function Map({
|
|||
keyboard={true}
|
||||
pitchWithRotate={false}
|
||||
minZoom={MAP_MIN_ZOOM}
|
||||
maxBounds={MAP_BOUNDS}
|
||||
maxBounds={maxBounds}
|
||||
>
|
||||
<DeckOverlay layers={layers} getTooltip={null} />
|
||||
{!screenshotMode && <ScaleControl position="bottom-left" maxWidth={100} unit="metric" />}
|
||||
|
|
@ -311,13 +494,15 @@ export default memo(function Map({
|
|||
) : null
|
||||
) : (
|
||||
<>
|
||||
<div className="absolute top-3 left-3 right-3 z-10 flex flex-wrap items-start justify-between gap-2 pointer-events-none">
|
||||
<LocationSearch
|
||||
onFlyTo={handleFlyTo}
|
||||
onLocationSearched={onLocationSearched}
|
||||
onCurrentLocationFound={onCurrentLocationFound}
|
||||
onMouseEnter={handleMouseLeave}
|
||||
/>
|
||||
<div className="absolute top-3 left-3 right-3 z-20 flex flex-wrap items-start justify-between gap-2 pointer-events-none">
|
||||
{!hideLocationSearch && (
|
||||
<LocationSearch
|
||||
onFlyTo={handleFlyTo}
|
||||
onLocationSearched={onLocationSearched}
|
||||
onCurrentLocationFound={onCurrentLocationFound}
|
||||
onMouseEnter={handleMouseLeave}
|
||||
/>
|
||||
)}
|
||||
{!hideLegend &&
|
||||
(viewFeature && colorRange ? (
|
||||
viewFeature.startsWith('tt_') ? (
|
||||
|
|
@ -330,6 +515,8 @@ export default memo(function Map({
|
|||
range={colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={onCancelPin}
|
||||
onResetScale={viewSource === 'eye' ? onResetPreviewScale : undefined}
|
||||
resetScaleDisabled={!canResetPreviewScale}
|
||||
mode="feature"
|
||||
theme={theme}
|
||||
suffix=" min"
|
||||
|
|
@ -344,12 +531,15 @@ export default memo(function Map({
|
|||
range={colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={onCancelPin}
|
||||
onResetScale={viewSource === 'eye' ? onResetPreviewScale : undefined}
|
||||
resetScaleDisabled={!canResetPreviewScale}
|
||||
mode="feature"
|
||||
enumValues={
|
||||
colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined
|
||||
}
|
||||
featureName={colorFeatureMeta.name}
|
||||
theme={theme}
|
||||
suffix={colorFeatureMeta.suffix}
|
||||
raw={colorFeatureMeta.raw}
|
||||
/>
|
||||
) : null
|
||||
|
|
@ -399,7 +589,12 @@ export default memo(function Map({
|
|||
<div className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={getPoiIconUrl(popupInfo.category, popupInfo.emoji)}
|
||||
src={getPoiIconUrl(
|
||||
popupInfo.category,
|
||||
popupInfo.emoji,
|
||||
popupInfo.icon_category,
|
||||
popupInfo.name
|
||||
)}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
|
|
@ -412,7 +607,7 @@ export default memo(function Map({
|
|||
<span
|
||||
className="inline-block w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: `rgb(${(POI_GROUP_COLORS[popupInfo.group] || POI_DEFAULT_COLOR).join(',')})`,
|
||||
backgroundColor: `rgb(${getPoiGroupColor(popupInfo.group).join(',')})`,
|
||||
}}
|
||||
/>
|
||||
{popupInfo.category}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,39 @@ import { gradientToCss } from '../../lib/utils';
|
|||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import { TickerValue } from '../ui/TickerValue';
|
||||
|
||||
function ResetScaleIcon({ className = 'w-4 h-4' }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12a9 9 0 0115.13-6.36L21 8" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 3v5h-5" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 01-15.13 6.36L3 16" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 21v-5h5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function requireFeatureName(featureName: string | undefined): string {
|
||||
if (!featureName) {
|
||||
throw new Error('Enum legend requested without a feature name');
|
||||
}
|
||||
return featureName;
|
||||
}
|
||||
|
||||
function requireEnumPalette(
|
||||
palette: [number, number, number][] | null
|
||||
): [number, number, number][] {
|
||||
if (!palette) {
|
||||
throw new Error('Enum legend requested without a palette');
|
||||
}
|
||||
return palette;
|
||||
}
|
||||
|
||||
function EnumSwatches({
|
||||
values,
|
||||
palette,
|
||||
|
|
@ -76,6 +109,8 @@ export default function MapLegend({
|
|||
suffix,
|
||||
raw,
|
||||
totalCount,
|
||||
onResetScale,
|
||||
resetScaleDisabled = false,
|
||||
}: {
|
||||
featureLabel: string;
|
||||
range: [number, number];
|
||||
|
|
@ -89,10 +124,15 @@ export default function MapLegend({
|
|||
suffix?: string;
|
||||
raw?: boolean;
|
||||
totalCount?: number;
|
||||
onResetScale?: () => void;
|
||||
resetScaleDisabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isEnum = enumValues && enumValues.length > 0;
|
||||
const enumPalette = getEnumPaletteForFeature(featureName ?? null, enumValues);
|
||||
const showResetScale = Boolean(onResetScale) && !isEnum;
|
||||
const enumPalette = isEnum
|
||||
? getEnumPaletteForFeature(requireFeatureName(featureName), enumValues)
|
||||
: null;
|
||||
const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
|
||||
const gradientStyle =
|
||||
mode === 'density'
|
||||
|
|
@ -121,17 +161,29 @@ export default function MapLegend({
|
|||
<span className="font-semibold text-xs text-warm-800 dark:text-warm-200 truncate">
|
||||
{featureLabel}
|
||||
</span>
|
||||
{showResetScale && (
|
||||
<button
|
||||
onClick={onResetScale}
|
||||
disabled={resetScaleDisabled}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 disabled:opacity-40 disabled:hover:text-warm-400 dark:disabled:hover:text-warm-400 shrink-0"
|
||||
title={t('mapLegend.resetColourScale')}
|
||||
aria-label={t('mapLegend.resetColourScale')}
|
||||
>
|
||||
<ResetScaleIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{showCancel && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
|
||||
title={t('mapLegend.clearColourView')}
|
||||
aria-label={t('mapLegend.clearColourView')}
|
||||
>
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{isEnum ? (
|
||||
<InlineEnumSwatches values={enumValues} palette={enumPalette} />
|
||||
<InlineEnumSwatches values={enumValues} palette={requireEnumPalette(enumPalette)} />
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-[40%] text-warm-500 dark:text-warm-400">
|
||||
{rangeMin}
|
||||
|
|
@ -149,19 +201,37 @@ export default function MapLegend({
|
|||
return (
|
||||
<div className="bg-white dark:bg-navy-800 dark:text-white rounded shadow-lg p-3 text-xs min-w-[300px] pointer-events-auto">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold text-sm dark:text-white">{featureLabel}</span>
|
||||
{showCancel && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 ml-2"
|
||||
title={t('mapLegend.clearColourView')}
|
||||
>
|
||||
<CloseIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="font-semibold text-sm dark:text-white min-w-0 truncate">
|
||||
{featureLabel}
|
||||
</span>
|
||||
{(showResetScale || showCancel) && (
|
||||
<div className="flex items-center gap-1.5 ml-2 shrink-0">
|
||||
{showResetScale && (
|
||||
<button
|
||||
onClick={onResetScale}
|
||||
disabled={resetScaleDisabled}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 disabled:opacity-40 disabled:hover:text-warm-400 dark:disabled:hover:text-warm-400"
|
||||
title={t('mapLegend.resetColourScale')}
|
||||
aria-label={t('mapLegend.resetColourScale')}
|
||||
>
|
||||
<ResetScaleIcon className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{showCancel && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||
title={t('mapLegend.clearColourView')}
|
||||
aria-label={t('mapLegend.clearColourView')}
|
||||
>
|
||||
<CloseIcon className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isEnum ? (
|
||||
<EnumSwatches values={enumValues} palette={enumPalette} />
|
||||
<EnumSwatches values={enumValues} palette={requireEnumPalette(enumPalette)} />
|
||||
) : (
|
||||
<>
|
||||
<div className="h-3 rounded" style={{ background: gradientStyle }} />
|
||||
|
|
|
|||
157
frontend/src/components/map/MobileBottomSheet.test.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import MobileBottomSheet from './MobileBottomSheet';
|
||||
|
||||
class FakeVisualViewport extends EventTarget {
|
||||
height: number;
|
||||
width = 390;
|
||||
offsetTop = 0;
|
||||
offsetLeft = 0;
|
||||
pageTop = 0;
|
||||
pageLeft = 0;
|
||||
scale = 1;
|
||||
onresize: ((this: VisualViewport, ev: Event) => unknown) | null = null;
|
||||
onscroll: ((this: VisualViewport, ev: Event) => unknown) | null = null;
|
||||
|
||||
constructor(height: number) {
|
||||
super();
|
||||
this.height = height;
|
||||
}
|
||||
}
|
||||
|
||||
const originalInnerHeight = window.innerHeight;
|
||||
const originalVisualViewport = window.visualViewport;
|
||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||
const originalSetPointerCapture = HTMLElement.prototype.setPointerCapture;
|
||||
|
||||
function installViewport({
|
||||
innerHeight,
|
||||
visualHeight,
|
||||
}: {
|
||||
innerHeight: number;
|
||||
visualHeight: number;
|
||||
}) {
|
||||
const visualViewport = new FakeVisualViewport(visualHeight);
|
||||
|
||||
Object.defineProperty(window, 'innerHeight', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: innerHeight,
|
||||
});
|
||||
Object.defineProperty(window, 'visualViewport', {
|
||||
configurable: true,
|
||||
value: visualViewport as unknown as VisualViewport,
|
||||
});
|
||||
|
||||
return visualViewport;
|
||||
}
|
||||
|
||||
function renderSheet() {
|
||||
const coveredHeights: number[] = [];
|
||||
const view = render(
|
||||
<MobileBottomSheet onCoveredHeightChange={(height) => coveredHeights.push(height)}>
|
||||
<label>
|
||||
Name
|
||||
<input aria-label="Name" />
|
||||
</label>
|
||||
<button type="button">Apply</button>
|
||||
</MobileBottomSheet>
|
||||
);
|
||||
const sheet = view.container.querySelector('section');
|
||||
if (!(sheet instanceof HTMLElement)) throw new Error('Expected bottom sheet section');
|
||||
|
||||
return { ...view, coveredHeights, sheet };
|
||||
}
|
||||
|
||||
describe('MobileBottomSheet keyboard avoidance', () => {
|
||||
beforeEach(() => {
|
||||
HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||
HTMLElement.prototype.setPointerCapture = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
Object.defineProperty(window, 'innerHeight', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalInnerHeight,
|
||||
});
|
||||
Object.defineProperty(window, 'visualViewport', {
|
||||
configurable: true,
|
||||
value: originalVisualViewport,
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: originalScrollIntoView,
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', {
|
||||
configurable: true,
|
||||
value: originalSetPointerCapture,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores visual viewport keyboard inset until a sheet text field is focused', () => {
|
||||
const visualViewport = installViewport({ innerHeight: 800, visualHeight: 500 });
|
||||
const { sheet } = renderSheet();
|
||||
|
||||
expect(sheet.style.bottom).toBe('0px');
|
||||
|
||||
act(() => {
|
||||
visualViewport.dispatchEvent(new Event('resize'));
|
||||
});
|
||||
|
||||
expect(sheet.style.bottom).toBe('0px');
|
||||
});
|
||||
|
||||
it('clears keyboard offset when focus leaves even if visual viewport is stale', async () => {
|
||||
installViewport({ innerHeight: 800, visualHeight: 500 });
|
||||
const { sheet } = renderSheet();
|
||||
const input = screen.getByLabelText('Name');
|
||||
|
||||
await act(async () => {
|
||||
input.focus();
|
||||
});
|
||||
expect(sheet.style.bottom).toBe('300px');
|
||||
|
||||
await act(async () => {
|
||||
input.blur();
|
||||
});
|
||||
expect(sheet.style.bottom).toBe('0px');
|
||||
});
|
||||
|
||||
it('leaves keyboard avoidance mode when tapping non-editable sheet content', async () => {
|
||||
installViewport({ innerHeight: 800, visualHeight: 500 });
|
||||
const { sheet } = renderSheet();
|
||||
const input = screen.getByLabelText('Name');
|
||||
|
||||
await act(async () => {
|
||||
input.focus();
|
||||
});
|
||||
expect(sheet.style.bottom).toBe('300px');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.pointerDown(screen.getByRole('button', { name: 'Apply' }));
|
||||
});
|
||||
|
||||
expect(document.activeElement).not.toBe(input);
|
||||
expect(sheet.style.bottom).toBe('0px');
|
||||
});
|
||||
|
||||
it('reports covered height while the drawer is being dragged', async () => {
|
||||
installViewport({ innerHeight: 800, visualHeight: 800 });
|
||||
const { coveredHeights, sheet } = renderSheet();
|
||||
const handle = sheet.firstElementChild;
|
||||
|
||||
if (!(handle instanceof HTMLElement)) throw new Error('Expected bottom sheet drag handle');
|
||||
|
||||
expect(coveredHeights[coveredHeights.length - 1]).toBe(352);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.pointerDown(handle, { pointerId: 1, clientY: 500 });
|
||||
fireEvent.pointerMove(handle, { pointerId: 1, clientY: 400 });
|
||||
});
|
||||
|
||||
expect(coveredHeights[coveredHeights.length - 1]).toBe(452);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface VisualViewportState {
|
||||
height: number;
|
||||
|
|
@ -8,12 +7,19 @@ interface VisualViewportState {
|
|||
}
|
||||
|
||||
interface MobileBottomSheetProps {
|
||||
activeCount: number;
|
||||
children: ReactNode;
|
||||
legend?: ReactNode;
|
||||
onCoveredHeightChange?: (height: number) => void;
|
||||
}
|
||||
|
||||
function getVisualViewportState(): VisualViewportState {
|
||||
function getVisualViewportState(avoidKeyboard: boolean): VisualViewportState {
|
||||
if (!avoidKeyboard) {
|
||||
return {
|
||||
height: window.innerHeight,
|
||||
bottomInset: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) {
|
||||
return {
|
||||
|
|
@ -29,25 +35,36 @@ function getVisualViewportState(): VisualViewportState {
|
|||
};
|
||||
}
|
||||
|
||||
function useVisualViewportState(): VisualViewportState {
|
||||
const [state, setState] = useState(getVisualViewportState);
|
||||
function useVisualViewportState(avoidKeyboard: boolean): VisualViewportState {
|
||||
const [state, setState] = useState(() => getVisualViewportState(avoidKeyboard));
|
||||
|
||||
useEffect(() => {
|
||||
const vv = window.visualViewport;
|
||||
const update = () => setState(getVisualViewportState());
|
||||
const update = () => {
|
||||
const next = getVisualViewportState(avoidKeyboard);
|
||||
setState((prev) =>
|
||||
prev.height === next.height && prev.bottomInset === next.bottomInset ? prev : next
|
||||
);
|
||||
};
|
||||
|
||||
update();
|
||||
|
||||
window.addEventListener('resize', update);
|
||||
window.addEventListener('orientationchange', update);
|
||||
vv?.addEventListener('resize', update);
|
||||
vv?.addEventListener('scroll', update);
|
||||
if (avoidKeyboard) {
|
||||
vv?.addEventListener('resize', update);
|
||||
vv?.addEventListener('scroll', update);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', update);
|
||||
window.removeEventListener('orientationchange', update);
|
||||
vv?.removeEventListener('resize', update);
|
||||
vv?.removeEventListener('scroll', update);
|
||||
if (avoidKeyboard) {
|
||||
vv?.removeEventListener('resize', update);
|
||||
vv?.removeEventListener('scroll', update);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [avoidKeyboard]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
|
@ -56,17 +73,46 @@ function clamp(value: number, min: number, max: number): number {
|
|||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function isKeyboardEditableElement(element: HTMLElement): boolean {
|
||||
if (element instanceof HTMLTextAreaElement) return true;
|
||||
if (element instanceof HTMLInputElement) {
|
||||
return ![
|
||||
'button',
|
||||
'checkbox',
|
||||
'color',
|
||||
'file',
|
||||
'hidden',
|
||||
'image',
|
||||
'radio',
|
||||
'range',
|
||||
'reset',
|
||||
'submit',
|
||||
].includes(element.type);
|
||||
}
|
||||
return element.isContentEditable;
|
||||
}
|
||||
|
||||
function getKeyboardEditableElement(target: EventTarget | null): HTMLElement | null {
|
||||
if (!(target instanceof Element)) return null;
|
||||
|
||||
const element = target.closest('input, textarea, [contenteditable]');
|
||||
if (!(element instanceof HTMLElement)) return null;
|
||||
|
||||
return isKeyboardEditableElement(element) ? element : null;
|
||||
}
|
||||
|
||||
export default function MobileBottomSheet({
|
||||
activeCount,
|
||||
children,
|
||||
legend,
|
||||
onCoveredHeightChange,
|
||||
}: MobileBottomSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const viewport = useVisualViewportState();
|
||||
const [keyboardAvoidanceActive, setKeyboardAvoidanceActive] = useState(false);
|
||||
const viewport = useVisualViewportState(keyboardAvoidanceActive);
|
||||
const sheetRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const dragStartYRef = useRef(0);
|
||||
const dragStartHeightRef = useRef(0);
|
||||
const scrollIntoViewTimerRef = useRef<number | null>(null);
|
||||
const [height, setHeight] = useState<number | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
|
|
@ -87,6 +133,10 @@ export default function MobileBottomSheet({
|
|||
);
|
||||
}, [heightBounds]);
|
||||
|
||||
useEffect(() => {
|
||||
onCoveredHeightChange?.(Math.round(currentHeight + viewport.bottomInset));
|
||||
}, [currentHeight, onCoveredHeightChange, viewport.bottomInset]);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -113,32 +163,61 @@ export default function MobileBottomSheet({
|
|||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleSheetPointerDown = useCallback((event: React.PointerEvent) => {
|
||||
if (getKeyboardEditableElement(event.target)) return;
|
||||
|
||||
const activeElement = document.activeElement;
|
||||
if (
|
||||
activeElement instanceof HTMLElement &&
|
||||
sheetRef.current?.contains(activeElement) &&
|
||||
isKeyboardEditableElement(activeElement)
|
||||
) {
|
||||
activeElement.blur();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const sheet = sheetRef.current;
|
||||
if (!sheet) return;
|
||||
|
||||
const handleFocusIn = (event: FocusEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
if (!target.matches('input, textarea, select, [contenteditable="true"]')) return;
|
||||
const target = getKeyboardEditableElement(event.target);
|
||||
if (!target || !sheet.contains(target)) return;
|
||||
|
||||
setKeyboardAvoidanceActive(true);
|
||||
const keyboardMinHeight = Math.min(heightBounds.max, Math.max(300, viewport.height * 0.55));
|
||||
setHeight((value) => Math.max(value ?? heightBounds.initial, keyboardMinHeight));
|
||||
window.setTimeout(() => {
|
||||
if (scrollIntoViewTimerRef.current != null) {
|
||||
window.clearTimeout(scrollIntoViewTimerRef.current);
|
||||
}
|
||||
scrollIntoViewTimerRef.current = window.setTimeout(() => {
|
||||
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}, 120);
|
||||
};
|
||||
|
||||
sheet.addEventListener('focusin', handleFocusIn);
|
||||
return () => sheet.removeEventListener('focusin', handleFocusIn);
|
||||
}, [heightBounds.initial, heightBounds.max, viewport.height]);
|
||||
const handleFocusOut = (event: FocusEvent) => {
|
||||
const nextTarget = getKeyboardEditableElement(event.relatedTarget);
|
||||
if (nextTarget && sheet.contains(nextTarget)) return;
|
||||
|
||||
const sheetTitle = activeCount === 0 ? t('filters.chooseFilters') : t('filters.activeFilters');
|
||||
setKeyboardAvoidanceActive(false);
|
||||
};
|
||||
|
||||
sheet.addEventListener('focusin', handleFocusIn);
|
||||
sheet.addEventListener('focusout', handleFocusOut);
|
||||
return () => {
|
||||
sheet.removeEventListener('focusin', handleFocusIn);
|
||||
sheet.removeEventListener('focusout', handleFocusOut);
|
||||
if (scrollIntoViewTimerRef.current != null) {
|
||||
window.clearTimeout(scrollIntoViewTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [heightBounds.initial, heightBounds.max, viewport.height]);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sheetRef}
|
||||
className="fixed inset-x-0 z-30 flex flex-col rounded-t-2xl bg-white dark:bg-navy-950 shadow-2xl border-t border-warm-200 dark:border-navy-700 overflow-hidden"
|
||||
onPointerDownCapture={handleSheetPointerDown}
|
||||
style={{
|
||||
bottom: viewport.bottomInset,
|
||||
height: currentHeight,
|
||||
|
|
@ -148,29 +227,16 @@ export default function MobileBottomSheet({
|
|||
? undefined
|
||||
: 'height 140ms ease, bottom 180ms ease',
|
||||
}}
|
||||
aria-label={sheetTitle}
|
||||
>
|
||||
<div
|
||||
className="shrink-0 touch-none px-4 pt-2 pb-1"
|
||||
className="shrink-0 touch-none px-4 py-2"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
>
|
||||
<div className="w-full flex flex-col items-center gap-2" role="presentation">
|
||||
<div className="w-full flex items-center justify-center" role="presentation">
|
||||
<span className="h-1.5 w-12 rounded-full bg-warm-300 dark:bg-navy-600" />
|
||||
<span className="w-full flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100 truncate">
|
||||
{sheetTitle}
|
||||
</span>
|
||||
{activeCount > 0 && (
|
||||
<span className="text-xs font-medium px-1.5 py-0.5 rounded-full bg-teal-50 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
|
||||
{activeCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import { TabButton } from '../ui/TabButton';
|
||||
|
|
@ -9,6 +9,7 @@ interface MobileDrawerProps {
|
|||
renderProperties: () => React.ReactNode;
|
||||
tab: 'area' | 'properties';
|
||||
onTabChange: (tab: 'area' | 'properties') => void;
|
||||
onPanelRectChange?: (rect: DOMRectReadOnly) => void;
|
||||
}
|
||||
|
||||
export default function MobileDrawer({
|
||||
|
|
@ -17,8 +18,30 @@ export default function MobileDrawer({
|
|||
renderProperties,
|
||||
tab,
|
||||
onTabChange,
|
||||
onPanelRectChange,
|
||||
}: MobileDrawerProps) {
|
||||
const { t } = useTranslation();
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const panel = panelRef.current;
|
||||
if (!panel || !onPanelRectChange) return;
|
||||
|
||||
const reportRect = () => onPanelRectChange(panel.getBoundingClientRect());
|
||||
reportRect();
|
||||
|
||||
const observer = new ResizeObserver(reportRect);
|
||||
observer.observe(panel);
|
||||
window.addEventListener('resize', reportRect);
|
||||
window.visualViewport?.addEventListener('resize', reportRect);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener('resize', reportRect);
|
||||
window.visualViewport?.removeEventListener('resize', reportRect);
|
||||
};
|
||||
}, [onPanelRectChange]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
|
|
@ -34,7 +57,10 @@ export default function MobileDrawer({
|
|||
<div className="h-[10%] bg-black/50" onClick={onClose} />
|
||||
|
||||
{/* Panel — bottom 90% */}
|
||||
<div className="h-[90%] bg-white dark:bg-navy-950 rounded-t-2xl flex flex-col shadow-xl overflow-hidden">
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="h-[90%] bg-white dark:bg-navy-950 rounded-t-2xl flex flex-col shadow-xl overflow-hidden"
|
||||
>
|
||||
{/* Tab bar + close */}
|
||||
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm shrink-0">
|
||||
<TabButton
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ export default function POIPane({
|
|||
const selectedCount = selectedCategories.size;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white dark:bg-warm-900 shadow-lg overflow-hidden">
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-white shadow-lg dark:bg-warm-900">
|
||||
<div className="flex-shrink-0 px-3 pt-3 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide">
|
||||
|
|
@ -150,7 +150,7 @@ export default function POIPane({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto border-t border-warm-200 dark:border-warm-700">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain border-t border-warm-200 dark:border-warm-700">
|
||||
<div className="px-3 pt-2 pb-1">
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
|
|
|
|||
29
frontend/src/components/map/PriceHistoryChart.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getPriceScale } from './PriceHistoryChart';
|
||||
|
||||
describe('PriceHistoryChart scale', () => {
|
||||
it('uses a high percentile ceiling instead of the absolute max', () => {
|
||||
const scale = getPriceScale([
|
||||
...Array.from({ length: 19 }, (_, i) => ({
|
||||
year: 2000 + i,
|
||||
price: 3_000_000 + i * 10_000,
|
||||
})),
|
||||
{ year: 2025, price: 10_000_000 },
|
||||
]);
|
||||
|
||||
expect(scale.max).toBeGreaterThan(3_000_000);
|
||||
expect(scale.max).toBeLessThan(10_000_000);
|
||||
expect(Math.max(...scale.ticks)).toBe(scale.max);
|
||||
});
|
||||
|
||||
it('keeps single-value data visible with a padded domain', () => {
|
||||
const scale = getPriceScale([
|
||||
{ year: 2020, price: 2_500_000 },
|
||||
{ year: 2021, price: 2_500_000 },
|
||||
]);
|
||||
|
||||
expect(scale.min).toBeLessThan(2_500_000);
|
||||
expect(scale.max).toBeGreaterThan(2_500_000);
|
||||
});
|
||||
});
|
||||
|
|
@ -6,10 +6,17 @@ interface PriceHistoryChartProps {
|
|||
points: PricePoint[];
|
||||
}
|
||||
|
||||
const PADDING = { top: 8, right: 8, bottom: 20, left: 42 };
|
||||
const PADDING = { top: 8, right: 24, bottom: 20, left: 42 };
|
||||
const HEIGHT = 120;
|
||||
const PRICE_SCALE_TOP_PERCENTILE = 95;
|
||||
const priceFmt = { prefix: '£' };
|
||||
|
||||
interface PriceScale {
|
||||
min: number;
|
||||
max: number;
|
||||
ticks: number[];
|
||||
}
|
||||
|
||||
export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
|
|
@ -25,7 +32,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const { yearMin, yearMax, priceMin, priceMax, medians, priceTicks } = useMemo(() => {
|
||||
const { yearMin, yearMax, priceScale, medians } = useMemo(() => {
|
||||
let yMin = Infinity,
|
||||
yMax = -Infinity;
|
||||
for (const p of points) {
|
||||
|
|
@ -33,14 +40,6 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
if (p.year > yMax) yMax = p.year;
|
||||
}
|
||||
|
||||
// Use p5/p95 to clip outliers
|
||||
const sorted = points.map((p) => p.price).sort((a, b) => a - b);
|
||||
const p5 = sorted[Math.floor(sorted.length * 0.05)];
|
||||
const p95 = sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95))];
|
||||
const pRange = p95 - p5 || 1;
|
||||
const pMin = Math.max(0, p5 - pRange * 0.1);
|
||||
const pMax = p95 + pRange * 0.1;
|
||||
|
||||
// Yearly medians (robust to outliers)
|
||||
const byYear = new Map<number, number[]>();
|
||||
for (const p of points) {
|
||||
|
|
@ -73,15 +72,11 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
return { year: pt.year + 0.5, price: sum / count };
|
||||
});
|
||||
|
||||
const ticks = niceTicksForRange(pMin, pMax, 4);
|
||||
|
||||
return {
|
||||
yearMin: yMin,
|
||||
yearMax: yMax,
|
||||
priceMin: pMin,
|
||||
priceMax: pMax,
|
||||
priceScale: getPriceScale(points),
|
||||
medians: meds,
|
||||
priceTicks: ticks,
|
||||
};
|
||||
}, [points]);
|
||||
|
||||
|
|
@ -91,8 +86,8 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
|
||||
const scaleX = (year: number) => PADDING.left + ((year - yearMin) / yearRange) * plotW;
|
||||
const scaleY = (price: number) => {
|
||||
const t = (price - priceMin) / (priceMax - priceMin || 1);
|
||||
return PADDING.top + (1 - Math.max(0, Math.min(1, t))) * plotH;
|
||||
const t = (price - priceScale.min) / (priceScale.max - priceScale.min || 1);
|
||||
return PADDING.top + (1 - t) * plotH;
|
||||
};
|
||||
|
||||
// Year labels: every 5 years
|
||||
|
|
@ -107,7 +102,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
{width > 0 && (
|
||||
<svg width={width} height={HEIGHT}>
|
||||
{/* Grid lines */}
|
||||
{priceTicks.map((tick) => (
|
||||
{priceScale.ticks.map((tick) => (
|
||||
<line
|
||||
key={tick}
|
||||
x1={PADDING.left}
|
||||
|
|
@ -119,7 +114,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
/>
|
||||
))}
|
||||
|
||||
{/* Dots (clamp outliers to visible range) */}
|
||||
{/* Dots */}
|
||||
{points.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
|
|
@ -143,7 +138,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
)}
|
||||
|
||||
{/* Y-axis labels */}
|
||||
{priceTicks.map((tick) => (
|
||||
{priceScale.ticks.map((tick) => (
|
||||
<text
|
||||
key={`label-${tick}`}
|
||||
x={PADDING.left - 4}
|
||||
|
|
@ -176,6 +171,40 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
);
|
||||
}
|
||||
|
||||
export function getPriceScale(points: PricePoint[]): PriceScale {
|
||||
const prices = points
|
||||
.map((p) => p.price)
|
||||
.filter(Number.isFinite)
|
||||
.sort((a, b) => a - b);
|
||||
if (prices.length === 0) {
|
||||
return { min: 0, max: 1, ticks: [0, 1] };
|
||||
}
|
||||
|
||||
const min = prices[0];
|
||||
const scaleTop = percentile(prices, PRICE_SCALE_TOP_PERCENTILE);
|
||||
const range = scaleTop - min;
|
||||
const padding = range > 0 ? range * 0.1 : Math.max(Math.abs(scaleTop) * 0.1, 1);
|
||||
const paddedMin = Math.max(0, min - padding);
|
||||
const paddedMax = scaleTop + padding;
|
||||
const ticks = niceTicksForRange(paddedMin, paddedMax, 4);
|
||||
|
||||
return {
|
||||
min: ticks[0] ?? paddedMin,
|
||||
max: ticks[ticks.length - 1] ?? paddedMax,
|
||||
ticks,
|
||||
};
|
||||
}
|
||||
|
||||
function percentile(sorted: number[], p: number): number {
|
||||
const clamped = Math.max(0, Math.min(100, p));
|
||||
const rank = ((sorted.length - 1) * clamped) / 100;
|
||||
const lower = Math.floor(rank);
|
||||
const upper = Math.ceil(rank);
|
||||
if (lower === upper) return sorted[lower];
|
||||
const weight = rank - lower;
|
||||
return sorted[lower] * (1 - weight) + sorted[upper] * weight;
|
||||
}
|
||||
|
||||
/** Generate ~count nice round tick values spanning [min, max]. */
|
||||
function niceTicksForRange(min: number, max: number, count: number): number[] {
|
||||
const range = max - min;
|
||||
|
|
@ -189,10 +218,17 @@ function niceTicksForRange(min: number, max: number, count: number): number[] {
|
|||
else if (normalized <= 7.5) step = 5 * magnitude;
|
||||
else step = 10 * magnitude;
|
||||
|
||||
const start =
|
||||
min >= 0 ? Math.max(0, Math.floor(min / step) * step) : Math.floor(min / step) * step;
|
||||
const end = Math.ceil(max / step) * step;
|
||||
|
||||
const ticks: number[] = [];
|
||||
const start = Math.ceil(min / step) * step;
|
||||
for (let t = start; t <= max; t += step) {
|
||||
ticks.push(t);
|
||||
for (let t = start; t <= end + step / 2; t += step) {
|
||||
ticks.push(cleanTick(t));
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
function cleanTick(value: number): number {
|
||||
return Number(value.toPrecision(12));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { useMemo } from 'react';
|
||||
import { SEGMENT_COLORS } from '../../lib/consts';
|
||||
import { formatValue, roundedPercentages } from '../../lib/format';
|
||||
|
||||
interface Segment {
|
||||
|
|
@ -10,8 +9,7 @@ interface Segment {
|
|||
interface StackedBarChartProps {
|
||||
segments: Segment[];
|
||||
total: number;
|
||||
/** Optional custom colors keyed by segment name. Falls back to SEGMENT_COLORS. */
|
||||
colorMap?: Record<string, string>;
|
||||
colorMap: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Strip common suffixes/prefixes to produce short legend labels */
|
||||
|
|
@ -44,6 +42,14 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
|
|||
return <div className="text-xs text-warm-400 dark:text-warm-500 italic">No data</div>;
|
||||
}
|
||||
|
||||
const colorFor = (segmentName: string): string => {
|
||||
const color = colorMap[segmentName];
|
||||
if (!color) {
|
||||
throw new Error(`Missing stacked bar color for '${segmentName}'`);
|
||||
}
|
||||
return color;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{/* Stacked bar */}
|
||||
|
|
@ -57,8 +63,7 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
|
|||
className="h-full"
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
backgroundColor:
|
||||
colorMap?.[segment.name] ?? SEGMENT_COLORS[i % SEGMENT_COLORS.length],
|
||||
backgroundColor: colorFor(segment.name),
|
||||
}}
|
||||
title={`${shortenLabel(segment.name)}: ${formatValue(segment.value)} (${roundedPcts[i].toFixed(1)}%)`}
|
||||
/>
|
||||
|
|
@ -68,13 +73,12 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
|
|||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5">
|
||||
{sortedSegments.map((segment, i) => (
|
||||
{sortedSegments.map((segment) => (
|
||||
<div key={segment.name} className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-2 h-2 rounded-sm shrink-0"
|
||||
style={{
|
||||
backgroundColor:
|
||||
colorMap?.[segment.name] ?? SEGMENT_COLORS[i % SEGMENT_COLORS.length],
|
||||
backgroundColor: colorFor(segment.name),
|
||||
}}
|
||||
/>
|
||||
<span className="text-[10px] text-warm-600 dark:text-warm-400">
|
||||
|
|
|
|||
|
|
@ -86,14 +86,14 @@ export function TravelTimeCard({
|
|||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<IconButton onClick={() => setShowInfo(true)} title={t('filters.featureInfo')}>
|
||||
<IconButton onClick={() => setShowInfo(true)} title={t('filters.aboutData')}>
|
||||
<InfoIcon className="w-3.5 h-3.5" />
|
||||
</IconButton>
|
||||
{slug && (
|
||||
<IconButton
|
||||
onClick={onTogglePin}
|
||||
active={isPinned || isActive}
|
||||
title={isPinned ? t('travel.stopPreviewing') : t('travel.previewOnMap')}
|
||||
title={isPinned ? t('filters.clearColourMap') : t('filters.colourMap')}
|
||||
>
|
||||
<EyeIcon className="w-3.5 h-3.5" filled={isPinned || isActive} />
|
||||
</IconButton>
|
||||
|
|
|
|||
253
frontend/src/components/map/filters/ActiveFilterList.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import { Fragment } from 'react';
|
||||
|
||||
import type { FeatureFilters, FeatureMeta } from '../../../types';
|
||||
import type { PercentileScale } from '../../../lib/format';
|
||||
import { getSpecificCrimeFeatureName, isSpecificCrimeFilterName } from '../../../lib/crime-filter';
|
||||
import { getEthnicityFeatureName, isEthnicityFilterName } from '../../../lib/ethnicity-filter';
|
||||
import { getSchoolBackendFeatureName, isSchoolFilterName } from '../../../lib/school-filter';
|
||||
import {
|
||||
getPoiDistanceFeatureName,
|
||||
isPoiDistanceFilterName,
|
||||
} from '../../../lib/poi-distance-filter';
|
||||
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
|
||||
import { EthnicityFilterCard } from './EthnicityFilterCard';
|
||||
import { PoiDistanceFilterCard } from './PoiDistanceFilterCard';
|
||||
import { SchoolFilterCard } from './SchoolFilterCard';
|
||||
import { SpecificCrimeFilterCard } from './SpecificCrimeFilterCard';
|
||||
import { EnumFeatureFilterCard } from './EnumFeatureFilterCard';
|
||||
import { NumericFeatureFilterCard } from './NumericFeatureFilterCard';
|
||||
import { TravelTimeFilterCards } from './TravelTimeFilterCards';
|
||||
|
||||
interface ActiveFilterListProps {
|
||||
features: FeatureMeta[];
|
||||
filters: FeatureFilters;
|
||||
enabledFeatureList: FeatureMeta[];
|
||||
activeFeature: string | null;
|
||||
dragValue: [number, number] | null;
|
||||
pinnedFeature: string | null;
|
||||
travelTimeEntries: TravelTimeEntry[];
|
||||
travelInsertIdx: number;
|
||||
filterImpacts?: Record<string, number>;
|
||||
percentileScales: Map<string, PercentileScale>;
|
||||
destinationDropdownPortal: boolean;
|
||||
onFilterChange: (name: string, value: [number, number] | string[]) => void;
|
||||
onRemoveFilter: (name: string) => void;
|
||||
onDragStart: (name: string) => void;
|
||||
onDragChange: (value: [number, number]) => void;
|
||||
onDragEnd: () => void;
|
||||
onTogglePin: (name: string) => void;
|
||||
onShowInfo: (feature: FeatureMeta) => void;
|
||||
onTravelTimeRemoveEntry: (index: number) => void;
|
||||
onTravelTimeSetDestination: (
|
||||
index: number,
|
||||
slug: string,
|
||||
label: string,
|
||||
lat: number,
|
||||
lon: number
|
||||
) => void;
|
||||
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
|
||||
onTravelTimeDragEnd: (index: number) => void;
|
||||
onTravelTimeToggleBest: (index: number) => void;
|
||||
}
|
||||
|
||||
export function ActiveFilterList({
|
||||
features,
|
||||
filters,
|
||||
enabledFeatureList,
|
||||
activeFeature,
|
||||
dragValue,
|
||||
pinnedFeature,
|
||||
travelTimeEntries,
|
||||
travelInsertIdx,
|
||||
filterImpacts,
|
||||
percentileScales,
|
||||
destinationDropdownPortal,
|
||||
onFilterChange,
|
||||
onRemoveFilter,
|
||||
onDragStart,
|
||||
onDragChange,
|
||||
onDragEnd,
|
||||
onTogglePin,
|
||||
onShowInfo,
|
||||
onTravelTimeRemoveEntry,
|
||||
onTravelTimeSetDestination,
|
||||
onTravelTimeRangeChange,
|
||||
onTravelTimeDragEnd,
|
||||
onTravelTimeToggleBest,
|
||||
}: ActiveFilterListProps) {
|
||||
const travelCards = (
|
||||
<TravelTimeFilterCards
|
||||
entries={travelTimeEntries}
|
||||
activeFeature={activeFeature}
|
||||
dragValue={dragValue}
|
||||
pinnedFeature={pinnedFeature}
|
||||
filterImpacts={filterImpacts}
|
||||
destinationDropdownPortal={destinationDropdownPortal}
|
||||
onTogglePin={onTogglePin}
|
||||
onTravelTimeRemoveEntry={onTravelTimeRemoveEntry}
|
||||
onTravelTimeSetDestination={onTravelTimeSetDestination}
|
||||
onTravelTimeRangeChange={onTravelTimeRangeChange}
|
||||
onTravelTimeDragEnd={onTravelTimeDragEnd}
|
||||
onTravelTimeToggleBest={onTravelTimeToggleBest}
|
||||
onDragStart={onDragStart}
|
||||
onDragChange={onDragChange}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="px-2 py-1 space-y-1">
|
||||
{enabledFeatureList.map((feature, featureIdx) => {
|
||||
const insertTravelCards = featureIdx === travelInsertIdx;
|
||||
|
||||
if (isSchoolFilterName(feature.name)) {
|
||||
const schoolBackendName = getSchoolBackendFeatureName(feature.name);
|
||||
return (
|
||||
<Fragment key={feature.name}>
|
||||
{insertTravelCards && travelCards}
|
||||
<SchoolFilterCard
|
||||
features={features}
|
||||
schoolFeature={feature}
|
||||
filters={filters}
|
||||
activeFeature={activeFeature}
|
||||
dragValue={dragValue}
|
||||
pinnedFeature={pinnedFeature}
|
||||
filterImpact={schoolBackendName ? filterImpacts?.[schoolBackendName] : undefined}
|
||||
onFilterChange={onFilterChange}
|
||||
onDragStart={onDragStart}
|
||||
onDragChange={onDragChange}
|
||||
onDragEnd={onDragEnd}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={onShowInfo}
|
||||
onRemove={() => onRemoveFilter(feature.name)}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSpecificCrimeFilterName(feature.name)) {
|
||||
const specificCrimeBackendName = getSpecificCrimeFeatureName(feature.name);
|
||||
return (
|
||||
<Fragment key={feature.name}>
|
||||
{insertTravelCards && travelCards}
|
||||
<SpecificCrimeFilterCard
|
||||
features={features}
|
||||
crimeFeature={feature}
|
||||
filters={filters}
|
||||
activeFeature={activeFeature}
|
||||
dragValue={dragValue}
|
||||
pinnedFeature={pinnedFeature}
|
||||
filterImpact={
|
||||
specificCrimeBackendName ? filterImpacts?.[specificCrimeBackendName] : undefined
|
||||
}
|
||||
percentileScale={
|
||||
specificCrimeBackendName
|
||||
? percentileScales.get(specificCrimeBackendName)
|
||||
: undefined
|
||||
}
|
||||
onFilterChange={onFilterChange}
|
||||
onDragStart={onDragStart}
|
||||
onDragChange={onDragChange}
|
||||
onDragEnd={onDragEnd}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={onShowInfo}
|
||||
onRemove={() => onRemoveFilter(feature.name)}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEthnicityFilterName(feature.name)) {
|
||||
const ethnicityBackendName = getEthnicityFeatureName(feature.name);
|
||||
return (
|
||||
<Fragment key={feature.name}>
|
||||
{insertTravelCards && travelCards}
|
||||
<EthnicityFilterCard
|
||||
features={features}
|
||||
ethnicityFeature={feature}
|
||||
filters={filters}
|
||||
activeFeature={activeFeature}
|
||||
dragValue={dragValue}
|
||||
pinnedFeature={pinnedFeature}
|
||||
filterImpact={
|
||||
ethnicityBackendName ? filterImpacts?.[ethnicityBackendName] : undefined
|
||||
}
|
||||
percentileScale={
|
||||
ethnicityBackendName ? percentileScales.get(ethnicityBackendName) : undefined
|
||||
}
|
||||
onFilterChange={onFilterChange}
|
||||
onDragStart={onDragStart}
|
||||
onDragChange={onDragChange}
|
||||
onDragEnd={onDragEnd}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={onShowInfo}
|
||||
onRemove={() => onRemoveFilter(feature.name)}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPoiDistanceFilterName(feature.name)) {
|
||||
const poiBackendName = getPoiDistanceFeatureName(feature.name);
|
||||
return (
|
||||
<Fragment key={feature.name}>
|
||||
{insertTravelCards && travelCards}
|
||||
<PoiDistanceFilterCard
|
||||
features={features}
|
||||
poiFeature={feature}
|
||||
filters={filters}
|
||||
activeFeature={activeFeature}
|
||||
dragValue={dragValue}
|
||||
pinnedFeature={pinnedFeature}
|
||||
filterImpact={poiBackendName ? filterImpacts?.[poiBackendName] : undefined}
|
||||
percentileScale={poiBackendName ? percentileScales.get(poiBackendName) : undefined}
|
||||
onFilterChange={onFilterChange}
|
||||
onDragStart={onDragStart}
|
||||
onDragChange={onDragChange}
|
||||
onDragEnd={onDragEnd}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={onShowInfo}
|
||||
onRemove={() => onRemoveFilter(feature.name)}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={feature.name}>
|
||||
{insertTravelCards && travelCards}
|
||||
{feature.type === 'enum' ? (
|
||||
<EnumFeatureFilterCard
|
||||
feature={feature}
|
||||
filters={filters}
|
||||
pinnedFeature={pinnedFeature}
|
||||
filterImpact={filterImpacts?.[feature.name]}
|
||||
onFilterChange={onFilterChange}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={onShowInfo}
|
||||
onRemoveFilter={onRemoveFilter}
|
||||
/>
|
||||
) : (
|
||||
<NumericFeatureFilterCard
|
||||
feature={feature}
|
||||
filters={filters}
|
||||
activeFeature={activeFeature}
|
||||
dragValue={dragValue}
|
||||
pinnedFeature={pinnedFeature}
|
||||
filterImpact={filterImpacts?.[feature.name]}
|
||||
percentileScale={percentileScales.get(feature.name)}
|
||||
onFilterChange={onFilterChange}
|
||||
onDragStart={onDragStart}
|
||||
onDragChange={onDragChange}
|
||||
onDragEnd={onDragEnd}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={onShowInfo}
|
||||
onRemoveFilter={onRemoveFilter}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{travelInsertIdx >= enabledFeatureList.length && travelCards}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
204
frontend/src/components/map/filters/ActiveFiltersPanel.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import type { RefObject } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { AiFilterErrorType } from '../../../hooks/useAiFilters';
|
||||
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
|
||||
import type { PercentileScale } from '../../../lib/format';
|
||||
import type { FeatureFilters, FeatureMeta } from '../../../types';
|
||||
import { ChevronIcon, LightbulbIcon } from '../../ui/icons';
|
||||
import AiFilterInput from '../AiFilterInput';
|
||||
import { ActiveFilterList } from './ActiveFilterList';
|
||||
|
||||
interface ActiveFiltersPanelProps {
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
collapsed: boolean;
|
||||
badgeCount: number;
|
||||
activeEntryCount: number;
|
||||
features: FeatureMeta[];
|
||||
filters: FeatureFilters;
|
||||
enabledFeatureList: FeatureMeta[];
|
||||
activeFeature: string | null;
|
||||
dragValue: [number, number] | null;
|
||||
pinnedFeature: string | null;
|
||||
travelTimeEntries: TravelTimeEntry[];
|
||||
travelInsertIdx: number;
|
||||
filterImpacts?: Record<string, number>;
|
||||
percentileScales: Map<string, PercentileScale>;
|
||||
destinationDropdownPortal: boolean;
|
||||
aiFilterLoading: boolean;
|
||||
aiFilterError: string | null;
|
||||
aiFilterErrorType: AiFilterErrorType | null;
|
||||
aiFilterNotes: string | null;
|
||||
aiFilterSummary: string | null;
|
||||
isLoggedIn: boolean;
|
||||
onToggleCollapsed: () => void;
|
||||
onClearAllClick: () => void;
|
||||
onShowPhilosophy: () => void;
|
||||
onAiFilterSubmit: (query: string) => void;
|
||||
onLoginRequired: () => void;
|
||||
onFilterChange: (name: string, value: [number, number] | string[]) => void;
|
||||
onRemoveFilter: (name: string) => void;
|
||||
onDragStart: (name: string) => void;
|
||||
onDragChange: (value: [number, number]) => void;
|
||||
onDragEnd: () => void;
|
||||
onTogglePin: (name: string) => void;
|
||||
onShowInfo: (feature: FeatureMeta) => void;
|
||||
onTravelTimeRemoveEntry: (index: number) => void;
|
||||
onTravelTimeSetDestination: (
|
||||
index: number,
|
||||
slug: string,
|
||||
label: string,
|
||||
lat: number,
|
||||
lon: number
|
||||
) => void;
|
||||
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
|
||||
onTravelTimeDragEnd: (index: number) => void;
|
||||
onTravelTimeToggleBest: (index: number) => void;
|
||||
}
|
||||
|
||||
export function ActiveFiltersPanel({
|
||||
scrollRef,
|
||||
collapsed,
|
||||
badgeCount,
|
||||
activeEntryCount,
|
||||
features,
|
||||
filters,
|
||||
enabledFeatureList,
|
||||
activeFeature,
|
||||
dragValue,
|
||||
pinnedFeature,
|
||||
travelTimeEntries,
|
||||
travelInsertIdx,
|
||||
filterImpacts,
|
||||
percentileScales,
|
||||
destinationDropdownPortal,
|
||||
aiFilterLoading,
|
||||
aiFilterError,
|
||||
aiFilterErrorType,
|
||||
aiFilterNotes,
|
||||
aiFilterSummary,
|
||||
isLoggedIn,
|
||||
onToggleCollapsed,
|
||||
onClearAllClick,
|
||||
onShowPhilosophy,
|
||||
onAiFilterSubmit,
|
||||
onLoginRequired,
|
||||
onFilterChange,
|
||||
onRemoveFilter,
|
||||
onDragStart,
|
||||
onDragChange,
|
||||
onDragEnd,
|
||||
onTogglePin,
|
||||
onShowInfo,
|
||||
onTravelTimeRemoveEntry,
|
||||
onTravelTimeSetDestination,
|
||||
onTravelTimeRangeChange,
|
||||
onTravelTimeDragEnd,
|
||||
onTravelTimeToggleBest,
|
||||
}: ActiveFiltersPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col md:min-h-0 ${
|
||||
collapsed ? 'md:[flex:0_0_auto]' : 'md:[flex:0_1_auto]'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={onToggleCollapsed}
|
||||
className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700 bg-teal-50 dark:bg-teal-900/30 cursor-pointer hover:bg-teal-100 dark:hover:bg-teal-900/50"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-teal-700 dark:text-teal-400">
|
||||
{t('filters.activeFilters')}
|
||||
</span>
|
||||
{badgeCount > 0 && (
|
||||
<span className="text-xs font-medium px-1.5 py-0.5 rounded-full bg-teal-50 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
|
||||
{badgeCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{badgeCount > 0 && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClearAllClick();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.stopPropagation();
|
||||
onClearAllClick();
|
||||
}
|
||||
}}
|
||||
className="text-xs text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-200 underline"
|
||||
>
|
||||
{t('filters.clearAll')}
|
||||
</span>
|
||||
)}
|
||||
<ChevronIcon
|
||||
direction={collapsed ? 'down' : 'up'}
|
||||
className="w-4 h-4 text-warm-400 dark:text-warm-500"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<div ref={scrollRef} className="md:min-h-0 md:overflow-y-auto overflow-x-hidden">
|
||||
<AiFilterInput
|
||||
loading={aiFilterLoading}
|
||||
error={aiFilterError}
|
||||
errorType={aiFilterErrorType}
|
||||
notes={aiFilterNotes}
|
||||
summary={aiFilterSummary}
|
||||
onSubmit={onAiFilterSubmit}
|
||||
isLoggedIn={isLoggedIn}
|
||||
onLoginRequired={onLoginRequired}
|
||||
/>
|
||||
<div className="px-3 pb-2 space-y-2">
|
||||
<button
|
||||
onClick={onShowPhilosophy}
|
||||
className="w-full px-3 py-1.5 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:bg-warm-50 dark:hover:bg-warm-700 text-teal-600 dark:text-teal-400 font-medium text-sm flex items-center justify-center gap-2"
|
||||
>
|
||||
<LightbulbIcon />
|
||||
{t('filters.findingPerfectPostcode')}
|
||||
</button>
|
||||
</div>
|
||||
{enabledFeatureList.length === 0 && activeEntryCount === 0 && (
|
||||
<p className="px-3 py-1.5 text-xs text-warm-400 dark:text-warm-500">
|
||||
{t('filters.addFiltersHint')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ActiveFilterList
|
||||
features={features}
|
||||
filters={filters}
|
||||
enabledFeatureList={enabledFeatureList}
|
||||
activeFeature={activeFeature}
|
||||
dragValue={dragValue}
|
||||
pinnedFeature={pinnedFeature}
|
||||
travelTimeEntries={travelTimeEntries}
|
||||
travelInsertIdx={travelInsertIdx}
|
||||
filterImpacts={filterImpacts}
|
||||
percentileScales={percentileScales}
|
||||
destinationDropdownPortal={destinationDropdownPortal}
|
||||
onFilterChange={onFilterChange}
|
||||
onRemoveFilter={onRemoveFilter}
|
||||
onDragStart={onDragStart}
|
||||
onDragChange={onDragChange}
|
||||
onDragEnd={onDragEnd}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={onShowInfo}
|
||||
onTravelTimeRemoveEntry={onTravelTimeRemoveEntry}
|
||||
onTravelTimeSetDestination={onTravelTimeSetDestination}
|
||||
onTravelTimeRangeChange={onTravelTimeRangeChange}
|
||||
onTravelTimeDragEnd={onTravelTimeDragEnd}
|
||||
onTravelTimeToggleBest={onTravelTimeToggleBest}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
162
frontend/src/components/map/filters/AddFilterPanel.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { TravelTimeEntry, TransportMode } from '../../../hooks/useTravelTime';
|
||||
import type { FeatureMeta } from '../../../types';
|
||||
import { ChevronIcon } from '../../ui/icons';
|
||||
import FeatureBrowser from '../FeatureBrowser';
|
||||
import { SPECIFIC_CRIMES_FILTER_NAME, isSpecificCrimeFilterName } from '../../../lib/crime-filter';
|
||||
import { ETHNICITIES_FILTER_NAME, isEthnicityFilterName } from '../../../lib/ethnicity-filter';
|
||||
import { SCHOOL_FILTER_NAME, isSchoolFilterName } from '../../../lib/school-filter';
|
||||
import {
|
||||
POI_DISTANCE_FILTER_NAME,
|
||||
POI_FILTER_NAMES,
|
||||
getPoiFilterName,
|
||||
isPoiDistanceFilterName,
|
||||
type PoiFilterName,
|
||||
} from '../../../lib/poi-distance-filter';
|
||||
|
||||
interface AddFilterPanelProps {
|
||||
collapsed: boolean;
|
||||
isLicensed: boolean;
|
||||
availableFeatures: FeatureMeta[];
|
||||
allFeatures: FeatureMeta[];
|
||||
pinnedFeature: string | null;
|
||||
defaultSchoolFeatureName: string | null;
|
||||
defaultSpecificCrimeFeatureName: string | null;
|
||||
defaultEthnicityFeatureName: string | null;
|
||||
defaultPoiFilterFeatureNames: Record<PoiFilterName, string | null>;
|
||||
openInfoFeature?: string | null;
|
||||
travelTimeEntries: TravelTimeEntry[];
|
||||
onToggleCollapsed: () => void;
|
||||
onAddFilter: (name: string) => void;
|
||||
onTogglePin: (name: string) => void;
|
||||
onNavigateToSource?: (slug: string, featureName: string) => void;
|
||||
onClearOpenInfoFeature?: () => void;
|
||||
onAddTravelTimeEntry: (mode: TransportMode) => void;
|
||||
onUpgradeClick?: () => void;
|
||||
}
|
||||
|
||||
export function AddFilterPanel({
|
||||
collapsed,
|
||||
isLicensed,
|
||||
availableFeatures,
|
||||
allFeatures,
|
||||
pinnedFeature,
|
||||
defaultSchoolFeatureName,
|
||||
defaultSpecificCrimeFeatureName,
|
||||
defaultEthnicityFeatureName,
|
||||
defaultPoiFilterFeatureNames,
|
||||
openInfoFeature,
|
||||
travelTimeEntries,
|
||||
onToggleCollapsed,
|
||||
onAddFilter,
|
||||
onTogglePin,
|
||||
onNavigateToSource,
|
||||
onClearOpenInfoFeature,
|
||||
onAddTravelTimeEntry,
|
||||
onUpgradeClick,
|
||||
}: AddFilterPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const browserPinnedFeature =
|
||||
pinnedFeature && isSchoolFilterName(pinnedFeature)
|
||||
? SCHOOL_FILTER_NAME
|
||||
: pinnedFeature && isSpecificCrimeFilterName(pinnedFeature)
|
||||
? SPECIFIC_CRIMES_FILTER_NAME
|
||||
: pinnedFeature && isEthnicityFilterName(pinnedFeature)
|
||||
? ETHNICITIES_FILTER_NAME
|
||||
: pinnedFeature && isPoiDistanceFilterName(pinnedFeature)
|
||||
? (getPoiFilterName(pinnedFeature) ?? POI_DISTANCE_FILTER_NAME)
|
||||
: pinnedFeature;
|
||||
|
||||
const handleTogglePin = (name: string) => {
|
||||
if (name === SCHOOL_FILTER_NAME) {
|
||||
if (defaultSchoolFeatureName) onTogglePin(defaultSchoolFeatureName);
|
||||
return;
|
||||
}
|
||||
if (name === SPECIFIC_CRIMES_FILTER_NAME) {
|
||||
if (defaultSpecificCrimeFeatureName) onTogglePin(defaultSpecificCrimeFeatureName);
|
||||
return;
|
||||
}
|
||||
if (name === ETHNICITIES_FILTER_NAME) {
|
||||
if (defaultEthnicityFeatureName) onTogglePin(defaultEthnicityFeatureName);
|
||||
return;
|
||||
}
|
||||
if (POI_FILTER_NAMES.includes(name as PoiFilterName)) {
|
||||
const defaultPoiFeatureName = defaultPoiFilterFeatureNames[name as PoiFilterName];
|
||||
if (defaultPoiFeatureName) onTogglePin(defaultPoiFeatureName);
|
||||
return;
|
||||
}
|
||||
onTogglePin(name);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col md:min-h-0 border-t border-warm-200 dark:border-warm-700 ${
|
||||
collapsed && isLicensed ? 'md:[flex:0_0_auto]' : 'md:[flex:1_1_0]'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={onToggleCollapsed}
|
||||
className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700 bg-teal-50 dark:bg-teal-900/30 cursor-pointer hover:bg-teal-100 dark:hover:bg-teal-900/50"
|
||||
>
|
||||
<span className="text-sm font-semibold text-teal-700 dark:text-teal-400">
|
||||
{t('filters.addFilter')}
|
||||
</span>
|
||||
<ChevronIcon
|
||||
direction={collapsed ? 'down' : 'up'}
|
||||
className="w-4 h-4 text-warm-400 dark:text-warm-500"
|
||||
/>
|
||||
</button>
|
||||
{(!collapsed || !isLicensed) && (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
{!collapsed && (
|
||||
<FeatureBrowser
|
||||
availableFeatures={availableFeatures}
|
||||
allFeatures={allFeatures}
|
||||
pinnedFeature={browserPinnedFeature}
|
||||
onAddFilter={onAddFilter}
|
||||
onTogglePin={handleTogglePin}
|
||||
onNavigateToSource={onNavigateToSource}
|
||||
openInfoFeature={openInfoFeature}
|
||||
onClearOpenInfoFeature={onClearOpenInfoFeature}
|
||||
travelTimeEntries={travelTimeEntries}
|
||||
onAddTravelTimeEntry={onAddTravelTimeEntry}
|
||||
/>
|
||||
)}
|
||||
{!isLicensed && (
|
||||
<div className="mt-auto shrink-0 flex flex-col items-center px-5 pt-4 pb-0 border-t border-warm-200 dark:border-warm-700">
|
||||
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-relaxed mb-1">
|
||||
{t('filters.upgradePrompt')}
|
||||
</p>
|
||||
<p className="text-xs text-warm-400 dark:text-warm-500 text-center mb-4">
|
||||
{t('filters.oneTimeLifetime')}
|
||||
</p>
|
||||
<button
|
||||
onClick={onUpgradeClick}
|
||||
className="px-5 py-2.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md"
|
||||
>
|
||||
{t('filters.upgradeToFullMap')}
|
||||
</button>
|
||||
<svg
|
||||
viewBox="0 120 1600 230"
|
||||
className="w-full mt-4 block shrink-0"
|
||||
preserveAspectRatio="xMidYMax meet"
|
||||
>
|
||||
<path
|
||||
d="M0,350 C400,150 1200,150 1600,350 Z"
|
||||
className="fill-green-500 dark:fill-green-600"
|
||||
/>
|
||||
<path d="M100,350 C450,180 1150,180 1500,350 Z" fill="#000" opacity="0.08" />
|
||||
<path d="M250,350 C550,220 1050,220 1350,350 Z" fill="#000" opacity="0.06" />
|
||||
<image href="/house.png" x="735" y="110" width="130" height="120" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
frontend/src/components/map/filters/ClearFiltersDialog.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { useEffect, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { CloseIcon, SpinnerIcon } from '../../ui/icons';
|
||||
|
||||
interface ClearFiltersDialogProps {
|
||||
open: boolean;
|
||||
saveName: string;
|
||||
saveError: string | null;
|
||||
savingSearch?: boolean;
|
||||
onClose: () => void;
|
||||
onSaveNameChange: (value: string) => void;
|
||||
onSaveAndClear: (e: FormEvent) => void;
|
||||
onClearWithoutSaving: () => void;
|
||||
}
|
||||
|
||||
export function ClearFiltersDialog({
|
||||
open,
|
||||
saveName,
|
||||
saveError,
|
||||
savingSearch,
|
||||
onClose,
|
||||
onSaveNameChange,
|
||||
onSaveAndClear,
|
||||
onClearWithoutSaving,
|
||||
}: ClearFiltersDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
|
||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
|
||||
<div
|
||||
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 pt-5 pb-3">
|
||||
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">
|
||||
{t('filters.clearAllTitle')}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
|
||||
>
|
||||
<CloseIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={onSaveAndClear} className="p-5 pt-2 space-y-4">
|
||||
<p className="text-sm text-warm-600 dark:text-warm-400">
|
||||
{t('filters.clearAllSavePrompt')}
|
||||
</p>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={saveName}
|
||||
onChange={(e) => onSaveNameChange(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
||||
placeholder={t('saveSearch.namePlaceholder')}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{saveError && <p className="text-sm text-red-600 dark:text-red-300">{saveError}</p>}
|
||||
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearWithoutSaving}
|
||||
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
|
||||
>
|
||||
{t('filters.clearWithoutSaving')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!saveName.trim() || savingSearch}
|
||||
className="flex items-center justify-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
|
||||
>
|
||||
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
|
||||
{savingSearch ? t('saveSearch.saving') : t('filters.saveAndClear')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { ts } from '../../../i18n/server';
|
||||
import type { FeatureFilters, FeatureMeta } from '../../../types';
|
||||
import { formatNumber } from '../../../lib/format';
|
||||
import { PillGroup } from '../../ui/PillGroup';
|
||||
import { PillToggle } from '../../ui/PillToggle';
|
||||
import { FeatureActions } from '../../ui/FeatureIcons';
|
||||
import { FeatureLabel } from '../../ui/FeatureLabel';
|
||||
|
||||
interface EnumFeatureFilterCardProps {
|
||||
feature: FeatureMeta;
|
||||
filters: FeatureFilters;
|
||||
pinnedFeature: string | null;
|
||||
filterImpact?: number;
|
||||
onFilterChange: (name: string, value: [number, number] | string[]) => void;
|
||||
onTogglePin: (name: string) => void;
|
||||
onShowInfo: (feature: FeatureMeta) => void;
|
||||
onRemoveFilter: (name: string) => void;
|
||||
}
|
||||
|
||||
export function EnumFeatureFilterCard({
|
||||
feature,
|
||||
filters,
|
||||
pinnedFeature,
|
||||
filterImpact,
|
||||
onFilterChange,
|
||||
onTogglePin,
|
||||
onShowInfo,
|
||||
onRemoveFilter,
|
||||
}: EnumFeatureFilterCardProps) {
|
||||
const selectedValues = (filters[feature.name] as string[]) || [];
|
||||
const allValues = feature.values || [];
|
||||
|
||||
return (
|
||||
<div
|
||||
data-filter-name={feature.name}
|
||||
className={`space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<FeatureLabel feature={feature} size="sm" />
|
||||
<FeatureActions
|
||||
feature={feature}
|
||||
isPinned={pinnedFeature === feature.name}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={onShowInfo}
|
||||
onRemove={onRemoveFilter}
|
||||
/>
|
||||
</div>
|
||||
<PillGroup>
|
||||
{allValues.map((val) => (
|
||||
<PillToggle
|
||||
key={val}
|
||||
label={ts(val)}
|
||||
active={selectedValues.includes(val)}
|
||||
onClick={() => {
|
||||
const next = selectedValues.includes(val)
|
||||
? selectedValues.filter((v) => v !== val)
|
||||
: [...selectedValues, val];
|
||||
onFilterChange(feature.name, next);
|
||||
}}
|
||||
size="xs"
|
||||
/>
|
||||
))}
|
||||
</PillGroup>
|
||||
{filterImpact != null && filterImpact > 0 && (
|
||||
<p className="text-[10px] text-warm-400 dark:text-warm-500 mt-0.5">
|
||||
+{formatNumber(filterImpact)} without this filter
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
223
frontend/src/components/map/filters/EthnicityFilterCard.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import { ts } from '../../../i18n/server';
|
||||
import { Slider } from '../../ui/Slider';
|
||||
import { ChevronIcon } from '../../ui/icons';
|
||||
import { FeatureActions } from '../../ui/FeatureIcons';
|
||||
import { FeatureLabel } from '../../ui/FeatureLabel';
|
||||
import type { FeatureFilters, FeatureMeta } from '../../../types';
|
||||
import { formatNumber, type PercentileScale } from '../../../lib/format';
|
||||
import { getFeatureIcon } from '../../../lib/feature-icons';
|
||||
import { getGroupIcon } from '../../../lib/group-icons';
|
||||
import {
|
||||
ETHNICITIES_FILTER_NAME,
|
||||
ETHNICITY_FEATURE_NAMES,
|
||||
clampEthnicityRange,
|
||||
getDefaultEthnicityFeatureName,
|
||||
getEthnicityFeatureName,
|
||||
getEthnicityFilterMeta,
|
||||
replaceEthnicityFilterKeySelection,
|
||||
} from '../../../lib/ethnicity-filter';
|
||||
import { SliderLabels } from './SliderLabels';
|
||||
|
||||
export function EthnicityFilterCard({
|
||||
features,
|
||||
ethnicityFeature,
|
||||
filters,
|
||||
activeFeature,
|
||||
dragValue,
|
||||
pinnedFeature,
|
||||
filterImpact,
|
||||
percentileScale,
|
||||
onFilterChange,
|
||||
onDragStart,
|
||||
onDragChange,
|
||||
onDragEnd,
|
||||
onTogglePin,
|
||||
onShowInfo,
|
||||
onRemove,
|
||||
}: {
|
||||
features: FeatureMeta[];
|
||||
ethnicityFeature: FeatureMeta;
|
||||
filters: FeatureFilters;
|
||||
activeFeature: string | null;
|
||||
dragValue: [number, number] | null;
|
||||
pinnedFeature: string | null;
|
||||
filterImpact?: number;
|
||||
percentileScale?: PercentileScale;
|
||||
onFilterChange: (name: string, value: [number, number] | string[]) => void;
|
||||
onDragStart: (name: string) => void;
|
||||
onDragChange: (value: [number, number]) => void;
|
||||
onDragEnd: () => void;
|
||||
onTogglePin: (name: string) => void;
|
||||
onShowInfo: (feature: FeatureMeta) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const ethnicityMeta = getEthnicityFilterMeta(features);
|
||||
const ethnicityOptions = ETHNICITY_FEATURE_NAMES.map((name) =>
|
||||
features.find((feature) => feature.name === name)
|
||||
).filter((feature): feature is FeatureMeta => Boolean(feature));
|
||||
const selectedFeatureName =
|
||||
getEthnicityFeatureName(ethnicityFeature.name) ?? getDefaultEthnicityFeatureName(features);
|
||||
const selectedFeature = selectedFeatureName
|
||||
? features.find((feature) => feature.name === selectedFeatureName)
|
||||
: undefined;
|
||||
|
||||
if (!selectedFeature || ethnicityOptions.length === 0 || !selectedFeatureName) return null;
|
||||
|
||||
const isActive = activeFeature === ethnicityFeature.name;
|
||||
const isPinned = pinnedFeature === ethnicityFeature.name;
|
||||
const hist = selectedFeature.histogram;
|
||||
const dataMin = hist?.min ?? selectedFeature.min ?? 0;
|
||||
const dataMax = hist?.max ?? selectedFeature.max ?? 100;
|
||||
const displayValue =
|
||||
isActive && dragValue
|
||||
? dragValue
|
||||
: (filters[ethnicityFeature.name] as [number, number]) || [dataMin, dataMax];
|
||||
const scale = percentileScale;
|
||||
const clampMin = displayValue[0] <= dataMin;
|
||||
const clampMax = displayValue[1] >= dataMax;
|
||||
const isAtMin = displayValue[0] === dataMin;
|
||||
const isAtMax = displayValue[1] === dataMax;
|
||||
const sliderValue: [number, number] = scale
|
||||
? [
|
||||
clampMin ? 0 : Math.round(scale.toPercentile(displayValue[0])),
|
||||
clampMax ? 100 : Math.round(scale.toPercentile(displayValue[1])),
|
||||
]
|
||||
: [
|
||||
clampMin ? (selectedFeature.min ?? dataMin) : displayValue[0],
|
||||
clampMax ? (selectedFeature.max ?? dataMax) : displayValue[1],
|
||||
];
|
||||
|
||||
const replaceEthnicityFeature = (nextFeatureName: string) => {
|
||||
const nextName = replaceEthnicityFilterKeySelection(ethnicityFeature.name, nextFeatureName);
|
||||
if (nextName === ethnicityFeature.name) return;
|
||||
|
||||
const nextFeature = features.find((feature) => feature.name === nextFeatureName);
|
||||
const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0;
|
||||
const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax);
|
||||
const nextRange = clampEthnicityRange(
|
||||
[
|
||||
displayValue[0] <= dataMin ? nextDataMin : displayValue[0],
|
||||
displayValue[1] >= dataMax ? nextDataMax : displayValue[1],
|
||||
],
|
||||
nextFeature
|
||||
);
|
||||
|
||||
onFilterChange(nextName, nextRange);
|
||||
if (isPinned) onTogglePin(nextName);
|
||||
};
|
||||
|
||||
const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0';
|
||||
const mobileIcon =
|
||||
getFeatureIcon(selectedFeature.name, mobileIconClass) ||
|
||||
(() => {
|
||||
const G = selectedFeature.group ? getGroupIcon(selectedFeature.group) : null;
|
||||
return G ? <G className={mobileIconClass} /> : null;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-filter-name={ETHNICITIES_FILTER_NAME}
|
||||
className={`space-y-2 rounded-lg border border-warm-200 bg-white px-2 py-2 shadow-sm dark:border-warm-700 dark:bg-warm-800 ${
|
||||
isActive
|
||||
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
||||
: isPinned
|
||||
? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<div className="relative z-10 flex items-center justify-between gap-1">
|
||||
<FeatureLabel
|
||||
feature={ethnicityMeta}
|
||||
size="sm"
|
||||
className="min-w-0 shrink"
|
||||
hideIconOnMobile
|
||||
/>
|
||||
<FeatureActions
|
||||
feature={selectedFeature}
|
||||
actionName={ethnicityFeature.name}
|
||||
isPinned={isPinned}
|
||||
isPreviewing={isActive}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={onShowInfo}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
Ethnicity
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedFeatureName}
|
||||
onChange={(e) => replaceEthnicityFeature(e.target.value)}
|
||||
className="w-full appearance-none rounded-md border border-warm-200 bg-warm-50 px-2 py-1.5 pr-8 text-sm font-medium text-navy-950 shadow-inner outline-none transition-colors hover:bg-white focus:border-teal-400 focus:ring-2 focus:ring-teal-200 dark:border-warm-700 dark:bg-navy-900 dark:text-warm-100 dark:hover:bg-navy-800 dark:focus:ring-teal-900/50"
|
||||
>
|
||||
{ethnicityOptions.map((option) => (
|
||||
<option key={option.name} value={option.name}>
|
||||
{ts(option.name)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronIcon
|
||||
direction="down"
|
||||
className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-warm-400 dark:text-warm-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-1.5 md:block">
|
||||
{mobileIcon && <div className="shrink-0 pt-0.5 md:hidden">{mobileIcon}</div>}
|
||||
<div className="min-w-0 flex-1">
|
||||
<Slider
|
||||
min={scale ? 0 : (selectedFeature.min ?? dataMin)}
|
||||
max={scale ? 100 : (selectedFeature.max ?? dataMax)}
|
||||
step={
|
||||
scale
|
||||
? 1
|
||||
: (selectedFeature.step ??
|
||||
((selectedFeature.max ?? dataMax) - (selectedFeature.min ?? dataMin)) / 100)
|
||||
}
|
||||
value={sliderValue}
|
||||
onValueChange={
|
||||
scale
|
||||
? ([pMin, pMax]) => {
|
||||
const step = selectedFeature.step ?? 1;
|
||||
const snap = (v: number) => Math.round(v / step) * step;
|
||||
onDragChange([
|
||||
pMin <= 0 ? dataMin : snap(scale.toValue(pMin)),
|
||||
pMax >= 100 ? dataMax : snap(scale.toValue(pMax)),
|
||||
]);
|
||||
}
|
||||
: ([min, max]) =>
|
||||
onDragChange([
|
||||
min <= (selectedFeature.min ?? dataMin) ? dataMin : min,
|
||||
max >= (selectedFeature.max ?? dataMax) ? dataMax : max,
|
||||
])
|
||||
}
|
||||
onPointerDown={() => onDragStart(ethnicityFeature.name)}
|
||||
onPointerUp={() => onDragEnd()}
|
||||
/>
|
||||
<SliderLabels
|
||||
min={scale ? 0 : (selectedFeature.min ?? dataMin)}
|
||||
max={scale ? 100 : (selectedFeature.max ?? dataMax)}
|
||||
value={sliderValue}
|
||||
displayValues={displayValue}
|
||||
isAtMin={isAtMin}
|
||||
isAtMax={isAtMax}
|
||||
raw={selectedFeature.raw}
|
||||
feature={selectedFeature}
|
||||
onValueChange={(v) =>
|
||||
onFilterChange(ethnicityFeature.name, clampEthnicityRange(v, selectedFeature))
|
||||
}
|
||||
/>
|
||||
{filterImpact != null && filterImpact > 0 && (
|
||||
<p className="-mt-1 ml-2.5 text-[10px] text-warm-400 dark:text-warm-500">
|
||||
+{formatNumber(filterImpact)} without this filter
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
frontend/src/components/map/filters/NumericFeatureFilterCard.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import type { FeatureFilters, FeatureMeta } from '../../../types';
|
||||
import { formatNumber, type PercentileScale } from '../../../lib/format';
|
||||
import { getFeatureIcon } from '../../../lib/feature-icons';
|
||||
import { getGroupIcon } from '../../../lib/group-icons';
|
||||
import { Slider } from '../../ui/Slider';
|
||||
import { FeatureActions } from '../../ui/FeatureIcons';
|
||||
import { FeatureLabel } from '../../ui/FeatureLabel';
|
||||
import { SliderLabels } from './SliderLabels';
|
||||
|
||||
interface NumericFeatureFilterCardProps {
|
||||
feature: FeatureMeta;
|
||||
filters: FeatureFilters;
|
||||
activeFeature: string | null;
|
||||
dragValue: [number, number] | null;
|
||||
pinnedFeature: string | null;
|
||||
filterImpact?: number;
|
||||
percentileScale?: PercentileScale;
|
||||
onFilterChange: (name: string, value: [number, number] | string[]) => void;
|
||||
onDragStart: (name: string) => void;
|
||||
onDragChange: (value: [number, number]) => void;
|
||||
onDragEnd: () => void;
|
||||
onTogglePin: (name: string) => void;
|
||||
onShowInfo: (feature: FeatureMeta) => void;
|
||||
onRemoveFilter: (name: string) => void;
|
||||
}
|
||||
|
||||
export function NumericFeatureFilterCard({
|
||||
feature,
|
||||
filters,
|
||||
activeFeature,
|
||||
dragValue,
|
||||
pinnedFeature,
|
||||
filterImpact,
|
||||
percentileScale,
|
||||
onFilterChange,
|
||||
onDragStart,
|
||||
onDragChange,
|
||||
onDragEnd,
|
||||
onTogglePin,
|
||||
onShowInfo,
|
||||
onRemoveFilter,
|
||||
}: NumericFeatureFilterCardProps) {
|
||||
const isActive = activeFeature === feature.name;
|
||||
const isPinned = pinnedFeature === feature.name;
|
||||
const hist = feature.histogram;
|
||||
const displayValue =
|
||||
isActive && dragValue
|
||||
? dragValue
|
||||
: (filters[feature.name] as [number, number]) || [
|
||||
hist?.min ?? feature.min!,
|
||||
hist?.max ?? feature.max!,
|
||||
];
|
||||
const scale = percentileScale;
|
||||
const dataMin = hist?.min ?? feature.min!;
|
||||
const dataMax = hist?.max ?? feature.max!;
|
||||
const clampMin = displayValue[0] <= dataMin;
|
||||
const clampMax = displayValue[1] >= dataMax;
|
||||
const isAtMin = displayValue[0] === dataMin;
|
||||
const isAtMax = displayValue[1] === dataMax;
|
||||
const sliderValue: [number, number] = scale
|
||||
? [
|
||||
clampMin ? 0 : Math.round(scale.toPercentile(displayValue[0])),
|
||||
clampMax ? 100 : Math.round(scale.toPercentile(displayValue[1])),
|
||||
]
|
||||
: [clampMin ? feature.min! : displayValue[0], clampMax ? feature.max! : displayValue[1]];
|
||||
|
||||
const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0';
|
||||
const mobileIcon =
|
||||
getFeatureIcon(feature.name, mobileIconClass) ||
|
||||
(() => {
|
||||
const G = feature.group ? getGroupIcon(feature.group) : null;
|
||||
return G ? <G className={mobileIconClass} /> : null;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-filter-name={feature.name}
|
||||
className={`space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="relative z-10 flex items-center justify-between gap-1">
|
||||
<FeatureLabel feature={feature} size="sm" className="min-w-0 shrink" hideIconOnMobile />
|
||||
<FeatureActions
|
||||
feature={feature}
|
||||
isPinned={isPinned}
|
||||
isPreviewing={isActive}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={onShowInfo}
|
||||
onRemove={onRemoveFilter}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex md:block items-start gap-1.5">
|
||||
{mobileIcon && <div className="md:hidden shrink-0 pt-0.5">{mobileIcon}</div>}
|
||||
<div className="min-w-0 flex-1">
|
||||
<Slider
|
||||
min={scale ? 0 : feature.min!}
|
||||
max={scale ? 100 : feature.max!}
|
||||
step={scale ? 1 : (feature.step ?? (feature.max! - feature.min!) / 100)}
|
||||
value={sliderValue}
|
||||
onValueChange={
|
||||
scale
|
||||
? ([pMin, pMax]) => {
|
||||
const step = feature.step ?? 1;
|
||||
const snap = (v: number) => Math.round(v / step) * step;
|
||||
onDragChange([
|
||||
pMin <= 0 ? (hist?.min ?? feature.min!) : snap(scale.toValue(pMin)),
|
||||
pMax >= 100 ? (hist?.max ?? feature.max!) : snap(scale.toValue(pMax)),
|
||||
]);
|
||||
}
|
||||
: ([min, max]) =>
|
||||
onDragChange([
|
||||
min <= feature.min! ? (hist?.min ?? feature.min!) : min,
|
||||
max >= feature.max! ? (hist?.max ?? feature.max!) : max,
|
||||
])
|
||||
}
|
||||
onPointerDown={() => onDragStart(feature.name)}
|
||||
onPointerUp={() => onDragEnd()}
|
||||
/>
|
||||
<SliderLabels
|
||||
min={scale ? 0 : feature.min!}
|
||||
max={scale ? 100 : feature.max!}
|
||||
value={sliderValue}
|
||||
displayValues={displayValue}
|
||||
isAtMin={isAtMin}
|
||||
isAtMax={isAtMax}
|
||||
raw={feature.raw}
|
||||
feature={feature}
|
||||
onValueChange={(v) => onFilterChange(feature.name, v)}
|
||||
/>
|
||||
{filterImpact != null && filterImpact > 0 && (
|
||||
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
|
||||
+{formatNumber(filterImpact)} without this filter
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
220
frontend/src/components/map/filters/PoiDistanceFilterCard.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import { ts } from '../../../i18n/server';
|
||||
import { Slider } from '../../ui/Slider';
|
||||
import { ChevronIcon } from '../../ui/icons';
|
||||
import { FeatureActions } from '../../ui/FeatureIcons';
|
||||
import { FeatureLabel } from '../../ui/FeatureLabel';
|
||||
import type { FeatureFilters, FeatureMeta } from '../../../types';
|
||||
import { formatNumber, type PercentileScale } from '../../../lib/format';
|
||||
import { getFeatureIcon } from '../../../lib/feature-icons';
|
||||
import { getGroupIcon } from '../../../lib/group-icons';
|
||||
import {
|
||||
POI_DISTANCE_FILTER_NAME,
|
||||
clampPoiFilterRange,
|
||||
getDefaultPoiFilterFeatureName,
|
||||
getPoiFeatureCategory,
|
||||
getPoiDistanceFeatureName,
|
||||
getPoiFilterFeatureOptions,
|
||||
getPoiFilterMeta,
|
||||
getPoiFilterName,
|
||||
replacePoiFilterKeySelection,
|
||||
} from '../../../lib/poi-distance-filter';
|
||||
import { SliderLabels } from './SliderLabels';
|
||||
|
||||
export function PoiDistanceFilterCard({
|
||||
features,
|
||||
poiFeature,
|
||||
filters,
|
||||
activeFeature,
|
||||
dragValue,
|
||||
pinnedFeature,
|
||||
filterImpact,
|
||||
percentileScale,
|
||||
onFilterChange,
|
||||
onDragStart,
|
||||
onDragChange,
|
||||
onDragEnd,
|
||||
onTogglePin,
|
||||
onShowInfo,
|
||||
onRemove,
|
||||
}: {
|
||||
features: FeatureMeta[];
|
||||
poiFeature: FeatureMeta;
|
||||
filters: FeatureFilters;
|
||||
activeFeature: string | null;
|
||||
dragValue: [number, number] | null;
|
||||
pinnedFeature: string | null;
|
||||
filterImpact?: number;
|
||||
percentileScale?: PercentileScale;
|
||||
onFilterChange: (name: string, value: [number, number] | string[]) => void;
|
||||
onDragStart: (name: string) => void;
|
||||
onDragChange: (value: [number, number]) => void;
|
||||
onDragEnd: () => void;
|
||||
onTogglePin: (name: string) => void;
|
||||
onShowInfo: (feature: FeatureMeta) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const filterName = getPoiFilterName(poiFeature.name) ?? POI_DISTANCE_FILTER_NAME;
|
||||
const poiMeta = getPoiFilterMeta(features, filterName);
|
||||
const poiOptions = getPoiFilterFeatureOptions(features, filterName);
|
||||
const selectedFeatureName =
|
||||
getPoiDistanceFeatureName(poiFeature.name) ??
|
||||
getDefaultPoiFilterFeatureName(features, filterName);
|
||||
const selectedFeature = selectedFeatureName
|
||||
? features.find((feature) => feature.name === selectedFeatureName)
|
||||
: undefined;
|
||||
|
||||
if (!selectedFeature || poiOptions.length === 0 || !selectedFeatureName) return null;
|
||||
|
||||
const isActive = activeFeature === poiFeature.name;
|
||||
const isPinned = pinnedFeature === poiFeature.name;
|
||||
const hist = selectedFeature.histogram;
|
||||
const dataMin = hist?.min ?? selectedFeature.min ?? 0;
|
||||
const dataMax = hist?.max ?? selectedFeature.max ?? 5;
|
||||
const displayValue =
|
||||
isActive && dragValue
|
||||
? dragValue
|
||||
: (filters[poiFeature.name] as [number, number]) || [dataMin, dataMax];
|
||||
const scale = percentileScale;
|
||||
const clampMin = displayValue[0] <= dataMin;
|
||||
const clampMax = displayValue[1] >= dataMax;
|
||||
const isAtMin = displayValue[0] === dataMin;
|
||||
const isAtMax = displayValue[1] === dataMax;
|
||||
const sliderValue: [number, number] = scale
|
||||
? [
|
||||
clampMin ? 0 : Math.round(scale.toPercentile(displayValue[0])),
|
||||
clampMax ? 100 : Math.round(scale.toPercentile(displayValue[1])),
|
||||
]
|
||||
: [
|
||||
clampMin ? (selectedFeature.min ?? dataMin) : displayValue[0],
|
||||
clampMax ? (selectedFeature.max ?? dataMax) : displayValue[1],
|
||||
];
|
||||
|
||||
const replacePoiFeature = (nextFeatureName: string) => {
|
||||
const nextName = replacePoiFilterKeySelection(poiFeature.name, nextFeatureName);
|
||||
if (nextName === poiFeature.name) return;
|
||||
|
||||
const nextFeature = features.find((feature) => feature.name === nextFeatureName);
|
||||
const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0;
|
||||
const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax);
|
||||
const nextRange = clampPoiFilterRange(
|
||||
[
|
||||
displayValue[0] <= dataMin ? nextDataMin : displayValue[0],
|
||||
displayValue[1] >= dataMax ? nextDataMax : displayValue[1],
|
||||
],
|
||||
nextFeature
|
||||
);
|
||||
|
||||
onFilterChange(nextName, nextRange);
|
||||
if (isPinned) onTogglePin(nextName);
|
||||
};
|
||||
|
||||
const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0';
|
||||
const mobileIcon =
|
||||
getFeatureIcon(selectedFeature.name, mobileIconClass) ||
|
||||
(() => {
|
||||
const G = selectedFeature.group ? getGroupIcon(selectedFeature.group) : null;
|
||||
return G ? <G className={mobileIconClass} /> : null;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-filter-name={filterName}
|
||||
className={`space-y-2 rounded-lg border border-warm-200 bg-white px-2 py-2 shadow-sm dark:border-warm-700 dark:bg-warm-800 ${
|
||||
isActive
|
||||
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
||||
: isPinned
|
||||
? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<div className="relative z-10 flex items-center justify-between gap-1">
|
||||
<FeatureLabel feature={poiMeta} size="sm" className="min-w-0 shrink" hideIconOnMobile />
|
||||
<FeatureActions
|
||||
feature={selectedFeature}
|
||||
actionName={poiFeature.name}
|
||||
isPinned={isPinned}
|
||||
isPreviewing={isActive}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={onShowInfo}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
POI type
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedFeatureName}
|
||||
onChange={(e) => replacePoiFeature(e.target.value)}
|
||||
className="w-full appearance-none rounded-md border border-warm-200 bg-warm-50 px-2 py-1.5 pr-8 text-sm font-medium text-navy-950 shadow-inner outline-none transition-colors hover:bg-white focus:border-teal-400 focus:ring-2 focus:ring-teal-200 dark:border-warm-700 dark:bg-navy-900 dark:text-warm-100 dark:hover:bg-navy-800 dark:focus:ring-teal-900/50"
|
||||
>
|
||||
{poiOptions.map((option) => (
|
||||
<option key={option.name} value={option.name}>
|
||||
{ts(getPoiFeatureCategory(option.name) ?? option.name)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronIcon
|
||||
direction="down"
|
||||
className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-warm-400 dark:text-warm-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-1.5 md:block">
|
||||
{mobileIcon && <div className="shrink-0 pt-0.5 md:hidden">{mobileIcon}</div>}
|
||||
<div className="min-w-0 flex-1">
|
||||
<Slider
|
||||
min={scale ? 0 : (selectedFeature.min ?? dataMin)}
|
||||
max={scale ? 100 : (selectedFeature.max ?? dataMax)}
|
||||
step={
|
||||
scale
|
||||
? 1
|
||||
: (selectedFeature.step ??
|
||||
((selectedFeature.max ?? dataMax) - (selectedFeature.min ?? dataMin)) / 100)
|
||||
}
|
||||
value={sliderValue}
|
||||
onValueChange={
|
||||
scale
|
||||
? ([pMin, pMax]) => {
|
||||
const step = selectedFeature.step ?? 0.1;
|
||||
const snap = (v: number) => Math.round(v / step) * step;
|
||||
onDragChange([
|
||||
pMin <= 0 ? dataMin : snap(scale.toValue(pMin)),
|
||||
pMax >= 100 ? dataMax : snap(scale.toValue(pMax)),
|
||||
]);
|
||||
}
|
||||
: ([min, max]) =>
|
||||
onDragChange([
|
||||
min <= (selectedFeature.min ?? dataMin) ? dataMin : min,
|
||||
max >= (selectedFeature.max ?? dataMax) ? dataMax : max,
|
||||
])
|
||||
}
|
||||
onPointerDown={() => onDragStart(poiFeature.name)}
|
||||
onPointerUp={() => onDragEnd()}
|
||||
/>
|
||||
<SliderLabels
|
||||
min={scale ? 0 : (selectedFeature.min ?? dataMin)}
|
||||
max={scale ? 100 : (selectedFeature.max ?? dataMax)}
|
||||
value={sliderValue}
|
||||
displayValues={displayValue}
|
||||
isAtMin={isAtMin}
|
||||
isAtMax={isAtMax}
|
||||
raw={selectedFeature.raw}
|
||||
feature={selectedFeature}
|
||||
onValueChange={(v) =>
|
||||
onFilterChange(poiFeature.name, clampPoiFilterRange(v, selectedFeature))
|
||||
}
|
||||
/>
|
||||
{filterImpact != null && filterImpact > 0 && (
|
||||
<p className="-mt-1 ml-2.5 text-[10px] text-warm-400 dark:text-warm-500">
|
||||
+{formatNumber(filterImpact)} without this filter
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
234
frontend/src/components/map/filters/SchoolFilterCard.tsx
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { Slider } from '../../ui/Slider';
|
||||
import { FeatureActions } from '../../ui/FeatureIcons';
|
||||
import { FeatureLabel } from '../../ui/FeatureLabel';
|
||||
import type { FeatureFilters, FeatureMeta } from '../../../types';
|
||||
import { formatNumber } from '../../../lib/format';
|
||||
import {
|
||||
SCHOOL_FILTER_NAME,
|
||||
clampSchoolRange,
|
||||
getSchoolBackendFeatureName,
|
||||
getSchoolFilterConfig,
|
||||
getSchoolFilterMeta,
|
||||
replaceSchoolFilterKeySelection,
|
||||
type SchoolDistance,
|
||||
type SchoolPhase,
|
||||
type SchoolRating,
|
||||
} from '../../../lib/school-filter';
|
||||
import { SliderLabels } from './SliderLabels';
|
||||
|
||||
export function SchoolFilterCard({
|
||||
features,
|
||||
schoolFeature,
|
||||
filters,
|
||||
activeFeature,
|
||||
dragValue,
|
||||
pinnedFeature,
|
||||
filterImpact,
|
||||
onFilterChange,
|
||||
onDragStart,
|
||||
onDragChange,
|
||||
onDragEnd,
|
||||
onTogglePin,
|
||||
onShowInfo,
|
||||
onRemove,
|
||||
}: {
|
||||
features: FeatureMeta[];
|
||||
schoolFeature: FeatureMeta;
|
||||
filters: FeatureFilters;
|
||||
activeFeature: string | null;
|
||||
dragValue: [number, number] | null;
|
||||
pinnedFeature: string | null;
|
||||
filterImpact?: number;
|
||||
onFilterChange: (name: string, value: [number, number] | string[]) => void;
|
||||
onDragStart: (name: string) => void;
|
||||
onDragChange: (value: [number, number]) => void;
|
||||
onDragEnd: () => void;
|
||||
onTogglePin: (name: string) => void;
|
||||
onShowInfo: (feature: FeatureMeta) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const config = getSchoolFilterConfig(schoolFeature.name);
|
||||
const schoolMeta = getSchoolFilterMeta(features);
|
||||
const backendFeature = config
|
||||
? features.find((feature) => feature.name === config.featureName)
|
||||
: undefined;
|
||||
const isActive = activeFeature === schoolFeature.name;
|
||||
const isPinned = pinnedFeature === schoolFeature.name;
|
||||
const hist = backendFeature?.histogram;
|
||||
const dataMin = hist?.min ?? backendFeature?.min ?? 0;
|
||||
const dataMax = hist?.max ?? backendFeature?.max ?? 10;
|
||||
const displayValue =
|
||||
isActive && dragValue
|
||||
? dragValue
|
||||
: (filters[schoolFeature.name] as [number, number]) || [dataMin, dataMax];
|
||||
const sliderValue: [number, number] = [
|
||||
displayValue[0] <= dataMin ? (backendFeature?.min ?? dataMin) : displayValue[0],
|
||||
displayValue[1] >= dataMax ? (backendFeature?.max ?? dataMax) : displayValue[1],
|
||||
];
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
const replaceSchoolFeature = (
|
||||
next: Partial<{
|
||||
phase: SchoolPhase;
|
||||
rating: SchoolRating;
|
||||
distance: SchoolDistance;
|
||||
}>
|
||||
) => {
|
||||
const nextName = replaceSchoolFilterKeySelection(schoolFeature.name, next);
|
||||
if (nextName === schoolFeature.name) return;
|
||||
|
||||
const nextBackendName = getSchoolBackendFeatureName(nextName);
|
||||
const nextFeature = nextBackendName
|
||||
? features.find((feature) => feature.name === nextBackendName)
|
||||
: undefined;
|
||||
const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0;
|
||||
const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax);
|
||||
const nextRange = clampSchoolRange(
|
||||
[
|
||||
displayValue[0] <= dataMin ? nextDataMin : displayValue[0],
|
||||
displayValue[1] >= dataMax ? nextDataMax : displayValue[1],
|
||||
],
|
||||
nextFeature
|
||||
);
|
||||
onFilterChange(nextName, nextRange);
|
||||
if (isPinned) onTogglePin(nextName);
|
||||
};
|
||||
|
||||
const segmentedClass =
|
||||
'grid grid-cols-2 overflow-hidden rounded-md border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800';
|
||||
const optionClass = (active: boolean) =>
|
||||
`px-2 py-1 text-xs font-medium border-r last:border-r-0 border-warm-200 dark:border-warm-700 transition-colors ${
|
||||
active
|
||||
? 'bg-teal-600 text-white dark:bg-teal-500'
|
||||
: 'text-warm-600 hover:bg-warm-100 dark:text-warm-300 dark:hover:bg-warm-700'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-filter-name={SCHOOL_FILTER_NAME}
|
||||
className={`space-y-1.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="relative z-10 flex items-center justify-between gap-1">
|
||||
<FeatureLabel feature={schoolMeta} size="sm" className="min-w-0 shrink" hideIconOnMobile />
|
||||
<FeatureActions
|
||||
feature={schoolMeta}
|
||||
isPinned={isPinned}
|
||||
isPreviewing={isActive}
|
||||
onTogglePin={() => onTogglePin(schoolFeature.name)}
|
||||
onShowInfo={() => onShowInfo(schoolMeta)}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div>
|
||||
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
School type
|
||||
</div>
|
||||
<div className={segmentedClass} role="radiogroup" aria-label="School type">
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={config.phase === 'primary'}
|
||||
onClick={() => replaceSchoolFeature({ phase: 'primary' })}
|
||||
className={optionClass(config.phase === 'primary')}
|
||||
>
|
||||
Primary
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={config.phase === 'secondary'}
|
||||
onClick={() => replaceSchoolFeature({ phase: 'secondary' })}
|
||||
className={optionClass(config.phase === 'secondary')}
|
||||
>
|
||||
Secondary
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
Rating
|
||||
</div>
|
||||
<div className={segmentedClass} role="radiogroup" aria-label="School rating">
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={config.rating === 'good'}
|
||||
onClick={() => replaceSchoolFeature({ rating: 'good' })}
|
||||
className={optionClass(config.rating === 'good')}
|
||||
>
|
||||
Good+
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={config.rating === 'outstanding'}
|
||||
onClick={() => replaceSchoolFeature({ rating: 'outstanding' })}
|
||||
className={optionClass(config.rating === 'outstanding')}
|
||||
>
|
||||
Outstanding
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
Distance
|
||||
</div>
|
||||
<div className={segmentedClass} role="radiogroup" aria-label="School distance">
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={config.distance === 2}
|
||||
onClick={() => replaceSchoolFeature({ distance: 2 })}
|
||||
className={optionClass(config.distance === 2)}
|
||||
>
|
||||
2 km
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={config.distance === 5}
|
||||
onClick={() => replaceSchoolFeature({ distance: 5 })}
|
||||
className={optionClass(config.distance === 5)}
|
||||
>
|
||||
5 km
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Slider
|
||||
min={backendFeature?.min ?? dataMin}
|
||||
max={backendFeature?.max ?? dataMax}
|
||||
step={backendFeature?.step ?? 1}
|
||||
value={sliderValue}
|
||||
onValueChange={([min, max]) =>
|
||||
onDragChange([
|
||||
min <= (backendFeature?.min ?? dataMin) ? dataMin : min,
|
||||
max >= (backendFeature?.max ?? dataMax) ? dataMax : max,
|
||||
])
|
||||
}
|
||||
onPointerDown={() => onDragStart(schoolFeature.name)}
|
||||
onPointerUp={() => onDragEnd()}
|
||||
/>
|
||||
<SliderLabels
|
||||
min={backendFeature?.min ?? dataMin}
|
||||
max={backendFeature?.max ?? dataMax}
|
||||
value={sliderValue}
|
||||
displayValues={displayValue}
|
||||
isAtMin={displayValue[0] === dataMin}
|
||||
isAtMax={displayValue[1] === dataMax}
|
||||
raw={backendFeature?.raw}
|
||||
feature={backendFeature}
|
||||
onValueChange={(v) => onFilterChange(schoolFeature.name, v)}
|
||||
/>
|
||||
{filterImpact != null && filterImpact > 0 && (
|
||||
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
|
||||
+{formatNumber(filterImpact)} without this filter
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
frontend/src/components/map/filters/SliderLabels.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import type React from 'react';
|
||||
|
||||
import type { FeatureMeta } from '../../../types';
|
||||
import { formatFilterValue, parseInputValue } from '../../../lib/format';
|
||||
|
||||
function EditableLabel({
|
||||
value,
|
||||
formatted,
|
||||
onCommit,
|
||||
prefix,
|
||||
suffix,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
value: number;
|
||||
formatted: string;
|
||||
onCommit: (v: number) => void;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [text, setText] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const startEdit = () => {
|
||||
setEditing(true);
|
||||
setText(String(Math.round(value)));
|
||||
};
|
||||
|
||||
const commit = () => {
|
||||
const parsed = parseInputValue(text, { prefix, suffix });
|
||||
if (parsed != null) onCommit(parsed);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commit();
|
||||
if (e.key === 'Escape') setEditing(false);
|
||||
}}
|
||||
onBlur={commit}
|
||||
className="absolute w-16 text-[10px] text-center rounded border border-warm-300 dark:border-warm-600 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 px-0.5 focus:outline-none focus:ring-1 focus:ring-teal-400"
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`absolute cursor-pointer hover:text-teal-600 dark:hover:text-teal-400 border-b border-dotted border-warm-400 dark:border-warm-500 ${className ?? ''}`}
|
||||
style={style}
|
||||
onClick={startEdit}
|
||||
>
|
||||
{formatted}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function SliderLabels({
|
||||
min,
|
||||
max,
|
||||
value,
|
||||
displayValues,
|
||||
isAtMin,
|
||||
isAtMax,
|
||||
raw,
|
||||
feature,
|
||||
onValueChange,
|
||||
}: {
|
||||
min: number;
|
||||
max: number;
|
||||
value: [number, number];
|
||||
displayValues?: [number, number];
|
||||
isAtMin?: boolean;
|
||||
isAtMax?: boolean;
|
||||
raw?: boolean;
|
||||
feature?: FeatureMeta;
|
||||
onValueChange?: (v: [number, number]) => void;
|
||||
}) {
|
||||
const range = max - min || 1;
|
||||
const leftPct = Math.max(0, Math.min(100, ((value[0] - min) / range) * 100));
|
||||
const rightPct = Math.max(0, Math.min(100, ((value[1] - min) / range) * 100));
|
||||
const labels = displayValues || value;
|
||||
const labelFormat = feature?.suffix === '%' ? { raw, suffix: feature.suffix } : raw;
|
||||
|
||||
const minLabel = isAtMin ? 'min' : formatFilterValue(labels[0], labelFormat);
|
||||
const maxLabel = isAtMax ? 'max' : formatFilterValue(labels[1], labelFormat);
|
||||
|
||||
// Smoothly spread labels apart as thumbs get close to prevent overlap.
|
||||
// t=1 (centered) when far apart, t=0 (split) when touching.
|
||||
const SPREAD_THRESHOLD = 20; // percentage gap below which labels start separating
|
||||
const gapPct = rightPct - leftPct;
|
||||
const t = Math.min(1, Math.max(0, gapPct / SPREAD_THRESHOLD));
|
||||
const leftTranslate = `translateX(${-100 + t * 50}%)`;
|
||||
const rightTranslate = `translateX(${-t * 50}%)`;
|
||||
|
||||
if (feature && onValueChange) {
|
||||
return (
|
||||
<div className="relative h-4 mt-2 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
||||
<EditableLabel
|
||||
value={labels[0]}
|
||||
formatted={minLabel}
|
||||
onCommit={(v) => onValueChange([v, Math.max(v, labels[1])])}
|
||||
prefix={feature.prefix}
|
||||
suffix={feature.suffix}
|
||||
style={{ left: `${leftPct}%`, transform: leftTranslate }}
|
||||
/>
|
||||
<EditableLabel
|
||||
value={labels[1]}
|
||||
formatted={maxLabel}
|
||||
onCommit={(v) => onValueChange([Math.min(labels[0], v), v])}
|
||||
prefix={feature.prefix}
|
||||
suffix={feature.suffix}
|
||||
style={{ left: `${rightPct}%`, transform: rightTranslate }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-4 mt-2 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
||||
<span className="absolute" style={{ left: `${leftPct}%`, transform: leftTranslate }}>
|
||||
{minLabel}
|
||||
</span>
|
||||
<span className="absolute" style={{ left: `${rightPct}%`, transform: rightTranslate }}>
|
||||
{maxLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
223
frontend/src/components/map/filters/SpecificCrimeFilterCard.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import { ts } from '../../../i18n/server';
|
||||
import { Slider } from '../../ui/Slider';
|
||||
import { ChevronIcon } from '../../ui/icons';
|
||||
import { FeatureActions } from '../../ui/FeatureIcons';
|
||||
import { FeatureLabel } from '../../ui/FeatureLabel';
|
||||
import type { FeatureFilters, FeatureMeta } from '../../../types';
|
||||
import { formatNumber, type PercentileScale } from '../../../lib/format';
|
||||
import { getFeatureIcon } from '../../../lib/feature-icons';
|
||||
import { getGroupIcon } from '../../../lib/group-icons';
|
||||
import {
|
||||
SPECIFIC_CRIMES_FILTER_NAME,
|
||||
SPECIFIC_CRIME_FEATURE_NAMES,
|
||||
clampSpecificCrimeRange,
|
||||
getDefaultSpecificCrimeFeatureName,
|
||||
getSpecificCrimeFeatureName,
|
||||
getSpecificCrimeFilterMeta,
|
||||
replaceSpecificCrimeFilterKeySelection,
|
||||
} from '../../../lib/crime-filter';
|
||||
import { SliderLabels } from './SliderLabels';
|
||||
|
||||
export function SpecificCrimeFilterCard({
|
||||
features,
|
||||
crimeFeature,
|
||||
filters,
|
||||
activeFeature,
|
||||
dragValue,
|
||||
pinnedFeature,
|
||||
filterImpact,
|
||||
percentileScale,
|
||||
onFilterChange,
|
||||
onDragStart,
|
||||
onDragChange,
|
||||
onDragEnd,
|
||||
onTogglePin,
|
||||
onShowInfo,
|
||||
onRemove,
|
||||
}: {
|
||||
features: FeatureMeta[];
|
||||
crimeFeature: FeatureMeta;
|
||||
filters: FeatureFilters;
|
||||
activeFeature: string | null;
|
||||
dragValue: [number, number] | null;
|
||||
pinnedFeature: string | null;
|
||||
filterImpact?: number;
|
||||
percentileScale?: PercentileScale;
|
||||
onFilterChange: (name: string, value: [number, number] | string[]) => void;
|
||||
onDragStart: (name: string) => void;
|
||||
onDragChange: (value: [number, number]) => void;
|
||||
onDragEnd: () => void;
|
||||
onTogglePin: (name: string) => void;
|
||||
onShowInfo: (feature: FeatureMeta) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const specificCrimeMeta = getSpecificCrimeFilterMeta(features);
|
||||
const crimeOptions = SPECIFIC_CRIME_FEATURE_NAMES.map((name) =>
|
||||
features.find((feature) => feature.name === name)
|
||||
).filter((feature): feature is FeatureMeta => Boolean(feature));
|
||||
const selectedFeatureName =
|
||||
getSpecificCrimeFeatureName(crimeFeature.name) ?? getDefaultSpecificCrimeFeatureName(features);
|
||||
const selectedFeature = selectedFeatureName
|
||||
? features.find((feature) => feature.name === selectedFeatureName)
|
||||
: undefined;
|
||||
|
||||
if (!selectedFeature || crimeOptions.length === 0 || !selectedFeatureName) return null;
|
||||
|
||||
const isActive = activeFeature === crimeFeature.name;
|
||||
const isPinned = pinnedFeature === crimeFeature.name;
|
||||
const hist = selectedFeature.histogram;
|
||||
const dataMin = hist?.min ?? selectedFeature.min ?? 0;
|
||||
const dataMax = hist?.max ?? selectedFeature.max ?? 100;
|
||||
const displayValue =
|
||||
isActive && dragValue
|
||||
? dragValue
|
||||
: (filters[crimeFeature.name] as [number, number]) || [dataMin, dataMax];
|
||||
const scale = percentileScale;
|
||||
const clampMin = displayValue[0] <= dataMin;
|
||||
const clampMax = displayValue[1] >= dataMax;
|
||||
const isAtMin = displayValue[0] === dataMin;
|
||||
const isAtMax = displayValue[1] === dataMax;
|
||||
const sliderValue: [number, number] = scale
|
||||
? [
|
||||
clampMin ? 0 : Math.round(scale.toPercentile(displayValue[0])),
|
||||
clampMax ? 100 : Math.round(scale.toPercentile(displayValue[1])),
|
||||
]
|
||||
: [
|
||||
clampMin ? (selectedFeature.min ?? dataMin) : displayValue[0],
|
||||
clampMax ? (selectedFeature.max ?? dataMax) : displayValue[1],
|
||||
];
|
||||
|
||||
const replaceCrimeFeature = (nextFeatureName: string) => {
|
||||
const nextName = replaceSpecificCrimeFilterKeySelection(crimeFeature.name, nextFeatureName);
|
||||
if (nextName === crimeFeature.name) return;
|
||||
|
||||
const nextFeature = features.find((feature) => feature.name === nextFeatureName);
|
||||
const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0;
|
||||
const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax);
|
||||
const nextRange = clampSpecificCrimeRange(
|
||||
[
|
||||
displayValue[0] <= dataMin ? nextDataMin : displayValue[0],
|
||||
displayValue[1] >= dataMax ? nextDataMax : displayValue[1],
|
||||
],
|
||||
nextFeature
|
||||
);
|
||||
|
||||
onFilterChange(nextName, nextRange);
|
||||
if (isPinned) onTogglePin(nextName);
|
||||
};
|
||||
|
||||
const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0';
|
||||
const mobileIcon =
|
||||
getFeatureIcon(selectedFeature.name, mobileIconClass) ||
|
||||
(() => {
|
||||
const G = selectedFeature.group ? getGroupIcon(selectedFeature.group) : null;
|
||||
return G ? <G className={mobileIconClass} /> : null;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-filter-name={SPECIFIC_CRIMES_FILTER_NAME}
|
||||
className={`space-y-2 rounded-lg border border-warm-200 bg-white px-2 py-2 shadow-sm dark:border-warm-700 dark:bg-warm-800 ${
|
||||
isActive
|
||||
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
||||
: isPinned
|
||||
? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<div className="relative z-10 flex items-center justify-between gap-1">
|
||||
<FeatureLabel
|
||||
feature={specificCrimeMeta}
|
||||
size="sm"
|
||||
className="min-w-0 shrink"
|
||||
hideIconOnMobile
|
||||
/>
|
||||
<FeatureActions
|
||||
feature={selectedFeature}
|
||||
actionName={crimeFeature.name}
|
||||
isPinned={isPinned}
|
||||
isPreviewing={isActive}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={onShowInfo}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
Crime type
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedFeatureName}
|
||||
onChange={(e) => replaceCrimeFeature(e.target.value)}
|
||||
className="w-full appearance-none rounded-md border border-warm-200 bg-warm-50 px-2 py-1.5 pr-8 text-sm font-medium text-navy-950 shadow-inner outline-none transition-colors hover:bg-white focus:border-teal-400 focus:ring-2 focus:ring-teal-200 dark:border-warm-700 dark:bg-navy-900 dark:text-warm-100 dark:hover:bg-navy-800 dark:focus:ring-teal-900/50"
|
||||
>
|
||||
{crimeOptions.map((option) => (
|
||||
<option key={option.name} value={option.name}>
|
||||
{ts(option.name)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronIcon
|
||||
direction="down"
|
||||
className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-warm-400 dark:text-warm-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-1.5 md:block">
|
||||
{mobileIcon && <div className="shrink-0 pt-0.5 md:hidden">{mobileIcon}</div>}
|
||||
<div className="min-w-0 flex-1">
|
||||
<Slider
|
||||
min={scale ? 0 : (selectedFeature.min ?? dataMin)}
|
||||
max={scale ? 100 : (selectedFeature.max ?? dataMax)}
|
||||
step={
|
||||
scale
|
||||
? 1
|
||||
: (selectedFeature.step ??
|
||||
((selectedFeature.max ?? dataMax) - (selectedFeature.min ?? dataMin)) / 100)
|
||||
}
|
||||
value={sliderValue}
|
||||
onValueChange={
|
||||
scale
|
||||
? ([pMin, pMax]) => {
|
||||
const step = selectedFeature.step ?? 1;
|
||||
const snap = (v: number) => Math.round(v / step) * step;
|
||||
onDragChange([
|
||||
pMin <= 0 ? dataMin : snap(scale.toValue(pMin)),
|
||||
pMax >= 100 ? dataMax : snap(scale.toValue(pMax)),
|
||||
]);
|
||||
}
|
||||
: ([min, max]) =>
|
||||
onDragChange([
|
||||
min <= (selectedFeature.min ?? dataMin) ? dataMin : min,
|
||||
max >= (selectedFeature.max ?? dataMax) ? dataMax : max,
|
||||
])
|
||||
}
|
||||
onPointerDown={() => onDragStart(crimeFeature.name)}
|
||||
onPointerUp={() => onDragEnd()}
|
||||
/>
|
||||
<SliderLabels
|
||||
min={scale ? 0 : (selectedFeature.min ?? dataMin)}
|
||||
max={scale ? 100 : (selectedFeature.max ?? dataMax)}
|
||||
value={sliderValue}
|
||||
displayValues={displayValue}
|
||||
isAtMin={isAtMin}
|
||||
isAtMax={isAtMax}
|
||||
raw={selectedFeature.raw}
|
||||
feature={selectedFeature}
|
||||
onValueChange={(v) =>
|
||||
onFilterChange(crimeFeature.name, clampSpecificCrimeRange(v, selectedFeature))
|
||||
}
|
||||
/>
|
||||
{filterImpact != null && filterImpact > 0 && (
|
||||
<p className="-mt-1 ml-2.5 text-[10px] text-warm-400 dark:text-warm-500">
|
||||
+{formatNumber(filterImpact)} without this filter
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { type TravelTimeEntry, travelFieldKey } from '../../../hooks/useTravelTime';
|
||||
import { TravelTimeCard } from '../TravelTimeCard';
|
||||
|
||||
interface TravelTimeFilterCardsProps {
|
||||
entries: TravelTimeEntry[];
|
||||
activeFeature: string | null;
|
||||
dragValue: [number, number] | null;
|
||||
pinnedFeature: string | null;
|
||||
filterImpacts?: Record<string, number>;
|
||||
destinationDropdownPortal: boolean;
|
||||
onTogglePin: (name: string) => void;
|
||||
onTravelTimeRemoveEntry: (index: number) => void;
|
||||
onTravelTimeSetDestination: (
|
||||
index: number,
|
||||
slug: string,
|
||||
label: string,
|
||||
lat: number,
|
||||
lon: number
|
||||
) => void;
|
||||
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
|
||||
onTravelTimeDragEnd: (index: number) => void;
|
||||
onTravelTimeToggleBest: (index: number) => void;
|
||||
onDragStart: (name: string) => void;
|
||||
onDragChange: (value: [number, number]) => void;
|
||||
}
|
||||
|
||||
export function TravelTimeFilterCards({
|
||||
entries,
|
||||
activeFeature,
|
||||
dragValue,
|
||||
pinnedFeature,
|
||||
filterImpacts,
|
||||
destinationDropdownPortal,
|
||||
onTogglePin,
|
||||
onTravelTimeRemoveEntry,
|
||||
onTravelTimeSetDestination,
|
||||
onTravelTimeRangeChange,
|
||||
onTravelTimeDragEnd,
|
||||
onTravelTimeToggleBest,
|
||||
onDragStart,
|
||||
onDragChange,
|
||||
}: TravelTimeFilterCardsProps) {
|
||||
return (
|
||||
<>
|
||||
{entries.map((entry, index) => {
|
||||
const fieldKey = travelFieldKey(entry);
|
||||
return (
|
||||
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
|
||||
<TravelTimeCard
|
||||
mode={entry.mode}
|
||||
slug={entry.slug}
|
||||
label={entry.label}
|
||||
timeRange={entry.timeRange}
|
||||
useBest={entry.useBest}
|
||||
isPinned={pinnedFeature === fieldKey}
|
||||
isActive={activeFeature === fieldKey}
|
||||
dragValue={activeFeature === fieldKey ? dragValue : null}
|
||||
onTogglePin={() => onTogglePin(fieldKey)}
|
||||
onSetDestination={(slug, label, lat, lon) =>
|
||||
onTravelTimeSetDestination(index, slug, label, lat, lon)
|
||||
}
|
||||
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
|
||||
onDragStart={() => onDragStart(fieldKey)}
|
||||
onDragChange={onDragChange}
|
||||
onDragEnd={() => onTravelTimeDragEnd(index)}
|
||||
onToggleBest={() => onTravelTimeToggleBest(index)}
|
||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||
filterImpact={filterImpacts?.[fieldKey]}
|
||||
destinationDropdownPortal={destinationDropdownPortal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
225
frontend/src/components/map/map-page/DesktopMapPage.tsx
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import { Suspense, type MutableRefObject, type ReactNode } from 'react';
|
||||
|
||||
import type { FeatureFilters, FeatureMeta, POI, PostcodeGeometry, ViewState } from '../../../types';
|
||||
import type { useMapData } from '../../../hooks/useMapData';
|
||||
import type { useTutorial } from '../../../hooks/useTutorial';
|
||||
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
|
||||
import type { getTutorialStyles } from '../../../lib/tutorial-styles';
|
||||
import type { SearchedLocation } from '../LocationSearch';
|
||||
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
|
||||
import type { MapFlyTo, PaneResizeHandlers } from './types';
|
||||
import { MapFallback, PaneFallback } from './Fallbacks';
|
||||
import { LoadingOverlay } from './LoadingOverlay';
|
||||
import { Joyride, Map, MapPageSelectionPane } from './lazyComponents';
|
||||
|
||||
type MapData = ReturnType<typeof useMapData>;
|
||||
type Tutorial = ReturnType<typeof useTutorial>;
|
||||
type TutorialTheme = ReturnType<typeof getTutorialStyles>;
|
||||
type RightPaneTab = 'properties' | 'area';
|
||||
|
||||
interface DesktopMapPageProps {
|
||||
initialLoading: boolean;
|
||||
tutorial: Tutorial;
|
||||
tutorialTheme: TutorialTheme;
|
||||
leftPaneWidth: number;
|
||||
leftPaneHandlers: PaneResizeHandlers;
|
||||
filtersPane: ReactNode;
|
||||
mapData: MapData;
|
||||
pois: POI[];
|
||||
mapViewFeature: string | null;
|
||||
filterRange: [number, number] | null;
|
||||
viewSource: 'drag' | 'eye' | null;
|
||||
onCancelPin: () => void;
|
||||
features: FeatureMeta[];
|
||||
selectedHexagonId: string | null;
|
||||
hoveredHexagonId: string | null;
|
||||
onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void;
|
||||
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
|
||||
initialViewState: ViewState;
|
||||
flyToRef: MutableRefObject<MapFlyTo | null>;
|
||||
theme: 'light' | 'dark';
|
||||
filters: FeatureFilters;
|
||||
selectedPostcodeGeometry: PostcodeGeometry | null;
|
||||
onLocationSearched: (location: SearchedLocation | null) => void;
|
||||
onCurrentLocationFound: (lat: number, lng: number) => void;
|
||||
currentLocation: { lat: number; lng: number } | null;
|
||||
travelTimeEntries: TravelTimeEntry[];
|
||||
densityLabel: string;
|
||||
totalCount?: number;
|
||||
poiPaneOpen: boolean;
|
||||
onTogglePoiPane: () => void;
|
||||
poiPane: ReactNode;
|
||||
showSelectionPane: boolean;
|
||||
rightPaneWidth: number;
|
||||
rightPaneHandlers: PaneResizeHandlers;
|
||||
rightPaneTab: RightPaneTab;
|
||||
onAreaTabClick: () => void;
|
||||
onPropertiesTabClick: () => void;
|
||||
onCloseSelection: () => void;
|
||||
renderAreaPane: () => ReactNode;
|
||||
renderPropertiesPane: () => ReactNode;
|
||||
toasts: ReactNode;
|
||||
upgradeModal: ReactNode;
|
||||
}
|
||||
|
||||
export function DesktopMapPage({
|
||||
initialLoading,
|
||||
tutorial,
|
||||
tutorialTheme,
|
||||
leftPaneWidth,
|
||||
leftPaneHandlers,
|
||||
filtersPane,
|
||||
mapData,
|
||||
pois,
|
||||
mapViewFeature,
|
||||
filterRange,
|
||||
viewSource,
|
||||
onCancelPin,
|
||||
features,
|
||||
selectedHexagonId,
|
||||
hoveredHexagonId,
|
||||
onHexagonClick,
|
||||
onHexagonHover,
|
||||
initialViewState,
|
||||
flyToRef,
|
||||
theme,
|
||||
filters,
|
||||
selectedPostcodeGeometry,
|
||||
onLocationSearched,
|
||||
onCurrentLocationFound,
|
||||
currentLocation,
|
||||
travelTimeEntries,
|
||||
densityLabel,
|
||||
totalCount,
|
||||
poiPaneOpen,
|
||||
onTogglePoiPane,
|
||||
poiPane,
|
||||
showSelectionPane,
|
||||
rightPaneWidth,
|
||||
rightPaneHandlers,
|
||||
rightPaneTab,
|
||||
onAreaTabClick,
|
||||
onPropertiesTabClick,
|
||||
onCloseSelection,
|
||||
renderAreaPane,
|
||||
renderPropertiesPane,
|
||||
toasts,
|
||||
upgradeModal,
|
||||
}: DesktopMapPageProps) {
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden relative">
|
||||
<LoadingOverlay show={initialLoading} />
|
||||
|
||||
{tutorial.run && (
|
||||
<Suspense fallback={null}>
|
||||
<Joyride
|
||||
steps={tutorial.steps}
|
||||
run={tutorial.run}
|
||||
continuous
|
||||
onEvent={tutorial.handleCallback}
|
||||
styles={tutorialTheme.styles}
|
||||
options={{
|
||||
...tutorialTheme.options,
|
||||
buttons: ['back', 'close', 'primary', 'skip'],
|
||||
showProgress: true,
|
||||
skipScroll: true,
|
||||
}}
|
||||
locale={{ last: 'Finish' }}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
<div
|
||||
data-tutorial="filters"
|
||||
className="flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden"
|
||||
style={{ width: leftPaneWidth }}
|
||||
>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">{filtersPane}</div>
|
||||
<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"
|
||||
{...leftPaneHandlers}
|
||||
>
|
||||
<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>
|
||||
|
||||
<div data-tutorial="map" className="flex-1 relative">
|
||||
{tutorial.run && (
|
||||
<div
|
||||
data-tutorial="map-anchor"
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-1/2 top-1/2 z-20 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white bg-teal-500 shadow-lg ring-4 ring-teal-500/30 dark:border-navy-950"
|
||||
/>
|
||||
)}
|
||||
<Suspense fallback={<MapFallback />}>
|
||||
<Map
|
||||
data={mapData.data}
|
||||
postcodeData={mapData.postcodeData}
|
||||
usePostcodeView={mapData.usePostcodeView}
|
||||
pois={pois}
|
||||
onViewChange={mapData.handleViewChange}
|
||||
viewFeature={mapViewFeature}
|
||||
colorRange={mapData.colorRange}
|
||||
filterRange={filterRange}
|
||||
viewSource={viewSource}
|
||||
onCancelPin={onCancelPin}
|
||||
onResetPreviewScale={mapData.handleResetPreviewScale}
|
||||
canResetPreviewScale={mapData.canResetPreviewScale}
|
||||
features={features}
|
||||
selectedHexagonId={selectedHexagonId}
|
||||
hoveredHexagonId={hoveredHexagonId}
|
||||
onHexagonClick={onHexagonClick}
|
||||
onHexagonHover={onHexagonHover}
|
||||
initialViewState={initialViewState}
|
||||
flyToRef={flyToRef}
|
||||
theme={theme}
|
||||
filters={filters}
|
||||
selectedPostcodeGeometry={selectedPostcodeGeometry}
|
||||
onLocationSearched={onLocationSearched}
|
||||
onCurrentLocationFound={onCurrentLocationFound}
|
||||
currentLocation={currentLocation}
|
||||
bounds={mapData.bounds}
|
||||
travelTimeEntries={travelTimeEntries}
|
||||
densityLabel={densityLabel}
|
||||
totalCount={totalCount}
|
||||
/>
|
||||
</Suspense>
|
||||
<button
|
||||
data-tutorial="poi-button"
|
||||
onClick={onTogglePoiPane}
|
||||
className={`absolute bottom-4 right-4 z-10 px-3 py-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 flex items-center gap-2 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
|
||||
>
|
||||
<MapPinIcon className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">Points of interest</span>
|
||||
</button>
|
||||
{poiPaneOpen && (
|
||||
<div className="absolute bottom-14 right-4 z-10 flex h-[60vh] min-h-0 w-80 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
|
||||
{poiPane}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showSelectionPane && (
|
||||
<Suspense fallback={<PaneFallback />}>
|
||||
<MapPageSelectionPane
|
||||
width={rightPaneWidth}
|
||||
resizeHandlers={rightPaneHandlers}
|
||||
tab={rightPaneTab}
|
||||
onAreaTabClick={onAreaTabClick}
|
||||
onPropertiesTabClick={onPropertiesTabClick}
|
||||
onClose={onCloseSelection}
|
||||
renderAreaPane={renderAreaPane}
|
||||
renderPropertiesPane={renderPropertiesPane}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{toasts}
|
||||
{upgradeModal}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
frontend/src/components/map/map-page/Fallbacks.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { SpinnerIcon } from '../../ui/icons/SpinnerIcon';
|
||||
|
||||
export function MapFallback() {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-warm-100 dark:bg-navy-950">
|
||||
<SpinnerIcon className="h-8 w-8 animate-spin text-teal-600 dark:text-teal-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaneFallback() {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-white dark:bg-navy-950">
|
||||
<SpinnerIcon className="h-6 w-6 animate-spin text-teal-600 dark:text-teal-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/map/map-page/LoadingOverlay.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { SpinnerIcon } from '../../ui/icons/SpinnerIcon';
|
||||
|
||||
interface LoadingOverlayProps {
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
export function LoadingOverlay({ show }: LoadingOverlayProps) {
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<SpinnerIcon className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" />
|
||||
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">
|
||||
Connecting to server...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
frontend/src/components/map/map-page/MobileMapLegend.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { FeatureMeta } from '../../../types';
|
||||
import { useTranslatedModes, type TransportMode } from '../../../hooks/useTravelTime';
|
||||
import { ts } from '../../../i18n/server';
|
||||
import MapLegend from '../MapLegend';
|
||||
|
||||
interface MobileMapLegendProps {
|
||||
mapViewFeature: string | null;
|
||||
colorRange: [number, number] | null;
|
||||
viewSource: 'drag' | 'eye' | null;
|
||||
mobileLegendMeta: FeatureMeta | null;
|
||||
densityLabel: string;
|
||||
densityRange: [number, number];
|
||||
theme: 'light' | 'dark';
|
||||
canResetPreviewScale: boolean;
|
||||
onCancelPin: () => void;
|
||||
onResetPreviewScale: () => void;
|
||||
}
|
||||
|
||||
export function MobileMapLegend({
|
||||
mapViewFeature,
|
||||
colorRange,
|
||||
viewSource,
|
||||
mobileLegendMeta,
|
||||
densityLabel,
|
||||
densityRange,
|
||||
theme,
|
||||
canResetPreviewScale,
|
||||
onCancelPin,
|
||||
onResetPreviewScale,
|
||||
}: MobileMapLegendProps) {
|
||||
const { t } = useTranslation();
|
||||
const modes = useTranslatedModes();
|
||||
|
||||
if (mapViewFeature && colorRange) {
|
||||
if (mapViewFeature.startsWith('tt_')) {
|
||||
return (
|
||||
<MapLegend
|
||||
featureLabel={t('travel.travelTime', {
|
||||
mode: modes.label(mapViewFeature.split('_')[1] as TransportMode),
|
||||
})}
|
||||
range={colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={onCancelPin}
|
||||
onResetScale={viewSource === 'eye' ? onResetPreviewScale : undefined}
|
||||
resetScaleDisabled={!canResetPreviewScale}
|
||||
mode="feature"
|
||||
theme={theme}
|
||||
inline
|
||||
suffix=" min"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (mobileLegendMeta) {
|
||||
return (
|
||||
<MapLegend
|
||||
featureLabel={
|
||||
viewSource === 'eye'
|
||||
? t('mapLegend.previewing', { name: ts(mobileLegendMeta.name) })
|
||||
: ts(mobileLegendMeta.name)
|
||||
}
|
||||
range={colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={onCancelPin}
|
||||
onResetScale={viewSource === 'eye' ? onResetPreviewScale : undefined}
|
||||
resetScaleDisabled={!canResetPreviewScale}
|
||||
mode="feature"
|
||||
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
|
||||
featureName={mobileLegendMeta.name}
|
||||
theme={theme}
|
||||
inline
|
||||
suffix={mobileLegendMeta.suffix}
|
||||
raw={mobileLegendMeta.raw}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MapLegend
|
||||
featureLabel={densityLabel}
|
||||
range={densityRange}
|
||||
showCancel={false}
|
||||
onCancel={onCancelPin}
|
||||
mode="density"
|
||||
theme={theme}
|
||||
inline
|
||||
/>
|
||||
);
|
||||
}
|
||||
177
frontend/src/components/map/map-page/MobileMapPage.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { Suspense, type MutableRefObject, type ReactNode } from 'react';
|
||||
|
||||
import type { FeatureFilters, FeatureMeta, POI, PostcodeGeometry, ViewState } from '../../../types';
|
||||
import type { useMapData } from '../../../hooks/useMapData';
|
||||
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
|
||||
import type { SearchedLocation } from '../LocationSearch';
|
||||
import MobileBottomSheet from '../MobileBottomSheet';
|
||||
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
|
||||
import type { MapFlyTo } from './types';
|
||||
import { MapFallback, PaneFallback } from './Fallbacks';
|
||||
import { LoadingOverlay } from './LoadingOverlay';
|
||||
import { Map, MobileDrawer } from './lazyComponents';
|
||||
|
||||
type MapData = ReturnType<typeof useMapData>;
|
||||
type RightPaneTab = 'properties' | 'area';
|
||||
|
||||
interface MobileMapPageProps {
|
||||
initialLoading: boolean;
|
||||
mapData: MapData;
|
||||
pois: POI[];
|
||||
mapViewFeature: string | null;
|
||||
filterRange: [number, number] | null;
|
||||
viewSource: 'drag' | 'eye' | null;
|
||||
onCancelPin: () => void;
|
||||
features: FeatureMeta[];
|
||||
selectedHexagonId: string | null;
|
||||
hoveredHexagonId: string | null;
|
||||
onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void;
|
||||
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
|
||||
initialViewState: ViewState;
|
||||
flyToRef: MutableRefObject<MapFlyTo | null>;
|
||||
theme: 'light' | 'dark';
|
||||
filters: FeatureFilters;
|
||||
selectedPostcodeGeometry: PostcodeGeometry | null;
|
||||
onLocationSearched: (location: SearchedLocation | null) => void;
|
||||
onCurrentLocationFound: (lat: number, lng: number) => void;
|
||||
currentLocation: { lat: number; lng: number } | null;
|
||||
travelTimeEntries: TravelTimeEntry[];
|
||||
bottomScreenInset: number;
|
||||
onBottomSheetCoveredHeightChange: (height: number) => void;
|
||||
mobileDrawerOpen: boolean;
|
||||
onMobileDrawerClose: () => void;
|
||||
onMobileDrawerPanelRectChange: (rect: DOMRectReadOnly) => void;
|
||||
rightPaneTab: RightPaneTab;
|
||||
onMobileDrawerTabChange: (tab: RightPaneTab) => void;
|
||||
poiPaneOpen: boolean;
|
||||
onTogglePoiPane: () => void;
|
||||
poiButtonLabel: string;
|
||||
poiPane: ReactNode;
|
||||
filtersPane: ReactNode;
|
||||
mobileLegend: ReactNode;
|
||||
renderAreaPane: () => ReactNode;
|
||||
renderPropertiesPane: () => ReactNode;
|
||||
toasts: ReactNode;
|
||||
upgradeModal: ReactNode;
|
||||
}
|
||||
|
||||
export function MobileMapPage({
|
||||
initialLoading,
|
||||
mapData,
|
||||
pois,
|
||||
mapViewFeature,
|
||||
filterRange,
|
||||
viewSource,
|
||||
onCancelPin,
|
||||
features,
|
||||
selectedHexagonId,
|
||||
hoveredHexagonId,
|
||||
onHexagonClick,
|
||||
onHexagonHover,
|
||||
initialViewState,
|
||||
flyToRef,
|
||||
theme,
|
||||
filters,
|
||||
selectedPostcodeGeometry,
|
||||
onLocationSearched,
|
||||
onCurrentLocationFound,
|
||||
currentLocation,
|
||||
travelTimeEntries,
|
||||
bottomScreenInset,
|
||||
onBottomSheetCoveredHeightChange,
|
||||
mobileDrawerOpen,
|
||||
onMobileDrawerClose,
|
||||
onMobileDrawerPanelRectChange,
|
||||
rightPaneTab,
|
||||
onMobileDrawerTabChange,
|
||||
poiPaneOpen,
|
||||
onTogglePoiPane,
|
||||
poiButtonLabel,
|
||||
poiPane,
|
||||
filtersPane,
|
||||
mobileLegend,
|
||||
renderAreaPane,
|
||||
renderPropertiesPane,
|
||||
toasts,
|
||||
upgradeModal,
|
||||
}: MobileMapPageProps) {
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
<LoadingOverlay show={initialLoading} />
|
||||
|
||||
<div className="absolute inset-0">
|
||||
<Suspense fallback={<MapFallback />}>
|
||||
<Map
|
||||
data={mapData.data}
|
||||
postcodeData={mapData.postcodeData}
|
||||
usePostcodeView={mapData.usePostcodeView}
|
||||
pois={pois}
|
||||
onViewChange={mapData.handleViewChange}
|
||||
viewFeature={mapViewFeature}
|
||||
colorRange={mapData.colorRange}
|
||||
filterRange={filterRange}
|
||||
viewSource={viewSource}
|
||||
onCancelPin={onCancelPin}
|
||||
onResetPreviewScale={mapData.handleResetPreviewScale}
|
||||
canResetPreviewScale={mapData.canResetPreviewScale}
|
||||
features={features}
|
||||
selectedHexagonId={selectedHexagonId}
|
||||
hoveredHexagonId={hoveredHexagonId}
|
||||
onHexagonClick={onHexagonClick}
|
||||
onHexagonHover={onHexagonHover}
|
||||
initialViewState={initialViewState}
|
||||
flyToRef={flyToRef}
|
||||
theme={theme}
|
||||
filters={filters}
|
||||
selectedPostcodeGeometry={selectedPostcodeGeometry}
|
||||
onLocationSearched={onLocationSearched}
|
||||
onCurrentLocationFound={onCurrentLocationFound}
|
||||
currentLocation={currentLocation}
|
||||
bounds={mapData.bounds}
|
||||
hideLegend
|
||||
hideLocationSearch={mobileDrawerOpen && !!selectedHexagonId}
|
||||
travelTimeEntries={travelTimeEntries}
|
||||
bottomScreenInset={bottomScreenInset}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onTogglePoiPane}
|
||||
className={`absolute top-3 right-3 z-20 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
|
||||
aria-label={poiButtonLabel}
|
||||
>
|
||||
<MapPinIcon className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{poiPaneOpen && (
|
||||
<div className="absolute top-14 right-3 left-3 z-20 flex h-[45dvh] min-h-0 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
|
||||
{poiPane}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MobileBottomSheet
|
||||
legend={mobileLegend}
|
||||
onCoveredHeightChange={onBottomSheetCoveredHeightChange}
|
||||
>
|
||||
{filtersPane}
|
||||
</MobileBottomSheet>
|
||||
|
||||
{mobileDrawerOpen && selectedHexagonId && (
|
||||
<Suspense fallback={<PaneFallback />}>
|
||||
<MobileDrawer
|
||||
onClose={onMobileDrawerClose}
|
||||
renderArea={renderAreaPane}
|
||||
renderProperties={renderPropertiesPane}
|
||||
tab={rightPaneTab}
|
||||
onPanelRectChange={onMobileDrawerPanelRectChange}
|
||||
onTabChange={onMobileDrawerTabChange}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{toasts}
|
||||
{upgradeModal}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
frontend/src/components/map/map-page/ScreenshotMapPage.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { Suspense } from 'react';
|
||||
|
||||
import type { FeatureMeta, ViewState } from '../../../types';
|
||||
import type { useMapData } from '../../../hooks/useMapData';
|
||||
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
|
||||
import { MapFallback } from './Fallbacks';
|
||||
import { Map } from './lazyComponents';
|
||||
|
||||
type MapData = ReturnType<typeof useMapData>;
|
||||
|
||||
interface ScreenshotMapPageProps {
|
||||
mapData: MapData;
|
||||
mapViewFeature: string | null;
|
||||
filterRange: [number, number] | null;
|
||||
viewSource: 'drag' | 'eye' | null;
|
||||
features: FeatureMeta[];
|
||||
initialViewState: ViewState;
|
||||
theme: 'light' | 'dark';
|
||||
ogMode?: boolean;
|
||||
travelTimeEntries: TravelTimeEntry[];
|
||||
}
|
||||
|
||||
export function ScreenshotMapPage({
|
||||
mapData,
|
||||
mapViewFeature,
|
||||
filterRange,
|
||||
viewSource,
|
||||
features,
|
||||
initialViewState,
|
||||
theme,
|
||||
ogMode,
|
||||
travelTimeEntries,
|
||||
}: ScreenshotMapPageProps) {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<Suspense fallback={<MapFallback />}>
|
||||
<Map
|
||||
data={mapData.data}
|
||||
postcodeData={mapData.postcodeData}
|
||||
usePostcodeView={mapData.usePostcodeView}
|
||||
pois={[]}
|
||||
onViewChange={mapData.handleViewChange}
|
||||
viewFeature={mapViewFeature}
|
||||
colorRange={mapData.colorRange}
|
||||
filterRange={filterRange}
|
||||
viewSource={viewSource}
|
||||
onCancelPin={() => {}}
|
||||
onResetPreviewScale={mapData.handleResetPreviewScale}
|
||||
canResetPreviewScale={mapData.canResetPreviewScale}
|
||||
features={features}
|
||||
selectedHexagonId={null}
|
||||
hoveredHexagonId={null}
|
||||
onHexagonClick={() => {}}
|
||||
onHexagonHover={() => {}}
|
||||
initialViewState={initialViewState}
|
||||
theme={theme}
|
||||
screenshotMode
|
||||
ogMode={ogMode}
|
||||
bounds={mapData.bounds}
|
||||
travelTimeEntries={travelTimeEntries}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
frontend/src/components/map/map-page/Toasts.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import type { ExportNotice } from './types';
|
||||
import { BookmarkIcon } from '../../ui/icons/BookmarkIcon';
|
||||
import { CheckIcon } from '../../ui/icons/CheckIcon';
|
||||
import { CloseIcon } from '../../ui/icons/CloseIcon';
|
||||
import { InfoIcon } from '../../ui/icons/InfoIcon';
|
||||
|
||||
interface BookmarkToastProps {
|
||||
show: boolean;
|
||||
onViewSaved: () => void;
|
||||
onDismissForever: () => void;
|
||||
}
|
||||
|
||||
export function BookmarkToast({ show, onViewSaved, onDismissForever }: BookmarkToastProps) {
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[60] flex items-center gap-3 px-4 py-3 rounded-lg bg-navy-900 text-white text-sm shadow-lg animate-fade-in">
|
||||
<BookmarkIcon className="w-4 h-4 text-teal-400 shrink-0" filled />
|
||||
<span>Property saved!</span>
|
||||
<button
|
||||
onClick={onViewSaved}
|
||||
className="px-3 py-1 rounded bg-teal-600 hover:bg-teal-500 text-white text-xs font-medium whitespace-nowrap"
|
||||
>
|
||||
View saved
|
||||
</button>
|
||||
<button
|
||||
onClick={onDismissForever}
|
||||
className="text-warm-400 hover:text-warm-200 text-xs whitespace-nowrap"
|
||||
>
|
||||
Don't show again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExportToastProps {
|
||||
notice: ExportNotice | null;
|
||||
offsetForBookmark: boolean;
|
||||
closeLabel: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ExportToast({ notice, offsetForBookmark, closeLabel, onClose }: ExportToastProps) {
|
||||
if (!notice) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role={notice.kind === 'error' ? 'alert' : 'status'}
|
||||
aria-live={notice.kind === 'error' ? 'assertive' : 'polite'}
|
||||
className={`fixed ${offsetForBookmark ? 'bottom-24' : 'bottom-6'} left-1/2 z-[60] flex max-w-[calc(100vw-2rem)] -translate-x-1/2 items-center gap-3 rounded-lg bg-navy-900 px-4 py-3 text-sm text-white shadow-lg animate-fade-in`}
|
||||
>
|
||||
{notice.kind === 'success' ? (
|
||||
<CheckIcon className="h-4 w-4 shrink-0 text-teal-400" />
|
||||
) : (
|
||||
<InfoIcon className="h-4 w-4 shrink-0 text-red-300" />
|
||||
)}
|
||||
<span className="min-w-0">{notice.message}</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label={closeLabel}
|
||||
className="-mr-1 flex h-7 w-7 shrink-0 items-center justify-center rounded text-warm-300 hover:bg-navy-800 hover:text-white"
|
||||
>
|
||||
<CloseIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
frontend/src/components/map/map-page/derivedState.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { useMemo } from 'react';
|
||||
import { cellToLatLng } from 'h3-js';
|
||||
|
||||
import type { FeatureMeta, HexagonStatsResponse, PostcodeFeature } from '../../../types';
|
||||
import type { HexagonLocation } from '../../../lib/external-search';
|
||||
import type { useMapData } from '../../../hooks/useMapData';
|
||||
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
|
||||
import { getSpecificCrimeFeatureName } from '../../../lib/crime-filter';
|
||||
import { getEthnicityFeatureName } from '../../../lib/ethnicity-filter';
|
||||
import { getPoiDistanceFeatureName } from '../../../lib/poi-distance-filter';
|
||||
import { getSchoolBackendFeatureName } from '../../../lib/school-filter';
|
||||
|
||||
type MapData = ReturnType<typeof useMapData>;
|
||||
|
||||
interface SelectedHexagon {
|
||||
id: string;
|
||||
type: 'hexagon' | 'postcode';
|
||||
resolution: number;
|
||||
}
|
||||
|
||||
export function getMapPageBackendFeatureName(featureName: string): string {
|
||||
return (
|
||||
getSchoolBackendFeatureName(featureName) ??
|
||||
getSpecificCrimeFeatureName(featureName) ??
|
||||
getEthnicityFeatureName(featureName) ??
|
||||
getPoiDistanceFeatureName(featureName) ??
|
||||
featureName
|
||||
);
|
||||
}
|
||||
|
||||
export function useJourneyDestination(entries: TravelTimeEntry[]) {
|
||||
return useMemo(() => {
|
||||
const entry = entries.find((item) => item.mode === 'transit' && item.slug);
|
||||
return entry ? { mode: entry.mode, slug: entry.slug } : null;
|
||||
}, [entries]);
|
||||
}
|
||||
|
||||
export function useMapViewFeature(viewFeature: string | null) {
|
||||
return useMemo(
|
||||
() => (viewFeature ? getMapPageBackendFeatureName(viewFeature) : null),
|
||||
[viewFeature]
|
||||
);
|
||||
}
|
||||
|
||||
export function useMobileLegendMeta(viewFeature: string | null, features: FeatureMeta[]) {
|
||||
return useMemo(() => {
|
||||
const featureName = viewFeature ? getMapPageBackendFeatureName(viewFeature) : null;
|
||||
return featureName ? features.find((feature) => feature.name === featureName) || null : null;
|
||||
}, [viewFeature, features]);
|
||||
}
|
||||
|
||||
export function useMobileDensityRange(mapData: MapData): [number, number] {
|
||||
return useMemo(() => {
|
||||
const items = mapData.usePostcodeView ? mapData.postcodeData : mapData.data;
|
||||
if (items.length === 0) return [0, 1];
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const item of items) {
|
||||
const count = 'count' in item ? item.count : item.properties.count;
|
||||
if (count < min) min = count;
|
||||
if (count > max) max = count;
|
||||
}
|
||||
if (min === Infinity) return [0, 1];
|
||||
if (min === max) return [min, min + 1];
|
||||
return [min, max];
|
||||
}, [mapData.data, mapData.postcodeData, mapData.usePostcodeView]);
|
||||
}
|
||||
|
||||
export function useHexagonLocation(
|
||||
selectedHexagon: SelectedHexagon | null,
|
||||
postcodeData: PostcodeFeature[],
|
||||
resolution: number,
|
||||
areaStats: HexagonStatsResponse | null
|
||||
): HexagonLocation | null {
|
||||
return useMemo(() => {
|
||||
const hexId = selectedHexagon?.id;
|
||||
const isPostcode = selectedHexagon?.type === 'postcode';
|
||||
|
||||
if (isPostcode) {
|
||||
const postcodeFeature = postcodeData.find((feature) => feature.properties.postcode === hexId);
|
||||
if (!postcodeFeature?.properties.centroid) return null;
|
||||
const [lon, lat] = postcodeFeature.properties.centroid;
|
||||
return { lat, lon, resolution, postcode: hexId, isPostcode: true };
|
||||
}
|
||||
|
||||
if (!hexId) return null;
|
||||
const [lat, lon] = cellToLatLng(hexId);
|
||||
return {
|
||||
lat,
|
||||
lon,
|
||||
resolution: selectedHexagon?.resolution ?? resolution,
|
||||
postcode: areaStats?.central_postcode,
|
||||
};
|
||||
}, [selectedHexagon, postcodeData, resolution, areaStats?.central_postcode]);
|
||||
}
|
||||
138
frontend/src/components/map/map-page/effects.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { useEffect } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
|
||||
import type { PostcodeGeometry, ViewState } from '../../../types';
|
||||
import type { useMapData } from '../../../hooks/useMapData';
|
||||
import { authHeaders } from '../../../lib/api';
|
||||
import { canWheelScrollInsideTarget } from '../../../lib/dom-scroll';
|
||||
import type { MapFlyTo } from './types';
|
||||
|
||||
type MapData = ReturnType<typeof useMapData>;
|
||||
type RightPaneTab = 'properties' | 'area';
|
||||
|
||||
export function useInitialMapPageView(
|
||||
mapData: MapData,
|
||||
initialViewState: ViewState,
|
||||
initialTab: RightPaneTab,
|
||||
setRightPaneTab: (tab: RightPaneTab) => void
|
||||
) {
|
||||
useEffect(() => {
|
||||
mapData.setInitialView(initialViewState);
|
||||
setRightPaneTab(initialTab);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}
|
||||
|
||||
interface UseInitialPostcodeSelectionOptions {
|
||||
initialPostcode?: string;
|
||||
isMobile: boolean;
|
||||
flyTo: MutableRefObject<MapFlyTo | null>;
|
||||
onLocationSearch: (
|
||||
postcode: string,
|
||||
geometry: PostcodeGeometry,
|
||||
lat?: number,
|
||||
lng?: number
|
||||
) => void;
|
||||
onOpenMobileDrawer: () => void;
|
||||
}
|
||||
|
||||
export function useInitialPostcodeSelection({
|
||||
initialPostcode,
|
||||
isMobile,
|
||||
flyTo,
|
||||
onLocationSearch,
|
||||
onOpenMobileDrawer,
|
||||
}: UseInitialPostcodeSelectionOptions) {
|
||||
useEffect(() => {
|
||||
if (!initialPostcode) return;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete('pc');
|
||||
const newUrl = params.toString() ? `/dashboard?${params}` : '/dashboard';
|
||||
window.history.replaceState(window.history.state, '', newUrl);
|
||||
|
||||
fetch(`/api/postcode/${encodeURIComponent(initialPostcode)}`, authHeaders())
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Postcode not found');
|
||||
return res.json();
|
||||
})
|
||||
.then(
|
||||
(data: {
|
||||
postcode: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
geometry: PostcodeGeometry;
|
||||
}) => {
|
||||
flyTo.current?.(data.latitude, data.longitude, 16);
|
||||
onLocationSearch(data.postcode, data.geometry, data.latitude, data.longitude);
|
||||
if (isMobile) onOpenMobileDrawer();
|
||||
}
|
||||
)
|
||||
.catch(() => {
|
||||
// Silently fail because the postcode might no longer exist.
|
||||
});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}
|
||||
|
||||
export function useHorizontalSwipeNavigationGuard() {
|
||||
useEffect(() => {
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (
|
||||
Math.abs(e.deltaX) > Math.abs(e.deltaY) &&
|
||||
!canWheelScrollInsideTarget(e.target, e.deltaX, e.deltaY)
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
document.addEventListener('wheel', handleWheel, { passive: false });
|
||||
return () => document.removeEventListener('wheel', handleWheel);
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function useMobileBackNavigationGuard(isMobile: boolean) {
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
window.history.pushState({ dashboardGuard: true }, '');
|
||||
const handlePopState = () => {
|
||||
window.history.pushState({ dashboardGuard: true }, '');
|
||||
};
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, [isMobile]);
|
||||
}
|
||||
|
||||
interface UseScreenshotReadySignalOptions {
|
||||
screenshotMode?: boolean;
|
||||
loading: boolean;
|
||||
dataLength: number;
|
||||
postcodeDataLength: number;
|
||||
usePostcodeView: boolean;
|
||||
}
|
||||
|
||||
export function useScreenshotReadySignal({
|
||||
screenshotMode,
|
||||
loading,
|
||||
dataLength,
|
||||
postcodeDataLength,
|
||||
usePostcodeView,
|
||||
}: UseScreenshotReadySignalOptions) {
|
||||
useEffect(() => {
|
||||
if (screenshotMode && !loading) {
|
||||
const hasData = usePostcodeView ? postcodeDataLength > 0 : dataLength > 0;
|
||||
if (hasData) {
|
||||
// Wait for both deck.gl data and MapLibre base map tile rendering.
|
||||
const waitAndSignal = () => {
|
||||
if (window.__map_idle) {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
window.__screenshot_ready = true;
|
||||
});
|
||||
});
|
||||
} else {
|
||||
requestAnimationFrame(waitAndSignal);
|
||||
}
|
||||
};
|
||||
waitAndSignal();
|
||||
}
|
||||
}
|
||||
}, [screenshotMode, loading, dataLength, postcodeDataLength, usePostcodeView]);
|
||||
}
|
||||
17
frontend/src/components/map/map-page/lazyComponents.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { lazy } from 'react';
|
||||
|
||||
export const Map = lazy(() => import('../Map'));
|
||||
export const Filters = lazy(() => import('../Filters'));
|
||||
export const POIPane = lazy(() => import('../POIPane'));
|
||||
export const AreaPane = lazy(() => import('../AreaPane'));
|
||||
export const PropertiesPane = lazy(() =>
|
||||
import('../PropertiesPane').then((module) => ({ default: module.PropertiesPane }))
|
||||
);
|
||||
export const MobileDrawer = lazy(() => import('../MobileDrawer'));
|
||||
export const MapPageSelectionPane = lazy(() =>
|
||||
import('../MapPageSelectionPane').then((module) => ({ default: module.MapPageSelectionPane }))
|
||||
);
|
||||
export const UpgradeModal = lazy(() => import('../../ui/UpgradeModal'));
|
||||
export const Joyride = lazy(() =>
|
||||
import('react-joyride').then((module) => ({ default: module.Joyride }))
|
||||
);
|
||||
60
frontend/src/components/map/map-page/types.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import type {
|
||||
FeatureFilters,
|
||||
FeatureMeta,
|
||||
MapFlyToOptions,
|
||||
POICategoryGroup,
|
||||
Property,
|
||||
ViewState,
|
||||
} from '../../../types';
|
||||
import type { TravelTimeInitial } from '../../../hooks/useTravelTime';
|
||||
import type { Page } from '../../ui/Header';
|
||||
import type { PointerEvent } from 'react';
|
||||
|
||||
export interface ExportState {
|
||||
onExport: () => void;
|
||||
exporting: boolean;
|
||||
}
|
||||
|
||||
export type ExportNotice = {
|
||||
kind: 'success' | 'error';
|
||||
message: string;
|
||||
};
|
||||
|
||||
export interface MapPageProps {
|
||||
features: FeatureMeta[];
|
||||
poiCategoryGroups: POICategoryGroup[];
|
||||
initialFilters: FeatureFilters;
|
||||
initialViewState: ViewState;
|
||||
initialPOICategories: Set<string>;
|
||||
initialTab: 'properties' | 'area';
|
||||
initialLoading: boolean;
|
||||
theme: 'light' | 'dark';
|
||||
pendingInfoFeature: string | null;
|
||||
onClearPendingInfoFeature: () => void;
|
||||
onNavigateTo: (page: Page, hash?: string, infoFeature?: string) => void;
|
||||
onExportStateChange?: (state: ExportState) => void;
|
||||
screenshotMode?: boolean;
|
||||
ogMode?: boolean;
|
||||
isMobile?: boolean;
|
||||
initialTravelTime?: TravelTimeInitial;
|
||||
initialPostcode?: string;
|
||||
shareCode?: string;
|
||||
user?: { id: string; subscription: string; isAdmin?: boolean } | null;
|
||||
onLoginClick: () => void;
|
||||
onRegisterClick: () => void;
|
||||
onSaveProperty?: (property: Property) => void;
|
||||
onUnsaveProperty?: (id: string) => void;
|
||||
isPropertySaved?: (address?: string, postcode?: string) => boolean;
|
||||
getSavedPropertyId?: (address?: string, postcode?: string) => string | undefined;
|
||||
deferTutorial?: boolean;
|
||||
onSaveSearch?: (name: string) => Promise<void>;
|
||||
savingSearch?: boolean;
|
||||
}
|
||||
|
||||
export type MapFlyTo = (lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void;
|
||||
|
||||
export interface PaneResizeHandlers {
|
||||
onPointerDown: (event: PointerEvent) => void;
|
||||
onPointerMove: (event: PointerEvent) => void;
|
||||
onPointerUp: () => void;
|
||||
}
|
||||
176
frontend/src/components/map/map-page/useExportController.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import type { Bounds, FeatureFilters, FeatureMeta } from '../../../types';
|
||||
import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../../lib/api';
|
||||
import { trackEvent } from '../../../lib/analytics';
|
||||
import type { ExportNotice, ExportState } from './types';
|
||||
|
||||
const EXPORT_FILE_NAME = 'perfect-postcode-export.xlsx';
|
||||
const EXPORT_TIMEOUT_MS = 150_000;
|
||||
const EXPORT_NOTICE_MS = 6000;
|
||||
const EXPORT_ERROR_NOTICE_MS = 9000;
|
||||
|
||||
function getExportFileName(res: Response): string {
|
||||
const disposition = res.headers.get('content-disposition');
|
||||
if (!disposition) return EXPORT_FILE_NAME;
|
||||
|
||||
const encodedMatch = disposition.match(/filename\*=UTF-8''([^;]+)/i);
|
||||
if (encodedMatch?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(encodedMatch[1].trim());
|
||||
} catch {
|
||||
return encodedMatch[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
const match = disposition.match(/filename="?([^";]+)"?/i);
|
||||
return match?.[1]?.trim() || EXPORT_FILE_NAME;
|
||||
}
|
||||
|
||||
async function getExportErrorMessage(res: Response): Promise<string> {
|
||||
const fallback = `HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ''}`;
|
||||
const contentType = res.headers.get('content-type') ?? '';
|
||||
|
||||
try {
|
||||
if (contentType.includes('application/json')) {
|
||||
const data: unknown = await res.json();
|
||||
if (data && typeof data === 'object') {
|
||||
const record = data as Record<string, unknown>;
|
||||
const message = record.message ?? record.error;
|
||||
if (typeof message === 'string' && message.trim()) return message.trim();
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
return text.trim() || fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function triggerExportDownload(blob: Blob, fileName: string): void {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = objectUrl;
|
||||
link.download = fileName;
|
||||
link.rel = 'noopener';
|
||||
link.style.display = 'none';
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
|
||||
}
|
||||
|
||||
interface UseExportControllerOptions {
|
||||
bounds: Bounds | null;
|
||||
filters: FeatureFilters;
|
||||
features: FeatureMeta[];
|
||||
t: TFunction;
|
||||
onExportStateChange?: (state: ExportState) => void;
|
||||
}
|
||||
|
||||
export function useExportController({
|
||||
bounds,
|
||||
filters,
|
||||
features,
|
||||
t,
|
||||
onExportStateChange,
|
||||
}: UseExportControllerOptions) {
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportNotice, setExportNotice] = useState<ExportNotice | null>(null);
|
||||
const exportNoticeTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const clearExportNoticeTimer = useCallback(() => {
|
||||
if (exportNoticeTimeoutRef.current !== null) {
|
||||
window.clearTimeout(exportNoticeTimeoutRef.current);
|
||||
exportNoticeTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearExportNotice = useCallback(() => {
|
||||
clearExportNoticeTimer();
|
||||
setExportNotice(null);
|
||||
}, [clearExportNoticeTimer]);
|
||||
|
||||
const showExportNotice = useCallback(
|
||||
(notice: ExportNotice) => {
|
||||
clearExportNoticeTimer();
|
||||
setExportNotice(notice);
|
||||
exportNoticeTimeoutRef.current = window.setTimeout(
|
||||
() => {
|
||||
setExportNotice(null);
|
||||
exportNoticeTimeoutRef.current = null;
|
||||
},
|
||||
notice.kind === 'error' ? EXPORT_ERROR_NOTICE_MS : EXPORT_NOTICE_MS
|
||||
);
|
||||
},
|
||||
[clearExportNoticeTimer]
|
||||
);
|
||||
|
||||
useEffect(() => clearExportNoticeTimer, [clearExportNoticeTimer]);
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
if (exporting) return;
|
||||
if (!bounds) {
|
||||
showExportNotice({ kind: 'error', message: t('header.exportUnavailable') });
|
||||
return;
|
||||
}
|
||||
|
||||
const { south, west, north, east } = bounds;
|
||||
const params = new URLSearchParams({
|
||||
bounds: `${south},${west},${north},${east}`,
|
||||
});
|
||||
const filterStr = buildFilterString(filters, features);
|
||||
if (filterStr) params.set('filters', filterStr);
|
||||
const url = apiUrl('export', params);
|
||||
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, EXPORT_TIMEOUT_MS);
|
||||
|
||||
setExporting(true);
|
||||
clearExportNotice();
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(url, authHeaders({ signal: controller.signal }));
|
||||
if (!res.ok) throw new Error(await getExportErrorMessage(res));
|
||||
|
||||
const blob = await res.blob();
|
||||
if (blob.size === 0) throw new Error(t('header.exportEmpty'));
|
||||
|
||||
triggerExportDownload(blob, getExportFileName(res));
|
||||
trackEvent('Export');
|
||||
showExportNotice({ kind: 'success', message: t('header.exportReady') });
|
||||
} catch (err) {
|
||||
if (!timedOut) logNonAbortError('Export failed', err);
|
||||
const detail = err instanceof Error && err.message.trim() ? ` ${err.message}` : '';
|
||||
showExportNotice({
|
||||
kind: 'error',
|
||||
message: timedOut ? t('header.exportTimedOut') : `${t('header.exportFailed')}${detail}`,
|
||||
});
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
setExporting(false);
|
||||
}
|
||||
})();
|
||||
}, [bounds, clearExportNotice, exporting, features, filters, showExportNotice, t]);
|
||||
|
||||
useEffect(() => {
|
||||
onExportStateChange?.({ onExport: handleExport, exporting });
|
||||
}, [handleExport, exporting, onExportStateChange]);
|
||||
|
||||
return {
|
||||
exporting,
|
||||
exportNotice,
|
||||
clearExportNotice,
|
||||
handleExport,
|
||||
};
|
||||
}
|
||||
|
|
@ -65,26 +65,27 @@ export default function PricingPage({
|
|||
}, []);
|
||||
|
||||
const isLicensed = user?.subscription === 'licensed' || user?.isAdmin;
|
||||
const currentPrice = pricing?.current_price_pence ?? 10000;
|
||||
const isFree = currentPrice === 0;
|
||||
|
||||
// Find current tier index and remaining spots
|
||||
let currentTierIndex = (pricing?.tiers.length ?? 1) - 1;
|
||||
let spotsRemaining = 0;
|
||||
if (pricing) {
|
||||
for (let i = 0; i < pricing.tiers.length; i++) {
|
||||
const tier = pricing.tiers[i];
|
||||
if (tier.up_to === null || pricing.licensed_count < tier.up_to) {
|
||||
currentTierIndex = i;
|
||||
spotsRemaining = tier.up_to === null ? 0 : tier.up_to - pricing.licensed_count;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const isFree = pricing?.current_price_pence === 0;
|
||||
const currentTier = pricing
|
||||
? (() => {
|
||||
let index = pricing.tiers.length - 1;
|
||||
let spotsRemaining = 0;
|
||||
for (let i = 0; i < pricing.tiers.length; i++) {
|
||||
const tier = pricing.tiers[i];
|
||||
if (tier.up_to === null || pricing.licensed_count < tier.up_to) {
|
||||
index = i;
|
||||
spotsRemaining = tier.up_to === null ? 0 : tier.up_to - pricing.licensed_count;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { index, spotsRemaining };
|
||||
})()
|
||||
: null;
|
||||
const currentTierIndex = currentTier?.index;
|
||||
|
||||
useEffect(() => {
|
||||
if (!pricing || !scrollRef.current || !activeCardRef.current) return;
|
||||
if (currentTierIndex === 0) return;
|
||||
if (currentTierIndex == null || currentTierIndex === 0) return;
|
||||
const container = scrollRef.current;
|
||||
const card = activeCardRef.current;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
|
@ -98,34 +99,42 @@ export default function PricingPage({
|
|||
setScrolledLeft(container.scrollLeft > 0);
|
||||
}, [pricing, currentTierIndex]);
|
||||
|
||||
const ctaButton = isLicensed ? (
|
||||
<button
|
||||
onClick={onOpenDashboard}
|
||||
className="w-full mt-auto px-5 py-3 bg-teal-600 text-white rounded-lg font-semibold hover:bg-teal-700 transition-colors"
|
||||
>
|
||||
{t('pricingPage.openDashboard')}
|
||||
</button>
|
||||
) : user ? (
|
||||
<button
|
||||
onClick={() => license.startCheckout()}
|
||||
disabled={license.checkingOut}
|
||||
className="w-full mt-auto px-5 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors shadow-lg shadow-coral-500/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
|
||||
>
|
||||
{license.checkingOut && <SpinnerIcon className="w-5 h-5 animate-spin" />}
|
||||
{license.checkingOut
|
||||
? t('upgrade.redirecting')
|
||||
: isFree
|
||||
? t('upgrade.claimFreeAccess')
|
||||
: t('pricingPage.getStartedPrice', { price: formatPricePence(currentPrice) })}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onRegisterClick}
|
||||
className="w-full mt-auto px-5 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors shadow-lg shadow-coral-500/25"
|
||||
>
|
||||
{isFree ? t('upgrade.claimFreeAccess') : t('pricingPage.getStarted')}
|
||||
</button>
|
||||
);
|
||||
if (pricing && pricing.tiers.length === 0) {
|
||||
throw new Error('Pricing data did not include any tiers');
|
||||
}
|
||||
|
||||
const ctaButton = pricing ? (
|
||||
isLicensed ? (
|
||||
<button
|
||||
onClick={onOpenDashboard}
|
||||
className="w-full mt-auto px-5 py-3 bg-teal-600 text-white rounded-lg font-semibold hover:bg-teal-700 transition-colors"
|
||||
>
|
||||
{t('pricingPage.openDashboard')}
|
||||
</button>
|
||||
) : user ? (
|
||||
<button
|
||||
onClick={() => license.startCheckout()}
|
||||
disabled={license.checkingOut}
|
||||
className="w-full mt-auto px-5 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors shadow-lg shadow-coral-500/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
|
||||
>
|
||||
{license.checkingOut && <SpinnerIcon className="w-5 h-5 animate-spin" />}
|
||||
{license.checkingOut
|
||||
? t('upgrade.redirecting')
|
||||
: isFree
|
||||
? t('upgrade.claimFreeAccess')
|
||||
: t('pricingPage.getStartedPrice', {
|
||||
price: formatPricePence(pricing.current_price_pence),
|
||||
})}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onRegisterClick}
|
||||
className="w-full mt-auto px-5 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors shadow-lg shadow-coral-500/25"
|
||||
>
|
||||
{isFree ? t('upgrade.claimFreeAccess') : t('pricingPage.getStarted')}
|
||||
</button>
|
||||
)
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-navy-950 relative">
|
||||
|
|
@ -174,8 +183,9 @@ export default function PricingPage({
|
|||
>
|
||||
<div className="flex w-max gap-6 mx-auto">
|
||||
{pricing.tiers.map((tier, i) => {
|
||||
const isCurrent = i === currentTierIndex;
|
||||
const isCurrent = i === currentTier?.index;
|
||||
const isFilled = tier.up_to !== null && pricing.licensed_count >= tier.up_to;
|
||||
const spotsRemaining = isCurrent ? currentTier!.spotsRemaining : 0;
|
||||
const filledInTier = isCurrent
|
||||
? pricing.licensed_count - (i > 0 ? (pricing.tiers[i - 1].up_to ?? 0) : 0)
|
||||
: 0;
|
||||
|
|
@ -245,11 +255,15 @@ export default function PricingPage({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{isCurrent && spotsRemaining > 0 && (
|
||||
{spotsRemaining > 0 && (
|
||||
<p className="text-teal-300 text-sm mt-2 font-medium">
|
||||
{spotsRemaining === 1
|
||||
? t('pricingPage.spotsRemaining', { count: spotsRemaining })
|
||||
: t('pricingPage.spotsRemainingPlural', { count: spotsRemaining })}
|
||||
? t('pricingPage.spotsRemaining', {
|
||||
count: spotsRemaining,
|
||||
})
|
||||
: t('pricingPage.spotsRemainingPlural', {
|
||||
count: spotsRemaining,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{isFilled && (
|
||||
|
|
|
|||