Skip to content
Merged
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
82 changes: 69 additions & 13 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,24 @@ async function profileFor(user:AppUser) {
trips, goals, stats: { completedCount: completed.length, plannedCount: trips.length-completed.length,
totalLengthMeters: completed.reduce((sum,trip) => sum+(trip.lengthMeters || 0),0), countyCount: counties[0].count } }
}
// Kartutsnitt rundt turene i en kommune, slik at kartet på Utforsk kan starte der.
// Vi har ikke kommunegrensene i databasen, så utsnittet regnes ut fra rutene som ligger i kommunen.
// Svært lange ruter (over 30 km) strekker seg ofte langt ut av kommunen, så de tas bare med
// hvis kommunen ikke har kortere ruter. Gir null hvis kommunen ikke har noen ruter.
async function municipalityBbox(code:string) {
await routeCatalogReady
const { rows } = await pool.query(`
WITH routes AS (
SELECT c.geom, c.length_meters FROM norwegian_explorer_app.route_areas a
JOIN norwegian_explorer_app.route_catalog c ON c.id=a.route_id
WHERE a.municipality_number=$1
), chosen AS (
SELECT geom FROM routes WHERE length_meters<=30000
UNION ALL SELECT geom FROM routes WHERE NOT EXISTS (SELECT 1 FROM routes WHERE length_meters<=30000)
), extent AS (SELECT ST_Transform(ST_SetSRID(ST_Extent(geom)::geometry,25833),4326) AS box FROM chosen)
SELECT CASE WHEN box IS NULL THEN NULL ELSE ARRAY[ST_XMin(box),ST_YMin(box),ST_XMax(box),ST_YMax(box)]::float8[] END AS bbox FROM extent`, [code])
return rows[0]?.bbox ?? null
}
async function municipalityByCode(code:string|null) {
if (!code) return null
await routeCatalogReady
Expand All @@ -171,6 +189,20 @@ async function requireUser(token:string) {
if (!user) throw new GraphQLError('Du må logge inn.')
return user
}
// Filtre i routes som kan ha flere verdier. Innen ett filter er det «eller» (Enkel eller Middels),
// for steder langs turen er det «og» (turen må ha både hytte og utsiktspunkt i nærheten).
type RouteListArgs = { difficulty: string[] | null; routeType: string[] | null; facility: string[] | null; sort: RouteSort }
type RouteSort = 'NAME' | 'SHORTEST' | 'LONGEST' | 'POPULAR'
// ORDER BY for hver sortering. Teksten settes rett inn i SQL, men kommer bare fra denne lista (ikke fra brukeren).
// key til slutt gjør rekkefølgen entydig, så pagineringen ikke hopper over eller gjentar turer.
const ROUTE_SORT_ORDER: Record<RouteSort, string> = {
NAME: 'name, key',
SHORTEST: '"minLengthMeters", name, key',
LONGEST: '"maxLengthMeters" DESC, name, key',
POPULAR: '"favoriteCount" DESC, name, key',
}
const validFilterList = (list: string[] | null) => list == null || (list.length <= 12 && list.every(item => typeof item === 'string' && item.length <= 40))

const schema = createSchema({
// Fjellområdene og turpunktene ligger i server/server.ts og legges til her
typeDefs: [`
Expand All @@ -191,11 +223,16 @@ const schema = createSchema({
geojson: String!
}
type RouteSummary { id: ID!, name: String!, number: String!, lengthMeters: Float!, difficulty: String, routeType: String, duplicateCount: Int!, variantCount: Int! }
type RouteGroup { key: String!, name: String!, variantCount: Int!, minLengthMeters: Float!, maxLengthMeters: Float!, variants: [RouteSummary!]! }
"Sortering av turlister: navn, korteste/lengste først, eller flest favoritter først"
enum RouteSort { NAME SHORTEST LONGEST POPULAR }
type RouteGroup { key: String!, name: String!, variantCount: Int!, minLengthMeters: Float!, maxLengthMeters: Float!, favoriteCount: Int!, variants: [RouteSummary!]! }
type RoutePage { items: [RouteGroup!]!, total: Int! }
type PopularRoute { favoriteCount: Int!, route: RouteSummary! }
type Municipality { code: String!, name: String! }
"Kommune. bbox er [vest, sør, øst, nord] i grader rundt turene i kommunen, brukes til å zoome kartet dit."
type Municipality { code: String!, name: String!, bbox: [Float!] }
type AreaOption { code: String!, name: String!, routeCount: Int! }
"Kommune fra søket. bbox som på Municipality, så kartet kan fly dit."
type MunicipalityHit { code: String!, name: String!, routeCount: Int!, bbox: [Float!] }
type AreaOptions { counties: [AreaOption!]!, municipalities: [AreaOption!]! }
type OsmPage { geojson: String!, truncated: Boolean! }
type Favorite { id: ID!, name: String!, number: String!, createdAt: String! }
Expand All @@ -217,12 +254,12 @@ const schema = createSchema({
type AuthPayload { token: String!, user: UserProfile! }
type Query {
osmNearby(west: Float!, south: Float!, east: Float!, north: Float!): OsmPage!
routes(search: String = "", minLength: Float, maxLength: Float, difficulty: String, routeType: String, county: String, municipality: String, facility: String, west: Float, south: Float, east: Float, north: Float, offset: Int = 0, limit: Int = 12): RoutePage!
routes(search: String = "", minLength: Float, maxLength: Float, difficulty: [String!], routeType: [String!], county: String, municipality: String, facility: [String!], sort: RouteSort = NAME, west: Float, south: Float, east: Float, north: Float, offset: Int = 0, limit: Int = 12): RoutePage!
areas(county: String): AreaOptions!
route(name: String!, number: String!): Route
routeVariants(name: String!): [RouteSummary!]!
popularRoutes(limit: Int = 6): [PopularRoute!]!
searchMunicipalities(query: String!, limit: Int = 4): [AreaOption!]!
searchMunicipalities(query: String!, limit: Int = 4): [MunicipalityHit!]!
exampleRoute: Route
me(token: String!): UserProfile
builderNetwork(token: String!, routeId: ID): String!
Expand Down Expand Up @@ -286,7 +323,10 @@ const schema = createSchema({
])
return { counties: countyResult.rows, municipalities: municipalityResult.rows }
},
routes: async (_:unknown, { search, minLength, maxLength, difficulty, routeType, county, municipality, facility, west, south, east, north, offset, limit }:ApiArgs) => {
routes: async (_:unknown, args:ApiArgs) => {
const { search, minLength, maxLength, county, municipality, west, south, east, north, offset, limit } = args
const { difficulty, routeType, facility, sort } = args as unknown as RouteListArgs
if (![difficulty, routeType, facility].every(validFilterList) || !Object.hasOwn(ROUTE_SORT_ORDER, sort)) throw new GraphQLError('Ugyldig filter eller sortering.')
await routeCatalogReady
const bounds = [west,south,east,north]
const hasBounds = bounds.every(Number.isFinite)
Expand All @@ -296,9 +336,16 @@ const schema = createSchema({
(bounds.some(value => value != null) && !hasBounds) || (hasBounds && (west < -180 || east > 180 || south < -90 || north > 90 || west >= east || south >= north))) {
throw new GraphQLError('Ugyldig søk eller sidestørrelse.')
}
const facilityIds=facility?await nearbyFacilityRouteIds(facility):null
// Turer nær alle valgte steder: snittet av id-listene for hver stedstype (listene er mellomlagret i routeStops.ts)
let facilityIds: string[] | null = null
for (const item of new Set(facility ?? [])) {
const ids = new Set(await nearbyFacilityRouteIds(item) ?? [])
facilityIds = facilityIds ? facilityIds.filter(id => ids.has(id)) : [...ids]
}
const { rows } = await pool.query(`
WITH base AS (
WITH favorite_counts AS (
SELECT route_id, count(*)::int AS n FROM norwegian_explorer_app.favorites WHERE route_id IS NOT NULL GROUP BY route_id
), base AS (
SELECT c.id,c.name,c.number,c.length_meters,c.difficulty,c.route_type,c.geom,
d.duplicate_count AS "duplicateCount"
FROM norwegian_explorer_app.route_catalog c
Expand All @@ -320,8 +367,8 @@ const schema = createSchema({
AND ($14::text[] IS NULL OR id=ANY($14))
AND ($2::float8 IS NULL OR length_meters >= $2 * 1000)
AND ($3::float8 IS NULL OR length_meters <= $3 * 1000)
AND ($8::text IS NULL OR $8 = ANY(string_to_array(coalesce(difficulty,'Ikke gradert'), ', ')))
AND ($9::text IS NULL OR $9 = ANY(string_to_array(coalesce(route_type,'Ikke oppgitt'), ', ')))
AND ($8::text[] IS NULL OR string_to_array(coalesce(difficulty,'Ikke gradert'), ', ') && $8::text[])
AND ($9::text[] IS NULL OR string_to_array(coalesce(route_type,'Ikke oppgitt'), ', ') && $9::text[])
AND ($10::text IS NULL OR EXISTS (SELECT 1 FROM norwegian_explorer_app.route_areas a WHERE a.route_id=canonical.id AND a.county_number=$10))
AND ($11::text IS NULL OR EXISTS (SELECT 1 FROM norwegian_explorer_app.route_areas a WHERE a.route_id=canonical.id AND a.municipality_number=$11))
AND ($4::float8 IS NULL OR (
Expand All @@ -330,15 +377,19 @@ const schema = createSchema({
), grouped AS (
SELECT lower(name) AS key,min(name) AS name,count(*)::int AS "variantCount",
min("lengthMeters") AS "minLengthMeters",max("lengthMeters") AS "maxLengthMeters",
coalesce(sum(fc.n),0)::int AS "favoriteCount",
json_agg(json_build_object(
'id',id,'name',name,'number',number,'lengthMeters',"lengthMeters",
'difficulty',difficulty,'routeType',"routeType",'duplicateCount',"duplicateCount",
'variantCount',"variantCount") ORDER BY "lengthMeters",number) AS variants
FROM matches GROUP BY lower(name)
), page AS (SELECT * FROM grouped ORDER BY name LIMIT $12 OFFSET $13)
FROM matches LEFT JOIN favorite_counts fc ON fc.route_id=matches.id GROUP BY lower(name)
), page AS (
SELECT *, row_number() OVER (ORDER BY ${ROUTE_SORT_ORDER[sort]}) AS position
FROM grouped ORDER BY ${ROUTE_SORT_ORDER[sort]} LIMIT $12 OFFSET $13
)
SELECT (SELECT count(*)::int FROM grouped) AS total,
coalesce((SELECT json_agg(page ORDER BY name) FROM page),'[]'::json) AS items
`, [search.trim(), minLength ?? null, maxLength ?? null, hasBounds ? west : null, hasBounds ? south : null, hasBounds ? east : null, hasBounds ? north : null, difficulty || null, routeType || null, county || null, municipality || null, limit, offset,facilityIds])
coalesce((SELECT json_agg(page ORDER BY position) FROM page),'[]'::json) AS items
`, [search.trim(), minLength ?? null, maxLength ?? null, hasBounds ? west : null, hasBounds ? south : null, hasBounds ? east : null, hasBounds ? north : null, difficulty?.length ? difficulty : null, routeType?.length ? routeType : null, county || null, municipality || null, limit, offset,facilityIds])
return rows[0]
},
popularRoutes: async (_:unknown, { limit }:ApiArgs) => {
Expand Down Expand Up @@ -585,6 +636,11 @@ const schema = createSchema({
[user.id, cleanEmail, cleanPhone, cleanAvatar])
return profileFor(rows[0])
},
}, Municipality: {
// Regnes bare ut når klienten ber om feltet
bbox: ({ code }:{ code:string }) => municipalityBbox(code),
}, MunicipalityHit: {
bbox: ({ code }:{ code:string }) => municipalityBbox(code),
} }, mountainResolvers],
})
async function loadRoute(name:string, number:string) {
Expand Down
12 changes: 2 additions & 10 deletions src/App.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading