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', () => {