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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"dev:server": "node --env-file-if-exists=.env --watch server/index.mjs",
"start:server": "node --env-file-if-exists=.env server/index.mjs"
"dev:server": "node --env-file-if-exists=.env --watch server/index.ts",
"start:server": "node --env-file-if-exists=.env server/index.ts"
},
"dependencies": {
"graphql": "^17.0.2",
Expand Down
Binary file added public/images/explore-nature.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions server/authLimits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {GraphQLError} from 'graphql'
// In-memory guard for this single-process prototype; do not trust forwarded IP headers.
const attempts=new Map<string,{start:number;count:number}>()
let globalWindow={start:0,count:0}
export function checkAuthAttempt(username:string,now=Date.now()){
if(now-globalWindow.start>=60000)globalWindow={start:now,count:0}
if(++globalWindow.count>120)throw new GraphQLError('For mange innloggingsforsøk. Vent ett minutt og prøv igjen.')
for(const [key,value] of attempts)if(now-value.start>=900000)attempts.delete(key)
const key=username.trim().toLowerCase().slice(0,100)
const value=attempts.get(key)||{start:now,count:0}
attempts.set(key,value)
if(++value.count>20)throw new GraphQLError('For mange forsøk for dette brukernavnet. Vent 15 minutter og prøv igjen.')
}
167 changes: 167 additions & 0 deletions server/customRoutes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { GraphQLError } from 'graphql'
import { pool } from './db.ts'

export type BuilderPoint={lon:number;lat:number}
type NetworkNode={id:string;xy:number[];ll:number[];neighbors:Map<string,number>}
type LineRow={projected:{coordinates:number[][]};geographic:{coordinates:number[][]}}
type RingRow=LineRow & {id:string;length:number}
type Network={nodes:Map<string,NetworkNode>;sourceRouteId:string;sourceName:string;geojson:string;cycles?:Promise<RingRow[]>}
const networks = new Map<string,Promise<Network>>()
export function builderNetwork(routeId:string|null = null) {
if (routeId && !/^[a-f0-9]{32}$/.test(routeId)) throw new GraphQLError('Ugyldig kilderute.')
const key = routeId || 'Bymarka'
if (!networks.has(key)) {
if (networks.size >= 4) networks.delete(networks.keys().next().value!)
networks.set(key,loadNetwork(routeId).catch(error => {networks.delete(key);throw error}))
}
return networks.get(key)!
}
async function loadNetwork(routeId:string|null):Promise<Network> {
const { rows: sources } = await pool.query(`SELECT id,name FROM norwegian_explorer_app.route_catalog
WHERE (($1::text IS NOT NULL AND id=$1) OR ($1::text IS NULL AND name='Bymarka'))
AND NOT EXISTS(SELECT 1 FROM norwegian_explorer_app.route_exclusions e WHERE e.route_id=route_catalog.id)
UNION ALL SELECT id,name FROM norwegian_explorer_app.route_loops WHERE id=$1`,[routeId])
if (!sources.length) throw new GraphQLError('Kilderuten finnes ikke lenger. Velg en annen rute.')
const source = sources[0]
const { rows } = await pool.query<LineRow>(`WITH network AS (
SELECT ST_Node(ST_UnaryUnion(ST_Collect(geom))) AS geom
FROM (SELECT geom FROM norwegian_explorer_app.route_catalog WHERE id=$1
UNION ALL SELECT geom FROM norwegian_explorer_app.route_loops WHERE id=$1) source
), lines AS (
SELECT (ST_Dump(ST_Segmentize(geom,30))).geom AS geom FROM network
) SELECT ST_AsGeoJSON(geom)::json AS projected,
ST_AsGeoJSON(ST_Transform(geom,4326))::json AS geographic FROM lines`,[source.id])
if (!rows.length) throw new GraphQLError('Denne ruten har ikke et tilgjengelig stinett.')
const pointCount=rows.reduce((total,row)=>total+row.projected.coordinates.length,0)
if(pointCount>250000)throw new GraphQLError('Stinettet er for stort for rutebyggeren. Velg en mindre rutevariant.')
const nodes = new Map<string,NetworkNode>()
const key = (point:number[]) => point.map(n => n.toFixed(6)).join(',')
for (const row of rows) {
const xy = row.projected.coordinates
const ll = row.geographic.coordinates
for (let i=0;i<xy.length;i++) {
const id = key(xy[i])
if (!nodes.has(id)) nodes.set(id, { id, xy: xy[i], ll: ll[i], neighbors: new Map() })
if (i) {
const a = nodes.get(key(xy[i-1]))!, b = nodes.get(id)!
const distance = Math.hypot(a.xy[0]-b.xy[0],a.xy[1]-b.xy[1])
a.neighbors.set(b.id,distance); b.neighbors.set(a.id,distance)
}
}
}
return { nodes, sourceRouteId:source.id, sourceName:source.name,
geojson: JSON.stringify({ type:'MultiLineString',coordinates:rows.map(row => row.geographic.coordinates) }) }
}

function shortestPath(nodes:Map<string,NetworkNode>, start:string, end:string) {
const distances = new Map([[start,0]]), previous = new Map(), visited = new Set()
const queue:[number,string][] = [[0,start]]
function push(entry:[number,string]) {
queue.push(entry)
let i=queue.length-1
while(i>0){const parent=Math.floor((i-1)/2);if(queue[parent][0]<=entry[0])break;queue[i]=queue[parent];i=parent}
queue[i]=entry
}
function pop() {
const result=queue[0],last=queue.pop()!
if(queue.length){let i=0;queue[0]=last;while(true){let child=i*2+1;if(child>=queue.length)break;if(child+1<queue.length&&queue[child+1][0]<queue[child][0])child++;if(queue[child][0]>=last[0])break;queue[i]=queue[child];i=child}queue[i]=last}
return result
}
while (queue.length) {
const [distance,id] = pop()
if (visited.has(id)) continue
visited.add(id)
if (id === end) {
const path = [id]
while (path[path.length-1] !== start) path.push(previous.get(path[path.length-1]))
path.reverse()
return { path, distance }
}
for (const [neighbor,cost] of nodes.get(id)!.neighbors) {
const next = distance+cost
if (next < (distances.get(neighbor) ?? Infinity)) { distances.set(neighbor,next); previous.set(neighbor,id); push([next,neighbor]) }
}
}
throw new GraphQLError('Punktene ligger på adskilte deler av stinettet. Flytt et punkt til en sti som er koblet til de andre.')
}

export async function planCustomRoute(points:BuilderPoint[], routeId:string|null = null) {
if (!Array.isArray(points) || points.length<2 || points.length>12 || points.some(p => !p || !Number.isFinite(p.lon) || !Number.isFinite(p.lat) || p.lon<-180 || p.lon>180 || p.lat<-90 || p.lat>90)) throw new GraphQLError('Velg 2–12 gyldige punkter på den valgte ruten.')
const network = await builderNetwork(routeId)
const { rows } = await pool.query(`SELECT ST_X(p) AS x,ST_Y(p) AS y FROM (
SELECT ST_Transform(ST_SetSRID(ST_MakePoint((v->>'lon')::float8,(v->>'lat')::float8),4326),25833) AS p
FROM jsonb_array_elements($1::jsonb) WITH ORDINALITY AS input(v,n) ORDER BY n
) transformed`, [JSON.stringify(points)])
const snapped = rows.map(point => {
let best:NetworkNode|undefined, distance = Infinity
for (const node of network.nodes.values()) {
const d = Math.hypot(node.xy[0]-point.x,node.xy[1]-point.y)
if (d<distance) { best=node; distance=d }
}
if (!best || distance>120) throw new GraphQLError('Et punkt er mer enn 120 meter fra de registrerte stiene. Flytt det nærmere en sti.')
return best
})
let lengthMeters=0
const coordinates=[]
for (let i=1;i<snapped.length;i++) {
const leg = shortestPath(network.nodes,snapped[i-1].id,snapped[i].id)
lengthMeters+=leg.distance
for (const id of leg.path.slice(i===1?0:1)) coordinates.push(network.nodes.get(id)!.ll)
}
if (lengthMeters<50) throw new GraphQLError('Velg punkter som gir en rute på minst 50 meter.')
return { sourceRouteId:network.sourceRouteId,sourceName:network.sourceName,geojson:JSON.stringify({type:'LineString',coordinates}),lengthMeters,
waypoints:snapped.map(node => ({lon:node.ll[0],lat:node.ll[1]})) }
}

export async function suggestRoundTrips(start:BuilderPoint, desiredLengthMeters:number, routeId:string|null = null) {
if (!start || !Number.isFinite(start.lon) || !Number.isFinite(start.lat) || Math.abs(start.lon)>180 || Math.abs(start.lat)>90 || !Number.isFinite(desiredLengthMeters) || desiredLengthMeters<500 || desiredLengthMeters>50000) throw new GraphQLError('Velg et gyldig startpunkt og en lengde mellom 0,5 og 50 km.')
const network=await builderNetwork(routeId)
const {rows:[position]}=await pool.query('SELECT ST_X(p) AS x,ST_Y(p) AS y FROM (SELECT ST_Transform(ST_SetSRID(ST_MakePoint($1,$2),4326),25833) p) point',[start.lon,start.lat])
let startNode:NetworkNode|undefined,distance=Infinity
for(const node of network.nodes.values()){const d=Math.hypot(node.xy[0]-position.x,node.xy[1]-position.y);if(d<distance){distance=d;startNode=node}}
if(!startNode||distance>120)throw new GraphQLError('Startpunktet må være innen 120 meter fra det valgte stinettet.')
if(!network.cycles) network.cycles=pool.query<RingRow>(`WITH source AS (
SELECT geom FROM norwegian_explorer_app.route_catalog WHERE id=$1
UNION ALL SELECT geom FROM norwegian_explorer_app.route_loops WHERE id=$1
), network AS (SELECT ST_Node(ST_UnaryUnion(ST_Collect(geom))) geom FROM source),
polygons AS (SELECT (ST_Dump(ST_CollectionExtract(ST_Polygonize(ARRAY[geom]),3))).geom geom FROM network),
rings AS (SELECT ST_ExteriorRing(geom) geom FROM polygons)
SELECT md5(ST_AsEWKB(ST_Normalize(geom))::text) id,ST_AsGeoJSON(geom)::json projected,
ST_AsGeoJSON(ST_Transform(geom,4326))::json geographic,ST_Length(geom) length
FROM rings WHERE ST_Length(geom) BETWEEN 200 AND 50000`,[network.sourceRouteId]).then(result=>result.rows).catch(error=>{network.cycles=undefined;throw error})
const rings=await network.cycles
const candidates=[]
for(const ring of rings){
let anchorIndex=0,near=Infinity
const xy=ring.projected.coordinates.slice(0,-1)
const ll=ring.geographic.coordinates.slice(0,-1)
let directLoop=null
for(let i=0;i<xy.length;i++){
const a=xy[i],b=xy[(i+1)%xy.length],dx=b[0]-a[0],dy=b[1]-a[1]
const t=((startNode.xy[0]-a[0])*dx+(startNode.xy[1]-a[1])*dy)/(dx*dx+dy*dy)
if(t>=0&&t<=1&&Math.hypot(a[0]+t*dx-startNode.xy[0],a[1]+t*dy-startNode.xy[1])<0.0001){directLoop=[startNode.ll,...ll.slice(i+1),...ll.slice(0,i+1),startNode.ll];break}
}
for(let i=0;i<xy.length;i++){const d=Math.hypot(xy[i][0]-startNode.xy[0],xy[i][1]-startNode.xy[1]);if(d<near){near=d;anchorIndex=i}}
const anchor=directLoop?startNode:network.nodes.get(xy[anchorIndex].map(n=>n.toFixed(6)).join(','))
if(anchor)candidates.push({ring,anchor,anchorIndex,directLoop,score:Math.abs(ring.length+2*(directLoop?0:near)-desiredLengthMeters)})
}
const proposals=[]
for(const {ring,anchor,anchorIndex,directLoop} of candidates.sort((a,b)=>a.score-b.score).slice(0,40)){
let connector
try{connector=shortestPath(network.nodes,startNode.id,anchor.id)}catch{continue}
const lengthMeters=ring.length+2*connector.distance
if(Math.abs(lengthMeters-desiredLengthMeters)>Math.max(300,desiredLengthMeters*0.4))continue
const access=connector.path.map(id=>network.nodes.get(id)!.ll)
const ringCoords=ring.geographic.coordinates.slice(0,-1)
const loop=directLoop || [...ringCoords.slice(anchorIndex),...ringCoords.slice(0,anchorIndex),ringCoords[anchorIndex]]
const coordinates=[]
for(const point of access)coordinates.push(point)
for(const point of loop.slice(1))coordinates.push(point)
for(const point of access.slice(0,-1).reverse())coordinates.push(point)
const [lon,lat]=startNode.ll
proposals.push({id:ring.id,sourceRouteId:network.sourceRouteId,sourceName:network.sourceName,
geojson:JSON.stringify({type:'LineString',coordinates}),lengthMeters,connectorMeters:2*connector.distance,
differenceMeters:lengthMeters-desiredLengthMeters,waypoints:[{lon,lat},{lon,lat}]})
}
return proposals.sort((a,b)=>Math.abs(a.differenceMeters)-Math.abs(b.differenceMeters)||a.connectorMeters-b.connectorMeters).slice(0,3)
}
2 changes: 1 addition & 1 deletion server/db.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Felles tilkobling til PostgreSQL-databasen, brukt av hele backenden (index.mjs og modulene i server/).
// Felles tilkobling til PostgreSQL-databasen, brukt av hele backenden (index.ts og modulene i server/).
// Med DATABASE_URL i .env kobler vi til den (se .env.example). Uten den brukes PG-variablene,
// eller en lokal database via socket i /tmp.
import { userInfo } from 'node:os'
Expand Down
76 changes: 76 additions & 0 deletions server/elevation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {createHash} from 'node:crypto'
import {GraphQLError} from 'graphql'

type SamplePoint={distanceMeters:number;lon:number;lat:number}
type Sample={points:SamplePoint[];lengthMeters:number;sampleIntervalMeters:number}
type HeightResponse={koordsys:number;punkter:{x:number;y:number;z:number|null}[]}
const cache=new Map<string,{expires:number;value:ReturnType<typeof summarizeElevation>}>()
const endpoint='https://ws.geonorge.no/hoydedata/v1/punkt'
function distance(a:number[],b:number[]){const rad=Math.PI/180;const dlat=(b[1]-a[1])*rad,dlon=(b[0]-a[0])*rad;const h=Math.sin(dlat/2)**2+Math.cos(a[1]*rad)*Math.cos(b[1]*rad)*Math.sin(dlon/2)**2;return 6371000*2*Math.atan2(Math.sqrt(h),Math.sqrt(Math.max(0,1-h)))}

export function sampleRoute(geojson:string):Sample {
if(typeof geojson!=='string'||geojson.length>5000000)throw new GraphQLError('Kartlinjen er for stor for høydeprofil.')
let geometry:{type?:string;coordinates?:number[][]}|null
try{geometry=JSON.parse(geojson)}catch{throw new GraphQLError('Ugyldig kartlinje.')}
if(!geometry || geometry.type!=='LineString'||!Array.isArray(geometry.coordinates)||geometry.coordinates.length<2||geometry.coordinates.length>100000)throw new GraphQLError('Høydeprofil krever én sammenhengende rute.')
const coordinates=geometry.coordinates!
if(coordinates.some(p=>!Array.isArray(p)||!Number.isFinite(p[0])||!Number.isFinite(p[1])||Math.abs(p[0])>180||Math.abs(p[1])>90))throw new GraphQLError('Ruten har ugyldige koordinater.')
const cumulative=[0]
for(let i=1;i<coordinates.length;i++)cumulative.push(cumulative[i-1]+distance(coordinates[i-1],coordinates[i]))
const length=cumulative.at(-1)!
if(length<1)throw new GraphQLError('Ruten er for kort for en høydeprofil.')
const count=Math.min(200,Math.max(2,Math.ceil(length/50)+1)),points=[]
let segment=1
for(let i=0;i<count;i++){
const meters=i*length/(count-1)
while(segment<cumulative.length-1&&cumulative[segment]<meters)segment++
const a=coordinates[segment-1],b=coordinates[segment],span=cumulative[segment]-cumulative[segment-1]
const ratio=span?(meters-cumulative[segment-1])/span:0
points.push({distanceMeters:meters,lon:a[0]+(b[0]-a[0])*ratio,lat:a[1]+(b[1]-a[1])*ratio})
}
return {points,lengthMeters:length,sampleIntervalMeters:length/(count-1)}
}

export function summarizeElevation(sample:Sample,heights:(number|null)[]) {
const points=sample.points.map((point,i)=>({...point,elevation:Number.isFinite(heights[i])?heights[i]:null}))
const valid=points.filter((p):p is (typeof points)[number]&{elevation:number}=>p.elevation!=null),complete=valid.length===points.length
let ascent=0,descent=0
if(complete)for(let i=1;i<points.length;i++){const delta=points[i].elevation!-points[i-1].elevation!;ascent+=Math.max(0,delta);descent+=Math.max(0,-delta)}
return {...sample,points,ascentMeters:complete?ascent:null,descentMeters:complete?descent:null,
minElevation:valid.length?Math.min(...valid.map(p=>p.elevation)):null,
maxElevation:valid.length?Math.max(...valid.map(p=>p.elevation)):null,
complete,missingCount:points.length-valid.length,source:'Kartverkets høydedata-API'}
}

export async function elevationProfile(geojson:string,fetcher:typeof fetch=fetch) {
const sample=sampleRoute(geojson)
const key=createHash('sha256').update(JSON.stringify(sample.points)).digest('hex')
const existing=cache.get(key)
if(existing&&existing.expires>Date.now())return existing.value
const heights=new Array(sample.points.length).fill(null)
const signal=AbortSignal.timeout(20000)
const batches:number[]=[]
for(let i=0;i<sample.points.length;i+=50)batches.push(i)
let next=0
async function worker(){
while(next<batches.length){
const offset=batches[next++],batch=sample.points.slice(offset,offset+50)
const url=new URL(endpoint);url.searchParams.set('koordsys','4326');url.searchParams.set('punkter',JSON.stringify(batch.map(p=>[p.lon,p.lat])))
const response=await fetcher(url,{signal})
if(!response.ok)throw new Error('Høydetjenesten svarte ikke.')
const result=await response.json() as HeightResponse
if(result.koordsys!==4326||!Array.isArray(result.punkter))throw new Error('Uventet svar fra høydetjenesten.')
for(let i=0;i<batch.length;i++){
const point=batch[i]
const match=result.punkter.find(p=>Number.isFinite(p.x)&&Number.isFinite(p.y)&&Math.abs(p.x-point.lon)<0.000001&&Math.abs(p.y-point.lat)<0.000001)
if(match&&Number.isFinite(match.z))heights[offset+i]=match.z
}
}
}
try{await Promise.all([worker(),worker()])}catch{throw new GraphQLError('Høydetjenesten er utilgjengelig akkurat nå. Ruten kan fortsatt brukes og lagres; prøv høydeprofilen igjen senere.')}
if(heights.every(value=>value==null))throw new GraphQLError('Ingen høydedata er tilgjengelige for denne ruten.')
const result=summarizeElevation(sample,heights)
if(cache.size>=50)cache.delete(cache.keys().next().value!)
cache.set(key,{expires:Date.now()+3600000,value:result})
return result
}
Loading