From 7c99d07cb0e1828d38ec9dbdef8516f32ce4beb5 Mon Sep 17 00:00:00 2001 From: Christopher Date: Mon, 5 Oct 2026 22:42:12 +0200 Subject: [PATCH] Filter studios and associations by city, open /places on a country For #258, so a country page can link into the lists and the map: - /studios and /associations take ?city= (case-insensitive), shown as a removable "In Rennes" filter. The city stays across the other filters (a hidden field) and is dropped when the country changes. - /places?country=XX opens on the area most of the country's organisations are in: their 5th-95th percentile box, so overseas places don't zoom the map out to the world, fitted to the map's size (mapFocus). The list beside the map follows, as it lists what's in view. Co-Authored-By: Claude Opus 5.5 --- app/components/Filters.jsx | 41 +++++++++++++++++++++++++-- app/routes/associations.jsx | 8 ++++-- app/routes/places.jsx | 29 ++++++++++++++++--- app/routes/studios.jsx | 8 ++++-- app/utils/mapFocus.js | 48 +++++++++++++++++++++++++++++++ app/utils/mapFocus.test.js | 56 +++++++++++++++++++++++++++++++++++++ e2e/orgs.spec.js | 36 ++++++++++++++++++++++++ e2e/places.spec.js | 25 +++++++++++++++++ 8 files changed, 240 insertions(+), 11 deletions(-) create mode 100644 app/utils/mapFocus.js create mode 100644 app/utils/mapFocus.test.js diff --git a/app/components/Filters.jsx b/app/components/Filters.jsx index 66534f3..6e8e4d5 100644 --- a/app/components/Filters.jsx +++ b/app/components/Filters.jsx @@ -1,10 +1,20 @@ -import { Box, NativeSelect, Checkbox, Wrap } from '@chakra-ui/react'; +import { Box, NativeSelect, Checkbox, Tag, Wrap } from '@chakra-ui/react'; -import { Form, useSubmit } from 'react-router'; +import { Form, useNavigate, useSearchParams, useSubmit } from 'react-router'; import CountryCombobox from './CountryCombobox'; const Filters = ({ facets, selected, type }) => { const submit = useSubmit(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + + // Lists filtered to a city (from a country page, #258) keep the city + // across the other filters, until it's removed or the country changes. + const withoutCity = () => { + const next = new URLSearchParams(searchParams); + next.delete('city'); + return `?${next}`; + }; const handleChange = (event) => { submit(event.currentTarget.form); @@ -17,9 +27,34 @@ const Filters = ({ facets, selected, type }) => { submit(form)} + onChange={(form) => { + form.querySelector('input[name="city"]')?.remove(); + submit(form); + }} /> + {selected.city && ( + <> + + + In {selected.city} + + navigate(withoutCity())} + /> + + + + )} + {type === 'event' ? ( { const { searchParams } = new URL(request.url); const page = Number(searchParams.get('page') || '1'); const country = searchParams.get('country'); + // From a country page's cities (#258). + const city = searchParams.get('city')?.trim() || null; const has_games = searchParams.get('has_games'); const has_events = searchParams.get('has_events'); @@ -26,9 +28,10 @@ export const loader = async ({ request }) => { type: 'association', }; - if (country) { + if (country || city) { where.location = { - country_code: country, + ...(country && { country_code: country }), + ...(city && { city: { equals: city, mode: 'insensitive' } }), }; } @@ -82,6 +85,7 @@ export const loader = async ({ request }) => { }, selected: { country, + city, has_games, has_events, }, diff --git a/app/routes/places.jsx b/app/routes/places.jsx index 0e6047d..f97af78 100644 --- a/app/routes/places.jsx +++ b/app/routes/places.jsx @@ -16,6 +16,7 @@ import computeOrg from '../models/org'; // import Error from '../../../client/pages/_error'; import { Tooltip } from '../components/ui/tooltip'; import ClusterMap from '../components/ClusterMap'; +import { focusOn, zoomToFit } from '../utils/mapFocus'; import OrgCard from '../components/OrgCard'; import SwipeableEdgeDrawer from '../components/SwipeableEdgeDrawer'; import SectionHeading from '../components/SectionHeading'; @@ -154,6 +155,11 @@ const MovingBand = React.memo(({ header, children, isOpen, onClose }) => { }); export const loader = async ({ request }) => { + // ?country=XX opens the map on that country's organisations (#258). + const countryCode = new URL(request.url).searchParams + .get('country') + ?.toUpperCase(); + const orgs = await db.entity .findMany({ where: { @@ -168,8 +174,16 @@ export const loader = async ({ request }) => { }) .then((orgs) => orgs.map(computeOrg)); + const computed = await Promise.all(orgs); const data = { - orgs: await Promise.all(orgs), + orgs: computed, + focus: countryCode + ? focusOn( + computed + .filter((org) => org.location?.country_code === countryCode) + .map((org) => org.location) + ) + : null, }; return data; }; @@ -185,8 +199,11 @@ const Places = () => { const containerRef = useRef(); const listRef = useRef(); const [isPending, startTransition] = useTransition(); - const [center, setCenter] = useState([0, 0]); - const [zoom, setZoom] = useState(2); + const { orgs, focus } = useLoaderData(); + const [center, setCenter] = useState(focus?.center ?? [0, 0]); + const [zoom, setZoom] = useState(() => + focus ? zoomToFit(focus, { width: 1240, height: 835 }) : 2 + ); const [currentBounds, setCurrentBounds] = useState(); const [highlightedOrg, setHighlightedOrg] = useState(); const [isMovingBandOpen, setMovingBandOpen] = useState(false); @@ -195,7 +212,11 @@ const Places = () => { const selectedId = location.hash?.substring(1); const prevSelectedId = usePrevious(selectedId); - const { orgs } = useLoaderData(); + // Fit the country to the map's real size once it's on screen. + useEffect(() => { + const box = containerRef.current?.getBoundingClientRect(); + if (focus && box?.width) setZoom(zoomToFit(focus, box)); + }, [focus]); useEffect(() => { if (!window) return; diff --git a/app/routes/studios.jsx b/app/routes/studios.jsx index 3c83584..62ee2c7 100644 --- a/app/routes/studios.jsx +++ b/app/routes/studios.jsx @@ -17,6 +17,8 @@ export const loader = async ({ request }) => { const { searchParams } = new URL(request.url); const page = Number(searchParams.get('page') || '1'); const country = searchParams.get('country'); + // From a country page's cities (#258). + const city = searchParams.get('city')?.trim() || null; const has_games = searchParams.get('has_games'); const has_events = searchParams.get('has_events'); @@ -26,9 +28,10 @@ export const loader = async ({ request }) => { type: 'studio', }; - if (country) { + if (country || city) { where.location = { - country_code: country, + ...(country && { country_code: country }), + ...(city && { city: { equals: city, mode: 'insensitive' } }), }; } @@ -82,6 +85,7 @@ export const loader = async ({ request }) => { }, selected: { country, + city, has_games, has_events, }, diff --git a/app/utils/mapFocus.js b/app/utils/mapFocus.js new file mode 100644 index 0000000..f483b36 --- /dev/null +++ b/app/utils/mapFocus.js @@ -0,0 +1,48 @@ +// Where /places opens when given a country (#258): the area most of the +// country's organisations are in, and the zoom that fits it. + +const quantile = (sorted, q) => + sorted[ + Math.min( + sorted.length - 1, + Math.max(0, Math.round(q * (sorted.length - 1))) + ) + ]; + +/** + * The box around `points` ({ latitude, longitude }), as { sw, ne, center } + * in [lat, lng]. With enough points, the outer 5% on each side are left + * out, so a few far-off places (overseas regions) don't zoom the map out + * to the whole world. Null without points. + */ +export function focusOn(points) { + const placed = points.filter( + (p) => Number.isFinite(p?.latitude) && Number.isFinite(p?.longitude) + ); + if (placed.length === 0) return null; + const trim = placed.length >= 20 ? 0.05 : 0; + const lats = placed.map((p) => p.latitude).sort((a, b) => a - b); + const lngs = placed.map((p) => p.longitude).sort((a, b) => a - b); + const sw = [quantile(lats, trim), quantile(lngs, trim)]; + const ne = [quantile(lats, 1 - trim), quantile(lngs, 1 - trim)]; + return { sw, ne, center: [(sw[0] + ne[0]) / 2, (sw[1] + ne[1]) / 2] }; +} + +const mercatorY = (lat) => + Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360)); + +/** The web-map zoom at which the box fits a map of `width` × `height` pixels. */ +export function zoomToFit( + { sw, ne }, + { width, height }, + { max = 12, margin = 0.4 } = {} +) { + const lngShare = (ne[1] - sw[1]) / 360; + const latShare = (mercatorY(ne[0]) - mercatorY(sw[0])) / (2 * Math.PI); + if (lngShare <= 0 && latShare <= 0) return max; // a single place + const zoom = Math.min( + lngShare > 0 ? Math.log2(width / 256 / lngShare) : max, + latShare > 0 ? Math.log2(height / 256 / latShare) : max + ); + return Math.max(1, Math.min(max, zoom - margin)); +} diff --git a/app/utils/mapFocus.test.js b/app/utils/mapFocus.test.js new file mode 100644 index 0000000..5053485 --- /dev/null +++ b/app/utils/mapFocus.test.js @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { focusOn, zoomToFit } from './mapFocus'; + +const at = (latitude, longitude) => ({ latitude, longitude }); + +describe('focusOn', () => { + it('boxes the points and centres on the box', () => { + expect(focusOn([at(43, -1), at(51, 7), at(48, 2)])).toEqual({ + sw: [43, -1], + ne: [51, 7], + center: [47, 3], + }); + }); + + it('leaves out far-off outliers once there are enough points', () => { + // Twenty places in mainland France, and one in Réunion. + const mainland = Array.from({ length: 20 }, (_, i) => + at(43 + (i % 8), -1 + (i % 9)) + ); + const { sw, ne } = focusOn([...mainland, at(-21, 55.5)]); + expect(sw[0]).toBeGreaterThan(40); + expect(ne[1]).toBeLessThan(10); + }); + + it('ignores places without coordinates, and gives nothing for none', () => { + expect(focusOn([at(null, 2), at(48, 2)]).center).toEqual([48, 2]); + expect(focusOn([at(null, null)])).toBeNull(); + }); +}); + +describe('zoomToFit', () => { + it('zooms to a country on a 1240 × 835 map', () => { + const france = zoomToFit( + { sw: [43, -1.7], ne: [50.7, 7.5] }, + { width: 1240, height: 835 } + ); + expect(france).toBeGreaterThan(4.5); + expect(france).toBeLessThan(6.5); + }); + + it('zooms out further for a bigger area, and caps a single place', () => { + const small = zoomToFit( + { sw: [43, -1.7], ne: [50.7, 7.5] }, + { width: 1240, height: 835 } + ); + const big = zoomToFit( + { sw: [25, -124], ne: [49, -67] }, + { width: 1240, height: 835 } + ); + expect(big).toBeLessThan(small); + expect( + zoomToFit({ sw: [48, 2], ne: [48, 2] }, { width: 1240, height: 835 }) + ).toBe(12); + }); +}); diff --git a/e2e/orgs.spec.js b/e2e/orgs.spec.js index c9dc20c..c3c3d8f 100644 --- a/e2e/orgs.spec.js +++ b/e2e/orgs.spec.js @@ -95,3 +95,39 @@ test.describe('Bluesky handles on organizations', () => { await expect(link).toHaveAttribute('href', 'https://bsky.app/profile/sky-studio.bsky.social'); }); }); + +// #258: a country page's cities link to the lists filtered by city. +test('the studios list filters by city, and keeps it across other filters', async ({ page }) => { + const { PrismaClient } = await import('@prisma/client'); + const db = new PrismaClient(); + const tag = Date.now() % 100000; + const places = await Promise.all( + ['Fjordheim', 'Snowdale'].map((city) => + db.location.create({ data: { country_code: 'NO', city: `${city} ${tag}`, region: 'Vestland' } }) + ) + ); + const [here, there] = await Promise.all( + places.map((place, i) => + db.entity.create({ data: { name: `Aurora Studio ${tag}-${i}`, type: 'studio', location_id: place.id } }) + ) + ); + try { + await page.goto(`/studios?country=NO&city=${encodeURIComponent(`fjordheim ${tag}`)}`); + await expect(page.getByText(`In fjordheim ${tag}`)).toBeVisible(); + await expect(page.getByText(here.name)).toBeVisible(); + await expect(page.getByText(there.name)).toHaveCount(0); + + // Another filter keeps the city. + await page.getByText('Has published games').click(); + await expect(page).toHaveURL(/has_games=on/); + await expect(page).toHaveURL(/city=fjordheim/); + + await page.getByRole('button', { name: 'Show every city' }).click(); + await expect(page).not.toHaveURL(/city=/); + await expect(page.getByText(`In fjordheim ${tag}`)).toHaveCount(0); + } finally { + await db.entity.deleteMany({ where: { id: { in: [here.id, there.id] } } }); + await db.location.deleteMany({ where: { id: { in: places.map((p) => p.id) } } }); + await db.$disconnect(); + } +}); diff --git a/e2e/places.spec.js b/e2e/places.spec.js index 4ca79cd..4e637cd 100644 --- a/e2e/places.spec.js +++ b/e2e/places.spec.js @@ -46,6 +46,31 @@ test('clicking a place on the map highlights its card', async ({ page }) => { expect(errors).toEqual([]); }); +// #258: "Explore on the map" from a country page opens /places on it. +test('places opens on a country when given one', async ({ page }) => { + const { PrismaClient } = await import('@prisma/client'); + const db = new PrismaClient(); + const tag = Date.now() % 100000; + const [reykjavik, sydney] = await Promise.all([ + db.location.create({ data: { country_code: 'IS', city: `Reykjavík ${tag}`, region: 'Capital Region', latitude: 64.1466, longitude: -21.9426 } }), + db.location.create({ data: { country_code: 'AU', city: `Sydney ${tag}`, region: 'New South Wales', latitude: -33.8688, longitude: 151.2093 } }), + ]); + const [near, far] = await Promise.all([ + db.entity.create({ data: { name: `Geyser Games ${tag}`, type: 'studio', location_id: reykjavik.id } }), + db.entity.create({ data: { name: `Harbour Games ${tag}`, type: 'studio', location_id: sydney.id } }), + ]); + try { + await page.goto('/places?country=IS', { waitUntil: 'networkidle' }); + // The list beside the map shows what's in view: Iceland, not Australia. + await expect(page.locator(`[id="${near.id}"]`)).toBeAttached(); + await expect(page.locator(`[id="${far.id}"]`)).toHaveCount(0); + } finally { + await db.entity.deleteMany({ where: { id: { in: [near.id, far.id] } } }); + await db.location.deleteMany({ where: { id: { in: [reykjavik.id, sydney.id] } } }); + await db.$disconnect(); + } +}); + // #212: the event form asked a random a./b./c. OSM subdomain for "@2x" // tiles, which OSM answers with 400; every map now uses one tile provider. test.describe('on a high-density screen', () => {