Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions app/components/Filters.jsx
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -17,9 +27,34 @@ const Filters = ({ facets, selected, type }) => {
<CountryCombobox
countries={facets.countries}
defaultValue={selected.country}
onChange={(form) => submit(form)}
onChange={(form) => {
form.querySelector('input[name="city"]')?.remove();
submit(form);
}}
/>

{selected.city && (
<>
<input type="hidden" name="city" value={selected.city} />
<Tag.Root
size="lg"
variant="subtle"
colorPalette="green"
alignSelf="center"
>
<Tag.Label>In {selected.city}</Tag.Label>
<Tag.EndElement>
<Tag.CloseTrigger
// Inside the form: a submit button would send the city again.
type="button"
aria-label="Show every city"
onClick={() => navigate(withoutCity())}
/>
</Tag.EndElement>
</Tag.Root>
</>
)}

{type === 'event' ? (
<NativeSelect.Root>
<NativeSelect.Field
Expand Down
8 changes: 6 additions & 2 deletions app/routes/associations.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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' } }),
};
}

Expand Down Expand Up @@ -82,6 +85,7 @@ export const loader = async ({ request }) => {
},
selected: {
country,
city,
has_games,
has_events,
},
Expand Down
29 changes: 25 additions & 4 deletions app/routes/places.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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: {
Expand All @@ -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;
};
Expand All @@ -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);
Expand All @@ -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;
Expand Down
8 changes: 6 additions & 2 deletions app/routes/studios.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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' } }),
};
}

Expand Down Expand Up @@ -82,6 +85,7 @@ export const loader = async ({ request }) => {
},
selected: {
country,
city,
has_games,
has_events,
},
Expand Down
48 changes: 48 additions & 0 deletions app/utils/mapFocus.js
Original file line number Diff line number Diff line change
@@ -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));
}
56 changes: 56 additions & 0 deletions app/utils/mapFocus.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
36 changes: 36 additions & 0 deletions e2e/orgs.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
25 changes: 25 additions & 0 deletions e2e/places.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading