From 46cdc760f1543ac4511fc2cab2c047ac12cebb9a Mon Sep 17 00:00:00 2001
From: Martine
Date: Thu, 15 Oct 2026 16:48:31 +0200
Subject: [PATCH 1/4] =?UTF-8?q?F=C3=B8rste=20utkast=20av=20nytt=20utseende?=
=?UTF-8?q?=20(#36)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
server/index.ts | 82 +++++--
src/App.tsx | 271 +----------------------
src/{ => Explore}/Explore.css | 0
src/Explore/Explore.tsx | 330 +++++++++++++++++++++++++++++
src/Explore/ExploreFilters.css | 33 +++
src/Explore/ExploreFilters.tsx | 163 ++++++++++++++
src/Explore/ExploreMap.css | 14 ++
src/Explore/ExploreMap.tsx | 161 ++++++++++++++
src/Explore/ExploreSearchPanel.css | 47 ++++
src/Explore/ExploreSearchPanel.tsx | 211 ++++++++++++++++++
src/{ => Explore}/Filters.css | 0
src/Explore/routeTypes.ts | 69 ++++++
12 files changed, 1108 insertions(+), 273 deletions(-)
rename src/{ => Explore}/Explore.css (100%)
create mode 100644 src/Explore/Explore.tsx
create mode 100644 src/Explore/ExploreFilters.css
create mode 100644 src/Explore/ExploreFilters.tsx
create mode 100644 src/Explore/ExploreMap.css
create mode 100644 src/Explore/ExploreMap.tsx
create mode 100644 src/Explore/ExploreSearchPanel.css
create mode 100644 src/Explore/ExploreSearchPanel.tsx
rename src/{ => Explore}/Filters.css (100%)
create mode 100644 src/Explore/routeTypes.ts
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.tsx b/src/App.tsx
index 21d0cf0..023fc59 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}
}
-
-
- { setShowOsm(event.target.checked); setOsmData(null); setOsmStatus(event.target.checked ? 'Henter OSM-data …' : ''); setError('') }} /> Vis OSM-stier og steder i området
- {showOsm && <>
- Vis stedersetOsmKind(event.target.value)}>Alle kategorier {placeCategories.map(item=>{item.label} )}
- 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,14 @@ 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' }))
+ window.requestAnimationFrame(() => document.getElementById('selected-route-heading')?.scrollIntoView({ behavior: 'smooth', block: 'start' }))
}
- 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 +41,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'})}} />
-
-
- {counties.filter(item=>['Trøndelag','Vestland','Nordland','Innlandet'].some(name=>item.name.startsWith(name))).map((item,index)=>{setCounty(item.code);setMunicipality(null);setPage(0);setListStatus('loading');document.getElementById('route-results')?.scrollIntoView({behavior:'smooth'})}}>{['△','≋','⋀','♧'][index%4]} {item.name.split(' - ')[0]} {item.routeCount.toLocaleString('nb-NO')} registrerte ruter ↗ )}
- {!counties.length&&Områdene vises når rutedataene er tilgjengelige.
}
-
-
Tilpass turen din Lengde, vanskelighetsgrad, område og steder langs veien Filtrer +
- FINN DIN NESTE TUR
Utforsk etter kategori {(difficulty || routeType || county || municipality || facility || minLength != null || maxLength != null) &&
{ setFacility(null); setDifficulty(null); setRouteType(null); setCounty(null); setMunicipality(null); setMinLength(null); setMaxLength(null); setMinInput(''); setMaxInput(''); setPage(0); setListStatus('loading') }}>Nullstill kategorier }
-
-
Lengde { setMinLength(null); setMaxLength(5); setMinInput(''); setMaxInput('5'); setPage(0); setListStatus('loading') }}>Kort tur Under 5 km { setMinLength(5); setMaxLength(15); setMinInput('5'); setMaxInput('15'); setPage(0); setListStatus('loading') }}>Mellomlang 5–15 km { setMinLength(15); setMaxLength(null); setMinInput('15'); setMaxInput(''); setPage(0); setListStatus('loading') }}>Lang tur Over 15 km
-
Vanskelighetsgrad { setDifficulty('Enkel'); setPage(0); setListStatus('loading') }}>Enkel { setDifficulty('Middels'); setPage(0); setListStatus('loading') }}>Middels { setDifficulty('Krevende'); setPage(0); setListStatus('loading') }}>Krevende { setDifficulty('Ekspert'); setPage(0); setListStatus('loading') }}>Ekspert
-
Rutetype { setRouteType('Avledet rundtur'); setPage(0); setListStatus('loading') }}>Rundturer Sammenhengende runder { setRouteType('Hovedrute'); setPage(0); setListStatus('loading') }}>Hovedruter Registrerte hovedtraséer { setRouteType('Materute'); setPage(0); setListStatus('loading') }}>Materuter Ruter inn til hovedruter
-
Område Fylke { setCounty(event.target.value || null); setMunicipality(null); setPage(0); setListStatus('loading') }}>Alle fylker {counties.map(item => {item.name} ({item.routeCount.toLocaleString('nb-NO')}) )} Kommune { setMunicipality(event.target.value || null); setPage(0); setListStatus('loading') }}>Alle kommuner {municipalities.map(item => {item.name} ({item.routeCount.toLocaleString('nb-NO')}) )}
-
-
- Finn ruter nær et sted{setFacility(event.target.value || null);setPage(0);setListStatus('loading')}}>Alle ruter {placeCategories.map(item=>{item.label} innen 500 meter )} Avstand i luftlinje fra rutelinjen. Stedet er ikke bekreftet tilgjengelig fra stien.
-
-
-
ET SKRITT UT ER ALT SOM TRENGS
{search ? `Turer for «${search}»` : 'Utforsk turer'} {county&&
{setCounty(null);setMunicipality(null);setPage(0);setListStatus('loading')}}>Vis alle områder }
- {listStatus === 'loading' && Henter ruter …
}
- {listStatus === 'error' && Kunne ikke hente rutelisten. Prøv søket igjen.
}
- {listStatus === 'ready' && <>
- {total.toLocaleString('nb-NO')} rutenavn{total > 0 ? ` · viser ${page * 12 + 1}–${Math.min(page * 12 + 12, total)}` : '. Prøv et annet navn.'}
- {items.map((group) => {
- const active = group.variants.some(item => selected?.name === item.name && selected?.number === item.number)
- const lengthText = group.minLengthMeters === group.maxLengthMeters
- ? `${(group.minLengthMeters / 1000).toLocaleString('nb-NO', { maximumFractionDigits: 1 })} km`
- : `${(group.minLengthMeters / 1000).toLocaleString('nb-NO', { maximumFractionDigits: 1 })}–${(group.maxLengthMeters / 1000).toLocaleString('nb-NO', { maximumFractionDigits: 1 })} km`
- return openRoute(group.variants[0])}>FOTTUR ↗ {group.name} {group.variantCount === 1 ? lengthText : `${group.variantCount} varianter · ${lengthText}`}
- })}
- { setPage(page - 1); setListStatus('loading') }}>Forrige = total} onClick={() => { setPage(page + 1); setListStatus('loading') }}>Neste
- >}
-
- {selected &&
{selected.name} {if(!authenticated){setGuest(false);setShowLogin(true);}setView('profile')}}>{authenticated?'Lagre og planlegg i profilen →':'Logg inn for å lagre turen'} }
- {selected && status === 'loading' &&
Henter ruten fra databasen …
}
- {selected && status === 'error' &&
Kunne ikke hente ruten Kontroller at databasen og backend kjører.
{ setStatus('loading'); setAttempt(attempt + 1) }}>Prøv igjen }
- {selected && status === 'empty' &&
Denne ruten har ingen tilgjengelig kartgeometri.
}
- {status === 'ready' && route &&
-
- {'FOTTUR · REGISTRERT RUTE'}
{route.name}
- {route.nameQuality === 'derived' ? variantLabel(route.number) : route.name === 'Bymarka' ? 'Hele stinettet i Bymarka' : `Rutenummer ${route.number}`}
- {routeVariants.length > 1 && Velg rutevariant {
- const variant = routeVariants.find(item => item.id === event.target.value)
- if (variant) { setSelected({ ...variant }); setStatus('loading') }
- }}>{routeVariants.map(item => {variantLabel(item.number)} · {(item.lengthMeters / 1000).toLocaleString('nb-NO', { maximumFractionDigits: 1 })} km · {item.difficulty || 'Ikke gradert'} )}
}
- {(route.lengthMeters / 1000).toLocaleString('nb-NO', { maximumFractionDigits: 1 })} km registrert strekning
-
Vanskelighetsgrad {route.difficulty || 'Ikke oppgitt'}
Rutedeler {route.segmentCount} · {route.connected ? 'sammenhengende' : 'flere adskilte linjer'}
Vedlikeholdsansvarlig {route.maintainer || 'Ikke oppgitt'}
- Om denne strekningen {route.number === 'SAMLET_RUNDE' ? 'Denne lukkede linjen er beregnet fra registrerte rutevarianter med samme navn. Blindgrener er utelatt. Den er ikke et eget registrert turforslag og må ikke brukes som bekreftet navigasjonsgrunnlag.' : 'Linjen viser det som er registrert i datasettet. Vi har ikke bekreftet at den dekker hele turen. Lengden gjelder bare den viste strekningen.'}
- Turinformasjon {route.description || 'Ingen turbeskrivelse er oppgitt i kildedataene.'}
-
-
-
}
- {status === 'ready' && route &&
}
+
setSelected(null)} onRouteLoaded={setLoadedRoute} authenticated={authenticated} onSaveRoute={()=>{if(!authenticated){setGuest(false);setShowLogin(true)}setView('profile')}} />
DITT EGET TURUNIVERS
Min profil. Favorittene dine, turene dine og neste eventyr.
- {authenticated&&
{setView('explore');window.location.hash='explore';requestAnimationFrame(()=>{const input=document.getElementById('route-search');input?.scrollIntoView({behavior:'smooth',block:'center'});input?.focus({preventScroll:true})})}} onSelectRoute={openRoute} onLogout={handleLogout} />}
+ {authenticated&&{setView('explore');window.location.hash='explore';requestAnimationFrame(()=>{const input=document.getElementById('route-search');input?.scrollIntoView({behavior:'smooth',block:'center'});input?.focus({preventScroll:true})})}} onSelectRoute={openRoute} onLogout={handleLogout} />}
diff --git a/src/Explore.css b/src/Explore/Explore.css
similarity index 100%
rename from src/Explore.css
rename to src/Explore/Explore.css
diff --git a/src/Explore/Explore.tsx b/src/Explore/Explore.tsx
new file mode 100644
index 0000000..c156f6a
--- /dev/null
+++ b/src/Explore/Explore.tsx
@@ -0,0 +1,330 @@
+// Utforsk turer-siden: søk, filtrering og paginert liste over fotruter, og detaljvisning
+// (info, kart og høydeprofil) for valgt tur. Hvilken tur som er valgt eies av App.tsx,
+// fordi forsiden og profilen også kan åpne en tur.
+import { useEffect, useRef, useState } from 'react'
+import * as maplibregl from 'maplibre-gl'
+import type { FeatureCollection, LineString, MultiLineString } from 'geojson'
+import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'
+import './Filters.css'
+import './Explore.css'
+import ElevationProfile from '../ElevationProfile'
+import ExploreMap, { type MapFocus } from './ExploreMap'
+import ExploreSearchPanel, { type PlaceHit } from './ExploreSearchPanel'
+import ExploreFilters from './ExploreFilters'
+import { variantLabel, type AreaOption, type Bounds, type Route, type RouteFilterValue, type RouteGroup, type RouteSort, type RouteSummary, type SelectedRoute } from './routeTypes'
+import { graphql, TOKEN_KEY } from '../graphql'
+import {placeCategories} from '../placeCategories'
+
+maplibregl.setWorkerUrl(workerUrl)
+
+/** 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 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}
}
+
+
+ { setShowOsm(event.target.checked); setOsmData(null); setOsmStatus(event.target.checked ? 'Henter OSM-data …' : ''); setError('') }} /> Vis OSM-stier og steder i området
+ {showOsm && <>
+ Vis stedersetOsmKind(event.target.value)}>Alle kategorier {placeCategories.map(item=>{item.label} )}
+ 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.
+ >}
+
+
+}
+
+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 i profilen» / «Logg inn for å lagre turen» */
+ onSaveRoute: () => void
+}
+
+export default function Explore({ selected, onSelect, onDeselect, onRouteLoaded, authenticated, onSaveRoute }: Props) {
+ const detailsRef = useRef(null)
+ 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)
+ 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)
+ // Filtrene kan ha flere verdier (filterraden i kartet). Den gamle visningen under setter bare én verdi.
+ const [facilities, setFacilities] = useState([])
+ const [difficulties, setDifficulties] = useState([])
+ const [routeTypes, setRouteTypes] = useState([])
+ const [sort, setSort] = useState('POPULAR')
+ 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')
+ // 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 }))
+ // Filtrene samlet, slik filterraden og søkepanelet bruker dem
+ const filters: RouteFilterValue = { difficulties, routeTypes, facilities, minLength, maxLength }
+ function changeFilters(next: RouteFilterValue) {
+ setDifficulties(next.difficulties); setRouteTypes(next.routeTypes); setFacilities(next.facilities)
+ setMinLength(next.minLength); setMaxLength(next.maxLength)
+ setMinInput(next.minLength?.toString() ?? ''); setMaxInput(next.maxLength?.toString() ?? '')
+ setPage(0); setListStatus('loading')
+ }
+ // 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)
+ 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])
+ // 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') }
+ function openRoute(item: SelectedRoute) {
+ onSelect({ ...item })
+ window.requestAnimationFrame(() => detailsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }))
+ }
+ 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!],$sort:RouteSort,$west:Float,$south:Float,$east:Float,$north:Float,$offset:Int!){ routes(search:$search,minLength:$minLength,maxLength:$maxLength,difficulty:$difficulty,routeType:$routeType,county:$county,municipality:$municipality,facility:$facility,sort:$sort,west:$west,south:$south,east:$east,north:$north,offset:$offset,limit:12){total items{key name variantCount minLengthMeters maxLengthMeters favoriteCount variants{id name number lengthMeters difficulty routeType duplicateCount variantCount}}} }', variables: { search, minLength, maxLength, difficulty: difficulties.length ? difficulties : null, routeType: routeTypes.length ? routeTypes : null, county, municipality, facility: facilities.length ? facilities : null, sort, west: bounds?.west ?? null, south: bounds?.south ?? null, east: bounds?.east ?? null, north: bounds?.north ?? null, 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, difficulties, routeTypes, county, municipality, facilities, sort, bounds, 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')
+ onRouteLoaded(result.data.route ? { id: result.data.route.id, name: result.data.route.name, number: result.data.route.number } : null)
+ }
+ } catch { if (!controller.signal.aborted) setStatus('error') }
+ }
+ void load()
+ return () => controller.abort()
+ }, [selected, attempt, onRouteLoaded])
+
+ return <>
+ {/* NY: stort kart øverst. Alt under er den gamle visningen, urørt, så vi kan sammenligne og slette det vi ikke vil ha. */}
+
+ { setBounds(next); setPage(0); setListStatus('loading'); if (byUser) setPlace(null) }} />
+
+ { setSort(next); setPage(0); setListStatus('loading') }}
+ 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); setPage(0); setListStatus('loading') }}
+ 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}
+ onShowDetails={() => detailsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })} />
+
+
+
+ {counties.filter(item=>['Trøndelag','Vestland','Nordland','Innlandet'].some(name=>item.name.startsWith(name))).map((item,index)=>{setCounty(item.code);setMunicipality(null);setPage(0);setListStatus('loading');document.getElementById('route-results')?.scrollIntoView({behavior:'smooth'})}}>{['△','≋','⋀','♧'][index%4]} {item.name.split(' - ')[0]} {item.routeCount.toLocaleString('nb-NO')} registrerte ruter ↗ )}
+ {!counties.length&&Områdene vises når rutedataene er tilgjengelige.
}
+
+ Tilpass turen din Lengde, vanskelighetsgrad, område og steder langs veien Filtrer +
+ FINN DIN NESTE TUR
Utforsk etter kategori {(difficulties.length > 0 || routeTypes.length > 0 || county || municipality || facilities.length > 0 || minLength != null || maxLength != null) &&
{ setFacilities([]); setDifficulties([]); setRouteTypes([]); setCounty(null); setMunicipality(null); setMinLength(null); setMaxLength(null); setMinInput(''); setMaxInput(''); setPage(0); setListStatus('loading') }}>Nullstill kategorier }
+
+
Lengde { setMinLength(null); setMaxLength(5); setMinInput(''); setMaxInput('5'); setPage(0); setListStatus('loading') }}>Kort tur Under 5 km { setMinLength(5); setMaxLength(15); setMinInput('5'); setMaxInput('15'); setPage(0); setListStatus('loading') }}>Mellomlang 5–15 km { setMinLength(15); setMaxLength(null); setMinInput('15'); setMaxInput(''); setPage(0); setListStatus('loading') }}>Lang tur Over 15 km
+
Vanskelighetsgrad { setDifficulties(['Enkel']); setPage(0); setListStatus('loading') }}>Enkel { setDifficulties(['Middels']); setPage(0); setListStatus('loading') }}>Middels { setDifficulties(['Krevende']); setPage(0); setListStatus('loading') }}>Krevende { setDifficulties(['Ekspert']); setPage(0); setListStatus('loading') }}>Ekspert
+
Rutetype { setRouteTypes(['Avledet rundtur']); setPage(0); setListStatus('loading') }}>Rundturer Sammenhengende runder { setRouteTypes(['Hovedrute']); setPage(0); setListStatus('loading') }}>Hovedruter Registrerte hovedtraséer { setRouteTypes(['Materute']); setPage(0); setListStatus('loading') }}>Materuter Ruter inn til hovedruter
+
Område Fylke { setCounty(event.target.value || null); setMunicipality(null); setPage(0); setListStatus('loading') }}>Alle fylker {counties.map(item => {item.name} ({item.routeCount.toLocaleString('nb-NO')}) )} Kommune { setMunicipality(event.target.value || null); setPage(0); setListStatus('loading') }}>Alle kommuner {municipalities.map(item => {item.name} ({item.routeCount.toLocaleString('nb-NO')}) )}
+
+
+ Finn ruter nær et sted{setFacilities(event.target.value ? [event.target.value] : []);setPage(0);setListStatus('loading')}}>Alle ruter {placeCategories.map(item=>{item.label} innen 500 meter )} Avstand i luftlinje fra rutelinjen. Stedet er ikke bekreftet tilgjengelig fra stien.
+
+
+ ET SKRITT UT ER ALT SOM TRENGS
{search ? `Turer for «${search}»` : 'Utforsk turer'} {county&&
{setCounty(null);setMunicipality(null);setPage(0);setListStatus('loading')}}>Vis alle områder }
+ {listStatus === 'loading' && Henter ruter …
}
+ {listStatus === 'error' && Kunne ikke hente rutelisten. Prøv søket igjen.
}
+ {listStatus === 'ready' && <>
+ {total.toLocaleString('nb-NO')} rutenavn{bounds ? ' i kartutsnittet' : ''}{total > 0 ? ` · viser ${page * 12 + 1}–${Math.min(page * 12 + 12, total)}` : '. Prøv et annet navn.'}
+ {items.map((group) => {
+ const active = group.variants.some(item => selected?.name === item.name && selected?.number === item.number)
+ const lengthText = group.minLengthMeters === group.maxLengthMeters
+ ? `${(group.minLengthMeters / 1000).toLocaleString('nb-NO', { maximumFractionDigits: 1 })} km`
+ : `${(group.minLengthMeters / 1000).toLocaleString('nb-NO', { maximumFractionDigits: 1 })}–${(group.maxLengthMeters / 1000).toLocaleString('nb-NO', { maximumFractionDigits: 1 })} km`
+ return openRoute(group.variants[0])}>FOTTUR ↗ {group.name} {group.variantCount === 1 ? lengthText : `${group.variantCount} varianter · ${lengthText}`}
+ })}
+ { setPage(page - 1); setListStatus('loading') }}>Forrige = total} onClick={() => { setPage(page + 1); setListStatus('loading') }}>Neste
+ >}
+
+ {selected &&
{selected.name} {authenticated?'Lagre og planlegg i profilen →':'Logg inn for å lagre turen'}
}
+ {selected && status === 'loading' && Henter ruten fra databasen …
}
+ {selected && status === 'error' && Kunne ikke hente ruten Kontroller at databasen og backend kjører.
{ setStatus('loading'); setAttempt(attempt + 1) }}>Prøv igjen }
+ {selected && status === 'empty' && Denne ruten har ingen tilgjengelig kartgeometri.
}
+ {status === 'ready' && route &&
+
+ {'FOTTUR · REGISTRERT RUTE'}
{route.name}
+ {route.nameQuality === 'derived' ? variantLabel(route.number) : route.name === 'Bymarka' ? 'Hele stinettet i Bymarka' : `Rutenummer ${route.number}`}
+ {routeVariants.length > 1 && Velg rutevariant {
+ const variant = routeVariants.find(item => item.id === event.target.value)
+ if (variant) onSelect({ ...variant })
+ }}>{routeVariants.map(item => {variantLabel(item.number)} · {(item.lengthMeters / 1000).toLocaleString('nb-NO', { maximumFractionDigits: 1 })} km · {item.difficulty || 'Ikke gradert'} )}
}
+ {(route.lengthMeters / 1000).toLocaleString('nb-NO', { maximumFractionDigits: 1 })} km registrert strekning
+
Vanskelighetsgrad {route.difficulty || 'Ikke oppgitt'}
Rutedeler {route.segmentCount} · {route.connected ? 'sammenhengende' : 'flere adskilte linjer'}
Vedlikeholdsansvarlig {route.maintainer || 'Ikke oppgitt'}
+ Om denne strekningen {route.number === 'SAMLET_RUNDE' ? 'Denne lukkede linjen er beregnet fra registrerte rutevarianter med samme navn. Blindgrener er utelatt. Den er ikke et eget registrert turforslag og må ikke brukes som bekreftet navigasjonsgrunnlag.' : 'Linjen viser det som er registrert i datasettet. Vi har ikke bekreftet at den dekker hele turen. Lengden gjelder bare den viste strekningen.'}
+ Turinformasjon {route.description || 'Ingen turbeskrivelse er oppgitt i kildedataene.'}
+
+
+
}
+ {status === 'ready' && route && }
+ >
+}
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..c3b571c
--- /dev/null
+++ b/src/Explore/ExploreFilters.tsx
@@ -0,0 +1,163 @@
+// 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',
+}
+
+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 =>
{ buttonRefs.current[button.id] = element }}
+ data-active={button.active ? 'true' : undefined}
+ aria-expanded={open === button.id} aria-controls={open === button.id ? 'ef-menu' : undefined}
+ aria-label={button.active ? `${button.label}: ${button.active}` : button.label}
+ onClick={() => toggleMenu(button.id)}>
+ {button.active ?? button.label}
+
+ )}
+ {anyActive &&
{ onChange(EMPTY_FILTERS); setOpen(null) }}>Nullstill filtre }
+
+
+ {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..d3ce2b5
--- /dev/null
+++ b/src/Explore/ExploreMap.tsx
@@ -0,0 +1,161 @@
+// 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 type { Feature, FeatureCollection, LineString, MultiLineString } from 'geojson'
+import type { Bounds } from './routeTypes'
+import './ExploreMap.css'
+
+/** 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
+}
diff --git a/src/Explore/ExploreSearchPanel.css b/src/Explore/ExploreSearchPanel.css
new file mode 100644
index 0000000..abe1efa
--- /dev/null
+++ b/src/Explore/ExploreSearchPanel.css
@@ -0,0 +1,47 @@
+/* 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; }
diff --git a/src/Explore/ExploreSearchPanel.tsx b/src/Explore/ExploreSearchPanel.tsx
new file mode 100644
index 0000000..0d2e25b
--- /dev/null
+++ b/src/Explore/ExploreSearchPanel.tsx
@@ -0,0 +1,211 @@
+// 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. Selve ruten tegnes i kartet av ExploreMap.
+// 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 { SORT_OPTIONS, filterVariables, km, lengthText, variantLabel, 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
+const PAGE_SIZE = 12
+// 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
+ /** Scroller ned til høydeprofil og detaljkart under kartet */
+ onShowDetails: () => 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
+ △
+ {group.name} {meta}
+
+}
+
+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, onShowDetails } = 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
+
+ // 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
+
+
+
+ {mode === 'search' && <>
+
+ {searchStatus === 'loading' && 'Søker …'}
+ {searchStatus === 'error' && 'Søket feilet. Kontroller at backend kjører.'}
+ {hits && (hits.searchMunicipalities.length || hits.routes.total
+ ? [hits.searchMunicipalities.length ? `${hits.searchMunicipalities.length} ${hits.searchMunicipalities.length === 1 ? 'sted' : 'steder'}` : '',
+ hits.routes.total ? `${hits.routes.total.toLocaleString('nb-NO')} turer` : ''].filter(Boolean).join(' og ')
+ : 'Ingen steder eller turer passer søket.')}
+
+ {hits && hits.searchMunicipalities.length > 0 && <>
+
Steder
+
{hits.searchMunicipalities.map(hit =>
+ choosePlace(hit)}>
+ ⌖
+ {hit.name} {hit.routeCount.toLocaleString('nb-NO')} turer · Vis i kartet
+
+ )}
+ >}
+ {hits && hits.routes.items.length > 0 && <>
+
Turer{hits.routes.total > hits.routes.items.length ? ` (viser ${hits.routes.items.length} av ${hits.routes.total.toLocaleString('nb-NO')})` : ''}
+
{hits.routes.items.map(group =>
+ chooseRoute(group.variants[0])} />)}
+ >}
+ >}
+
+ {mode === 'route' && <>
+
{ onBack(); window.requestAnimationFrame(() => inputRef.current?.focus()) }}>← Tilbake til turene
+ {status === 'loading' &&
Henter turen …
}
+ {status === 'error' &&
Kunne ikke hente turen.
Prøv igjen }
+ {status === 'empty' &&
Denne turen har ingen kartgeometri.
}
+ {status === 'ready' && route &&
+ Viser {route.name} i kartet
+ FOTTUR
+ {route.name}
+ {route.nameQuality === 'derived' ? variantLabel(route.number) : route.name === 'Bymarka' ? 'Hele stinettet i Bymarka' : `Rutenummer ${route.number}`}
+ {routeVariants.length > 1 &&
+ Velg rutevariant ({routeVariants.length})
+ {
+ const variant = routeVariants.find(item => item.id === event.target.value)
+ if (variant) onChooseRoute({ ...variant })
+ }}>{routeVariants.map(item => {variantLabel(item.number)} · {km(item.lengthMeters)} km · {item.difficulty || 'Ikke gradert'} )}
+
}
+
+
{km(route.lengthMeters)} km Lengde
+
{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.'}
+
+ {authenticated ? 'Lagre og planlegg' : 'Logg inn for å lagre'}
+ Høydeprofil og detaljer ↓
+
+ }
+ >}
+
+ {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 && Vis hele Norge }
+
+
Sorter
+ onSortChange(event.target.value as RouteSort)}>
+ {SORT_OPTIONS.map(option => {option.label} )}
+
+
+ {listStatus === 'ready' && items.length > 0 && <>
+
{items.map(group =>
+ selected?.name === item.name && selected?.number === item.number)} onChoose={() => chooseRoute(group.variants[0])} />)}
+ {pages > 1 &&
+ onPageChange(page - 1)}>← Forrige
+ Side {page + 1} av {pages.toLocaleString('nb-NO')}
+ = pages} onClick={() => onPageChange(page + 1)}>Neste →
+ }
+ >}
+ >}
+
+
+}
diff --git a/src/Filters.css b/src/Explore/Filters.css
similarity index 100%
rename from src/Filters.css
rename to src/Explore/Filters.css
diff --git a/src/Explore/routeTypes.ts b/src/Explore/routeTypes.ts
new file mode 100644
index 0000000..987469d
--- /dev/null
+++ b/src/Explore/routeTypes.ts
@@ -0,0 +1,69 @@
+// 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[] }
+export type AreaOption = { code: string; name: string; routeCount: number }
+/** 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' },
+]
From e873494e51a4205482e7b2c0b148b5b7b6a189bf Mon Sep 17 00:00:00 2001
From: Martine
Date: Thu, 15 Oct 2026 16:58:00 +0200
Subject: [PATCH 2/4] Slettet gammel kode (#36)
---
src/App.css | 12 +-
src/App.tsx | 5 +-
src/Explore/Explore.css | 69 +-----
src/Explore/Explore.tsx | 338 +++++++----------------------
src/Explore/ExploreMap.tsx | 4 +
src/Explore/ExploreSearchPanel.css | 5 +
src/Explore/Filters.css | 27 ---
7 files changed, 97 insertions(+), 363 deletions(-)
delete mode 100644 src/Explore/Filters.css
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 023fc59..684780e 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -30,7 +30,8 @@ export default function App() {
function openRoute(item: SelectedRoute) {
setView('explore')
setSelected({ ...item })
- window.requestAnimationFrame(() => document.getElementById('selected-route-heading')?.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' }))
}
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)}} />
@@ -44,7 +45,7 @@ export default function App() {
setSelected(null)} onRouteLoaded={setLoadedRoute} authenticated={authenticated} onSaveRoute={()=>{if(!authenticated){setGuest(false);setShowLogin(true)}setView('profile')}} />
DITT EGET TURUNIVERS
Min profil. Favorittene dine, turene dine og neste eventyr.
- {authenticated&&
{setView('explore');window.location.hash='explore';requestAnimationFrame(()=>{const input=document.getElementById('route-search');input?.scrollIntoView({behavior:'smooth',block:'center'});input?.focus({preventScroll:true})})}} onSelectRoute={openRoute} onLogout={handleLogout} />}
+ {authenticated&&{setView('explore');window.location.hash='explore';requestAnimationFrame(()=>{const input=document.getElementById('esp-input');input?.scrollIntoView({behavior:'smooth',block:'center'});input?.focus({preventScroll:true})})}} onSelectRoute={openRoute} onLogout={handleLogout} />}
diff --git a/src/Explore/Explore.css b/src/Explore/Explore.css
index 1ff0e44..53df50a 100644
--- a/src/Explore/Explore.css
+++ b/src/Explore/Explore.css
@@ -1,72 +1,22 @@
.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}}
+@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;
@@ -82,5 +32,4 @@
.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.tsx b/src/Explore/Explore.tsx
index c156f6a..d85daa0 100644
--- a/src/Explore/Explore.tsx
+++ b/src/Explore/Explore.tsx
@@ -1,130 +1,34 @@
-// Utforsk turer-siden: søk, filtrering og paginert liste over fotruter, og detaljvisning
-// (info, kart og høydeprofil) for valgt tur. Hvilken tur som er valgt eies av App.tsx,
-// fordi forsiden og profilen også kan åpne en tur.
-import { useEffect, useRef, useState } from 'react'
-import * as maplibregl from 'maplibre-gl'
-import type { FeatureCollection, LineString, MultiLineString } from 'geojson'
-import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'
-import './Filters.css'
+// 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 ElevationProfile from '../ElevationProfile'
import ExploreMap, { type MapFocus } from './ExploreMap'
import ExploreSearchPanel, { type PlaceHit } from './ExploreSearchPanel'
import ExploreFilters from './ExploreFilters'
-import { variantLabel, type AreaOption, type Bounds, type Route, type RouteFilterValue, type RouteGroup, type RouteSort, type RouteSummary, type SelectedRoute } from './routeTypes'
+import { filterVariables, type Bounds, type Route, type RouteFilterValue, type RouteGroup, type RouteSort, type RouteSummary, type SelectedRoute } from './routeTypes'
import { graphql, TOKEN_KEY } from '../graphql'
-import {placeCategories} from '../placeCategories'
-
-maplibregl.setWorkerUrl(workerUrl)
/** 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 PAGE_SIZE = 12
-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}
}
-
-
- { setShowOsm(event.target.checked); setOsmData(null); setOsmStatus(event.target.checked ? 'Henter OSM-data …' : ''); setError('') }} /> Vis OSM-stier og steder i området
- {showOsm && <>
- Vis stedersetOsmKind(event.target.value)}>Alle kategorier {placeCategories.map(item=>{item.label} )}
- 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.
- >}
-
-
-}
+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) */
@@ -135,52 +39,44 @@ type Props = {
/** 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 i profilen» / «Logg inn for å lagre turen» */
+ /** Knappen «Lagre og planlegg» / «Logg inn for å lagre» */
onSaveRoute: () => void
}
export default function Explore({ selected, onSelect, onDeselect, onRouteLoaded, authenticated, onSaveRoute }: Props) {
- const detailsRef = useRef(null)
+ // --- 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)
- 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)
- // Filtrene kan ha flere verdier (filterraden i kartet). Den gamle visningen under setter bare én verdi.
- const [facilities, setFacilities] = useState([])
- const [difficulties, setDifficulties] = useState([])
- const [routeTypes, setRouteTypes] = useState([])
+ // 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({ difficulties: [], routeTypes: [], facilities: [], minLength: null, maxLength: null })
const [sort, setSort] = useState('POPULAR')
- const [county, setCounty] = useState(null)
- const [municipality, setMunicipality] = useState(null)
- const [counties, setCounties] = useState([])
- const [municipalities, setMunicipalities] = useState([])
+
+ // --- Turlista i kartutsnittet ---
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')
+ /** 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 }))
- // Filtrene samlet, slik filterraden og søkepanelet bruker dem
- const filters: RouteFilterValue = { difficulties, routeTypes, facilities, minLength, maxLength }
- function changeFilters(next: RouteFilterValue) {
- setDifficulties(next.difficulties); setRouteTypes(next.routeTypes); setFacilities(next.facilities)
- setMinLength(next.minLength); setMaxLength(next.maxLength)
- setMinInput(next.minLength?.toString() ?? ''); setMaxInput(next.maxLength?.toString() ?? '')
- setPage(0); setListStatus('loading')
- }
// 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
@@ -194,137 +90,51 @@ export default function Explore({ selected, onSelect, onDeselect, onRouteLoaded,
.catch(() => undefined)
return () => controller.abort()
}, [authenticated])
- // 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') }
- function openRoute(item: SelectedRoute) {
- onSelect({ ...item })
- window.requestAnimationFrame(() => detailsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }))
- }
- 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])
+
+ // 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()
- 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!],$sort:RouteSort,$west:Float,$south:Float,$east:Float,$north:Float,$offset:Int!){ routes(search:$search,minLength:$minLength,maxLength:$maxLength,difficulty:$difficulty,routeType:$routeType,county:$county,municipality:$municipality,facility:$facility,sort:$sort,west:$west,south:$south,east:$east,north:$north,offset:$offset,limit:12){total items{key name variantCount minLengthMeters maxLengthMeters favoriteCount variants{id name number lengthMeters difficulty routeType duplicateCount variantCount}}} }', variables: { search, minLength, maxLength, difficulty: difficulties.length ? difficulties : null, routeType: routeTypes.length ? routeTypes : null, county, municipality, facility: facilities.length ? facilities : null, sort, west: bounds?.west ?? null, south: bounds?.south ?? null, east: bounds?.east ?? null, north: bounds?.north ?? null, 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()
+ 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()
- }, [search, minLength, maxLength, difficulties, routeTypes, county, municipality, facilities, sort, bounds, page, listAttempt])
+ }, [filters, sort, bounds, page])
+
+ // Henter valgt tur med geometri og alle varianter med samme navn
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')
- onRouteLoaded(result.data.route ? { id: result.data.route.id, name: result.data.route.name, number: result.data.route.number } : null)
- }
- } catch { if (!controller.signal.aborted) setStatus('error') }
- }
- void load()
+ 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 <>
- {/* NY: stort kart øverst. Alt under er den gamle visningen, urørt, så vi kan sammenligne og slette det vi ikke vil ha. */}
-
- { setBounds(next); setPage(0); setListStatus('loading'); if (byUser) setPlace(null) }} />
-
- { setSort(next); setPage(0); setListStatus('loading') }}
- 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); setPage(0); setListStatus('loading') }}
- 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}
- onShowDetails={() => detailsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })} />
-
-
-
- {counties.filter(item=>['Trøndelag','Vestland','Nordland','Innlandet'].some(name=>item.name.startsWith(name))).map((item,index)=>{setCounty(item.code);setMunicipality(null);setPage(0);setListStatus('loading');document.getElementById('route-results')?.scrollIntoView({behavior:'smooth'})}}>{['△','≋','⋀','♧'][index%4]} {item.name.split(' - ')[0]} {item.routeCount.toLocaleString('nb-NO')} registrerte ruter ↗ )}
- {!counties.length&&Områdene vises når rutedataene er tilgjengelige.
}
-
- Tilpass turen din Lengde, vanskelighetsgrad, område og steder langs veien Filtrer +
- FINN DIN NESTE TUR
Utforsk etter kategori {(difficulties.length > 0 || routeTypes.length > 0 || county || municipality || facilities.length > 0 || minLength != null || maxLength != null) &&
{ setFacilities([]); setDifficulties([]); setRouteTypes([]); setCounty(null); setMunicipality(null); setMinLength(null); setMaxLength(null); setMinInput(''); setMaxInput(''); setPage(0); setListStatus('loading') }}>Nullstill kategorier }
-
-
Lengde { setMinLength(null); setMaxLength(5); setMinInput(''); setMaxInput('5'); setPage(0); setListStatus('loading') }}>Kort tur Under 5 km { setMinLength(5); setMaxLength(15); setMinInput('5'); setMaxInput('15'); setPage(0); setListStatus('loading') }}>Mellomlang 5–15 km { setMinLength(15); setMaxLength(null); setMinInput('15'); setMaxInput(''); setPage(0); setListStatus('loading') }}>Lang tur Over 15 km
-
Vanskelighetsgrad { setDifficulties(['Enkel']); setPage(0); setListStatus('loading') }}>Enkel { setDifficulties(['Middels']); setPage(0); setListStatus('loading') }}>Middels { setDifficulties(['Krevende']); setPage(0); setListStatus('loading') }}>Krevende { setDifficulties(['Ekspert']); setPage(0); setListStatus('loading') }}>Ekspert
-
Rutetype { setRouteTypes(['Avledet rundtur']); setPage(0); setListStatus('loading') }}>Rundturer Sammenhengende runder { setRouteTypes(['Hovedrute']); setPage(0); setListStatus('loading') }}>Hovedruter Registrerte hovedtraséer { setRouteTypes(['Materute']); setPage(0); setListStatus('loading') }}>Materuter Ruter inn til hovedruter
-
Område Fylke { setCounty(event.target.value || null); setMunicipality(null); setPage(0); setListStatus('loading') }}>Alle fylker {counties.map(item => {item.name} ({item.routeCount.toLocaleString('nb-NO')}) )} Kommune { setMunicipality(event.target.value || null); setPage(0); setListStatus('loading') }}>Alle kommuner {municipalities.map(item => {item.name} ({item.routeCount.toLocaleString('nb-NO')}) )}
-
-
- Finn ruter nær et sted{setFacilities(event.target.value ? [event.target.value] : []);setPage(0);setListStatus('loading')}}>Alle ruter {placeCategories.map(item=>{item.label} innen 500 meter )} Avstand i luftlinje fra rutelinjen. Stedet er ikke bekreftet tilgjengelig fra stien.
-
-
- ET SKRITT UT ER ALT SOM TRENGS
{search ? `Turer for «${search}»` : 'Utforsk turer'} {county&&
{setCounty(null);setMunicipality(null);setPage(0);setListStatus('loading')}}>Vis alle områder }
- {listStatus === 'loading' && Henter ruter …
}
- {listStatus === 'error' && Kunne ikke hente rutelisten. Prøv søket igjen.
}
- {listStatus === 'ready' && <>
- {total.toLocaleString('nb-NO')} rutenavn{bounds ? ' i kartutsnittet' : ''}{total > 0 ? ` · viser ${page * 12 + 1}–${Math.min(page * 12 + 12, total)}` : '. Prøv et annet navn.'}
- {items.map((group) => {
- const active = group.variants.some(item => selected?.name === item.name && selected?.number === item.number)
- const lengthText = group.minLengthMeters === group.maxLengthMeters
- ? `${(group.minLengthMeters / 1000).toLocaleString('nb-NO', { maximumFractionDigits: 1 })} km`
- : `${(group.minLengthMeters / 1000).toLocaleString('nb-NO', { maximumFractionDigits: 1 })}–${(group.maxLengthMeters / 1000).toLocaleString('nb-NO', { maximumFractionDigits: 1 })} km`
- return openRoute(group.variants[0])}>FOTTUR ↗ {group.name} {group.variantCount === 1 ? lengthText : `${group.variantCount} varianter · ${lengthText}`}
- })}
- { setPage(page - 1); setListStatus('loading') }}>Forrige = total} onClick={() => { setPage(page + 1); setListStatus('loading') }}>Neste
- >}
-
- {selected &&
{selected.name} {authenticated?'Lagre og planlegg i profilen →':'Logg inn for å lagre turen'} }
- {selected && status === 'loading' && Henter ruten fra databasen …
}
- {selected && status === 'error' && Kunne ikke hente ruten Kontroller at databasen og backend kjører.
{ setStatus('loading'); setAttempt(attempt + 1) }}>Prøv igjen }
- {selected && status === 'empty' && Denne ruten har ingen tilgjengelig kartgeometri.
}
- {status === 'ready' && route &&
-
- {'FOTTUR · REGISTRERT RUTE'}
{route.name}
- {route.nameQuality === 'derived' ? variantLabel(route.number) : route.name === 'Bymarka' ? 'Hele stinettet i Bymarka' : `Rutenummer ${route.number}`}
- {routeVariants.length > 1 && Velg rutevariant {
- const variant = routeVariants.find(item => item.id === event.target.value)
- if (variant) onSelect({ ...variant })
- }}>{routeVariants.map(item => {variantLabel(item.number)} · {(item.lengthMeters / 1000).toLocaleString('nb-NO', { maximumFractionDigits: 1 })} km · {item.difficulty || 'Ikke gradert'} )}
}
- {(route.lengthMeters / 1000).toLocaleString('nb-NO', { maximumFractionDigits: 1 })} km registrert strekning
-
Vanskelighetsgrad {route.difficulty || 'Ikke oppgitt'}
Rutedeler {route.segmentCount} · {route.connected ? 'sammenhengende' : 'flere adskilte linjer'}
Vedlikeholdsansvarlig {route.maintainer || 'Ikke oppgitt'}
- Om denne strekningen {route.number === 'SAMLET_RUNDE' ? 'Denne lukkede linjen er beregnet fra registrerte rutevarianter med samme navn. Blindgrener er utelatt. Den er ikke et eget registrert turforslag og må ikke brukes som bekreftet navigasjonsgrunnlag.' : 'Linjen viser det som er registrert i datasettet. Vi har ikke bekreftet at den dekker hele turen. Lengden gjelder bare den viste strekningen.'}
- Turinformasjon {route.description || 'Ingen turbeskrivelse er oppgitt i kildedataene.'}
-
-
-
}
- {status === 'ready' && route && }
- >
+ 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/ExploreMap.tsx b/src/Explore/ExploreMap.tsx
index d3ce2b5..c6eda24 100644
--- a/src/Explore/ExploreMap.tsx
+++ b/src/Explore/ExploreMap.tsx
@@ -7,10 +7,14 @@
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 }
diff --git a/src/Explore/ExploreSearchPanel.css b/src/Explore/ExploreSearchPanel.css
index abe1efa..986c31f 100644
--- a/src/Explore/ExploreSearchPanel.css
+++ b/src/Explore/ExploreSearchPanel.css
@@ -45,3 +45,8 @@
/* 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/Filters.css b/src/Explore/Filters.css
deleted file mode 100644
index 835ac44..0000000
--- a/src/Explore/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}}
From fc5e3f49fd380067f7f70cd00c431ab7918c68eb Mon Sep 17 00:00:00 2001
From: Martine
Date: Thu, 15 Oct 2026 16:59:21 +0200
Subject: [PATCH 3/4] Gammel kode som har blitt flyttet (#36)
---
src/Explore/ExploreSearchPanel.tsx | 9 ++++-----
1 file changed, 4 insertions(+), 5 deletions(-)
diff --git a/src/Explore/ExploreSearchPanel.tsx b/src/Explore/ExploreSearchPanel.tsx
index 0d2e25b..d5ec137 100644
--- a/src/Explore/ExploreSearchPanel.tsx
+++ b/src/Explore/ExploreSearchPanel.tsx
@@ -1,10 +1,11 @@
// 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. Selve ruten tegnes i kartet av ExploreMap.
+// 3. En tur er valgt: informasjon om turen og høydeprofil. Selve ruten tegnes i kartet av ExploreMap.
// 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 ElevationProfile from '../ElevationProfile'
import { SORT_OPTIONS, filterVariables, km, lengthText, variantLabel, type Route, type RouteFilterValue, type RouteGroup, type RouteSort, type RouteSummary, type SelectedRoute } from './routeTypes'
import './ExploreSearchPanel.css'
@@ -52,8 +53,6 @@ type Props = {
onBack: () => void
authenticated: boolean
onSaveRoute: () => void
- /** Scroller ned til høydeprofil og detaljkart under kartet */
- onShowDetails: () => void
}
/** Ett turkort i lister og søk */
@@ -69,7 +68,7 @@ function RouteItem({ group, active, onChoose }: { group: RouteGroup; active: boo
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, onShowDetails } = props
+ selected, route, routeVariants, status, onRetry, onBack, authenticated, onSaveRoute } = props
const inputRef = useRef(null)
const backRef = useRef(null)
@@ -178,8 +177,8 @@ export default function ExploreSearchPanel(props: Props) {
: 'Linjen viser det som er registrert i datasettet. Vi har ikke bekreftet at den dekker hele turen.'}
{authenticated ? 'Lagre og planlegg' : 'Logg inn for å lagre'}
- Høydeprofil og detaljer ↓
+
}
>}
From 502eec48750f103ad05a28712152883b93e942ab Mon Sep 17 00:00:00 2001
From: Martine
Date: Thu, 15 Oct 2026 17:10:36 +0200
Subject: [PATCH 4/4] =?UTF-8?q?Del=20opp=20s=C3=B8kepanelet=20og=20fjern?=
=?UTF-8?q?=20duplisert=20kode=20i=20Utforsk=20(#36)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/Explore/Explore.tsx | 5 ++-
src/Explore/ExploreFilters.tsx | 10 +++---
src/Explore/ExploreSearchPanel.tsx | 51 ++++++++----------------------
src/Explore/RouteDetails.tsx | 47 +++++++++++++++++++++++++++
src/Explore/routeTypes.ts | 4 ++-
5 files changed, 71 insertions(+), 46 deletions(-)
create mode 100644 src/Explore/RouteDetails.tsx
diff --git a/src/Explore/Explore.tsx b/src/Explore/Explore.tsx
index d85daa0..36a8b46 100644
--- a/src/Explore/Explore.tsx
+++ b/src/Explore/Explore.tsx
@@ -8,7 +8,7 @@ import './Explore.css'
import ExploreMap, { type MapFocus } from './ExploreMap'
import ExploreSearchPanel, { type PlaceHit } from './ExploreSearchPanel'
import ExploreFilters from './ExploreFilters'
-import { filterVariables, type Bounds, type Route, type RouteFilterValue, type RouteGroup, type RouteSort, type RouteSummary, type SelectedRoute } from './routeTypes'
+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 */
@@ -16,7 +16,6 @@ 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 PAGE_SIZE = 12
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!){
@@ -55,7 +54,7 @@ export default function Explore({ selected, onSelect, onDeselect, onRouteLoaded,
if (selected !== prevSelected) { setPrevSelected(selected); setStatus(selected ? 'loading' : 'empty') }
// --- Filtre og sortering ---
- const [filters, setFilters] = useState({ difficulties: [], routeTypes: [], facilities: [], minLength: null, maxLength: null })
+ const [filters, setFilters] = useState(EMPTY_FILTERS)
const [sort, setSort] = useState('POPULAR')
// --- Turlista i kartutsnittet ---
diff --git a/src/Explore/ExploreFilters.tsx b/src/Explore/ExploreFilters.tsx
index c3b571c..08a2425 100644
--- a/src/Explore/ExploreFilters.tsx
+++ b/src/Explore/ExploreFilters.tsx
@@ -33,6 +33,11 @@ const ICONS: Record = {
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» */
@@ -152,10 +157,7 @@ export default function ExploreFilters({ value, onChange }: Props) {
>}
- onChange(
- open === 'difficulty' ? { ...value, difficulties: [] }
- : open === 'length' ? { ...value, minLength: null, maxLength: null }
- : open === 'routeType' ? { ...value, routeTypes: [] } : { ...value, facilities: [] })}>Nullstill
+ onChange({ ...value, ...CLEARED[open] })}>Nullstill
Ferdig
}
diff --git a/src/Explore/ExploreSearchPanel.tsx b/src/Explore/ExploreSearchPanel.tsx
index d5ec137..52f2243 100644
--- a/src/Explore/ExploreSearchPanel.tsx
+++ b/src/Explore/ExploreSearchPanel.tsx
@@ -1,12 +1,12 @@
// 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 og høydeprofil. Selve ruten tegnes i kartet av ExploreMap.
+// 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 ElevationProfile from '../ElevationProfile'
-import { SORT_OPTIONS, filterVariables, km, lengthText, variantLabel, type Route, type RouteFilterValue, type RouteGroup, type RouteSort, type RouteSummary, type SelectedRoute } from './routeTypes'
+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. */
@@ -15,7 +15,6 @@ type SearchData = { routes: { total: number; items: RouteGroup[] }; searchMunici
/** Hvor lenge vi venter etter siste tastetrykk før vi søker (debouncing, sparer requests) */
const SEARCH_DELAY_MS = 300
-const PAGE_SIZE = 12
// 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){
@@ -92,6 +91,10 @@ export default function ExploreSearchPanel(props: Props) {
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'
@@ -127,14 +130,11 @@ export default function ExploreSearchPanel(props: Props) {
{searchStatus === 'loading' && 'Søker …'}
{searchStatus === 'error' && 'Søket feilet. Kontroller at backend kjører.'}
- {hits && (hits.searchMunicipalities.length || hits.routes.total
- ? [hits.searchMunicipalities.length ? `${hits.searchMunicipalities.length} ${hits.searchMunicipalities.length === 1 ? 'sted' : 'steder'}` : '',
- hits.routes.total ? `${hits.routes.total.toLocaleString('nb-NO')} turer` : ''].filter(Boolean).join(' og ')
- : 'Ingen steder eller turer passer søket.')}
+ {hits && hitSummary}
- {hits && hits.searchMunicipalities.length > 0 && <>
+ {placeCount > 0 && <>
Steder
-