diff --git a/server/index.ts b/server/index.ts index 655cd44..356bffa 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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 @@ -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 = { + 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: [` @@ -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! } @@ -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! @@ -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) @@ -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 @@ -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 ( @@ -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) => { @@ -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) { diff --git a/src/App.css b/src/App.css index 6b1aa45..8dc9638 100644 --- a/src/App.css +++ b/src/App.css @@ -1,12 +1,4 @@ -header{height:90px;padding:0 5%;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #dce1d7;background:#fff}.brand{text-decoration:none;font-weight:700;font-size:25px;display:flex;gap:12px;align-items:center}.brand span{font-size:19px}.badge{font-size:12px;background:#edf2e9;padding:8px 13px;border-radius:20px}main{max-width:1450px;margin:auto;padding:44px 5% 20px}.intro{margin-bottom:32px}.eyebrow{font-size:11px;letter-spacing:2px;font-weight:700;color:#557365}h1{font-size:clamp(34px,5vw,58px);letter-spacing:-2px;margin:10px 0}.intro>p:last-child{color:#5a6c61;margin:12px 0}.route-layout{display:grid;grid-template-columns:370px 1fr;border:1px solid #dce1d7;border-radius:18px;overflow:hidden;background:white;box-shadow:0 15px 45px #233a3010}.route-card{padding:30px}.route-card h2{font-size:30px;margin:12px 0}.route-number{font-size:12px;color:#66766b}.distance{display:flex;align-items:baseline;gap:9px;margin:26px 0}.distance strong{font-size:42px;letter-spacing:-2px}.distance span{font-size:13px;color:#5b6c61}dl{margin:0}dl>div{padding:13px 0;border-top:1px solid #e9ece5}dt{font-size:12px;color:#647267;margin-bottom:5px}dd{margin:0;font-size:14px;font-weight:500}.notice{padding:16px;background:#f4f0e4;border-radius:9px;font-size:12px;margin:22px 0}.notice p{margin:6px 0 0}.route-card h3{font-size:15px}.route-card>p:last-child{font-size:13px;color:#647267}.map-panel{position:relative;min-width:0;min-height:580px;background:#e4eadb}.map{position:absolute;inset:0}.map-label{position:absolute;top:18px;left:18px;background:white;padding:11px 14px;border-radius:8px;font-size:12px;box-shadow:0 3px 12px #0001;pointer-events:none}.map-label span{display:inline-block;width:20px;height:4px;background:#126b51;margin:0 7px 3px 0}.map-error{position:absolute;bottom:28px;left:15px;right:15px;background:white;padding:10px;font-size:12px}footer{font-size:11px;color:#687669;margin:24px 0}.state{padding:35px;background:white;border-radius:12px}.skip-link{position:absolute;top:-100px;left:10px;padding:10px;background:white;z-index:10}.skip-link:focus{top:10px}@media(max-width:800px){header{height:72px}.badge{display:none}main{padding-top:28px}.route-layout{grid-template-columns:1fr}.map-panel{min-height:420px}.route-card{padding:24px}.intro{margin-bottom:24px}} +header{height:90px;padding:0 5%;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #dce1d7;background:#fff}main{max-width:1450px;margin:auto;padding:44px 5% 20px}.eyebrow{font-size:11px;letter-spacing:2px;font-weight:700;color:#557365}h1{font-size:clamp(34px,5vw,58px);letter-spacing:-2px;margin:10px 0}dl{margin:0}dl>div{padding:13px 0;border-top:1px solid #e9ece5}dt{font-size:12px;color:#647267;margin-bottom:5px}dd{margin:0;font-size:14px;font-weight:500}.map{position:absolute;inset:0}footer{font-size:11px;color:#687669;margin:24px 0}.skip-link{position:absolute;top:-100px;left:10px;padding:10px;background:white;z-index:10}.skip-link:focus{top:10px}@media(max-width:800px){header{height:72px}main{padding-top:28px}} -.route-search{background:white;border:1px solid #dce1d7;border-radius:16px;padding:24px;margin-bottom:32px}.route-search label{display:block;font-weight:600;margin-bottom:10px}.search-row{display:flex;gap:10px}.search-row input{font:inherit;min-width:0;flex:1;border:1px solid #9cac9e;border-radius:8px;padding:12px}.search-help{font-size:12px;color:#647267}.route-results{list-style:none;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;padding:0}.route-results button{width:100%;height:100%;text-align:left;color:#203b32;background:#f5f6f0;border:2px solid transparent;padding:14px;overflow-wrap:anywhere}.route-results button[aria-pressed=true]{border-color:#126b51;background:#e7f1e9}.route-results strong,.route-results span{display:block}.route-results span{font-size:11px;margin-top:8px;color:#647267}.pagination{display:flex;gap:10px;justify-content:flex-end}button:disabled{opacity:.4;cursor:default}.selected-heading{font-size:19px;margin:24px 0 16px}input:focus-visible{outline:3px solid #c27730;outline-offset:3px}@media(max-width:800px){.route-results{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:480px){.route-results{grid-template-columns:1fr}.route-search{padding:18px}} +button:disabled{opacity:.4;cursor:default}input:focus-visible{outline:3px solid #c27730;outline-offset:3px} -.map-and-osm{min-width:0;background:#fff}.map-and-osm .map-panel{height:580px}.osm-panel{padding:20px;font-size:12px;border-top:1px solid #dce1d7}.osm-panel label{font-size:14px;font-weight:600;display:flex;gap:8px;align-items:center}.osm-panel input{width:18px;height:18px;accent-color:#126b51}.osm-panel p{margin:10px 0}.osm-legend{font-weight:600}.osm-panel details{margin:15px 0}.osm-panel li{margin:8px 0}.osm-panel summary{cursor:pointer}@media(max-width:800px){.map-and-osm .map-panel{height:420px;min-height:420px}} -.nav-link{color:#126b51;font-weight:600;font-size:14px;text-decoration:none}.nav-link:hover{text-decoration:underline}.header-actions{display:flex;align-items:center;gap:12px}.profile-link{display:flex;align-items:center;gap:8px;text-decoration:none;background:#126b51;color:#fff;padding:9px 14px;border-radius:20px;font-size:12px;font-weight:700}.profile-link span{font-size:15px} - -.explore-search-bar { margin: 24px 0; } -.explore-search-form { display: flex; align-items: center; gap: 12px; padding: 8px 12px 8px 18px; border: 1px solid #d8dfd5; border-radius: 10px; background: #fff; } -.explore-search-form input { flex: 1; min-width: 0; border: 0; background: transparent; box-shadow: none; padding: 12px 0; } -.explore-search-form button { flex-shrink: 0; } -@media (max-width: 480px) { .explore-search-form { gap: 8px; padding-left: 12px; } .explore-search-form > span, .explore-search-form button span { display: none; } } diff --git a/src/App.tsx b/src/App.tsx index 21d0cf0..684780e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,139 +1,12 @@ import SiteNav from './SiteNav' -import { useCallback, useEffect, useRef, useState } from 'react' -import * as maplibregl from 'maplibre-gl' -import type { FeatureCollection, LineString, MultiLineString } from 'geojson' -import 'maplibre-gl/dist/maplibre-gl.css' -import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url' +import { useCallback, useEffect, useState } from 'react' +import 'maplibre-gl/dist/maplibre-gl.css' // før App.css, slik at våre regler for kartet vinner import './App.css' -import './Filters.css' -import './Explore.css' +import Explore from './Explore/Explore' +import type { SelectedRoute } from './Explore/routeTypes' import Home from './Home' import {graphql,TOKEN_KEY} from './graphql' import Profile, { AuthGate } from './Profile' -import ElevationProfile from './ElevationProfile' -import {placeCategories} from './placeCategories' - -maplibregl.setWorkerUrl(workerUrl) - -type Route = { - id: string; name: string; number: string; lengthMeters: number; segmentCount: number - connected: boolean; maintainer: string | null; difficulty: string | null - routeType: string | null; nameQuality: string; duplicateCount: number; variantCount: number - description: string | null; geojson: string -} -type RouteSummary = { id: string; name: string; number: string; lengthMeters: number; difficulty: string | null; routeType: string | null; duplicateCount: number; variantCount: number } -type RouteGroup = { key: string; name: string; variantCount: number; minLengthMeters: number; maxLengthMeters: number; variants: RouteSummary[] } -type AreaOption = { code: string; name: string; routeCount: number } -function variantLabel(number: string) { - if (number === 'SAMLET_RUNDE') return 'Samlet rundtur' - if (number.startsWith('RUNDTUR_')) return `Rundtur ${number.slice(8)}` - if (number.startsWith('STREKNING_')) return `Sammenhengende strekning ${number.slice(10)}` - if (number === 'F_20170113_01') return 'Hele stinettet' - return `Nr. ${number}` -} - -const labels: Record = { parking:'Parkering', toilets:'Toalett', drinking_water:'Drikkevann', shelter:'Leskur', viewpoint:'Utsiktspunkt', alpine_hut:'Turhytte', wilderness_hut:'Villmarkshytte', picnic_site:'Rasteplass', camp_site:'Campingplass', peak:'Topp', saddle:'Skar', chalet:'Hytte', lean_to:'Gapahuk' } - -function RouteMap({ route }: { route: Route }) { - const container = useRef(null) - const osmMap = useRef(null) - const [osmKind,setOsmKind] = useState('all') - const [error, setError] = useState('') - const [showOsm, setShowOsm] = useState(false) - const [osmStatus, setOsmStatus] = useState('') - const [osmData, setOsmData] = useState(null) - - useEffect(() => { - if (!container.current) return - let map: maplibregl.Map | undefined - const controller = new AbortController() - try { - const geometry = JSON.parse(route.geojson) as LineString | MultiLineString - const points = geometry.type === 'LineString' ? geometry.coordinates : geometry.coordinates.flat() - const bounds = new maplibregl.LngLatBounds() - points.forEach(([lng, lat]) => bounds.extend([lng, lat])) - map = new maplibregl.Map({ - container: container.current, - scrollZoom: false, - bounds, fitBoundsOptions: { padding: 60, maxZoom: 14 }, - style: { - version: 8, - sources: { - osm: { type: 'raster', tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], tileSize: 256, - maxzoom: 19, attribution: '© OpenStreetMap' }, - }, - layers: [{ id: 'background', type: 'raster', source: 'osm' }], - }, - }) - map.addControl(new maplibregl.NavigationControl(), 'top-right') - osmMap.current=map - map.on('error', () => setError('En del av kartet kunne ikke lastes. Ruteinformasjonen er fortsatt tilgjengelig.')) - map.on('load', () => { - if (showOsm) { - const variables = { west: bounds.getWest()-0.01, south: bounds.getSouth()-0.01, east: bounds.getEast()+0.01, north: bounds.getNorth()+0.01 } - if (variables.east-variables.west > 0.5 || variables.north-variables.south > 0.5) { - setOsmStatus('Denne ruten dekker et for stort område. Velg en kortere rute for å se OSM-supplementet.') - } else { - void fetch('/graphql', { method:'POST', headers:{'Content-Type':'application/json'}, signal:controller.signal, - body:JSON.stringify({query:'query($west:Float!,$south:Float!,$east:Float!,$north:Float!){osmNearby(west:$west,south:$south,east:$east,north:$north){geojson truncated}}',variables}) - }).then(async response => { - const result = await response.json() - if (!response.ok || result.errors) throw new Error('OSM request failed') - if (controller.signal.aborted) return - const data = JSON.parse(result.data.osmNearby.geojson) as FeatureCollection - map!.addSource('osm-extra', {type:'geojson',data}) - map!.addLayer({id:'osm-trails',type:'line',source:'osm-extra',filter:['==',['get','category'],'trail'],paint:{'line-color':'#b46524','line-width':3,'line-dasharray':[2,1]}},'route-outline') - map!.addLayer({id:'osm-places',type:'circle',source:'osm-extra',filter:['==',['get','category'],'place'],paint:{'circle-color':'#355fb8','circle-radius':6,'circle-stroke-color':'white','circle-stroke-width':2}}) - map!.on('click','osm-places',event => { - const feature = event.features?.[0] - if (!feature) return - new maplibregl.Popup().setLngLat(event.lngLat).setText(String(feature.properties.name || labels[String(feature.properties.kind)] || 'OSM-sted')).addTo(map!) - }) - setOsmData(data) - setOsmStatus(result.data.osmNearby.truncated ? 'Viser de første 1 000 objektene i området; utvalget er begrenset.' : `${data.features.length} OSM-objekter i området rundt rutens kartutsnitt.`) - }).catch(() => { if (!controller.signal.aborted) setOsmStatus('Kunne ikke hente OSM-data. Slå laget av og på for å prøve igjen.') }) - } - } - map!.addSource('route', { type: 'geojson', data: { type: 'Feature', properties: {}, geometry } }) - map!.addLayer({ id: 'route-outline', type: 'line', source: 'route', paint: { 'line-color': '#fff', 'line-width': 9 } }) - map!.addLayer({ id: 'route-line', type: 'line', source: 'route', paint: { 'line-color': '#126b51', 'line-width': 5 } }) - map!.fitBounds(bounds, { padding: 50, maxZoom: 14, duration: 0 }) - }) - } catch { - // Report failure of the external WebGL system; this is not derived React state. - // eslint-disable-next-line react-hooks/set-state-in-effect - setError('Kartet kunne ikke åpnes i nettleseren. Du kan lese ruteinformasjonen ved siden av.') - } - return () => { controller.abort(); map?.remove(); osmMap.current=null } - }, [route, showOsm]) - useEffect(()=>{ - const map=osmMap.current - if(!map?.getLayer('osm-places'))return - const kinds=placeCategories.find(item=>item.value===osmKind)?.kinds - map.setFilter('osm-places',kinds?['all',['==',['get','category'],'place'],['in',['get','kind'],['literal',kinds]]]:['==',['get','category'],'place']) - },[osmKind,osmData]) - const visiblePlaces=osmData?.features.filter(f=>f.properties?.category==='place'&&(osmKind==='all'||placeCategories.find(item=>item.value===osmKind)?.kinds.includes(String(f.properties?.kind)))) || [] - return
-
-
Registrert rutestrekning
- {error &&

{error}

} -
-
- - {showOsm && <> - -

Grønn: valgt rute · Oransje stiplet: OSM-stier · Blå: steder

-

{osmStatus}

-

Stedene ligger i området rundt ruta, men er ikke nødvendigvis tilgjengelige fra den. Stedsmarkører er omtrentlige og viser ikke nødvendigvis inngangen. Stier er ikke ferdige turforslag.

-

Objekter merket «private» eller «no» for ferdsel er utelatt. Andre begrensninger kan gjelde.

- {osmData &&
Steder i valgt kategori ({visiblePlaces.length}) - -
} -

© OpenStreetMap-bidragsytere · ODbL. Uttrekk fra Geofabrik, 27.09.2026.

- } -
-
-} export default function App() { const [view,setView] = useState<'explore'|'profile'>(()=>window.location.hash.startsWith('#profile')?'profile':'explore') @@ -151,83 +24,15 @@ export default function App() { .then(data=>{if(!controller.signal.aborted&&localStorage.getItem(TOKEN_KEY)===token){setAuthenticated(Boolean(data.me));if(!data.me)localStorage.removeItem(TOKEN_KEY)}}).catch(()=>undefined) return ()=>controller.abort() },[]) - const detailsRef = useRef(null) - const [route, setRoute] = useState(null) - const [routeVariants, setRouteVariants] = useState([]) - const [status, setStatus] = useState<'loading' | 'ready' | 'error' | 'empty'>('empty') - const [attempt, setAttempt] = useState(0) - const [selected, setSelected] = useState<{ name: string; number: string } | null>(null) - const [input, setInput] = useState('') - const [search, setSearch] = useState('') - const [minInput, setMinInput] = useState('') - const [maxInput, setMaxInput] = useState('') - const [minLength, setMinLength] = useState(null) - const [maxLength, setMaxLength] = useState(null) - const [facility,setFacility] = useState(null) - const [difficulty, setDifficulty] = useState(null) - const [routeType, setRouteType] = useState(null) - const [county, setCounty] = useState(null) - const [municipality, setMunicipality] = useState(null) - const [counties, setCounties] = useState([]) - const [municipalities, setMunicipalities] = useState([]) - const [page, setPage] = useState(0) - const [listAttempt, setListAttempt] = useState(0) - const [items, setItems] = useState([]) - const [total, setTotal] = useState(0) - const [listStatus, setListStatus] = useState<'loading' | 'ready' | 'error'>('loading') - function openRoute(item: { name: string; number: string }) { + // Valgt tur deles mellom Utforsk, forsiden og profilen, derfor ligger den her + const [selected, setSelected] = useState(null) + const [loadedRoute, setLoadedRoute] = useState<{ id: string; name: string; number: string } | null>(null) + function openRoute(item: SelectedRoute) { setView('explore') setSelected({ ...item }) - setStatus('loading') - window.requestAnimationFrame(() => detailsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })) + // Turen vises i panelet oppå kartet, som ligger øverst på Utforsk-siden + window.requestAnimationFrame(() => window.scrollTo({ top: 0, behavior: 'smooth' })) } - useEffect(() => { - const controller = new AbortController() - void fetch('/graphql', { method:'POST', headers:{'Content-Type':'application/json'}, signal:controller.signal, - body:JSON.stringify({query:'query($county:String){areas(county:$county){counties{code name routeCount} municipalities{code name routeCount}}}',variables:{county}}) - }).then(response => response.json()).then(result => { - if (!controller.signal.aborted && result.data?.areas) { setCounties(result.data.areas.counties); setMunicipalities(result.data.areas.municipalities) } - }).catch(() => undefined) - return () => controller.abort() - }, [county]) - useEffect(() => { - const controller = new AbortController() - async function loadList() { - try { - const response = await fetch('/graphql', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: controller.signal, - body: JSON.stringify({ query: 'query($search:String!,$minLength:Float,$maxLength:Float,$difficulty:String,$routeType:String,$county:String,$municipality:String,$facility:String,$offset:Int!){ routes(search:$search,minLength:$minLength,maxLength:$maxLength,difficulty:$difficulty,routeType:$routeType,county:$county,municipality:$municipality,facility:$facility,offset:$offset,limit:12){total items{key name variantCount minLengthMeters maxLengthMeters variants{id name number lengthMeters difficulty routeType duplicateCount variantCount}}} }', variables: { search, minLength, maxLength, difficulty, routeType, county, municipality, facility, offset: page * 12 } }), - }) - const result = await response.json() - if (!response.ok || result.errors) throw new Error('Request failed') - if (!controller.signal.aborted) { setItems(result.data.routes.items); setTotal(result.data.routes.total); setListStatus('ready') } - } catch { if (!controller.signal.aborted) setListStatus('error') } - } - void loadList() - return () => controller.abort() - }, [search, minLength, maxLength, difficulty, routeType, county, municipality, facility, page, listAttempt]) - useEffect(() => { - if (!selected) return - const selectedRoute = selected - const controller = new AbortController() - async function load() { - try { - const response = await fetch('/graphql', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: controller.signal, - body: JSON.stringify({ query: 'query($name:String!,$number:String!){ route(name:$name,number:$number) { id name number lengthMeters segmentCount connected maintainer difficulty routeType nameQuality duplicateCount variantCount description geojson } routeVariants(name:$name){id name number lengthMeters difficulty routeType duplicateCount variantCount} }', variables: selectedRoute }), - }) - const result = await response.json() - if (!response.ok || result.errors) throw new Error('Request failed') - if (!controller.signal.aborted) { - setRoute(result.data.route) - setRouteVariants(result.data.routeVariants) - setStatus(result.data.route ? 'ready' : 'empty') - } - } catch { if (!controller.signal.aborted) setStatus('error') } - } - void load() - return () => controller.abort() - }, [selected, attempt]) if(showLogin&&!authenticated)return {setShowLogin(false);setShowHome(true)}} onSkip={()=>{setGuest(true);setShowLogin(false);setShowHome(false);setView('explore')}} /> if(showHome)return {setGuest(!authenticated);setShowHome(false);setView('explore')}} onProfileClick={()=>{setView('profile');setShowHome(false)}} onLoginClick={()=>setShowLogin(true)} onSelectRoute={item=>{openRoute(item);setGuest(!authenticated);setShowHome(false)}} /> if(!authenticated&&!guest)return setShowHome(true)} onSkip={()=>{setGuest(true);setView('explore')}} /> @@ -237,63 +42,10 @@ export default function App() { setShowHome(true)} onExplore={()=>{setView('explore');window.scrollTo({top:0,behavior:'smooth'})}} onProfile={()=>{if(!authenticated){setGuest(false);setShowLogin(true)}setView('profile');window.scrollTo({top:0,behavior:'smooth'})}} />
diff --git a/src/Explore.css b/src/Explore.css deleted file mode 100644 index 1ff0e44..0000000 --- a/src/Explore.css +++ /dev/null @@ -1,86 +0,0 @@ -.explore-app,.auth-page{--ink:#23382c;--muted:#758075;--green:#435d47;--line:#e3e8df;--paper:#fafbf8;font-family:'Avenir Next',system-ui,sans-serif;color:var(--ink);background:var(--paper)} -.explore-app [hidden]{display:none!important} -.explore-app>header,.auth-page>header{height:86px;padding:0 max(5%,calc((100vw - 1320px)/2));background:var(--paper);border-bottom:1px solid var(--line)} -.explore-app .brand,.auth-page .brand{font-size:20px;letter-spacing:-.6px;gap:9px} -.explore-app button,.auth-page button{background:var(--green);border-radius:9px;font-weight:600;padding:12px 18px;transition:background .15s,transform .15s} -.explore-app button:hover:not(:disabled),.auth-page button:hover:not(:disabled){background:#304c37} -.explore-app .nav-tab{background:transparent;color:var(--muted);font-size:13px;padding:10px 12px} -.explore-app .nav-tab[aria-pressed=true]{color:var(--ink)} -.explore-app .profile-link{background:#eaf0e6;color:var(--green);font-size:13px;border-radius:9px} -.explore-app .nav-link{font-size:13px;color:var(--muted)} -.explore-app main{max-width:1432px;padding:24px 56px 24px} -.explore-hero{position:relative;min-height:480px;border-radius:16px;overflow:hidden;background:#385143;background-image:linear-gradient(90deg,rgba(17,37,24,.8),rgba(17,37,24,.36) 50%,rgba(17,37,24,.06)),url('/images/explore-nature.jpg');background-size:cover;background-position:center;display:flex;align-items:center;padding:60px;color:#fff} -.hero-copy{position:relative;max-width:670px;z-index:1} -.hero-copy .eyebrow{color:#d4dfcc;letter-spacing:2.4px;font-size:10px} -.hero-copy h1{font-size:clamp(40px,5.6vw,68px);font-weight:600;line-height:1.03;letter-spacing:-2.5px;margin:20px 0} -.hero-copy>p:not(.eyebrow){font-size:16px;line-height:1.65;color:#e6ebe1;margin-bottom:28px} -.hero-search{display:flex;align-items:center;gap:12px;background:#fff;padding:7px 7px 7px 17px;border-radius:10px;width:min(590px,100%);box-shadow:0 8px 24px #10211720;color:var(--ink)} -.hero-search>span{font-size:28px;color:#667c64;line-height:1} -.hero-search input{border:0;background:transparent;font:inherit;font-size:13px;flex:1;min-width:0;padding:9px 0;color:var(--ink)} -.hero-search button{font-size:12px;white-space:nowrap;display:flex;gap:14px;align-items:center} -.hero-image-note{position:absolute;right:18px;bottom:14px;font-size:9px;letter-spacing:.3px;color:#edf1e8;background:#152e2455;padding:5px 9px;border-radius:4px} -.visually-hidden{position:absolute;width:1px;height:1px;clip-path:inset(50%);overflow:hidden;white-space:nowrap} -.explore-areas{padding:42px 0 28px} -.section-title{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:20px} -.section-title .eyebrow{font-size:9px;letter-spacing:1.9px;color:var(--muted);margin:0 0 10px} -.section-title h2{font-size:27px;letter-spacing:-.9px;font-weight:600;margin:0} -.section-title>a{font-size:12px;text-decoration:none;color:var(--green)} -.area-cards{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px} -.explore-app .area-card{position:relative;background:#eff3ea;color:var(--ink);text-align:left;border:1px solid transparent;padding:23px;min-height:154px;display:flex;flex-direction:column;align-items:flex-start} -.explore-app .area-card:nth-child(2){background:#ecefe8}.explore-app .area-card:nth-child(3){background:#e8efeb}.explore-app .area-card:nth-child(4){background:#f1efe4} -.explore-app .area-card:hover:not(:disabled),.explore-app .area-card[aria-pressed=true]{background:#dfe9d8;border-color:#869a7b} -.area-symbol{font-size:30px;font-weight:400;line-height:1;margin-bottom:20px;color:#677c60} -.area-name{font-size:17px;font-weight:600;letter-spacing:-.3px} -.area-count{font-size:10px;color:#73806e;margin-top:7px;font-weight:400} -.area-arrow{position:absolute;right:18px;top:20px;font-size:16px;color:#73806e} -.area-loading{font-size:13px;color:var(--muted)} -.explore-filters{border:1px solid var(--line);border-radius:10px;background:#fff;margin-bottom:34px;scroll-margin-top:24px} -.explore-filters>summary{display:flex;align-items:center;gap:16px;cursor:pointer;padding:19px 22px;font-size:13px;font-weight:600;list-style:none} -.explore-filters>summary::-webkit-details-marker{display:none} -.explore-filters>summary>span{font-size:11px;font-weight:400;color:var(--muted);flex:1} -.explore-filters>summary>b{font-size:12px;font-weight:500;color:var(--green);margin-left:auto;white-space:nowrap} -.explore-filters[open]>summary{border-bottom:1px solid var(--line)} -.explore-filters .category-section{padding:24px;margin:0}.explore-filters .category-heading h2{font-size:20px}.explore-filters .category-heading .eyebrow{display:none} -.explore-filters .category-group{border-radius:9px;padding:15px;background:#fafbf8} -.explore-app .category-buttons button{background:#edf1e9;color:var(--ink);font-size:12px;padding:10px}.explore-app .category-buttons button:hover:not(:disabled){background:#dce5d5} -.explore-filters .route-facility-filter{border:0;border-top:1px solid var(--line);border-radius:0;margin:0;padding:20px 24px} -.exact-length-form{padding:0 24px 24px}.exact-length-form .length-filters{align-items:end}.exact-length-form button{font-size:12px} -.explore-app .route-search{padding:0;background:transparent;border:0;border-radius:0;scroll-margin-top:24px} -.explore-app .route-search>p[role=status]{color:var(--muted);font-size:12px;margin-bottom:18px} -.explore-app .route-results{gap:16px;margin:0 0 24px} -.explore-app .route-results button{position:relative;background:#fff;color:var(--ink);border:1px solid var(--line);min-height:162px;padding:23px;display:flex;flex-direction:column;justify-content:space-between;box-shadow:0 3px 12px #23382c03} -.explore-app .route-results button:hover:not(:disabled){background:#f1f5ee;border-color:#a2b398;transform:translateY(-2px)} -.explore-app .route-results button[aria-pressed=true]{border-color:var(--green);background:#eef3e9} -.explore-app .route-results .route-card-top{display:flex;align-items:center;justify-content:space-between;margin:0 0 18px;color:#75866c;width:100%;font-size:17px} -.explore-app .route-results .route-type-label{font-size:9px;letter-spacing:1.5px;margin:0;font-weight:500} -.explore-app .route-results strong{font-size:17px;line-height:1.35;letter-spacing:-.3px;margin-bottom:15px;font-weight:600} -.explore-app .route-results .route-card-meta{font-size:12px;color:#788271;margin:0} -.explore-app .pagination button{border:1px solid var(--line);background:#fff;color:var(--green);font-size:12px;padding:10px 18px} -.explore-app .pagination button:hover:not(:disabled){background:#edf2e9} -.selected-title{display:flex;gap:16px;align-items:center;justify-content:space-between;margin:42px 0 20px}.selected-title h2{margin:0;font-size:26px}.selected-title button{font-size:12px;white-space:nowrap} -.explore-app .route-layout{border-radius:12px;box-shadow:none}.explore-app .route-card{padding:28px}.explore-app .route-card h2{font-size:28px} -.explore-app .text-button{background:transparent;color:var(--green)}.explore-app .text-button:hover:not(:disabled){background:transparent;color:#263c2b} -.profile-page-title{margin:32px 0}.profile-page-title h1{font-size:44px;letter-spacing:-1.6px}.profile-page-title>p:last-child{color:var(--muted);font-size:14px} -.explore-app .profile-panel{margin-top:24px;border-radius:12px;padding:30px}.explore-app .profile-tabs button{background:#eef2ea;color:var(--ink)} -.auth-page{background:#f4f6ef}.auth-main{padding:48px 24px}.auth-card{border-radius:14px;box-shadow:0 16px 45px #243a3010;padding:42px}.auth-intro h1{font-family:'Avenir Next',system-ui,sans-serif;font-size:44px;font-weight:600;letter-spacing:-1.8px}.auth-card .profile-form input{background:#fafbf8;border-color:#dce3d5;padding:14px}.auth-card .text-button{background:transparent;color:var(--green)} -@media(max-width:1050px){.explore-app main{padding:20px 28px}.explore-hero{padding:40px;min-height:430px}.explore-filters .category-groups{grid-template-columns:repeat(2,minmax(0,1fr))}.area-card{padding:18px}.header-actions{gap:8px}} -@media(max-width:720px){.explore-app>header,.auth-page>header{padding:0 20px;height:74px}.explore-app .brand{font-size:16px}.explore-app .nav-link{display:none}.explore-app main{padding:16px}.explore-hero{padding:30px 24px;min-height:430px;background-position:60% center}.hero-copy h1{font-size:48px}.hero-search{gap:8px;padding-left:12px}.hero-search input{font-size:12px}.hero-search button{padding:12px;font-size:11px}.area-cards{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.section-title h2{font-size:23px}.section-title>a{font-size:11px}.explore-filters>summary>span{display:none}.explore-filters .category-groups{grid-template-columns:1fr}.explore-app .route-results{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.explore-app .route-results button{padding:16px;min-height:165px}.explore-app .route-results strong{font-size:15px}.selected-title{align-items:start;flex-direction:column}.auth-card{padding:28px}.exact-length-form .length-filters{display:grid}.explore-app .nav-tab{font-size:11px;padding:8px}.explore-app .profile-link{font-size:11px;padding:9px}.explore-app .profile-panel{padding:20px}} -@media(max-width:390px){.explore-app .brand span{font-size:14px}.explore-app .brand{gap:4px}.explore-app .route-results{grid-template-columns:1fr}.hero-copy h1{font-size:42px}.hero-search>span{display:none}.hero-search button span{display:none}} -@media(prefers-reduced-motion:reduce){.explore-app button{transition:none}.explore-app .route-results button:hover{transform:none}} -/* Give closed native selects the same appearance across browsers. */ -.explore-app select{ - -webkit-appearance:none;appearance:none; - display:block;max-width:100%;min-height:46px; - padding:12px 42px 12px 14px!important; - border:1px solid #d9e1d3!important;border-radius:9px!important; - background-color:#fff!important; - background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none'%3E%3Cpath d='m4 6 4 4 4-4' stroke='%23566b50' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")!important; - background-repeat:no-repeat!important;background-position:right 14px center!important; - color:#293e2e!important;font-family:inherit;font-size:13px!important;line-height:1.4; - box-shadow:none;cursor:pointer; -} -.explore-app select:hover{border-color:#9bad91!important} -.explore-app select:focus-visible{outline:2px solid #677f5d;outline-offset:3px} -.explore-app select:disabled{opacity:.5;cursor:default} -.explore-app .geography-category label{font-size:11px;color:#718069;font-weight:500;margin:16px 0 7px} -.explore-app .geography-category h3{font-size:13px;font-weight:600;margin-bottom:16px} diff --git a/src/Explore/Explore.css b/src/Explore/Explore.css new file mode 100644 index 0000000..53df50a --- /dev/null +++ b/src/Explore/Explore.css @@ -0,0 +1,35 @@ +.explore-app,.auth-page{--ink:#23382c;--muted:#758075;--green:#435d47;--line:#e3e8df;--paper:#fafbf8;font-family:'Avenir Next',system-ui,sans-serif;color:var(--ink);background:var(--paper)} +.explore-app [hidden]{display:none!important} +.explore-app>header,.auth-page>header{height:86px;padding:0 max(5%,calc((100vw - 1320px)/2));background:var(--paper);border-bottom:1px solid var(--line)} + +.explore-app button,.auth-page button{background:var(--green);border-radius:9px;font-weight:600;padding:12px 18px;transition:background .15s,transform .15s} +.explore-app button:hover:not(:disabled),.auth-page button:hover:not(:disabled){background:#304c37} + +.explore-app main{max-width:1432px;padding:24px 56px 24px} + +.visually-hidden{position:absolute;width:1px;height:1px;clip-path:inset(50%);overflow:hidden;white-space:nowrap} + +.explore-app .text-button{background:transparent;color:var(--green)}.explore-app .text-button:hover:not(:disabled){background:transparent;color:#263c2b} +.profile-page-title{margin:32px 0}.profile-page-title h1{font-size:44px;letter-spacing:-1.6px}.profile-page-title>p:last-child{color:var(--muted);font-size:14px} +.explore-app .profile-panel{margin-top:24px;border-radius:12px;padding:30px}.explore-app .profile-tabs button{background:#eef2ea;color:var(--ink)} +.auth-page{background:#f4f6ef}.auth-main{padding:48px 24px}.auth-card{border-radius:14px;box-shadow:0 16px 45px #243a3010;padding:42px}.auth-intro h1{font-family:'Avenir Next',system-ui,sans-serif;font-size:44px;font-weight:600;letter-spacing:-1.8px}.auth-card .profile-form input{background:#fafbf8;border-color:#dce3d5;padding:14px}.auth-card .text-button{background:transparent;color:var(--green)} +@media(max-width:1050px){.explore-app main{padding:20px 28px}} +@media(max-width:720px){.explore-app>header,.auth-page>header{padding:0 20px;height:74px}.explore-app main{padding:16px}.auth-card{padding:28px}.explore-app .profile-panel{padding:20px}} + +@media(prefers-reduced-motion:reduce){.explore-app button{transition:none}} +/* Give closed native selects the same appearance across browsers. */ +.explore-app select{ + -webkit-appearance:none;appearance:none; + display:block;max-width:100%;min-height:46px; + padding:12px 42px 12px 14px!important; + border:1px solid #d9e1d3!important;border-radius:9px!important; + background-color:#fff!important; + background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none'%3E%3Cpath d='m4 6 4 4 4-4' stroke='%23566b50' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")!important; + background-repeat:no-repeat!important;background-position:right 14px center!important; + color:#293e2e!important;font-family:inherit;font-size:13px!important;line-height:1.4; + box-shadow:none;cursor:pointer; +} +.explore-app select:hover{border-color:#9bad91!important} +.explore-app select:focus-visible{outline:2px solid #677f5d;outline-offset:3px} +.explore-app select:disabled{opacity:.5;cursor:default} + diff --git a/src/Explore/Explore.tsx b/src/Explore/Explore.tsx new file mode 100644 index 0000000..36a8b46 --- /dev/null +++ b/src/Explore/Explore.tsx @@ -0,0 +1,139 @@ +// Utforsk turer-siden: et stort kart over Norge med filterrad og søkepanel oppå. +// - ExploreMap: kartet. Kartutsnittet brukes som filter i routes-spørringen, så lista viser turene i området. +// - ExploreFilters: filterraden (vanskelighetsgrad, lengde, turtype, steder langs turen). +// - ExploreSearchPanel: søk etter turer og steder, lista over turer i kartutsnittet og informasjon om valgt tur. +// Hvilken tur som er valgt eies av App.tsx, fordi forsiden og profilen også kan åpne en tur. +import { useEffect, useState } from 'react' +import './Explore.css' +import ExploreMap, { type MapFocus } from './ExploreMap' +import ExploreSearchPanel, { type PlaceHit } from './ExploreSearchPanel' +import ExploreFilters from './ExploreFilters' +import { EMPTY_FILTERS, PAGE_SIZE, filterVariables, type Bounds, type Route, type RouteFilterValue, type RouteGroup, type RouteSort, type RouteSummary, type SelectedRoute } from './routeTypes' +import { graphql, TOKEN_KEY } from '../graphql' + +/** Hvor kartet starter når brukeren ikke har valgt et sted i profilen */ +const OSLO: Bounds = { west: 10.48, south: 59.81, east: 10.95, north: 60.14 } +/** Utsnittet «Vis hele Norge» går til */ +const NORWAY: Bounds = { west: 4.5, south: 57.9, east: 31.2, north: 71.2 } +const toBounds = (bbox: number[]): Bounds => ({ west: bbox[0], south: bbox[1], east: bbox[2], north: bbox[3] }) + +const LIST_QUERY = `query($minLength:Float,$maxLength:Float,$difficulty:[String!],$routeType:[String!],$facility:[String!],$sort:RouteSort, + $west:Float,$south:Float,$east:Float,$north:Float,$offset:Int!,$limit:Int!){ + routes(search:"",minLength:$minLength,maxLength:$maxLength,difficulty:$difficulty,routeType:$routeType,facility:$facility,sort:$sort, + west:$west,south:$south,east:$east,north:$north,offset:$offset,limit:$limit){ + total items{key name variantCount minLengthMeters maxLengthMeters favoriteCount variants{id name number lengthMeters difficulty routeType duplicateCount variantCount}} + } +}` +const ROUTE_QUERY = `query($name:String!,$number:String!){ + route(name:$name,number:$number){ id name number lengthMeters segmentCount connected maintainer difficulty routeType nameQuality duplicateCount variantCount description geojson } + routeVariants(name:$name){ id name number lengthMeters difficulty routeType duplicateCount variantCount } +}` + +type Props = { + /** Turen som er valgt (styres av App, så forsiden og profilen også kan åpne en tur) */ + selected: SelectedRoute | null + onSelect: (route: SelectedRoute) => void + /** Fjerner valgt tur («Tilbake til turene» i søkepanelet) */ + onDeselect: () => void + /** Kalles når en tur er hentet, så profilen kan lagre den med riktig id */ + onRouteLoaded: (route: { id: string; name: string; number: string } | null) => void + authenticated: boolean + /** Knappen «Lagre og planlegg» / «Logg inn for å lagre» */ + onSaveRoute: () => void +} + +export default function Explore({ selected, onSelect, onDeselect, onRouteLoaded, authenticated, onSaveRoute }: Props) { + // --- Valgt tur --- + const [route, setRoute] = useState(null) + const [routeVariants, setRouteVariants] = useState([]) + const [status, setStatus] = useState<'loading' | 'ready' | 'error' | 'empty'>(selected ? 'loading' : 'empty') + const [attempt, setAttempt] = useState(0) + // Når en ny tur velges (her eller fra forsiden/profilen), vis «henter» til den er lastet. + // Justeres under rendering i stedet for i en effekt, slik React anbefaler. + const [prevSelected, setPrevSelected] = useState(selected) + if (selected !== prevSelected) { setPrevSelected(selected); setStatus(selected ? 'loading' : 'empty') } + + // --- Filtre og sortering --- + const [filters, setFilters] = useState(EMPTY_FILTERS) + const [sort, setSort] = useState('POPULAR') + + // --- Turlista i kartutsnittet --- + const [page, setPage] = useState(0) + const [items, setItems] = useState([]) + const [total, setTotal] = useState(0) + const [listStatus, setListStatus] = useState<'loading' | 'ready' | 'error'>('loading') + /** Starter lista på nytt fra side 1 (når filtre, sortering eller kartutsnitt endres) */ + const restartList = () => { setPage(0); setListStatus('loading') } + + // --- Kartet --- + // Kartutsnittet fra ExploreMap. null = ingen begrensning (hele Norge). + const [bounds, setBounds] = useState(null) + // Hvor kartet skal være. Starter i Oslo, eller på stedet brukeren har valgt i profilen (samme sted som forsiden + // bruker). Endres også når man velger et sted i søket eller trykker «Vis hele Norge». + const [focus, setFocus] = useState({ bounds: OSLO, key: 0 }) + const flyTo = (next: Bounds, clearFilter = false) => setFocus(previous => ({ bounds: next, key: previous.key + 1, clearFilter })) + // Sted valgt fra søket i kartet. Vises som merkelapp, og fjernes når brukeren selv flytter kartet. + const [place, setPlace] = useState<{ code: string; name: string } | null>(null) + + // Innlogget bruker med et sted valgt i profilen: start kartet der + useEffect(() => { + const token = localStorage.getItem(TOKEN_KEY) + if (!authenticated || !token) return + const controller = new AbortController() + graphql<{ me: { homeMunicipality: { bbox: number[] | null } | null } | null }>( + 'query($token:String!){me(token:$token){homeMunicipality{bbox}}}', { token }, controller.signal) + .then(data => { + const bbox = data.me?.homeMunicipality?.bbox + if (bbox?.length === 4) flyTo(toBounds(bbox)) + }) + .catch(() => undefined) + return () => controller.abort() + }, [authenticated]) + + // Henter turene i kartutsnittet med valgte filtre og sortering. Filtrering og sortering skjer i databasen. + // En ny forespørsel avbryter den forrige, så svar som kommer i feil rekkefølge aldri vises. + useEffect(() => { + const controller = new AbortController() + graphql<{ routes: { total: number; items: RouteGroup[] } }>(LIST_QUERY, { + ...filterVariables(filters), sort, offset: page * PAGE_SIZE, limit: PAGE_SIZE, + west: bounds?.west ?? null, south: bounds?.south ?? null, east: bounds?.east ?? null, north: bounds?.north ?? null, + }, controller.signal) + .then(data => { setItems(data.routes.items); setTotal(data.routes.total); setListStatus('ready') }) + .catch(() => { if (!controller.signal.aborted) setListStatus('error') }) + return () => controller.abort() + }, [filters, sort, bounds, page]) + + // Henter valgt tur med geometri og alle varianter med samme navn + useEffect(() => { + if (!selected) return + const controller = new AbortController() + graphql<{ route: Route | null; routeVariants: RouteSummary[] }>(ROUTE_QUERY, { name: selected.name, number: selected.number }, controller.signal) + .then(data => { + setRoute(data.route) + setRouteVariants(data.routeVariants) + setStatus(data.route ? 'ready' : 'empty') + onRouteLoaded(data.route ? { id: data.route.id, name: data.route.name, number: data.route.number } : null) + }) + .catch(() => { if (!controller.signal.aborted) setStatus('error') }) + return () => controller.abort() + }, [selected, attempt, onRouteLoaded]) + + return
+ { setBounds(next); restartList(); if (byUser) setPlace(null) }} /> + { setFilters(next); restartList() }} /> + { setSort(next); restartList() }} + items={items} total={total} listStatus={listStatus} page={page} + onPageChange={next => { setPage(next); setListStatus('loading') }} + filtered={bounds !== null} + onShowAll={() => { flyTo(NORWAY, true); setBounds(null); setPlace(null); restartList() }} + place={place} onClearPlace={() => setPlace(null)} + onChoosePlace={(hit: PlaceHit) => { if (hit.bbox?.length === 4) { setPlace({ code: hit.code, name: hit.name }); flyTo(toBounds(hit.bbox)) } }} + onChooseRoute={onSelect} + selected={selected} route={route} routeVariants={routeVariants} status={status} + onRetry={() => { setStatus('loading'); setAttempt(attempt + 1) }} + onBack={onDeselect} + authenticated={authenticated} onSaveRoute={onSaveRoute} /> +
+} diff --git a/src/Explore/ExploreFilters.css b/src/Explore/ExploreFilters.css new file mode 100644 index 0000000..0904947 --- /dev/null +++ b/src/Explore/ExploreFilters.css @@ -0,0 +1,33 @@ +/* Filterraden øverst i kartet (ExploreFilters.tsx). Ligger til høyre for søkepanelet på stor skjerm. */ +.ef { position: absolute; z-index: 3; top: 16px; left: 432px; right: 64px; font-family: 'Avenir Next', system-ui, sans-serif; pointer-events: none; } +.ef-row, .ef-menu { pointer-events: auto; } +.ef-row { display: flex; gap: 8px; overflow-x: auto; padding: 3px; scrollbar-width: none; } +.ef-row::-webkit-scrollbar { display: none; } +.explore-app .ef-pill { flex-shrink: 0; display: inline-flex; align-items: center; gap: 8px; background: #fff; color: #23382c; border: 1px solid #dce3d5; border-radius: 12px; padding: 10px 14px; font-size: 13px; font-weight: 600; white-space: nowrap; box-shadow: 0 2px 10px #23382c1f; } +.explore-app .ef-pill:hover:not(:disabled), .explore-app .ef-pill[aria-expanded=true] { background: #f4f7f1; border-color: #a2b398; } +.explore-app .ef-pill[data-active] { background: #435d47; border-color: #435d47; color: #fff; } +.explore-app .ef-pill[data-active]:hover:not(:disabled) { background: #304c37; } +.explore-app .ef-reset { color: #435d47; } +.ef-pill svg { transition: transform .15s; } +.ef-pill[aria-expanded=true] svg { transform: rotate(180deg); } +.ef-menu { position: absolute; top: calc(100% + 8px); width: min(340px, 100%); max-height: min(480px, calc(100vh - 220px)); overflow-y: auto; padding: 16px; background: #fff; color: #23382c; border-radius: 14px; box-shadow: 0 12px 36px #23382c38; } +.ef-menu h3 { margin: 0 0 12px; font-size: 14px; } +.ef-options { display: flex; flex-wrap: wrap; gap: 8px; } +.ef-options.ef-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } +.ef-options.ef-grid-3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); } +.ef-options.ef-stack { display: grid; } +.explore-app .ef-option { display: flex; align-items: center; gap: 10px; text-align: left; background: #fff; color: #23382c; border: 1px solid #dce3d5; border-radius: 12px; padding: 10px 12px; font-size: 13px; font-weight: 500; } +.explore-app .ef-option:hover:not(:disabled) { background: #f4f7f1; border-color: #a2b398; } +.explore-app .ef-option[aria-pressed=true] { background: #eef3e9; border-color: #435d47; box-shadow: inset 0 0 0 1px #435d47; } +.ef-option small { display: block; margin-top: 2px; font-size: 11px; font-weight: 400; color: #6b7768; } +.ef-option svg { flex-shrink: 0; color: #435d47; } +.ef-length-form { display: flex; align-items: end; gap: 8px; margin-top: 14px; } +.ef-length-form label { flex: 1; display: grid; gap: 4px; font-size: 11px; font-weight: 600; color: #6b7768; } +.ef-length-form input { width: 100%; min-width: 0; font: inherit; font-size: 13px; color: #23382c; border: 1px solid #dce3d5; border-radius: 9px; padding: 9px 10px; } +.explore-app .ef-length-form button { font-size: 12px; padding: 10px 14px; } +.ef-note { margin: 12px 0 0; font-size: 11px; line-height: 1.5; color: #6b7768; } +.ef-menu-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; padding-top: 12px; border-top: 1px solid #e3e8df; } +.explore-app .ef-menu-footer .text-button { padding: 0; font-size: 13px; } +.explore-app .ef-menu-footer button:last-child { font-size: 13px; padding: 9px 16px; } +@media (max-width: 720px) { .ef { top: 10px; left: 10px; right: 56px; } .ef-options.ef-grid-3 { grid-template-columns: 1fr; } } +@media (prefers-reduced-motion: reduce) { .ef-pill svg { transition: none; } } diff --git a/src/Explore/ExploreFilters.tsx b/src/Explore/ExploreFilters.tsx new file mode 100644 index 0000000..08a2425 --- /dev/null +++ b/src/Explore/ExploreFilters.tsx @@ -0,0 +1,165 @@ +// Filterknappene øverst i kartet på Utforsk turer: Vanskelighetsgrad, Lengde, Turtype og Langs turen. +// Hver knapp åpner en liten meny. Endringer gjelder med en gang og påvirker både turene i kartutsnittet +// og turforslagene i søket. Selve filtreringen skjer i databasen (routes-spørringen). +// En knapp med aktivt filter blir fylt og viser hva som er valgt, så man ser det uten å åpne menyen. +import { useEffect, useRef, useState } from 'react' +import { placeCategories } from '../placeCategories' +import { EMPTY_FILTERS, type RouteFilterValue } from './routeTypes' +import './ExploreFilters.css' + +type MenuId = 'difficulty' | 'length' | 'routeType' | 'facilities' + +const DIFFICULTIES = ['Enkel', 'Middels', 'Krevende', 'Ekspert', 'Ikke gradert'] +const ROUTE_TYPES = [ + { value: 'Avledet rundtur', label: 'Rundtur', hint: 'Starter og slutter på samme sted' }, + { value: 'Hovedrute', label: 'Hovedrute', hint: 'Registrerte hovedtraséer' }, + { value: 'Materute', label: 'Materute', hint: 'Ruter inn til hovedruter' }, +] +const LENGTH_PRESETS: { label: string; hint: string; min: number | null; max: number | null }[] = [ + { label: 'Kort', hint: 'Under 5 km', min: null, max: 5 }, + { label: 'Mellomlang', hint: '5–15 km', min: 5, max: 15 }, + { label: 'Lang', hint: 'Over 15 km', min: 15, max: null }, +] +/** Enkle strekikoner (24×24) for stedstypene i placeCategories */ +const ICONS: Record = { + hut: 'M3 11 12 4l9 7M5 9.5V20h14V9.5M10 20v-5h4v5', + shelter: 'M3 20 13 5l8 15M2 20h20M9 20l4-6', + viewpoint: 'M2 12s3.6-6 10-6 10 6 10 6-3.6 6-10 6S2 12 2 12ZM12 9.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5Z', + picnic_site: 'M3 9h18M7 9l-3 10M17 9l3 10M5.5 14h13M12 9V5', + toilets: 'M7 3.5a1.5 1.5 0 1 0 0 .01M17 3.5a1.5 1.5 0 1 0 0 .01M5 21v-6H4l1.5-7h3L10 15H9v6M15 21V8h4v13', + drinking_water: 'M12 3s6 7 6 11a6 6 0 0 1-12 0c0-4 6-11 6-11Z', + parking: 'M5 3h14v18H5zM10 17V7h3a3 3 0 0 1 0 6h-3', + camp_site: 'M2 20h20M4 20 12 5l8 15M12 20l-3-5M12 20l3-5', + peak: 'M2 20 9 8l4 6 3-4 6 10H2Z', +} + +/** Hva «Nullstill» i hver meny setter tilbake */ +const CLEARED: Record> = { + difficulty: { difficulties: [] }, length: { minLength: null, maxLength: null }, routeType: { routeTypes: [] }, facilities: { facilities: [] }, +} + +const fmt = (value: number) => value.toLocaleString('nb-NO') +function toggle(list: string[], value: string) { return list.includes(value) ? list.filter(item => item !== value) : [...list, value] } +/** «Enkel, Middels» eller «Enkel +2» */ +function summary(values: string[], labelOf: (value: string) => string = value => value) { + if (!values.length) return null + return values.length <= 2 ? values.map(labelOf).join(', ') : `${labelOf(values[0])} +${values.length - 1}` +} +function lengthSummary(min: number | null, max: number | null) { + if (min == null && max == null) return null + if (min == null) return `Under ${fmt(max!)} km` + if (max == null) return `Over ${fmt(min)} km` + return `${fmt(min)}–${fmt(max)} km` +} +const routeTypeLabel = (value: string) => ROUTE_TYPES.find(type => type.value === value)?.label ?? value +const facilityLabel = (value: string) => placeCategories.find(category => category.value === value)?.label ?? value + +type Props = { value: RouteFilterValue; onChange: (next: RouteFilterValue) => void } + +export default function ExploreFilters({ value, onChange }: Props) { + const [open, setOpen] = useState(null) + /** Hvor menyen plasseres, i piksler fra venstre kant av filterraden */ + const [anchor, setAnchor] = useState(0) + const [minInput, setMinInput] = useState('') + const [maxInput, setMaxInput] = useState('') + const wrapRef = useRef(null) + const menuRef = useRef(null) + const buttonRefs = useRef>>({}) + + // Lukk menyen ved klikk utenfor eller Escape (samme mønster som profilmenyen i SiteNav) + useEffect(() => { + if (!open) return + const close = (event: PointerEvent) => { if (!wrapRef.current?.contains(event.target as Node)) setOpen(null) } + const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') { setOpen(null); buttonRefs.current[open]?.focus() } } + document.addEventListener('pointerdown', close) + document.addEventListener('keydown', escape) + // Flytt fokus inn i menyen, så tastaturbrukere kommer rett til valgene + const frame = window.requestAnimationFrame(() => menuRef.current?.querySelector('button, input')?.focus()) + return () => { document.removeEventListener('pointerdown', close); document.removeEventListener('keydown', escape); window.cancelAnimationFrame(frame) } + }, [open]) + + function toggleMenu(id: MenuId) { + if (open === id) { setOpen(null); return } + const button = buttonRefs.current[id], wrap = wrapRef.current + if (button && wrap) { + const left = button.getBoundingClientRect().left - wrap.getBoundingClientRect().left + setAnchor(Math.max(0, Math.min(left, wrap.clientWidth - 340))) + } + if (id === 'length') { setMinInput(value.minLength?.toString() ?? ''); setMaxInput(value.maxLength?.toString() ?? '') } + setOpen(id) + } + function done() { const current = open; setOpen(null); if (current) buttonRefs.current[current]?.focus() } + + const buttons: { id: MenuId; label: string; active: string | null }[] = [ + { id: 'difficulty', label: 'Vanskelighetsgrad', active: summary(value.difficulties) }, + { id: 'length', label: 'Lengde', active: lengthSummary(value.minLength, value.maxLength) }, + { id: 'routeType', label: 'Turtype', active: summary(value.routeTypes, routeTypeLabel) }, + { id: 'facilities', label: 'Langs turen', active: summary(value.facilities, facilityLabel) }, + ] + const anyActive = buttons.some(button => button.active) + const current = buttons.find(button => button.id === open) + + return
+
+ {buttons.map(button => )} + {anyActive && } +
+ + {open && current && } +
+} diff --git a/src/Explore/ExploreMap.css b/src/Explore/ExploreMap.css new file mode 100644 index 0000000..65496ba --- /dev/null +++ b/src/Explore/ExploreMap.css @@ -0,0 +1,14 @@ +/* Stort utforskningskart øverst på Utforsk turer (ExploreMap.tsx) */ +/* Kartet går helt ut til kantene av vinduet (bryter ut av main sin padding og maksbredde) + og fyller skjermhøyden under toppmenyen. calc(50% - 50vw) flytter kantene ut til vinduets kant. */ +.explore-app { overflow-x: clip; } +.explore-map { position: relative; height: clamp(480px, calc(100svh - 86px), 900px); margin: -24px calc(50% - 50vw) 32px; overflow: hidden; background: #e4eadb; border-bottom: 1px solid #e3e8df; } +.explore-map-canvas-wrap, .explore-map-canvas { position: absolute; inset: 0; } +.explore-map-error { position: absolute; z-index: 3; bottom: 16px; left: 50%; transform: translateX(-50%); max-width: 420px; margin: 0; padding: 10px 14px; background: #fff; border-radius: 10px; font-size: 13px; } +/* Hindrer at de generelle knappestilene i .explore-app gjør kartets zoomknapper grønne */ +.explore-app .explore-map .maplibregl-ctrl-group button { background-color: #fff; color: #23382c; padding: 0; border-radius: 0; width: 36px; height: 36px; transition: none; } +.explore-app .explore-map .maplibregl-ctrl-group button:hover:not(:disabled) { background-color: #edf2e9; } +.explore-app .explore-map .maplibregl-ctrl-group { border-radius: 10px; overflow: hidden; box-shadow: 0 4px 16px #23382c22; } +.explore-map .maplibregl-ctrl-top-right { margin: 6px; } +@media (max-width: 1050px) { .explore-map { margin-top: -20px; } } +@media (max-width: 720px) { .explore-map { margin-top: -16px; height: clamp(420px, calc(100svh - 130px), 700px); } .explore-map .maplibregl-ctrl-bottom-right { bottom: 55%; } .explore-map-error { bottom: auto; top: 10px; } } diff --git a/src/Explore/ExploreMap.tsx b/src/Explore/ExploreMap.tsx new file mode 100644 index 0000000..c6eda24 --- /dev/null +++ b/src/Explore/ExploreMap.tsx @@ -0,0 +1,165 @@ +// Stort kart øverst på Utforsk turer. Brukeren kan dra og zoome rundt i hele Norge. +// Kartutsnittet sendes til Explore, som bruker det som filter i routes-spørringen +// (west/south/east/north). Filtreringen skjer altså i databasen, ikke på klienten. +// +// Bygget etter samme mønster som AreaMap i Fjellomrade.tsx: kartet lages én gang, +// og etterpå oppdateres bare datakildene. Det sparer kartfliser og forespørsler. +import { useEffect, useRef, useState } from 'react' +import * as maplibregl from 'maplibre-gl' +import type { GeoJSONSource, StyleSpecification } from 'maplibre-gl' +import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url' +import type { Feature, FeatureCollection, LineString, MultiLineString } from 'geojson' +import type { Bounds } from './routeTypes' +import './ExploreMap.css' + +// MapLibre tegner kartet i en egen tråd (web worker). Vite trenger å vite hvor worker-fila ligger. +maplibregl.setWorkerUrl(workerUrl) + +/** Et område kartet skal fly til. key økes for hver ny forespørsel, så samme område kan velges flere ganger. */ +export type MapFocus = { bounds: Bounds; key: number; /** Ikke filtrer lista på det nye utsnittet («Vis hele Norge») */ clearFilter?: boolean } + +/** Hvor langt brukeren kan dra kartet bort fra Norge */ +const MAX_BOUNDS: maplibregl.LngLatBoundsLike = [[-10, 54], [45, 81.5]] +/** Venter litt etter at kartet stopper før lista oppdateres, så vi ikke sender en forespørsel per bevegelse */ +const BOUNDS_DELAY_MS = 400 + +// Kartverkets topografiske kart, samme kilde som Fjellområder +const MAP_STYLE: StyleSpecification = { + version: 8, + sources: { + topo: { + type: 'raster', + tiles: ['https://cache.kartverket.no/v1/wmts/1.0.0/topo/default/webmercator/{z}/{y}/{x}.png'], + tileSize: 256, + maxzoom: 18, + attribution: '© Kartverket · © OpenStreetMap-bidragsytere', + }, + }, + layers: [{ id: 'topo', type: 'raster', source: 'topo' }], +} + +const EMPTY: FeatureCollection = { type: 'FeatureCollection', features: [] } +const round = (value: number) => Math.round(value * 10000) / 10000 +const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches +const toLngLat = (b: Bounds): maplibregl.LngLatBoundsLike => [[b.west, b.south], [b.east, b.north]] + +/** Søkepanelet ligger oppå kartet (til venstre på stor skjerm, nederst på mobil). + * Når vi zoomer til et område eller en tur, holder vi av plass til panelet så det ikke dekker det. */ +function panelPadding(element: HTMLElement): maplibregl.PaddingOptions { + const { clientWidth: width, clientHeight: height } = element + return width > 720 + ? { top: 40, right: 60, bottom: 40, left: Math.min(440, width * 0.45) } + : { top: 30, right: 30, left: 30, bottom: height * 0.55 } +} + +type Props = { + /** Området kartet starter på, og som det flyr til når key endres */ + focus: MapFocus + /** Kalles med nytt kartutsnitt. byUser er true når brukeren selv har dratt eller zoomet. */ + onBoundsChange: (bounds: Bounds, byUser: boolean) => void + /** Geometrien til valgt tur, som tegnes og zoomes til */ + selectedGeojson: string | null +} + +export default function ExploreMap({ focus, onBoundsChange, selectedGeojson }: Props) { + const container = useRef(null) + const mapRef = useRef(null) + const [loaded, setLoaded] = useState(false) + const [error, setError] = useState('') + // Kartets hendelser bruker alltid siste versjon av callbacken, uten at kartet må lages på nytt + const onBoundsChangeRef = useRef(onBoundsChange) + useEffect(() => { onBoundsChangeRef.current = onBoundsChange }) + // Fokuset kartet ble laget med, og sist fokus vi har flydd til + const lastFocus = useRef(focus) + + // 1. Lag kartet én gang + useEffect(() => { + if (!container.current) return + let map: maplibregl.Map + try { + map = new maplibregl.Map({ + container: container.current, + style: MAP_STYLE, + bounds: toLngLat(lastFocus.current.bounds), + fitBoundsOptions: { padding: panelPadding(container.current) }, + maxBounds: MAX_BOUNDS, + // Krever Ctrl/⌘ + scroll for å zoome, så siden fortsatt kan scrolles forbi kartet + cooperativeGestures: true, + attributionControl: { compact: true }, + locale: { + 'Map.Title': 'Kart over turer i Norge', + 'NavigationControl.ZoomIn': 'Zoom inn', + 'NavigationControl.ZoomOut': 'Zoom ut', + 'NavigationControl.ResetBearing': 'Pek kartet mot nord', + 'CooperativeGesturesHandler.WindowsHelpText': 'Hold Ctrl og scroll for å zoome kartet', + 'CooperativeGesturesHandler.MacHelpText': 'Hold ⌘ og scroll for å zoome kartet', + 'CooperativeGesturesHandler.MobileHelpText': 'Bruk to fingre for å flytte kartet', + }, + }) + } catch { + // Nettleseren støtter ikke WebGL. Dette rapporterer feil fra et eksternt system, ikke avledet state. + // eslint-disable-next-line react-hooks/set-state-in-effect + setError('Kartet kunne ikke åpnes i denne nettleseren. Du kan fortsatt søke og filtrere i lista under.') + return + } + map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right') + mapRef.current = map + + map.on('load', () => { + map.addSource('selected-route', { type: 'geojson', data: EMPTY }) + map.addLayer({ id: 'selected-route-outline', type: 'line', source: 'selected-route', paint: { 'line-color': '#fff', 'line-width': 8 } }) + map.addLayer({ id: 'selected-route-line', type: 'line', source: 'selected-route', paint: { 'line-color': '#126b51', 'line-width': 4 } }) + setLoaded(true) + }) + map.on('error', () => setError('En del av kartet kunne ikke lastes. Lista fungerer fortsatt.')) + + // Hver gang kartet har stoppet, sendes utsnittet videre så lista viser turene der. + // Bevegelser brukeren gjør selv venter litt (debounce). Bevegelser vi starter selv er merket + // programmatic, og «Vis hele Norge» er merket skipReport fordi lista da ikke skal filtreres. + let timer: number | undefined + const report = (byUser: boolean) => { + const b = map.getBounds() + onBoundsChangeRef.current({ west: round(b.getWest()), south: round(b.getSouth()), east: round(b.getEast()), north: round(b.getNorth()) }, byUser) + } + map.once('load', () => report(false)) + map.on('moveend', event => { + const { programmatic, skipReport } = event as { programmatic?: boolean; skipReport?: boolean } + window.clearTimeout(timer) + if (skipReport) return + timer = window.setTimeout(() => report(!programmatic), programmatic ? 0 : BOUNDS_DELAY_MS) + }) + + return () => { window.clearTimeout(timer); map.remove(); mapRef.current = null; setLoaded(false) } + }, []) + + // 2. Fly til nytt fokus: brukerens sted fra profilen, et sted fra søket, eller hele Norge + useEffect(() => { + const map = mapRef.current + if (!map || !container.current || focus.key === lastFocus.current.key) return + lastFocus.current = focus + map.fitBounds(toLngLat(focus.bounds), { padding: panelPadding(container.current), duration: reducedMotion() ? 0 : 900 }, + { programmatic: true, skipReport: focus.clearFilter }) + }, [focus]) + + // 3. Tegn valgt tur og zoom til den + useEffect(() => { + const map = mapRef.current + if (!map || !loaded || !container.current) return + const source = map.getSource('selected-route') as GeoJSONSource | undefined + if (!selectedGeojson) { source?.setData(EMPTY); return } + try { + const geometry = JSON.parse(selectedGeojson) as LineString | MultiLineString + const feature: Feature = { type: 'Feature', properties: {}, geometry } + source?.setData(feature) + const points = geometry.type === 'LineString' ? geometry.coordinates : geometry.coordinates.flat() + const bounds = new maplibregl.LngLatBounds() + points.forEach(([lng, lat]) => bounds.extend([lng, lat])) + map.fitBounds(bounds, { padding: panelPadding(container.current), maxZoom: 14, duration: reducedMotion() ? 0 : 900 }, { programmatic: true }) + } catch { source?.setData(EMPTY) } + }, [selectedGeojson, loaded]) + + return
+
+ {error &&

{error}

} +
+} diff --git a/src/Explore/ExploreSearchPanel.css b/src/Explore/ExploreSearchPanel.css new file mode 100644 index 0000000..986c31f --- /dev/null +++ b/src/Explore/ExploreSearchPanel.css @@ -0,0 +1,52 @@ +/* Søkepanelet oppå kartet på Utforsk turer (ExploreSearchPanel.tsx). + Stor skjerm: kort øverst til venstre. Mobil: panel nederst, med kartet synlig over. */ +.esp { position: absolute; z-index: 2; top: 16px; left: 16px; width: min(400px, calc(100% - 100px)); max-height: calc(100% - 32px); display: flex; flex-direction: column; background: #fff; border-radius: 14px; box-shadow: 0 8px 30px #23382c2e; overflow: hidden; font-family: 'Avenir Next', system-ui, sans-serif; color: #23382c; } +.esp-search { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 10px; padding: 8px 14px; border-bottom: 1px solid #e3e8df; } +.esp-search:focus-within { box-shadow: inset 0 0 0 2px #435d47; } +.esp-search svg { flex-shrink: 0; color: #667c64; } +.esp-search input { flex: 1; min-width: 120px; border: 0; background: transparent; font: inherit; font-size: 15px; padding: 10px 0; color: inherit; } +.esp-search input:focus-visible { outline: none; } +.esp-chip { display: inline-flex; align-items: center; gap: 2px; background: #e9efe4; color: #435d47; border-radius: 999px; padding: 3px 3px 3px 11px; font-size: 12px; font-weight: 600; } +.explore-app .esp-chip button { background: transparent; color: inherit; padding: 2px 7px; font-size: 16px; line-height: 1; border-radius: 999px; } +.explore-app .esp-chip button:hover:not(:disabled) { background: #d8e3d1; } +.esp-body { overflow-y: auto; overscroll-behavior: contain; padding: 14px 16px 16px; } +.esp-title { font-size: 18px; letter-spacing: -.3px; margin: 0 0 4px; font-weight: 600; } +.esp-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; margin: 0 0 12px; font-size: 12px; color: #6b7768; line-height: 1.5; } +.explore-app .esp .text-button { padding: 0; font-size: 12px; text-decoration: underline; } +.esp-group { margin: 14px 0 8px; font-size: 10px; letter-spacing: 1.6px; text-transform: uppercase; color: #6b7768; font-weight: 600; } +.esp-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; } +.explore-app .esp-item { width: 100%; display: flex; gap: 12px; align-items: flex-start; text-align: left; background: #fff; color: #23382c; border: 1px solid #e3e8df; border-radius: 10px; padding: 12px 14px; font-weight: 400; } +.explore-app .esp-item:hover:not(:disabled) { background: #f1f5ee; border-color: #a2b398; } +.explore-app .esp-item[aria-pressed=true] { background: #eef3e9; border-color: #435d47; } +.esp-item strong { display: block; font-size: 14px; font-weight: 600; line-height: 1.3; overflow-wrap: anywhere; } +.esp-item-meta { display: block; margin-top: 3px; font-size: 12px; color: #6b7768; } +.esp-item-icon { font-size: 15px; line-height: 1.3; color: #677c60; } +.esp-pager { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 12px; font-size: 12px; color: #6b7768; } +.explore-app .esp-pager button { background: #fff; color: #435d47; border: 1px solid #e3e8df; font-size: 12px; padding: 8px 12px; } +.explore-app .esp-pager button:hover:not(:disabled) { background: #edf2e9; } +.explore-app .esp .esp-back { font-size: 13px; text-decoration: none; margin-bottom: 8px; } +.esp-eyebrow { margin: 8px 0 0; font-size: 10px; letter-spacing: 1.8px; color: #6b7768; } +.esp-route h2 { margin: 4px 0 4px; font-size: 24px; letter-spacing: -.6px; line-height: 1.15; overflow-wrap: anywhere; } +.esp-variant label { display: block; margin: 4px 0 6px; font-size: 11px; font-weight: 600; color: #6b7768; } +.esp-variant select { width: 100%; } +.esp-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 16px 0; } +.esp-stats div { background: #f4f6f1; border-radius: 10px; padding: 10px; min-width: 0; } +.esp-stats strong { display: block; font-size: 14px; overflow-wrap: anywhere; } +.esp-stats span { display: block; margin-top: 2px; font-size: 11px; color: #6b7768; } +.esp-description { font-size: 13px; line-height: 1.6; color: #3b4d40; margin: 0 0 12px; } +.esp-notice { font-size: 12px; line-height: 1.5; background: #f4f0e4; border-radius: 9px; padding: 10px 12px; margin: 12px 0; } +.esp-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; margin-top: 16px; } +.explore-app .esp-actions > button:first-child { font-size: 13px; } +.esp-error p { margin: 0 0 10px; font-size: 13px; } +@media (max-width: 720px) { + .esp { top: auto; left: 0; right: 0; bottom: 0; width: auto; max-height: 55%; border-radius: 16px 16px 0 0; } + .esp-stats { grid-template-columns: repeat(3, minmax(0, 1fr)); } +} +/* Sortering over turlista. Overstyrer de store standardstilene for select i .explore-app. */ +.esp-sort { display: flex; align-items: center; gap: 8px; margin: -4px 0 12px; font-size: 12px; font-weight: 600; color: #6b7768; } +.explore-app .esp-sort select { flex: 1; min-height: 34px; padding: 6px 32px 6px 10px !important; font-size: 12px !important; background-position: right 10px center !important; } +/* Høydeprofilen (ElevationProfile.tsx) inne i turvisningen i panelet */ +.esp .elevation-panel { margin: 18px 0 0; padding: 14px; border-radius: 10px; } +.esp .elevation-panel h3 { font-size: 14px; margin-bottom: 6px; } +.esp .elevation-panel p { font-size: 12px; } +.esp .elevation-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } diff --git a/src/Explore/ExploreSearchPanel.tsx b/src/Explore/ExploreSearchPanel.tsx new file mode 100644 index 0000000..52f2243 --- /dev/null +++ b/src/Explore/ExploreSearchPanel.tsx @@ -0,0 +1,185 @@ +// Søkefelt og panel oppå kartet på Utforsk turer. Panelet har tre tilstander: +// 1. Ingenting skrevet eller valgt: turene i kartutsnittet (samme routes-spørring som lista under kartet) +// 2. Brukeren skriver: forslag gruppert i steder (kommuner) og turer, hentet i én GraphQL-forespørsel +// 3. En tur er valgt: informasjon om turen (RouteDetails.tsx) +// Ett felt for både steder og turer, slik som på forsiden, så brukeren ikke trenger å vite hva som er hva. +import { useEffect, useRef, useState } from 'react' +import { graphql } from '../graphql' +import RouteDetails from './RouteDetails' +import { PAGE_SIZE, SORT_OPTIONS, filterVariables, lengthText, type Route, type RouteFilterValue, type RouteGroup, type RouteSort, type RouteSummary, type SelectedRoute } from './routeTypes' +import './ExploreSearchPanel.css' + +/** En kommune fra søket. bbox er [vest, sør, øst, nord], brukt til å fly kartet dit. */ +export type PlaceHit = { code: string; name: string; routeCount: number; bbox: number[] | null } +type SearchData = { routes: { total: number; items: RouteGroup[] }; searchMunicipalities: PlaceHit[] } + +/** Hvor lenge vi venter etter siste tastetrykk før vi søker (debouncing, sparer requests) */ +const SEARCH_DELAY_MS = 300 +// Turforslagene følger filtrene i filterraden. Stedene gjør ikke det: en kommune skal man finne uansett. +const SEARCH_QUERY = `query($search:String!,$limit:Int!,$difficulty:[String!],$routeType:[String!],$facility:[String!],$minLength:Float,$maxLength:Float){ + routes(search:$search,limit:$limit,difficulty:$difficulty,routeType:$routeType,facility:$facility,minLength:$minLength,maxLength:$maxLength){ + total items{key name variantCount minLengthMeters maxLengthMeters variants{id name number lengthMeters difficulty routeType duplicateCount variantCount}} + } + searchMunicipalities(query:$search,limit:4){ code name routeCount bbox } +}` + +type Props = { + /** Filtrene fra filterraden. Gjelder både lista og turforslagene i søket. */ + filters: RouteFilterValue + sort: RouteSort + onSortChange: (sort: RouteSort) => void + // Tilstand 1: turene i kartutsnittet + items: RouteGroup[] + total: number + listStatus: 'loading' | 'ready' | 'error' + page: number + onPageChange: (page: number) => void + /** Om lista er avgrenset til kartutsnittet */ + filtered: boolean + onShowAll: () => void + /** Stedet som er valgt fra søket, vises som en merkelapp i søkefeltet */ + place: { name: string } | null + onClearPlace: () => void + // Tilstand 2: søk + onChoosePlace: (place: PlaceHit) => void + onChooseRoute: (route: SelectedRoute) => void + // Tilstand 3: valgt tur + selected: SelectedRoute | null + route: Route | null + routeVariants: RouteSummary[] + status: 'loading' | 'ready' | 'error' | 'empty' + onRetry: () => void + onBack: () => void + authenticated: boolean + onSaveRoute: () => void +} + +/** Ett turkort i lister og søk */ +function RouteItem({ group, active, onChoose }: { group: RouteGroup; active: boolean; onChoose: () => void }) { + const first = group.variants[0] + const meta = [lengthText(group), group.variantCount > 1 ? `${group.variantCount} varianter` : null, first?.difficulty, + group.favoriteCount ? `♥ ${group.favoriteCount}` : null].filter(Boolean).join(' · ') + return
  • +} + +export default function ExploreSearchPanel(props: Props) { + const { filters, sort, onSortChange, items, total, listStatus, page, onPageChange, filtered, onShowAll, place, onClearPlace, onChoosePlace, onChooseRoute, + selected, route, routeVariants, status, onRetry, onBack, authenticated, onSaveRoute } = props + const inputRef = useRef(null) + const backRef = useRef(null) + + // --- Søk med debouncing. Utdaterte forespørsler avbrytes. --- + const [input, setInput] = useState('') + // Svaret lagres sammen med søket det hører til, så status kan avledes under rendering (som på forsiden) + const [searchResult, setSearchResult] = useState<{ search: string; data: SearchData | null } | null>(null) + // Filtrene gjøres om til tekst, så søket bare kjøres på nytt når de faktisk endres + const filterKey = JSON.stringify(filterVariables(filters)) + useEffect(() => { + const search = input.trim() + if (search.length < 2) return + const controller = new AbortController() + const timer = window.setTimeout(() => { + graphql(SEARCH_QUERY, { search, limit: 6, ...JSON.parse(filterKey) }, controller.signal) + .then(data => setSearchResult({ search: search + filterKey, data })) + .catch(() => { if (!controller.signal.aborted) setSearchResult({ search: search + filterKey, data: null }) }) + }, SEARCH_DELAY_MS) + return () => { window.clearTimeout(timer); controller.abort() } + }, [input, filterKey]) + const trimmed = input.trim() + const searchStatus = trimmed.length < 2 ? 'idle' : searchResult?.search !== trimmed + filterKey ? 'loading' : searchResult.data ? 'ready' : 'error' + const hits = searchStatus === 'ready' ? searchResult!.data! : null + const placeCount = hits?.searchMunicipalities.length ?? 0 + const routeTotal = hits?.routes.total ?? 0 + const hitSummary = [placeCount && `${placeCount} ${placeCount === 1 ? 'sted' : 'steder'}`, routeTotal && `${routeTotal.toLocaleString('nb-NO')} turer`] + .filter(Boolean).join(' og ') || 'Ingen steder eller turer passer søket.' + + // Søk går foran valgt tur, så man kan søke videre mens en tur vises + const mode = trimmed.length >= 2 ? 'search' : selected ? 'route' : 'list' + + function chooseRoute(item: SelectedRoute) { + setInput('') + onChooseRoute({ ...item }) + // Knappen som ble trykket forsvinner, så fokus flyttes til «Tilbake» i turvisningen + window.requestAnimationFrame(() => backRef.current?.focus()) + } + function choosePlace(hit: PlaceHit) { + setInput('') + onChoosePlace(hit) + window.requestAnimationFrame(() => inputRef.current?.focus()) + } + + const pages = Math.max(1, Math.ceil(total / PAGE_SIZE)) + const listTitle = place ? `Turer i ${place.name}` : filtered ? 'Turer i kartutsnittet' : 'Turer i hele Norge' + + return
    +
    event.preventDefault()}> + + + {place && {place.name}} + setInput(event.target.value)} + onKeyDown={event => { if (event.key === 'Escape') setInput('') }} + aria-describedby="esp-status" /> +
    + +
    + {mode === 'search' && <> +

    + {searchStatus === 'loading' && 'Søker …'} + {searchStatus === 'error' && 'Søket feilet. Kontroller at backend kjører.'} + {hits && hitSummary} +

    + {placeCount > 0 && <> +

    Steder

    +
      {hits!.searchMunicipalities.map(hit =>
    • + +
    • )}
    + } + {hits && hits.routes.items.length > 0 && <> +

    Turer{routeTotal > hits.routes.items.length ? ` (viser ${hits.routes.items.length} av ${routeTotal.toLocaleString('nb-NO')})` : ''}

    +
      {hits.routes.items.map(group => + chooseRoute(group.variants[0])} />)}
    + } + } + + {mode === 'route' && <> + + {status === 'loading' &&

    Henter turen …

    } + {status === 'error' &&

    Kunne ikke hente turen.

    } + {status === 'empty' &&

    Denne turen har ingen kartgeometri.

    } + {status === 'ready' && route && } + } + + {mode === 'list' && <> +

    {listTitle}

    +

    + {listStatus === 'loading' && 'Henter turer …'} + {listStatus === 'error' && 'Kunne ikke hente turene. Prøv å flytte kartet igjen.'} + {listStatus === 'ready' && (total ? `${total.toLocaleString('nb-NO')} turer` : 'Ingen turer her. Flytt eller zoom ut kartet.')} + {filtered && } +

    + + {listStatus === 'ready' && items.length > 0 && <> +
      {items.map(group => + selected?.name === item.name && selected?.number === item.number)} onChoose={() => chooseRoute(group.variants[0])} />)}
    + {pages > 1 && } + } + } +
    +
    +} diff --git a/src/Explore/RouteDetails.tsx b/src/Explore/RouteDetails.tsx new file mode 100644 index 0000000..c738383 --- /dev/null +++ b/src/Explore/RouteDetails.tsx @@ -0,0 +1,47 @@ +// Informasjon om valgt tur i søkepanelet: navn, varianter, nøkkeltall, beskrivelse og høydeprofil. +// Selve ruten tegnes i kartet av ExploreMap. +import ElevationProfile from '../ElevationProfile' +import { km, variantLabel, type Route, type RouteSummary, type SelectedRoute } from './routeTypes' + +type Props = { + route: Route + /** Alle registrerte varianter med samme navn */ + variants: RouteSummary[] + onChooseVariant: (variant: SelectedRoute) => void + authenticated: boolean + onSaveRoute: () => void +} + +export default function RouteDetails({ route, variants, onChooseVariant, authenticated, onSaveRoute }: Props) { + const subtitle = route.nameQuality === 'derived' ? variantLabel(route.number) + : route.name === 'Bymarka' ? 'Hele stinettet i Bymarka' : `Rutenummer ${route.number}` + return
    +

    Viser {route.name} i kartet

    +

    FOTTUR

    +

    {route.name}

    +

    {subtitle}

    + {variants.length > 1 &&
    + + +
    } +
    +
    {km(route.lengthMeters)} kmLengde
    +
    {route.difficulty || 'Ikke gradert'}Vanskelighetsgrad
    +
    {route.segmentCount}{route.connected ? 'Sammenhengende' : 'Adskilte deler'}
    +
    +

    {route.description || 'Ingen turbeskrivelse er oppgitt i kildedataene.'}

    + {route.maintainer &&

    Vedlikeholdes av {route.maintainer}

    } +

    {route.number === 'SAMLET_RUNDE' + ? 'Denne runden er beregnet fra registrerte rutevarianter med samme navn, og er ikke et eget registrert turforslag.' + : 'Linjen viser det som er registrert i datasettet. Vi har ikke bekreftet at den dekker hele turen.'}

    +
    + +
    + +
    +} diff --git a/src/Explore/routeTypes.ts b/src/Explore/routeTypes.ts new file mode 100644 index 0000000..3cebe80 --- /dev/null +++ b/src/Explore/routeTypes.ts @@ -0,0 +1,71 @@ +// Felles typer og hjelpefunksjoner for turer. Brukes av Utforsk-siden (Explore.tsx), +// søkepanelet i kartet (ExploreSearchPanel.tsx) og App.tsx. + +/** En tur slik route-spørringen returnerer den, med geometri */ +export type Route = { + id: string; name: string; number: string; lengthMeters: number; segmentCount: number + connected: boolean; maintainer: string | null; difficulty: string | null + routeType: string | null; nameQuality: string; duplicateCount: number; variantCount: number + description: string | null; geojson: string +} +export type RouteSummary = { id: string; name: string; number: string; lengthMeters: number; difficulty: string | null; routeType: string | null; duplicateCount: number; variantCount: number } +/** Turer med samme navn samles i én gruppe i lister og søk */ +export type RouteGroup = { key: string; name: string; variantCount: number; minLengthMeters: number; maxLengthMeters: number; favoriteCount?: number; variants: RouteSummary[] } +/** Antall turer per side i lista */ +export const PAGE_SIZE = 12 + +/** Det som trengs for å hente en tur: navn og rutenummer */ +export type SelectedRoute = { name: string; number: string } +/** Kartutsnitt i grader */ +export type Bounds = { west: number; south: number; east: number; north: number } + +/** Lesbart navn på en rutevariant ut fra rutenummeret */ +export function variantLabel(number: string) { + if (number === 'SAMLET_RUNDE') return 'Samlet rundtur' + if (number.startsWith('RUNDTUR_')) return `Rundtur ${number.slice(8)}` + if (number.startsWith('STREKNING_')) return `Sammenhengende strekning ${number.slice(10)}` + if (number === 'F_20170113_01') return 'Hele stinettet' + return `Nr. ${number}` +} + +/** Meter til kilometer med én desimal, norsk format */ +export const km = (meters: number) => (meters / 1000).toLocaleString('nb-NO', { maximumFractionDigits: 1 }) + +/** «5,8 km», eller «3,2–7,5 km» når variantene har ulik lengde */ +export function lengthText(group: RouteGroup) { + return group.minLengthMeters === group.maxLengthMeters + ? `${km(group.minLengthMeters)} km` + : `${km(group.minLengthMeters)}–${km(group.maxLengthMeters)} km` +} + +/** Filtrene i filterraden over kartet. Tomme lister / null betyr «ikke filtrert». */ +export type RouteFilterValue = { + difficulties: string[] + routeTypes: string[] + /** Stedstyper fra placeCategories som må finnes innen 500 m fra turen */ + facilities: string[] + /** Kilometer */ + minLength: number | null + maxLength: number | null +} +export const EMPTY_FILTERS: RouteFilterValue = { difficulties: [], routeTypes: [], facilities: [], minLength: null, maxLength: null } + +/** Variablene filtrene gir i routes-spørringen */ +export function filterVariables(filters: RouteFilterValue) { + return { + difficulty: filters.difficulties.length ? filters.difficulties : null, + routeType: filters.routeTypes.length ? filters.routeTypes : null, + facility: filters.facilities.length ? filters.facilities : null, + minLength: filters.minLength, + maxLength: filters.maxLength, + } +} + +/** Sorteringene backend støtter (enum RouteSort i server/index.ts) */ +export type RouteSort = 'POPULAR' | 'NAME' | 'SHORTEST' | 'LONGEST' +export const SORT_OPTIONS: { value: RouteSort; label: string }[] = [ + { value: 'POPULAR', label: 'Mest populære' }, + { value: 'NAME', label: 'Navn (A–Å)' }, + { value: 'SHORTEST', label: 'Korteste først' }, + { value: 'LONGEST', label: 'Lengste først' }, +] diff --git a/src/Filters.css b/src/Filters.css deleted file mode 100644 index 835ac44..0000000 --- a/src/Filters.css +++ /dev/null @@ -1,27 +0,0 @@ -.length-filters { display: flex; gap: 12px; margin-top: 14px; } -.length-filters label { flex: 0 1 210px; font-size: 13px; } -.length-filters input { width: 100%; margin-top: 6px; font: inherit; border: 1px solid #9cac9e; border-radius: 8px; padding: 10px; } -@media (max-width: 540px) { .length-filters { display: grid; } } -.route-variant-selector { margin: 18px 0; padding: 14px; background: #edf2e9; border-radius: 8px; } -.route-variant-selector label { display: block; margin-bottom: 7px; font-size: 12px; font-weight: 700; } -.route-variant-selector select { width: 100%; min-width: 0; padding: 10px; border: 1px solid #9cac9e; border-radius: 7px; background: #fff; color: #203b32; font: inherit; } -.selected-heading { scroll-margin-top: 20px; } -.route-facility-filter { padding: 20px; background: #fff; border: 1px solid #dce1d7; border-radius: 12px; margin-bottom: 20px; } -.route-facility-filter label { display: grid; gap: 8px; font-weight: 600; max-width: 420px; } -.route-facility-filter select,.osm-category select { font: inherit; padding: 10px; background: white; border: 1px solid #9cac9e; border-radius: 8px; min-width: 0; } -.route-facility-filter p { font-size: 12px; color: #647267; } -.category-section { margin-bottom: 32px; } -.category-heading { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 16px; } -.category-heading h2 { margin: 6px 0 0; font-size: 26px; } -.category-groups { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 14px; } -.category-group { background: #fff; border: 1px solid #dce1d7; border-radius: 15px; padding: 18px; } -.category-group h3 { margin: 0 0 12px; font-size: 14px; } -.category-buttons { display: grid; gap: 8px; } -.category-buttons button { text-align: left; color: #203b32; background: #f5f6f0; border: 2px solid transparent; padding: 11px 12px; } -.category-buttons button[aria-pressed=true] { border-color: #126b51; background: #e7f1e9; } -.category-buttons strong,.category-buttons span { display: block; } -.category-buttons span { margin-top: 3px; color: #647267; font-size: 11px; } -.geography-category label { display: block; margin: 12px 0 6px; color: #647267; font-size: 12px; font-weight: 700; } -.geography-category select { width: 100%; min-width: 0; padding: 10px; border: 1px solid #9cac9e; border-radius: 8px; background: #fff; color: #203b32; font: inherit; } -@media(max-width:850px){.category-groups{grid-template-columns:1fr}.category-buttons{grid-template-columns:repeat(2,minmax(0,1fr))}} -@media(max-width:480px){.category-buttons{grid-template-columns:1fr}.category-heading{align-items:start;flex-direction:column}}