From a50a75ba77a5220a3f49bd487e4692964bac20f7 Mon Sep 17 00:00:00 2001 From: karstenmidthaug Date: Fri, 9 Oct 2026 17:26:44 +0200 Subject: [PATCH 01/17] Legg til renset view over turpunkter fra OpenStreetMap --- server/sql/silver/osm_points_of_interest.sql | 68 ++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 server/sql/silver/osm_points_of_interest.sql diff --git a/server/sql/silver/osm_points_of_interest.sql b/server/sql/silver/osm_points_of_interest.sql new file mode 100644 index 0000000..4cd6ef5 --- /dev/null +++ b/server/sql/silver/osm_points_of_interest.sql @@ -0,0 +1,68 @@ +-- Ryddet utgave av turrelevante steder fra OpenStreetMap-dataene (osm_import.objects). +-- Lagres som et materialized view i skjemaet «silver»: resultatet lagres i databasen, +-- så appen slipper å kjøre hele spørringen hver gang. +-- +-- Skjemaene følger medallion-mønsteret: +-- bronze = rådata slik de ble importert (osm_import, turogfriluftsruter_…) +-- silver = rensede data som appen henter fra +-- +-- Kjør HELE filen i DBeaver med Option+X («Execute SQL Script»), ikke Cmd+Enter. +-- +-- Etter ny OSM-import oppdateres innholdet med: +-- REFRESH MATERIALIZED VIEW silver.osm_points_of_interest; + +-- Eget skjema for rensede data, adskilt fra rådataene (osm_import) og appens egne tabeller +CREATE SCHEMA IF NOT EXISTS silver; + +-- Slett den gamle versjonen først, så filen kan kjøres på nytt etter endringer +DROP MATERIALIZED VIEW IF EXISTS silver.osm_points_of_interest; + +CREATE MATERIALIZED VIEW silver.osm_points_of_interest AS +WITH classified AS ( + SELECT + osm_type || '/' || osm_id AS id, + -- Oversett OSM-tagger til våre egne typer. Første treff vinner. + CASE + WHEN tags->>'tourism' = 'alpine_hut' THEN 'HUT' -- turisthytte + WHEN tags->>'tourism' IN ('wilderness_hut', 'chalet') THEN 'CABIN' -- bu / koie + WHEN tags->>'amenity' = 'shelter' THEN 'SHELTER' -- gapahuk, rastebu + WHEN tags->>'tourism' = 'camp_site' THEN 'CAMPSITE' + WHEN tags->>'tourism' = 'viewpoint' THEN 'VIEWPOINT' + WHEN tags->>'natural' = 'peak' THEN 'PEAK' + WHEN tags->>'amenity' = 'parking' THEN 'PARKING' + WHEN tags->>'amenity' = 'toilets' THEN 'TOILETS' + WHEN tags->>'amenity' = 'drinking_water' THEN 'WATER' + END AS kind, + tags->>'dnt:classification' AS dnt_type, -- betjent / selvbetjent / ubetjent / nødbu + name, + ST_PointOnSurface(geom) AS point, -- ett punkt, også når stedet er tegnet som omriss + -- ele og capacity er tekst i OSM (f.eks. '2469' eller '1 234 m'), så vi plukker ut tallet + round(replace(substring(tags->>'ele' FROM '^-?\d+(?:[.,]\d+)?'), ',', '.')::numeric)::int AS elevation, + substring(tags->>'capacity' FROM '^\d+')::int AS capacity, + tags->>'operator' AS operator + FROM osm_import.objects + WHERE category = 'place' + AND coalesce(tags->>'access', '') NOT IN ('private', 'no') -- ikke private steder + AND coalesce(tags->>'shelter_type', '') <> 'public_transport' -- ikke busskur + AND (tags->>'amenity' IS DISTINCT FROM 'parking' OR tags->>'hiking' = 'yes') -- bare turparkering + AND (tags->>'tourism' IS DISTINCT FROM 'chalet' OR tags ? 'dnt:classification') -- bare DNT-hytter, ikke utleiehytter +) +SELECT + id, + kind, + dnt_type, + name, + round(ST_X(point)::numeric, 5) AS lon, + round(ST_Y(point)::numeric, 5) AS lat, + -- Galdhøpiggen (2469 moh.) er Norges høyeste punkt, så alt over det er tastefeil i OSM + CASE WHEN elevation <= 2469 THEN elevation END AS elevation, + capacity, + operator, + point AS geom -- PostGIS-punkt, brukes til å søke i kartutsnitt +FROM classified +WHERE kind IS NOT NULL; + +-- Indekser gjør oppslag raske: på id, på type (f.eks. «alle hytter») og på posisjon (kartutsnitt) +CREATE UNIQUE INDEX osm_poi_id_idx ON silver.osm_points_of_interest (id); +CREATE INDEX osm_poi_kind_idx ON silver.osm_points_of_interest (kind); +CREATE INDEX osm_poi_geom_idx ON silver.osm_points_of_interest USING gist (geom); From 805bd4598ab3f129f2a19a4d9574676db8937528 Mon Sep 17 00:00:00 2001 From: karstenmidthaug Date: Fri, 9 Oct 2026 17:27:15 +0200 Subject: [PATCH 02/17] =?UTF-8?q?Legg=20til=20fjellomr=C3=A5der=20med=20om?= =?UTF-8?q?trentlige=20grenser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/sql/silver/mountain_areas.sql | 39 ++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 server/sql/silver/mountain_areas.sql diff --git a/server/sql/silver/mountain_areas.sql b/server/sql/silver/mountain_areas.sql new file mode 100644 index 0000000..be485ce --- /dev/null +++ b/server/sql/silver/mountain_areas.sql @@ -0,0 +1,39 @@ +-- Fjellområder i Norge, brukt til oversikten over fjellområder og til å zoome kartet inn på ett område. +-- +-- Det finnes ingen offisielle grenser for f.eks. «Jotunheimen», så grensene er håndlagde rektangler. +-- De er kontrollert mot punktene Kartverket har for området i stedsnavnregisteret (SSR), +-- og ssr_id peker til Kartverkets offisielle navn der det finnes (api.kartverket.no/stedsnavn). +-- +-- Kjør HELE filen i DBeaver med Option+X («Execute SQL Script»), ikke Cmd+Enter. +-- Filen er kilden til dataene: endre her og kjør på nytt, ikke rediger tabellen direkte. + +-- Slett den gamle versjonen først, så filen kan kjøres på nytt etter endringer +DROP TABLE IF EXISTS silver.mountain_areas; + +CREATE TABLE silver.mountain_areas ( + id text PRIMARY KEY, -- f.eks. 'jotunheimen', brukes i URL-en + name text NOT NULL, + description text, + ssr_id int, -- Kartverkets stedsnummer, NULL hvis Kartverket ikke har området + area geometry(Polygon, 4326) NOT NULL -- rektangel: ST_MakeEnvelope(vest, sør, øst, nord, 4326) +); + +INSERT INTO silver.mountain_areas (id, name, description, ssr_id, area) VALUES + ('jotunheimen', 'Jotunheimen', 'Nord-Europas høyeste fjell, med Galdhøpiggen og Glittertind og et tett nett av DNT-hytter.', 328584, ST_MakeEnvelope(7.9, 61.35, 9.1, 61.8, 4326)), + ('rondane', 'Rondane', 'Norges første nasjonalpark. Runde, karrige topper over 2000 moh. og lange dalfører.', 435681, ST_MakeEnvelope(9.55, 61.75, 10.1, 62.1, 4326)), + ('hardangervidda', 'Hardangervidda', 'Europas største høyfjellsplatå med villrein, vann og lange hytte-til-hytte-ruter.', 85109, ST_MakeEnvelope(6.9, 59.9, 8.3, 60.6, 4326)), + ('dovrefjell', 'Dovrefjell', 'Snøhetta og moskus. Høyfjell mellom Østlandet og Trøndelag.', 450696, ST_MakeEnvelope(8.85, 62.15, 9.9, 62.45, 4326)), + ('trollheimen', 'Trollheimen', 'Alpine topper og klassiske DNT-ruter mellom Trollheimshytta, Gjevilvasshytta og Jøldalshytta.', 814547, ST_MakeEnvelope(8.7, 62.6, 9.4, 62.95, 4326)), + ('romsdalsalpane', 'Romsdalsalpane', 'Bratte tinder rundt Romsdalen, Romsdalseggen og Trollveggen.', NULL, ST_MakeEnvelope(7.4, 62.35, 8.1, 62.65, 4326)), + ('sunnmorsalpane', 'Sunnmørsalpane', 'Spisse tinder som stuper i fjordene. Kjent for toppturer.', NULL, ST_MakeEnvelope(6.2, 62.0, 7.1, 62.4, 4326)), + ('breheimen', 'Breheimen', 'Breer og villmark mellom Jotunheimen og Jostedalsbreen.', 572299, ST_MakeEnvelope(7.3, 61.55, 8.1, 61.95, 4326)), + ('skarvheimen', 'Skarvheimen', 'Høyfjell mellom Hallingskarvet og Aurlandsdalen.', 324756, ST_MakeEnvelope(7.5, 60.6, 8.4, 61.0, 4326)), + ('setesdalsheiane', 'Setesdalsheiane', 'Store heiområder med villrein og mange ubetjente hytter.', 1094317, ST_MakeEnvelope(7.0, 58.95, 7.9, 59.8, 4326)), + ('femundsmarka', 'Femundsmarka', 'Skog, myr og vann. Villmark ved svenskegrensen.', NULL, ST_MakeEnvelope(11.6, 62.0, 12.3, 62.45, 4326)), + ('blafjella-skjaekerfjella', 'Blåfjella–Skjækerfjella', 'Stor og lite besøkt villmark i Trøndelag.', NULL, ST_MakeEnvelope(13.0, 64.0, 13.9, 64.5, 4326)), + ('saltfjellet', 'Saltfjellet', 'Høyfjell og isbreer rundt polarsirkelen.', 622606, ST_MakeEnvelope(14.8, 66.5, 15.9, 66.95, 4326)), + ('lofoten', 'Lofoten', 'Tinder rett opp fra havet og korte, bratte turer.', 26064, ST_MakeEnvelope(12.8, 67.85, 15.5, 68.45, 4326)), + ('lyngsalpan', 'Lyngsalpan', 'Alpine tinder og breer i Troms, kjent for toppturer.', 444653, ST_MakeEnvelope(19.6, 69.2, 20.5, 70.0, 4326)); + +-- Romlig indeks, så det går raskt å finne hytter og topper innenfor et område +CREATE INDEX mountain_areas_area_idx ON silver.mountain_areas USING gist (area); From 4fc3624ba641e5110f452db7d59ae9e2f191e053 Mon Sep 17 00:00:00 2001 From: karstenmidthaug Date: Fri, 9 Oct 2026 17:27:30 +0200 Subject: [PATCH 03/17] =?UTF-8?q?Legg=20til=20TypeScript-backend=20med=20G?= =?UTF-8?q?raphQL=20for=20turpunkter=20og=20fjellomr=C3=A5der?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 8 +++ package-lock.json | 13 +++++ package.json | 4 +- server/db.ts | 12 ++++ server/mountainAreas.ts | 89 ++++++++++++++++++++++++++++ server/overview.ts | 72 +++++++++++++++++++++++ server/pointsOfInterest.ts | 68 ++++++++++++++++++++++ server/server.ts | 116 +++++++++++++++++++++++++++++++++++++ tsconfig.node.json | 2 +- 9 files changed, 382 insertions(+), 2 deletions(-) create mode 100644 .env.example create mode 100644 server/db.ts create mode 100644 server/mountainAreas.ts create mode 100644 server/overview.ts create mode 100644 server/pointsOfInterest.ts create mode 100644 server/server.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ca0a653 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Kopier denne filen til .env og fyll inn. .env skal ikke committes. + +# Lokalt, via SSH-tunnel til VM-en: +# ssh -N -L 5433:/var/run/postgresql/.s.PGSQL.5432 @it2810-34.idi.ntnu.no +DATABASE_URL=postgres://gruppe@localhost:5433/norwegian_explorer + +# På VM-en (koble til via socket i stedet for nettverk): +# DATABASE_URL=postgres://gruppe@%2Fvar%2Frun%2Fpostgresql/norwegian_explorer diff --git a/package-lock.json b/package-lock.json index 33049f6..6f2e021 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@rolldown/plugin-babel": "^0.2.3", "@types/babel__core": "^7.20.5", "@types/node": "^24.13.3", + "@types/pg": "^8.23.1", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.7", "@vitejs/plugin-react": "^6.1.1", @@ -1282,6 +1283,18 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", diff --git a/package.json b/package.json index a556df3..67ae035 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "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" + "start:server": "node --env-file-if-exists=.env server/index.mjs", + "dev:server:ts": "node --env-file-if-exists=.env --watch server/server.ts" }, "dependencies": { "graphql": "^17.0.2", @@ -25,6 +26,7 @@ "@rolldown/plugin-babel": "^0.2.3", "@types/babel__core": "^7.20.5", "@types/node": "^24.13.3", + "@types/pg": "^8.23.1", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.7", "@vitejs/plugin-react": "^6.1.1", diff --git a/server/db.ts b/server/db.ts new file mode 100644 index 0000000..0767d7a --- /dev/null +++ b/server/db.ts @@ -0,0 +1,12 @@ +// Felles tilkobling til PostgreSQL-databasen på VM-en. +// Innstillingene leses fra miljøvariabler (.env), så hver person kan ha sitt eget oppsett. +// Se .env.example for hvordan DATABASE_URL skal se ut. +import pg from 'pg' + +export const pool = new pg.Pool({ + connectionString: process.env.DATABASE_URL, + max: 4, + connectionTimeoutMillis: 5000, +}) + +pool.on('error', (error) => console.error('Feil i databasetilkoblingen:', error.message)) diff --git a/server/mountainAreas.ts b/server/mountainAreas.ts new file mode 100644 index 0000000..273aa77 --- /dev/null +++ b/server/mountainAreas.ts @@ -0,0 +1,89 @@ +// Fjellområder fra silver.mountain_areas, med nøkkeltall om hva som finnes i hvert område +// (antall hytter, buer, topper og høyeste topp) hentet fra silver.osm_points_of_interest. +// Se server/sql/silver/mountain_areas.sql for hvordan områdene er definert. +import { GraphQLError } from 'graphql' +import { pool } from './db.ts' + +export interface Peak { + name: string + elevation: number +} + +export interface MountainArea { + id: string + name: string + description: string | null + /** Kartverkets stedsnummer, eller null hvis Kartverket ikke har registrert området */ + ssrId: number | null + /** [vest, sør, øst, nord] i grader, brukes til å zoome kartet inn på området */ + bbox: [number, number, number, number] + /** Grensen som GeoJSON-tekst, så kartet kan tegne den (også når den ikke lenger er et rektangel) */ + geojson: string + hutCount: number + cabinCount: number + shelterCount: number + peakCount: number + highestPeak: Peak | null +} + +type MountainAreaRow = Omit & { + highestPeakName: string | null + highestPeakElevation: number | null +} + +// Én spørring brukes både for alle områdene og for ett område: $1 er enten NULL (alle) eller en id. +// For hvert område teller vi punktene som ligger innenfor (LATERAL betyr «kjør denne delspørringen +// én gang per område»). ST_Intersects bruker den romlige indeksen på osm_points_of_interest. +const AREAS_SQL = ` + SELECT a.id, + a.name, + a.description, + a.ssr_id AS "ssrId", + ARRAY[ST_XMin(a.area), ST_YMin(a.area), ST_XMax(a.area), ST_YMax(a.area)]::float8[] AS bbox, + ST_AsGeoJSON(a.area) AS geojson, + counts."hutCount", + counts."cabinCount", + counts."shelterCount", + counts."peakCount", + top.name AS "highestPeakName", + top.elevation AS "highestPeakElevation" + FROM silver.mountain_areas a + CROSS JOIN LATERAL ( + SELECT count(*) FILTER (WHERE p.kind = 'HUT')::int AS "hutCount", + count(*) FILTER (WHERE p.kind = 'CABIN')::int AS "cabinCount", + count(*) FILTER (WHERE p.kind = 'SHELTER')::int AS "shelterCount", + count(*) FILTER (WHERE p.kind = 'PEAK')::int AS "peakCount" + FROM silver.osm_points_of_interest p + WHERE ST_Intersects(p.geom, a.area) + ) counts + LEFT JOIN LATERAL ( + -- Høyeste topp med navn og kjent høyde + SELECT p.name, p.elevation + FROM silver.osm_points_of_interest p + WHERE p.kind = 'PEAK' AND p.name IS NOT NULL AND p.elevation IS NOT NULL + AND ST_Intersects(p.geom, a.area) + ORDER BY p.elevation DESC + LIMIT 1 + ) top ON true + WHERE $1::text IS NULL OR a.id = $1 + ORDER BY a.name +` + +// Databasen gir høyeste topp som to kolonner. GraphQL-typen samler dem i ett objekt. +function toMountainArea({ highestPeakName, highestPeakElevation, ...area }: MountainAreaRow): MountainArea { + const highestPeak = highestPeakName !== null && highestPeakElevation !== null + ? { name: highestPeakName, elevation: highestPeakElevation } + : null + return { ...area, highestPeak } +} + +export async function getMountainAreas(): Promise { + const { rows } = await pool.query(AREAS_SQL, [null]) + return rows.map(toMountainArea) +} + +export async function getMountainArea(id: string): Promise { + if (!id.trim() || id.length > 100) throw new GraphQLError('Ugyldig id for fjellområde.') + const { rows } = await pool.query(AREAS_SQL, [id]) + return rows[0] ? toMountainArea(rows[0]) : null +} diff --git a/server/overview.ts b/server/overview.ts new file mode 100644 index 0000000..ab8f8d4 --- /dev/null +++ b/server/overview.ts @@ -0,0 +1,72 @@ +// Oversikt over hva som ligger i databasen: skjemaer, tabeller, kolonner og omtrent antall rader. +// Brukes av den midlertidige oversiktssiden (oversikt.html). +import pg from 'pg' +import { pool } from './db.ts' + +export interface DatabaseInfo { + database: string + user: string + postgresVersion: string + postgisVersion: string | null +} + +export interface Column { + name: string + type: string +} + +export interface DatabaseTable { + schema: string + name: string + /** Antall rader, eller null hvis vi ikke fikk telt dem */ + rowCount: number | null + /** true hvis rowCount er Postgres sitt anslag, false hvis det er telt nøyaktig */ + isEstimate: boolean + /** Om brukeren vi er logget inn som har lov til å lese tabellen */ + canRead: boolean + columns: Column[] +} + +export async function getDatabaseInfo(): Promise { + const { rows } = await pool.query(` + SELECT current_database() AS database, + current_user AS "user", + current_setting('server_version') AS "postgresVersion", + (SELECT extversion FROM pg_extension WHERE extname = 'postgis') AS "postgisVersion" + `) + return rows[0] +} + +export async function getDatabaseTables(): Promise { + // Vi leser fra Postgres sine egne systemtabeller (pg_class, pg_namespace, pg_attribute). + // Fordelen er at de viser alle tabeller, også de vi ikke har tilgang til å lese innholdet i. + // reltuples er Postgres sitt anslag over antall rader. Det er raskt, men ikke helt nøyaktig. + const { rows } = await pool.query & { reltuples: number }>(` + SELECT n.nspname AS schema, + c.relname AS name, + c.reltuples::float8 AS reltuples, + has_schema_privilege(n.oid, 'USAGE') AND has_table_privilege(c.oid, 'SELECT') AS "canRead", + (SELECT coalesce(json_agg(json_build_object('name', a.attname, 'type', format_type(a.atttypid, a.atttypmod)) ORDER BY a.attnum), '[]') + FROM pg_attribute a + WHERE a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped) AS columns + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind IN ('r', 'p', 'v', 'm') -- tabeller, partisjonerte tabeller og views + AND n.nspname NOT IN ('pg_catalog', 'information_schema') + AND n.nspname NOT LIKE 'pg_toast%' + -- Hopp over tabeller som hører til utvidelser (f.eks. PostGIS sin spatial_ref_sys) + AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = c.oid AND d.deptype = 'e') + ORDER BY schema, name + `) + + // -1 betyr at Postgres aldri har analysert tabellen. Det gjelder stort sett små kodelister, + // så da teller vi radene nøyaktig i stedet (det går raskt). + return Promise.all(rows.map(async ({ reltuples, ...table }) => { + if (reltuples >= 0) return { ...table, rowCount: Math.round(reltuples), isEstimate: true } + if (!table.canRead) return { ...table, rowCount: null, isEstimate: false } + // Tabellnavn kan ikke sendes som $1-parametre, så de må escapes for å unngå SQL-injeksjon + const name = `${pg.escapeIdentifier(table.schema)}.${pg.escapeIdentifier(table.name)}` + const count = await pool.query<{ count: number }>(`SELECT count(*)::int AS count FROM ${name}`) + return { ...table, rowCount: count.rows[0].count, isEstimate: false } + })) +} diff --git a/server/pointsOfInterest.ts b/server/pointsOfInterest.ts new file mode 100644 index 0000000..4037f0c --- /dev/null +++ b/server/pointsOfInterest.ts @@ -0,0 +1,68 @@ +// Henter hytter, topper, parkering osv. fra det rensede viewet silver.osm_points_of_interest +// (se server/sql/silver/osm_points_of_interest.sql for hvordan det er laget). +import { GraphQLError } from 'graphql' +import { pool } from './db.ts' + +export const POI_KINDS = ['HUT', 'CABIN', 'SHELTER', 'CAMPSITE', 'VIEWPOINT', 'PEAK', 'PARKING', 'TOILETS', 'WATER'] as const +export type PoiKind = (typeof POI_KINDS)[number] + +export interface PointOfInterest { + id: string + kind: PoiKind + dntType: string | null + name: string | null + lon: number + lat: number + elevation: number | null + capacity: number | null + operator: string | null +} + +export interface PoiPage { + items: PointOfInterest[] + /** true hvis det fantes flere punkter enn vi sendte, og brukeren bør zoome inn */ + truncated: boolean +} + +/** Kartutsnitt i grader: vest, sør, øst, nord */ +export interface BBox { + west: number + south: number + east: number + north: number +} + +// Grenser som hindrer at én forespørsel henter hele Norge +const MAX_RESULTS = 5000 +const MAX_SPAN_DEGREES = 10 + +function validate({ west, south, east, north }: BBox, kinds: PoiKind[]) { + const finite = [west, south, east, north].every(Number.isFinite) + if (!finite || west >= east || south >= north || west < -180 || east > 180 || south < -90 || north > 90) { + throw new GraphQLError('Ugyldig kartutsnitt.') + } + if (east - west > MAX_SPAN_DEGREES || north - south > MAX_SPAN_DEGREES) { + throw new GraphQLError('Kartutsnittet er for stort. Zoom inn.') + } + if (kinds.some(kind => !POI_KINDS.includes(kind))) throw new GraphQLError('Ukjent punkttype.') +} + +export async function getPointsOfInterest(bbox: BBox, kinds: PoiKind[]): Promise { + validate(bbox, kinds) + if (kinds.length === 0) return { items: [], truncated: false } + + // geom && ST_MakeEnvelope(...) bruker den romlige indeksen (osm_poi_geom_idx), så bare punktene + // i kartutsnittet blir lest. Vi henter én rad ekstra for å vite om det fantes flere enn MAX_RESULTS. + const { rows } = await pool.query(` + SELECT id, kind, dnt_type AS "dntType", name, + lon::float8 AS lon, lat::float8 AS lat, + elevation, capacity, operator + FROM silver.osm_points_of_interest + WHERE geom && ST_MakeEnvelope($1, $2, $3, $4, 4326) + AND kind = ANY($5) + ORDER BY name IS NULL -- steder med navn først, hvis vi må kutte + LIMIT $6 + `, [bbox.west, bbox.south, bbox.east, bbox.north, kinds, MAX_RESULTS + 1]) + + return { items: rows.slice(0, MAX_RESULTS), truncated: rows.length > MAX_RESULTS } +} diff --git a/server/server.ts b/server/server.ts new file mode 100644 index 0000000..811a326 --- /dev/null +++ b/server/server.ts @@ -0,0 +1,116 @@ +// GraphQL-backend for Utforsk Norge, skrevet i TypeScript. +// Node 24 kjører .ts-filer direkte ved å fjerne typene, så vi trenger ikke et eget byggesteg. +// Kjør lokalt: npm run dev:server:ts → http://localhost:4000/graphql +import { createServer } from 'node:http' +import { createSchema, createYoga } from 'graphql-yoga' +import { pool } from './db.ts' +import { getDatabaseInfo, getDatabaseTables } from './overview.ts' +import { getPointsOfInterest, type BBox, type PoiKind } from './pointsOfInterest.ts' +import { getMountainArea, getMountainAreas } from './mountainAreas.ts' + +const schema = createSchema({ + typeDefs: /* GraphQL */ ` + type DatabaseInfo { + database: String! + user: String! + postgresVersion: String! + postgisVersion: String + } + + type Column { + name: String! + type: String! + } + + type DatabaseTable { + schema: String! + name: String! + rowCount: Int + isEstimate: Boolean! + canRead: Boolean! + columns: [Column!]! + } + + enum PoiKind { + HUT + CABIN + SHELTER + CAMPSITE + VIEWPOINT + PEAK + PARKING + TOILETS + WATER + } + + type PointOfInterest { + id: ID! + kind: PoiKind! + dntType: String + name: String + lon: Float! + lat: Float! + elevation: Int + capacity: Int + operator: String + } + + type PoiPage { + items: [PointOfInterest!]! + truncated: Boolean! + } + + type Peak { + name: String! + elevation: Int! + } + + type MountainArea { + id: ID! + name: String! + description: String + ssrId: Int + "[vest, sør, øst, nord] i grader" + bbox: [Float!]! + "Grensen som GeoJSON-tekst" + geojson: String! + hutCount: Int! + cabinCount: Int! + shelterCount: Int! + peakCount: Int! + highestPeak: Peak + } + + type Query { + databaseInfo: DatabaseInfo! + databaseTables: [DatabaseTable!]! + pointsOfInterest(west: Float!, south: Float!, east: Float!, north: Float!, kinds: [PoiKind!]!): PoiPage! + mountainAreas: [MountainArea!]! + mountainArea(id: ID!): MountainArea + } + `, + resolvers: { + Query: { + databaseInfo: () => getDatabaseInfo(), + databaseTables: () => getDatabaseTables(), + pointsOfInterest: (_: unknown, { kinds, ...bbox }: BBox & { kinds: PoiKind[] }) => getPointsOfInterest(bbox, kinds), + mountainAreas: () => getMountainAreas(), + mountainArea: (_: unknown, { id }: { id: string }) => getMountainArea(id), + }, + }, +}) + +// GraphiQL (testverktøyet i nettleseren) er bare på under utvikling +const yoga = createYoga({ schema, graphiql: process.env.NODE_ENV !== 'production' }) +const server = createServer(yoga) +const port = Number(process.env.PORT || 4000) + +server.listen(port, '127.0.0.1', () => console.log(`GraphQL kjører på http://127.0.0.1:${port}/graphql`)) + +// Lukk databasetilkoblingene ordentlig når serveren stoppes (Ctrl+C eller pm2 stop) +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => server.close(async () => { + await pool.end() + process.exit(0) + })) +} diff --git a/tsconfig.node.json b/tsconfig.node.json index 8455dcb..2eaed12 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -19,5 +19,5 @@ "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "server/*.ts"] } From 1207760f29433640cbee412cdc3c81c83575c800 Mon Sep 17 00:00:00 2001 From: karstenmidthaug Date: Fri, 9 Oct 2026 17:28:00 +0200 Subject: [PATCH 04/17] =?UTF-8?q?Dokumenter=20rensede=20data=20og=20fjello?= =?UTF-8?q?mr=C3=A5der=20i=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 967fe4e..d6f98f0 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,15 @@ Katalogen gir hver rute en stabil ID, slår sammen rutedeler, beregner lengde og GraphQL-spørringen `routes` støtter tekstsøk, minimums- og maksimumslengde, kartgrenser, paginering og antall treff. Resultatene grupperes etter rutenavn. Når en gruppe åpnes, velges variant i detaljpanelet ved kartet. Geometri hentes først gjennom `route` for den valgte varianten. +## Fjellområder og turpunkter + +Rensede data som appen henter fra, ligger i skjemaet `silver`. SQL-filene som lager dem ligger i `server/sql/silver/`, og originaldataene endres ikke. + +- `silver.osm_points_of_interest` er en materialisert visning med turisthytter, buer, gapahuker, teltplasser, topper, utsiktspunkter, turparkering, toaletter og drikkevann fra OpenStreetMap. Busskur, private steder og vanlig parkering er filtrert bort, og høyde og antall sengeplasser er gjort om til tall. Ved ny OSM-import oppdateres den med `REFRESH MATERIALIZED VIEW silver.osm_points_of_interest;`. +- `silver.mountain_areas` inneholder 15 fjellområder, blant annet Jotunheimen, Rondane og Hardangervidda. + +Grensene for fjellområdene er foreløpig omtrentlige. Det finnes ingen offisiell avgrensning av landskapsområder som «Jotunheimen», så hvert område er et håndlaget rektangel. Rektanglene er kontrollert mot punktene Kartverket har for området i stedsnavnregisteret (SSR), og kolonnen `ssr_id` peker til Kartverkets offisielle navn der området er registrert. Vi planlegger å erstatte rektanglene med faktiske grenser senere, for eksempel nasjonalpark- og verneområdegrenser fra Miljødirektoratet. Grensen ligger i én kolonne, så backend og frontend trenger ikke endres når den byttes ut. + ## Start databaseversjonen lokalt PostgreSQL må kjøre, og databasen `norwegian_explorer` må inneholde det importerte Kartverket-skjemaet. Kjør deretter i to terminaler: @@ -53,6 +62,7 @@ npm run build ## Datakilder - Kartverket, Tur- og friluftsruter: CC BY 4.0 +- Kartverket, Sentralt stedsnavnregister (SSR): CC BY 4.0 - OpenStreetMap-bidragsytere og Geofabrik-uttrekk: ODbL Registrerte rutestrekninger er ikke nødvendigvis komplette turforslag og skal ikke uten videre brukes til navigasjon. From 930eaee0db110a3cc0d77e1d909c094781137695 Mon Sep 17 00:00:00 2001 From: karstenmidthaug Date: Fri, 9 Oct 2026 19:34:57 +0200 Subject: [PATCH 05/17] Ignorer .nosync-mapper og Cesium-filer --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index f57fea2..aa47a0b 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,7 @@ dist-ssr .env .env.* !.env.example + +*.nosync/ +public/cesium/ + From 048e4f4508a051cc94131db031da7c33b5b79a19 Mon Sep 17 00:00:00 2001 From: karstenmidthaug Date: Fri, 9 Oct 2026 19:35:11 +0200 Subject: [PATCH 06/17] =?UTF-8?q?Les=20h=C3=B8yde=20og=20sengeplasser=20ri?= =?UTF-8?q?ktig=20n=C3=A5r=20OSM-verdien=20har=20mellomrom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/sql/silver/osm_points_of_interest.sql | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/server/sql/silver/osm_points_of_interest.sql b/server/sql/silver/osm_points_of_interest.sql index 4cd6ef5..237b648 100644 --- a/server/sql/silver/osm_points_of_interest.sql +++ b/server/sql/silver/osm_points_of_interest.sql @@ -36,9 +36,10 @@ WITH classified AS ( tags->>'dnt:classification' AS dnt_type, -- betjent / selvbetjent / ubetjent / nødbu name, ST_PointOnSurface(geom) AS point, -- ett punkt, også når stedet er tegnet som omriss - -- ele og capacity er tekst i OSM (f.eks. '2469' eller '1 234 m'), så vi plukker ut tallet - round(replace(substring(tags->>'ele' FROM '^-?\d+(?:[.,]\d+)?'), ',', '.')::numeric)::int AS elevation, - substring(tags->>'capacity' FROM '^\d+')::int AS capacity, + -- ele og capacity er tekst i OSM (f.eks. '2469' eller '1 234 m'), så vi fjerner mellomrom og plukker ut tallet. + -- Tall med flere enn 5 sifre er tastefeil og blir NULL, ellers ville ::int feile og stoppe hele viewet. + round(replace(substring(replace(tags->>'ele', ' ', '') FROM '^-?\d{1,5}(?:[.,]\d+)?(?![\d.,])'), ',', '.')::numeric)::int AS elevation, + substring(replace(tags->>'capacity', ' ', '') FROM '^\d{1,5}(?!\d)')::int AS capacity, tags->>'operator' AS operator FROM osm_import.objects WHERE category = 'place' From ab9638c342443312afbd586cf56f71e93d5ac731 Mon Sep 17 00:00:00 2001 From: karstenmidthaug Date: Fri, 9 Oct 2026 19:35:56 +0200 Subject: [PATCH 07/17] =?UTF-8?q?Samle=20fjellomr=C3=A5dene=20og=20turpunk?= =?UTF-8?q?tene=20i=20hovedbackenden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 +- server/db.ts | 16 +++- server/index.mjs | 25 +++--- server/mountainAreas.ts | 27 +++--- server/overview.ts | 72 ---------------- server/pointsOfInterest.ts | 70 ++++------------ server/server.ts | 166 ++++++++++++------------------------- 7 files changed, 104 insertions(+), 275 deletions(-) delete mode 100644 server/overview.ts diff --git a/package.json b/package.json index 67ae035..092a25d 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,7 @@ "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:ts": "node --env-file-if-exists=.env --watch server/server.ts" + "start:server": "node --env-file-if-exists=.env server/index.mjs" }, "dependencies": { "graphql": "^17.0.2", diff --git a/server/db.ts b/server/db.ts index 0767d7a..1982813 100644 --- a/server/db.ts +++ b/server/db.ts @@ -1,12 +1,20 @@ -// Felles tilkobling til PostgreSQL-databasen på VM-en. -// Innstillingene leses fra miljøvariabler (.env), så hver person kan ha sitt eget oppsett. -// Se .env.example for hvordan DATABASE_URL skal se ut. +// Felles tilkobling til PostgreSQL-databasen, brukt av hele backenden (index.mjs 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' import pg from 'pg' +if (!process.env.DATABASE_URL) console.warn('DATABASE_URL er ikke satt i .env, så backend kobler til en lokal database.') + export const pool = new pg.Pool({ - connectionString: process.env.DATABASE_URL, + ...(process.env.DATABASE_URL ? { connectionString: process.env.DATABASE_URL } : { + host: process.env.PGHOST || '/tmp', + user: process.env.PGUSER || userInfo().username, + database: process.env.PGDATABASE || 'norwegian_explorer', + }), max: 4, connectionTimeoutMillis: 5000, + statement_timeout: 10000, }) pool.on('error', (error) => console.error('Feil i databasetilkoblingen:', error.message)) diff --git a/server/index.mjs b/server/index.mjs index 13fbeae..a15d232 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -1,22 +1,13 @@ import { createServer } from 'node:http' -import { userInfo } from 'node:os' import { createHash, randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto' import { promisify } from 'node:util' -import pg from 'pg' import { GraphQLError } from 'graphql' import { createSchema, createYoga } from 'graphql-yoga' +import { pool } from './db.ts' +import { mountainResolvers, mountainTypeDefs } from './server.ts' const schemaName = process.env.DATA_SCHEMA || 'turogfriluftsruter_1b2541ab00554564ae88dabc8bb90c02' if (!/^[a-z_][a-z0-9_]*$/.test(schemaName)) throw new Error('Invalid DATA_SCHEMA') -const pool = new pg.Pool({ - ...(process.env.DATABASE_URL ? { connectionString: process.env.DATABASE_URL } : { - host: process.env.PGHOST || '/tmp', - user: process.env.PGUSER || userInfo().username, - database: process.env.PGDATABASE || 'norwegian_explorer', - }), - max: 4, connectionTimeoutMillis: 5000, statement_timeout: 10000, -}) -pool.on('error', (error) => console.error('Database connection error', error.message)) const scrypt = promisify(scryptCallback) const appSchemaReady = pool.query(` CREATE SCHEMA IF NOT EXISTS norwegian_explorer_app; @@ -51,6 +42,9 @@ const routeCatalogReady = appSchemaReady.then(async () => { const { rows } = await pool.query("SELECT to_regclass('norwegian_explorer_app.route_catalog') AS catalog") if (!rows[0].catalog) throw new Error('Route catalog is missing. Run scripts/create-route-catalog.sql first.') }) +// Får ikke databasebrukeren lage tabellene over, skal bare innlogging og ruter feile, ikke hele serveren +appSchemaReady.catch(error => console.error('Kunne ikke klargjøre appens tabeller:', error.message)) +routeCatalogReady.catch(error => console.error(error.message)) function validateCredentials(username, password) { const normalized = username.trim() @@ -89,7 +83,8 @@ async function requireUser(token) { return user } const schema = createSchema({ - typeDefs: ` + // Fjellområdene og turpunktene ligger i server/server.ts og legges til her + typeDefs: [` type Route { id: ID! name: String! @@ -131,8 +126,8 @@ const schema = createSchema({ toggleFavorite(token: String!, routeId: ID!): UserProfile! updateProfile(token: String!, email: String, phone: String, avatar: String): UserProfile! } - `, - resolvers: { Query: { + `, mountainTypeDefs], + resolvers: [{ Query: { osmNearby: async (_, { west, south, east, north }) => { if (![west,south,east,north].every(Number.isFinite) || west < -180 || east > 180 || south < -90 || north > 90 || west >= east || south >= north || east-west > 0.5 || north-south > 0.5) throw new GraphQLError('Velg et mindre kartområde for OSM-data.') const { rows } = await pool.query(` @@ -301,7 +296,7 @@ const schema = createSchema({ [user.id, cleanEmail, cleanPhone, cleanAvatar]) return profileFor(rows[0]) }, - } }, + } }, mountainResolvers], }) async function loadRoute(name, number) { await routeCatalogReady diff --git a/server/mountainAreas.ts b/server/mountainAreas.ts index 273aa77..e2b9f64 100644 --- a/server/mountainAreas.ts +++ b/server/mountainAreas.ts @@ -1,10 +1,9 @@ // Fjellområder fra silver.mountain_areas, med nøkkeltall om hva som finnes i hvert område // (antall hytter, buer, topper og høyeste topp) hentet fra silver.osm_points_of_interest. // Se server/sql/silver/mountain_areas.sql for hvordan områdene er definert. -import { GraphQLError } from 'graphql' import { pool } from './db.ts' -export interface Peak { +interface Peak { name: string elevation: number } @@ -13,8 +12,6 @@ export interface MountainArea { id: string name: string description: string | null - /** Kartverkets stedsnummer, eller null hvis Kartverket ikke har registrert området */ - ssrId: number | null /** [vest, sør, øst, nord] i grader, brukes til å zoome kartet inn på området */ bbox: [number, number, number, number] /** Grensen som GeoJSON-tekst, så kartet kan tegne den (også når den ikke lenger er et rektangel) */ @@ -31,14 +28,12 @@ type MountainAreaRow = Omit & { highestPeakElevation: number | null } -// Én spørring brukes både for alle områdene og for ett område: $1 er enten NULL (alle) eller en id. // For hvert område teller vi punktene som ligger innenfor (LATERAL betyr «kjør denne delspørringen // én gang per område»). ST_Intersects bruker den romlige indeksen på osm_points_of_interest. const AREAS_SQL = ` SELECT a.id, a.name, a.description, - a.ssr_id AS "ssrId", ARRAY[ST_XMin(a.area), ST_YMin(a.area), ST_XMax(a.area), ST_YMax(a.area)]::float8[] AS bbox, ST_AsGeoJSON(a.area) AS geojson, counts."hutCount", @@ -65,7 +60,6 @@ const AREAS_SQL = ` ORDER BY p.elevation DESC LIMIT 1 ) top ON true - WHERE $1::text IS NULL OR a.id = $1 ORDER BY a.name ` @@ -77,13 +71,16 @@ function toMountainArea({ highestPeakName, highestPeakElevation, ...area }: Moun return { ...area, highestPeak } } -export async function getMountainAreas(): Promise { - const { rows } = await pool.query(AREAS_SQL, [null]) - return rows.map(toMountainArea) -} +// Opptellingen kjører to romlige søk per område. Tallene endres bare når SQL-filene kjøres på nytt, +// så vi husker svaret en stund i stedet for å telle på nytt ved hver forespørsel. +const CACHE_MS = 10 * 60 * 1000 +let cache: { at: number; areas: Promise } | null = null -export async function getMountainArea(id: string): Promise { - if (!id.trim() || id.length > 100) throw new GraphQLError('Ugyldig id for fjellområde.') - const { rows } = await pool.query(AREAS_SQL, [id]) - return rows[0] ? toMountainArea(rows[0]) : null +export function getMountainAreas(): Promise { + if (!cache || Date.now() - cache.at > CACHE_MS) { + const areas = pool.query(AREAS_SQL).then(({ rows }) => rows.map(toMountainArea)) + cache = { at: Date.now(), areas } + areas.catch(() => { cache = null }) // en feil skal ikke huskes, neste forespørsel prøver igjen + } + return cache.areas } diff --git a/server/overview.ts b/server/overview.ts deleted file mode 100644 index ab8f8d4..0000000 --- a/server/overview.ts +++ /dev/null @@ -1,72 +0,0 @@ -// Oversikt over hva som ligger i databasen: skjemaer, tabeller, kolonner og omtrent antall rader. -// Brukes av den midlertidige oversiktssiden (oversikt.html). -import pg from 'pg' -import { pool } from './db.ts' - -export interface DatabaseInfo { - database: string - user: string - postgresVersion: string - postgisVersion: string | null -} - -export interface Column { - name: string - type: string -} - -export interface DatabaseTable { - schema: string - name: string - /** Antall rader, eller null hvis vi ikke fikk telt dem */ - rowCount: number | null - /** true hvis rowCount er Postgres sitt anslag, false hvis det er telt nøyaktig */ - isEstimate: boolean - /** Om brukeren vi er logget inn som har lov til å lese tabellen */ - canRead: boolean - columns: Column[] -} - -export async function getDatabaseInfo(): Promise { - const { rows } = await pool.query(` - SELECT current_database() AS database, - current_user AS "user", - current_setting('server_version') AS "postgresVersion", - (SELECT extversion FROM pg_extension WHERE extname = 'postgis') AS "postgisVersion" - `) - return rows[0] -} - -export async function getDatabaseTables(): Promise { - // Vi leser fra Postgres sine egne systemtabeller (pg_class, pg_namespace, pg_attribute). - // Fordelen er at de viser alle tabeller, også de vi ikke har tilgang til å lese innholdet i. - // reltuples er Postgres sitt anslag over antall rader. Det er raskt, men ikke helt nøyaktig. - const { rows } = await pool.query & { reltuples: number }>(` - SELECT n.nspname AS schema, - c.relname AS name, - c.reltuples::float8 AS reltuples, - has_schema_privilege(n.oid, 'USAGE') AND has_table_privilege(c.oid, 'SELECT') AS "canRead", - (SELECT coalesce(json_agg(json_build_object('name', a.attname, 'type', format_type(a.atttypid, a.atttypmod)) ORDER BY a.attnum), '[]') - FROM pg_attribute a - WHERE a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped) AS columns - FROM pg_class c - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE c.relkind IN ('r', 'p', 'v', 'm') -- tabeller, partisjonerte tabeller og views - AND n.nspname NOT IN ('pg_catalog', 'information_schema') - AND n.nspname NOT LIKE 'pg_toast%' - -- Hopp over tabeller som hører til utvidelser (f.eks. PostGIS sin spatial_ref_sys) - AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = c.oid AND d.deptype = 'e') - ORDER BY schema, name - `) - - // -1 betyr at Postgres aldri har analysert tabellen. Det gjelder stort sett små kodelister, - // så da teller vi radene nøyaktig i stedet (det går raskt). - return Promise.all(rows.map(async ({ reltuples, ...table }) => { - if (reltuples >= 0) return { ...table, rowCount: Math.round(reltuples), isEstimate: true } - if (!table.canRead) return { ...table, rowCount: null, isEstimate: false } - // Tabellnavn kan ikke sendes som $1-parametre, så de må escapes for å unngå SQL-injeksjon - const name = `${pg.escapeIdentifier(table.schema)}.${pg.escapeIdentifier(table.name)}` - const count = await pool.query<{ count: number }>(`SELECT count(*)::int AS count FROM ${name}`) - return { ...table, rowCount: count.rows[0].count, isEstimate: false } - })) -} diff --git a/server/pointsOfInterest.ts b/server/pointsOfInterest.ts index 4037f0c..7a54ed8 100644 --- a/server/pointsOfInterest.ts +++ b/server/pointsOfInterest.ts @@ -1,68 +1,32 @@ -// Henter hytter, topper, parkering osv. fra det rensede viewet silver.osm_points_of_interest -// (se server/sql/silver/osm_points_of_interest.sql for hvordan det er laget). +// Henter hyttene, buene, gapahukene og toppene i et fjellområde fra det rensede viewet +// silver.osm_points_of_interest (se server/sql/silver/osm_points_of_interest.sql for hvordan det er laget). import { GraphQLError } from 'graphql' import { pool } from './db.ts' -export const POI_KINDS = ['HUT', 'CABIN', 'SHELTER', 'CAMPSITE', 'VIEWPOINT', 'PEAK', 'PARKING', 'TOILETS', 'WATER'] as const -export type PoiKind = (typeof POI_KINDS)[number] - -export interface PointOfInterest { +interface PointOfInterest { id: string - kind: PoiKind + kind: 'HUT' | 'CABIN' | 'SHELTER' | 'PEAK' dntType: string | null name: string | null lon: number lat: number elevation: number | null capacity: number | null - operator: string | null -} - -export interface PoiPage { - items: PointOfInterest[] - /** true hvis det fantes flere punkter enn vi sendte, og brukeren bør zoome inn */ - truncated: boolean -} - -/** Kartutsnitt i grader: vest, sør, øst, nord */ -export interface BBox { - west: number - south: number - east: number - north: number } -// Grenser som hindrer at én forespørsel henter hele Norge -const MAX_RESULTS = 5000 -const MAX_SPAN_DEGREES = 10 +/** Turpunktene innenfor grensen til ett fjellområde (silver.mountain_areas) */ +export async function getAreaPointsOfInterest(areaId: string): Promise { + if (!areaId.trim() || areaId.length > 100) throw new GraphQLError('Ugyldig id for fjellområde.') -function validate({ west, south, east, north }: BBox, kinds: PoiKind[]) { - const finite = [west, south, east, north].every(Number.isFinite) - if (!finite || west >= east || south >= north || west < -180 || east > 180 || south < -90 || north > 90) { - throw new GraphQLError('Ugyldig kartutsnitt.') - } - if (east - west > MAX_SPAN_DEGREES || north - south > MAX_SPAN_DEGREES) { - throw new GraphQLError('Kartutsnittet er for stort. Zoom inn.') - } - if (kinds.some(kind => !POI_KINDS.includes(kind))) throw new GraphQLError('Ukjent punkttype.') -} - -export async function getPointsOfInterest(bbox: BBox, kinds: PoiKind[]): Promise { - validate(bbox, kinds) - if (kinds.length === 0) return { items: [], truncated: false } - - // geom && ST_MakeEnvelope(...) bruker den romlige indeksen (osm_poi_geom_idx), så bare punktene - // i kartutsnittet blir lest. Vi henter én rad ekstra for å vite om det fantes flere enn MAX_RESULTS. + // Samme ST_Intersects som når punktene telles i server/mountainAreas.ts, så lista og tallene stemmer + // også når grensen ikke er et rektangel const { rows } = await pool.query(` - SELECT id, kind, dnt_type AS "dntType", name, - lon::float8 AS lon, lat::float8 AS lat, - elevation, capacity, operator - FROM silver.osm_points_of_interest - WHERE geom && ST_MakeEnvelope($1, $2, $3, $4, 4326) - AND kind = ANY($5) - ORDER BY name IS NULL -- steder med navn først, hvis vi må kutte - LIMIT $6 - `, [bbox.west, bbox.south, bbox.east, bbox.north, kinds, MAX_RESULTS + 1]) - - return { items: rows.slice(0, MAX_RESULTS), truncated: rows.length > MAX_RESULTS } + SELECT p.id, p.kind, p.dnt_type AS "dntType", p.name, + p.lon::float8 AS lon, p.lat::float8 AS lat, + p.elevation, p.capacity + FROM silver.osm_points_of_interest p + JOIN silver.mountain_areas a ON a.id = $1 AND ST_Intersects(p.geom, a.area) + WHERE p.kind IN ('HUT', 'CABIN', 'SHELTER', 'PEAK') + `, [areaId]) + return rows } diff --git a/server/server.ts b/server/server.ts index 811a326..9336fa6 100644 --- a/server/server.ts +++ b/server/server.ts @@ -1,116 +1,54 @@ -// GraphQL-backend for Utforsk Norge, skrevet i TypeScript. -// Node 24 kjører .ts-filer direkte ved å fjerne typene, så vi trenger ikke et eget byggesteg. -// Kjør lokalt: npm run dev:server:ts → http://localhost:4000/graphql -import { createServer } from 'node:http' -import { createSchema, createYoga } from 'graphql-yoga' -import { pool } from './db.ts' -import { getDatabaseInfo, getDatabaseTables } from './overview.ts' -import { getPointsOfInterest, type BBox, type PoiKind } from './pointsOfInterest.ts' -import { getMountainArea, getMountainAreas } from './mountainAreas.ts' - -const schema = createSchema({ - typeDefs: /* GraphQL */ ` - type DatabaseInfo { - database: String! - user: String! - postgresVersion: String! - postgisVersion: String - } - - type Column { - name: String! - type: String! - } - - type DatabaseTable { - schema: String! - name: String! - rowCount: Int - isEstimate: Boolean! - canRead: Boolean! - columns: [Column!]! - } - - enum PoiKind { - HUT - CABIN - SHELTER - CAMPSITE - VIEWPOINT - PEAK - PARKING - TOILETS - WATER - } - - type PointOfInterest { - id: ID! - kind: PoiKind! - dntType: String - name: String - lon: Float! - lat: Float! - elevation: Int - capacity: Int - operator: String - } - - type PoiPage { - items: [PointOfInterest!]! - truncated: Boolean! - } - - type Peak { - name: String! - elevation: Int! - } - - type MountainArea { - id: ID! - name: String! - description: String - ssrId: Int - "[vest, sør, øst, nord] i grader" - bbox: [Float!]! - "Grensen som GeoJSON-tekst" - geojson: String! - hutCount: Int! - cabinCount: Int! - shelterCount: Int! - peakCount: Int! - highestPeak: Peak - } - - type Query { - databaseInfo: DatabaseInfo! - databaseTables: [DatabaseTable!]! - pointsOfInterest(west: Float!, south: Float!, east: Float!, north: Float!, kinds: [PoiKind!]!): PoiPage! - mountainAreas: [MountainArea!]! - mountainArea(id: ID!): MountainArea - } - `, - resolvers: { - Query: { - databaseInfo: () => getDatabaseInfo(), - databaseTables: () => getDatabaseTables(), - pointsOfInterest: (_: unknown, { kinds, ...bbox }: BBox & { kinds: PoiKind[] }) => getPointsOfInterest(bbox, kinds), - mountainAreas: () => getMountainAreas(), - mountainArea: (_: unknown, { id }: { id: string }) => getMountainArea(id), - }, +// GraphQL-delen for fjellområdesiden (fjellomrade.html): typene og spørringene for fjellområder og turpunkter. +// Fila starter ikke noen server selv. Den kobles inn i backenden i server/index.mjs, som startes med +// npm run dev:server, ved siden av rutesøket og innloggingen. +import { getMountainAreas } from './mountainAreas.ts' +import { getAreaPointsOfInterest } from './pointsOfInterest.ts' + +// «extend type Query» legger spørringene til i Query-typen som er definert i index.mjs +export const mountainTypeDefs = /* GraphQL */ ` + enum PoiKind { HUT CABIN SHELTER PEAK } + + type PointOfInterest { + id: ID! + kind: PoiKind! + dntType: String + name: String + lon: Float! + lat: Float! + elevation: Int + capacity: Int + } + + type Peak { + name: String! + elevation: Int! + } + + type MountainArea { + id: ID! + name: String! + description: String + "[vest, sør, øst, nord] i grader" + bbox: [Float!]! + "Grensen som GeoJSON-tekst" + geojson: String! + hutCount: Int! + cabinCount: Int! + shelterCount: Int! + peakCount: Int! + highestPeak: Peak + } + + extend type Query { + mountainAreas: [MountainArea!]! + "Hyttene, buene, gapahukene og toppene innenfor grensen til ett fjellområde" + areaPointsOfInterest(areaId: ID!): [PointOfInterest!]! + } +` + +export const mountainResolvers = { + Query: { + mountainAreas: () => getMountainAreas(), + areaPointsOfInterest: (_: unknown, { areaId }: { areaId: string }) => getAreaPointsOfInterest(areaId), }, -}) - -// GraphiQL (testverktøyet i nettleseren) er bare på under utvikling -const yoga = createYoga({ schema, graphiql: process.env.NODE_ENV !== 'production' }) -const server = createServer(yoga) -const port = Number(process.env.PORT || 4000) - -server.listen(port, '127.0.0.1', () => console.log(`GraphQL kjører på http://127.0.0.1:${port}/graphql`)) - -// Lukk databasetilkoblingene ordentlig når serveren stoppes (Ctrl+C eller pm2 stop) -for (const signal of ['SIGINT', 'SIGTERM']) { - process.on(signal, () => server.close(async () => { - await pool.end() - process.exit(0) - })) } From 6aea551770de194647ddd37fb4f39d1275db3482 Mon Sep 17 00:00:00 2001 From: karstenmidthaug Date: Fri, 9 Oct 2026 19:36:06 +0200 Subject: [PATCH 08/17] =?UTF-8?q?Legg=20til=20side=20for=20fjellomr=C3=A5d?= =?UTF-8?q?er=20med=20kart=20fra=20Kartverket?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fjellomrade.html | 13 ++ src/Fjellomrade.css | 135 +++++++++++++ src/Fjellomrade.tsx | 423 +++++++++++++++++++++++++++++++++++++++ src/fjellomrade-main.tsx | 11 + src/graphql.ts | 18 ++ src/mountainAreas.ts | 32 +++ vite.config.ts | 10 + 7 files changed, 642 insertions(+) create mode 100644 fjellomrade.html create mode 100644 src/Fjellomrade.css create mode 100644 src/Fjellomrade.tsx create mode 100644 src/fjellomrade-main.tsx create mode 100644 src/graphql.ts create mode 100644 src/mountainAreas.ts diff --git a/fjellomrade.html b/fjellomrade.html new file mode 100644 index 0000000..b0d65c3 --- /dev/null +++ b/fjellomrade.html @@ -0,0 +1,13 @@ + + + + + + + Fjellområder · Utforsk Norge + + +
+ + + diff --git a/src/Fjellomrade.css b/src/Fjellomrade.css new file mode 100644 index 0000000..794390d --- /dev/null +++ b/src/Fjellomrade.css @@ -0,0 +1,135 @@ +@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300..700&family=DM+Sans:opsz,wght@9..40,400..700&display=swap'); + +/* Fargene er de samme som i resten av appen (index.css og App.css) */ +:root { + --bg: #f5f6f0; + --text: #203b32; + --muted: #647267; + --line: #dce1d7; + --soft: #edf2e9; + --green: #126b51; + --copper: #c27730; + --serif: 'Fraunces', Georgia, serif; + --shadow: 0 12px 36px -14px rgba(35, 58, 48, 0.45); +} +body { margin: 0; background: var(--bg); color: var(--text); font-family: 'DM Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; } +.fo-page *, .fo-mapview * { box-sizing: border-box; } +.fo-page a, .fo-mapview a { color: inherit; text-decoration: none; } +.fo-page button, .fo-page input, .fo-mapview button { font: inherit; color: inherit; } +.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } +.fo-kicker { display: block; margin: 0 0 6px; font-size: 11px; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; color: var(--copper); } +.fo-note { margin: 10px 0; font-size: 13px; color: var(--muted); } +.fo-error { margin: 0 0 12px; padding: 12px 14px; border-radius: 12px; background: #f4f0e4; font-size: 14px; } +.fo-dot { width: 12px; height: 12px; flex-shrink: 0; border-radius: 50%; } + +/* ── Oversikten ── */ +.fo-page { --gutter: clamp(16px, 4vw, 56px); } +.fo-top-bar { display: flex; align-items: center; gap: 28px; padding: 18px var(--gutter); border-bottom: 1px solid var(--line); font-size: 14px; color: var(--muted); } +.fo-brand { margin-right: auto; font-family: var(--serif); font-size: 24px; color: var(--text) !important; } +/* Samme grønne knapp som «Min profil» på hovedsiden (App.css) */ +.fo-profile-link { padding: 9px 14px; border-radius: 20px; background: var(--green); color: #fff !important; font-size: 12px; font-weight: 700; } +.fo-profile-link:hover { background: #0d4f3b; } +.fo-top-bar .is-on { color: var(--green); font-weight: 600; border-bottom: 2px solid var(--copper); padding-bottom: 4px; } +.fo-hero { max-width: 1440px; margin: 0 auto; padding: clamp(20px, 2.5vw, 36px) var(--gutter) 28px; } +.fo-hero h1 { margin: 0; font-family: var(--serif); font-weight: 400; font-size: clamp(56px, 9vw, 132px); letter-spacing: -0.035em; line-height: 0.95; } +.fo-hero p { max-width: 36em; margin: 24px 0 0; font-size: clamp(16px, 1.3vw, 19px); line-height: 1.6; color: var(--muted); } +.fo-filters { max-width: 1440px; margin: 0 auto; padding: 0 var(--gutter) 24px; display: flex; flex-wrap: wrap; gap: 14px; align-items: center; justify-content: space-between; } +.fo-filters .fo-search input { width: 260px; margin: 0; } +.fo-messages { max-width: 1440px; margin: 0 auto; padding: 0 var(--gutter); } +.fo-grid { max-width: 1440px; margin: 0 auto; padding: 0 var(--gutter) 48px; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr)); gap: clamp(14px, 1.6vw, 24px); } +.fo-grid .fo-card { padding: 0; gap: 0; background: #fff; border-radius: 22px; overflow: hidden; box-shadow: 0 20px 50px -34px rgba(35, 58, 48, 0.25); transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.45s, border-color 0.3s; } +.fo-grid .fo-card:hover { transform: translateY(-5px); box-shadow: 0 34px 70px -30px rgba(18, 107, 81, 0.4); } +.fo-card-map { position: relative; aspect-ratio: 16 / 10; background: #e3ebdf; overflow: hidden; } +.fo-card-map img { width: 100%; height: 100%; object-fit: cover; transition: transform 1s cubic-bezier(0.22, 1, 0.36, 1); } +.fo-card:hover .fo-card-map img { transform: scale(1.06); } +.fo-card-map span { position: absolute; top: 14px; left: 14px; padding: 5px 11px; border-radius: 999px; background: rgba(255, 255, 255, 0.9); font-size: 12px; font-weight: 600; } +.fo-card-body { display: flex; flex-direction: column; gap: 12px; flex: 1; padding: 20px 22px 22px; } +.fo-card-body h3 { display: flex; justify-content: space-between; margin: 0; font-family: var(--serif); font-weight: 400; font-size: clamp(28px, 2.4vw, 36px); letter-spacing: -0.02em; line-height: 1; } +.fo-card-body h3 span { color: var(--green); font-size: 20px; } +.fo-card-body > p { margin: 0; font-size: 14px; line-height: 1.5; color: var(--muted); } +.fo-card-body .fo-counts { margin-top: auto; } + +/* ── Ett område: kartet fyller hele skjermen, og alt annet ligger oppå ── */ +.fo-mapview { position: fixed; inset: 0; overflow: hidden; } +.fo-map { position: absolute; inset: 0; } + +/* ── Panelet til venstre ── */ +.fo-panel { position: absolute; top: 12px; left: 12px; bottom: 12px; z-index: 2; display: flex; flex-direction: column; width: min(420px, calc(100% - 24px)); border-radius: 22px; background: #fff; box-shadow: var(--shadow); overflow: hidden; } +/* Sammenfelt panel: bare toppen vises, og pila snus */ +.fo-panel.is-closed { bottom: auto; } +.fo-panel.is-closed .fo-panel-body { display: none; } +.fo-panel.is-closed .fo-collapse span { transform: rotate(180deg); } + +.fo-panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 16px 12px 22px; border-bottom: 1px solid var(--line); } +.fo-panel-head h1 { margin: 0; font-family: var(--serif); font-weight: 400; font-size: 26px; letter-spacing: -0.01em; } +.fo-back { font-size: 14px; font-weight: 600; color: var(--muted) !important; } +.fo-back:hover { color: var(--green) !important; } +.fo-collapse { width: 38px; height: 38px; flex-shrink: 0; border: 0; border-radius: 50%; background: var(--soft); cursor: pointer; } +.fo-collapse:hover { background: var(--line); } +.fo-collapse span { display: inline-block; font-size: 18px; line-height: 1; transition: transform 0.3s; } +.fo-panel-body { flex: 1; overflow-y: auto; padding: 14px 22px 22px; scrollbar-width: thin; } + +.fo-chips { display: flex; flex-wrap: wrap; gap: 6px; } +.fo-chips button { height: 34px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--line); background: #fff; font-weight: 600; font-size: 13px; cursor: pointer; } +.fo-chips button:hover { border-color: var(--green); } +.fo-chips button[aria-pressed='true'] { background: var(--green); border-color: var(--green); color: #fff; } +.fo-search input { width: 100%; height: 40px; padding: 0 16px; border-radius: 999px; border: 1px solid var(--line); background: var(--soft); font-size: 14px; } +.fo-card { display: flex; flex-direction: column; border: 1px solid var(--line); } +.fo-card:hover { border-color: var(--green); } +.fo-counts { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin: 0; padding-top: 10px; border-top: 1px solid var(--line); } +.fo-counts div { display: flex; flex-direction: column-reverse; } +.fo-counts dd { margin: 0; font-family: var(--serif); font-size: 20px; line-height: 1; } +.fo-counts dt { margin-top: 3px; font-size: 11px; color: var(--muted); } +.fo-top { margin: 0; font-size: 12px; color: var(--muted); } +.fo-top strong { color: var(--text); font-weight: 600; } +.fo-top span { color: var(--copper); font-weight: 600; } + +/* ── Ett område ── */ +.fo-title { margin: 0; font-family: var(--serif); font-weight: 400; font-size: clamp(38px, 4vw, 52px); letter-spacing: -0.03em; line-height: 1; } +.fo-desc { margin: 12px 0 18px; font-size: 14px; line-height: 1.6; color: var(--muted); } +.fo-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-bottom: 16px; } +.fo-stats div { display: flex; flex-direction: column; gap: 4px; padding: 12px 10px; border-radius: 12px; border: 1px solid var(--line); border-top: 2px solid; background: var(--bg); } +.fo-stats strong { font-family: var(--serif); font-weight: 400; font-size: 26px; line-height: 1; } +.fo-stats span { font-size: 11px; color: var(--muted); } +.fo-peak { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; padding: 14px 16px; border-radius: 14px; border: 1px solid rgba(194, 119, 48, 0.45); background: linear-gradient(135deg, rgba(194, 119, 48, 0.12), rgba(194, 119, 48, 0.02)); } +.fo-peak .fo-kicker { margin: 0; } +.fo-peak strong { font-family: var(--serif); font-weight: 400; font-size: 20px; } +.fo-peak-elev { font-family: var(--serif); font-size: 24px; color: var(--green); white-space: nowrap; } +.fo-peak-elev small { font-family: 'DM Sans', sans-serif; font-size: 12px; color: var(--muted); } +.fo-tabs { display: flex; gap: 4px; padding: 4px; border-radius: 12px; background: var(--soft); } +.fo-tabs button { flex: 1; min-height: 34px; border: 0; border-radius: 9px; background: none; font-weight: 600; font-size: 12px; color: var(--muted); cursor: pointer; } +.fo-tabs button span { opacity: 0.6; font-weight: 500; } +.fo-tabs button[aria-pressed='true'] { background: #fff; color: var(--text); box-shadow: 0 1px 4px rgba(35, 58, 48, 0.18); } +.fo-rows { margin: 6px 0 0; padding: 0; list-style: none; } +.fo-rows li { border-bottom: 1px solid var(--line); } +.fo-rows button { display: flex; align-items: center; gap: 12px; width: 100%; padding: 12px 6px; border: 0; border-radius: 10px; background: none; text-align: left; cursor: pointer; } +.fo-rows button:hover, .fo-rows button[aria-pressed='true'] { background: var(--soft); } +.fo-rows button > span:last-child { color: var(--green); } +.fo-row-text { flex: 1; min-width: 0; } +.fo-row-text strong { display: block; font-size: 14px; } +.fo-row-text small { font-size: 12px; color: var(--muted); } + +/* ── Knapper og boks oppå kartet ── */ +.fo-basemap-btn, .fo-selected { position: absolute; z-index: 1; border: 1px solid var(--line); background: rgba(255, 255, 255, 0.95); box-shadow: var(--shadow); } +.fo-basemap-btn { top: 12px; right: 12px; height: 40px; padding: 0 18px; border-radius: 999px; font-weight: 600; font-size: 14px; cursor: pointer; } +.fo-basemap-btn:hover { border-color: var(--green); } +.fo-selected { right: 12px; bottom: 40px; display: flex; align-items: center; gap: 14px; width: min(340px, calc(100% - 24px)); padding: 14px 18px; border-radius: 18px; } +.fo-selected > div { flex: 1; } +.fo-selected .fo-kicker { margin: 0; } +.fo-selected strong { display: block; font-family: var(--serif); font-weight: 400; font-size: 20px; line-height: 1.1; } +.fo-selected small { font-size: 13px; color: var(--muted); } +.fo-selected button { width: 32px; height: 32px; border-radius: 50%; border: 1px solid var(--line); background: var(--bg); cursor: pointer; } + +/* Boksen som vises når musepekeren er over et punkt */ +.fo-hover { pointer-events: none; } +.fo-hover .maplibregl-popup-content { padding: 8px 12px; border-radius: 10px; box-shadow: var(--shadow); font-family: 'DM Sans', system-ui, sans-serif; } +.fo-hover strong { display: block; font-size: 14px; color: var(--text); } +.fo-hover small { font-size: 12px; color: var(--muted); } + +/* På mobil ligger panelet øverst og tar halve skjermen, så kartet synes under */ +@media (max-width: 760px) { + .fo-filters .fo-search, .fo-filters .fo-search input { width: 100%; } + .fo-panel { bottom: auto; max-height: 55dvh; } + .fo-basemap-btn { top: auto; bottom: 40px; left: 12px; right: auto; } + .fo-selected { left: 12px; width: auto; bottom: 92px; } +} diff --git a/src/Fjellomrade.tsx b/src/Fjellomrade.tsx new file mode 100644 index 0000000..e32da4e --- /dev/null +++ b/src/Fjellomrade.tsx @@ -0,0 +1,423 @@ +// Fjellområdesiden (fjellomrade.html): først en oversikt med ett kort per fjellområde. Klikker man på et kort, +// åpnes kartet over hele skjermen med et panel oppå, som viser hyttene, buene, gapahukene og toppene i området. +// Områdene kommer fra silver.mountain_areas og punktene fra silver.osm_points_of_interest. +// Valgt område står i adressen (#/jotunheimen), så tilbakeknappen og delte lenker virker. +import { useEffect, useRef, useState } from 'react' +import * as maplibregl from 'maplibre-gl' +import type { ExpressionSpecification, GeoJSONSource, StyleSpecification } from 'maplibre-gl' +import type { Feature, FeatureCollection, MultiPolygon, Point, Polygon, Position } from 'geojson' +import 'maplibre-gl/dist/maplibre-gl.css' +import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url' +import { graphql } from './graphql.ts' +import { fetchMountainAreas, type MountainArea } from './mountainAreas.ts' +import './Fjellomrade.css' + +maplibregl.setWorkerUrl(workerUrl) + +type PoiKind = 'HUT' | 'CABIN' | 'SHELTER' | 'PEAK' +type PointOfInterest = { + id: string + kind: PoiKind + dntType: string | null + name: string | null + lon: number + lat: number + elevation: number | null + capacity: number | null +} + +// Navn og farge for hver type. Rekkefølgen her er rekkefølgen i fanene. +const KINDS: Record = { + HUT: { short: 'Hytter', one: 'Turisthytte', color: '#c0392b' }, + CABIN: { short: 'Buer', one: 'Bu / koie', color: '#c27730' }, + SHELTER: { short: 'Gapahuker', one: 'Gapahuk', color: '#2f8f5b' }, + PEAK: { short: 'Topper', one: 'Topp', color: '#203b32' }, +} +const ALL_KINDS = Object.keys(KINDS) as PoiKind[] +const COLOR_BY_KIND = ['match', ['get', 'kind'], ...ALL_KINDS.flatMap(k => [k, KINDS[k].color]), '#999'] as unknown as ExpressionSpecification + +// Antallet som allerede er talt opp i databasen for hvert område, per type +const countOf = (area: MountainArea, kind: PoiKind) => + ({ HUT: area.hutCount, CABIN: area.cabinCount, SHELTER: area.shelterCount, PEAK: area.peakCount })[kind] + +// Grov inndeling i landsdel etter breddegraden midt i området +type Region = 'S' | 'M' | 'N' +const REGIONS: Record = { S: 'Sør-Norge', M: 'Midt-Norge', N: 'Nord-Norge' } +function regionOf(area: MountainArea): Region { + const lat = (area.bbox[1] + area.bbox[3]) / 2 + return lat >= 65.5 ? 'N' : lat >= 62 ? 'M' : 'S' +} + +const KARTVERKET_TILES = 'https://cache.kartverket.no/v1/wmts/1.0.0/topo/default/webmercator/{z}/{y}/{x}.png' +const MAP_STYLE: StyleSpecification = { + version: 8, + sources: { + topo: { + type: 'raster', + tiles: [KARTVERKET_TILES], + tileSize: 256, + maxzoom: 18, + attribution: '© Kartverket · © OpenStreetMap-bidragsytere', + }, + // Flyfoto fra Esri, samme kilde som i ex P2. Laget er skjult til brukeren bytter til flyfoto. + satellitt: { + type: 'raster', + tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'], + tileSize: 256, + maxzoom: 18, + attribution: 'Bilder © Esri, Maxar, Earthstar Geographics', + }, + }, + layers: [ + { id: 'topo', type: 'raster', source: 'topo' }, + { id: 'satellitt', type: 'raster', source: 'satellitt', layout: { visibility: 'none' } }, + ], +} + +// Én kartflis fra Kartverket midt i området, brukt som bilde på kortet i oversikten +function tileUrl(area: MountainArea, z = 7) { + const lon = (area.bbox[0] + area.bbox[2]) / 2 + const lat = ((area.bbox[1] + area.bbox[3]) / 2) * (Math.PI / 180) + const x = Math.floor(((lon + 180) / 360) * 2 ** z) + const y = Math.floor(((1 - Math.log(Math.tan(lat) + 1 / Math.cos(lat)) / Math.PI) / 2) * 2 ** z) + return KARTVERKET_TILES.replace('{z}', String(z)).replace('{y}', String(y)).replace('{x}', String(x)) +} + +const POI_QUERY = ` + query AreaPointsOfInterest($areaId: ID!) { + areaPointsOfInterest(areaId: $areaId) { id kind dntType name lon lat elevation capacity } + } +` + +// Backend henter punktene innenfor selve grensen, med samme metode som når de telles +async function fetchPoints(areaId: string, signal: AbortSignal): Promise { + const data = await graphql<{ areaPointsOfInterest: PointOfInterest[] }>(POI_QUERY, { areaId }, signal) + return data.areaPointsOfInterest +} + +function pointsToGeoJson(points: PointOfInterest[]): FeatureCollection { + return { + type: 'FeatureCollection', + features: points.map(p => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [p.lon, p.lat] }, properties: p })), + } +} + +const boundaryOf = (area: MountainArea) => JSON.parse(area.geojson) as Polygon | MultiPolygon + +// Et polygon som dekker hele verden, med området som hull, gjør at alt utenfor området blir blekere. +// Hullene er ytterkanten av hver del av grensen, så det virker både for Polygon og MultiPolygon. +function maskAround(area: MountainArea): Feature { + const geometry = boundaryOf(area) + const parts: Position[][][] = geometry.type === 'Polygon' ? [geometry.coordinates] : geometry.coordinates + const world = [[-180, -85], [180, -85], [180, 85], [-180, 85], [-180, -85]] + return { type: 'Feature', properties: {}, geometry: { type: 'Polygon', coordinates: [world, ...parts.map(rings => rings[0])] } } +} + +// Undertekst for et punkt i lista og i boksen for valgt punkt +function describe(p: PointOfInterest) { + if (p.kind === 'PEAK') return p.elevation !== null ? `${p.elevation} moh.` : 'Ukjent høyde' + return [p.capacity !== null && `${p.capacity} sengeplasser`, p.dntType, p.elevation !== null && `${p.elevation} moh.`].filter(Boolean).join(' · ') || KINDS[p.kind].one +} + +// Innholdet i boksen som vises når musepekeren er over et punkt. +// textContent (ikke innerHTML) fordi navnene kommer fra OpenStreetMap og ikke er til å stole på. +function hoverContent(p: PointOfInterest) { + const box = document.createElement('div') + const title = document.createElement('strong') + title.textContent = p.name ?? 'Uten navn' + const details = document.createElement('small') + details.textContent = `${KINDS[p.kind].one} · ${describe(p)}` + box.append(title, details) + return box +} + +const idFromHash = () => decodeURIComponent(window.location.hash.replace(/^#\/?/, '')) || null + +export default function Fjellomrade() { + const [areas, setAreas] = useState(null) + const [error, setError] = useState('') + const [selectedId, setSelectedId] = useState(idFromHash) + // Hvor langt ned på oversikten man var, så man kommer tilbake til samme sted fra kartet + const overviewScroll = useRef(0) + + useEffect(() => { + fetchMountainAreas().then(setAreas).catch((e: unknown) => setError(e instanceof Error ? e.message : 'Noe gikk galt')) + const onHashChange = () => setSelectedId(idFromHash()) + window.addEventListener('hashchange', onHashChange) + return () => window.removeEventListener('hashchange', onHashChange) + }, []) + + const area = areas?.find(a => a.id === selectedId) ?? null + + useEffect(() => { + if (!area) window.scrollTo(0, overviewScroll.current) + }, [area]) + + if (area) return + return ( + { overviewScroll.current = window.scrollY }} /> + ) +} + +type OverviewProps = { areas: MountainArea[] | null; error: string; notFound: string | null; onOpen: () => void } + +function Overview({ areas, error, notFound, onOpen }: OverviewProps) { + const [region, setRegion] = useState('all') + const [query, setQuery] = useState('') + const needle = query.trim().toLowerCase() + const shown = (areas ?? []) + .filter(a => (region === 'all' || regionOf(a) === region) && (!needle || `${a.name} ${a.description ?? ''}`.toLowerCase().includes(needle))) + .sort((a, b) => (b.highestPeak?.elevation ?? 0) - (a.highestPeak?.elevation ?? 0)) + + return ( +
+ +
+

Norges fjell

+

Velg et fjellområde for å åpne kartet, med alle hyttene, buene, gapahukene og toppene på ett sted.

+
+ +
+
+ {(['all', 'S', 'M', 'N'] as const).map(r => ( + + ))} +
+ +
+ +
+ {error &&

Fikk ikke hentet fjellområdene: {error}

} + {!areas && !error &&

Henter fjellområder …

} + {notFound &&

Fant ikke fjellområdet «{notFound}». Velg et av områdene under.

} + {areas && shown.length === 0 &&

Ingen fjellområder passer søket.

} +
+ +
+ {shown.map(a => ( + +
+ + {REGIONS[regionOf(a)]} +
+
+

{a.name}

+

{a.description}

+
+ {ALL_KINDS.map(k => ( +
{countOf(a, k)}
{KINDS[k].short.toLowerCase()}
+ ))} +
+ {a.highestPeak && ( +

Høyeste topp · {a.highestPeak.name} {a.highestPeak.elevation} moh.

+ )} +
+
+ ))} +
+
+ ) +} + +// Ett område: kartet over hele skjermen, med et panel oppå som kan felles sammen +function AreaView({ area }: { area: MountainArea }) { + const [points, setPoints] = useState(null) + const [error, setError] = useState('') + const [tab, setTab] = useState('HUT') + const [selected, setSelected] = useState(null) + const [panelOpen, setPanelOpen] = useState(true) + + useEffect(() => { + const controller = new AbortController() + fetchPoints(area.id, controller.signal) + .then(setPoints) + .catch((e: unknown) => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : 'Noe gikk galt') }) + return () => controller.abort() + }, [area]) + + // Toppene sorteres etter høyde, resten etter antall sengeplasser + const rows = (points ?? []) + .filter(p => p.kind === tab) + .sort((a, b) => (tab === 'PEAK' ? (b.elevation ?? 0) - (a.elevation ?? 0) : (b.capacity ?? 0) - (a.capacity ?? 0))) + + return ( +
+ setSelected(points?.find(p => p.id === id) ?? null)} /> + + + + {selected && ( +
+ +
{KINDS[selected.kind].one}{selected.name ?? 'Uten navn'}{describe(selected)}
+ +
+ )} +
+ ) +} + +type MapProps = { + area: MountainArea + points: PointOfInterest[] + selected: PointOfInterest | null + panelOpen: boolean + onSelect: (id: string) => void +} + +// Kartverkets kart over hele skjermen, zoomet inn på området. Alt utenfor grensen tones ned. +function AreaMap({ area, points, selected, panelOpen, onSelect }: MapProps) { + const container = useRef(null) + const mapRef = useRef(null) + const [loaded, setLoaded] = useState(false) + const [satellite, setSatellite] = useState(false) + // Klikk og hover i kartet bruker alltid siste versjon av onSelect og punktene, uten at kartet må lages på nytt + const onSelectRef = useRef(onSelect) + const pointsRef = useRef(points) + useEffect(() => { + onSelectRef.current = onSelect + pointsRef.current = points + }) + + // 1. Lag kartet én gang per område + useEffect(() => { + if (!container.current) return + const [west, south, east, north] = area.bbox + const map = new maplibregl.Map({ container: container.current, style: MAP_STYLE, bounds: [[west, south], [east, north]] }) + map.addControl(new maplibregl.NavigationControl(), 'bottom-right') + mapRef.current = map + + map.on('load', () => { + map.addSource('mask', { type: 'geojson', data: maskAround(area) }) + map.addLayer({ id: 'mask', type: 'fill', source: 'mask', paint: { 'fill-color': '#e3ebdf', 'fill-opacity': 0.75 } }) + map.addSource('area', { type: 'geojson', data: { type: 'Feature', properties: {}, geometry: boundaryOf(area) } }) + map.addLayer({ id: 'area-line', type: 'line', source: 'area', paint: { 'line-color': '#c27730', 'line-width': 2.5, 'line-dasharray': [3, 2] } }) + + map.addSource('pois', { type: 'geojson', data: pointsToGeoJson([]) }) + // Ring rundt det valgte punktet. Filteret settes når et punkt velges. + map.addLayer({ id: 'pois-selected', type: 'circle', source: 'pois', filter: ['==', ['get', 'id'], ''], paint: { 'circle-radius': 16, 'circle-color': COLOR_BY_KIND, 'circle-opacity': 0.25 } }) + map.addLayer({ + id: 'pois', type: 'circle', source: 'pois', + paint: { 'circle-color': COLOR_BY_KIND, 'circle-radius': ['interpolate', ['linear'], ['zoom'], 8, 4, 13, 8], 'circle-stroke-color': '#fff', 'circle-stroke-width': 2 }, + }) + // Kartet mister tomme verdier i egenskapene, så vi slår opp punktet på id i stedet + map.on('click', 'pois', event => onSelectRef.current(String(event.features?.[0]?.properties.id))) + // Vis navnet på punktet musepekeren er over + const hover = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 12, className: 'fo-hover' }) + map.on('mousemove', 'pois', event => { + const point = pointsRef.current.find(p => p.id === String(event.features?.[0]?.properties.id)) + if (!point) return + map.getCanvas().style.cursor = 'pointer' + hover.setLngLat([point.lon, point.lat]).setDOMContent(hoverContent(point)).addTo(map) + }) + map.on('mouseleave', 'pois', () => { + map.getCanvas().style.cursor = '' + hover.remove() + }) + setLoaded(true) + }) + + return () => { + mapRef.current = null + map.remove() + } + }, [area]) + + // 2. Vis hele området, med plass til panelet på venstre side når det er åpent + useEffect(() => { + const [west, south, east, north] = area.bbox + const left = panelOpen && window.innerWidth > 760 ? 460 : 60 + mapRef.current?.fitBounds([[west, south], [east, north]], { padding: { top: 60, bottom: 60, right: 60, left }, duration: 800 }) + }, [area, panelOpen]) + + // 3. Tegn punktene, marker det valgte punktet og fly dit + useEffect(() => { + if (loaded) (mapRef.current?.getSource('pois') as GeoJSONSource).setData(pointsToGeoJson(points)) + }, [points, loaded]) + + useEffect(() => { + const map = mapRef.current + if (!map || !loaded) return + map.setFilter('pois-selected', ['==', ['get', 'id'], selected?.id ?? '']) + if (selected) map.flyTo({ center: [selected.lon, selected.lat], zoom: Math.max(map.getZoom(), 12) }) + }, [selected, loaded]) + + // Bytt mellom Kartverkets kart og flyfoto ved å vise eller skjule flyfotolaget + const toggleSatellite = () => { + mapRef.current?.setLayoutProperty('satellitt', 'visibility', satellite ? 'none' : 'visible') + // Sløret utenfor området gjør flyfotoet tåkete, så det er svakere der + mapRef.current?.setPaintProperty('mask', 'fill-opacity', satellite ? 0.75 : 0.4) + setSatellite(!satellite) + } + + return ( + <> +
+ + + ) +} diff --git a/src/fjellomrade-main.tsx b/src/fjellomrade-main.tsx new file mode 100644 index 0000000..0c36f55 --- /dev/null +++ b/src/fjellomrade-main.tsx @@ -0,0 +1,11 @@ +// Startpunkt for fjellområdesiden (fjellomrade.html), på samme måte som main.tsx er det for appen. +// Åpnes på http://localhost:5173/fjellomrade.html mens både backend (npm run dev:server:ts) og frontend (npm run dev) kjører. +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import Fjellomrade from './Fjellomrade.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/src/graphql.ts b/src/graphql.ts new file mode 100644 index 0000000..5539adf --- /dev/null +++ b/src/graphql.ts @@ -0,0 +1,18 @@ +// Felles hjelpefunksjon for å sende en GraphQL-spørring til backend. +// Vite sender /graphql videre til backend på port 4000 (se vite.config.ts). +type GraphQLResponse = { data?: T; errors?: { message: string }[] } + +export async function graphql(query: string, variables: Record = {}, signal?: AbortSignal): Promise { + const response = await fetch('/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, variables }), + signal, + }) + // Feil i spørringen kommer som JSON med «errors», også når statuskoden ikke er 200 + const result = await response.json().catch(() => null) as GraphQLResponse | null + if (result?.errors) throw new Error(result.errors[0].message) + if (!response.ok) throw new Error(`Serveren svarte ${response.status}. Kjører backend?`) + if (!result?.data) throw new Error('Tomt svar fra serveren') + return result.data +} diff --git a/src/mountainAreas.ts b/src/mountainAreas.ts new file mode 100644 index 0000000..5797eb8 --- /dev/null +++ b/src/mountainAreas.ts @@ -0,0 +1,32 @@ +// Fjellområdene (silver.mountain_areas), hentet fra backend. +import { graphql } from './graphql.ts' + +export type MountainArea = { + id: string + name: string + description: string | null + /** [vest, sør, øst, nord] i grader */ + bbox: [number, number, number, number] + /** Grensen som GeoJSON-tekst */ + geojson: string + hutCount: number + cabinCount: number + shelterCount: number + peakCount: number + highestPeak: { name: string; elevation: number } | null +} + +const QUERY = ` + query MountainAreas { + mountainAreas { + id name description bbox geojson + hutCount cabinCount shelterCount peakCount + highestPeak { name elevation } + } + } +` + +export async function fetchMountainAreas(): Promise { + const data = await graphql<{ mountainAreas: MountainArea[] }>(QUERY) + return data.mountainAreas +} diff --git a/vite.config.ts b/vite.config.ts index a88dda6..cc53980 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,11 +1,21 @@ import react, { reactCompilerPreset } from '@vitejs/plugin-react' import babel from '@rolldown/plugin-babel' +import { fileURLToPath } from 'node:url' import { defineConfig } from 'vite' // https://vite.dev/config/ export default defineConfig({ server: { proxy: { '/graphql': 'http://127.0.0.1:4000' } }, preview: { proxy: { '/graphql': 'http://127.0.0.1:4000' } }, + // Appen har flere HTML-sider, og alle må listes her for å bli med i npm run build + build: { + rollupOptions: { + input: { + main: fileURLToPath(new URL('./index.html', import.meta.url)), + fjellomrade: fileURLToPath(new URL('./fjellomrade.html', import.meta.url)), + }, + }, + }, plugins: [ react(), babel({ presets: [reactCompilerPreset()] }) From 8884330fdea6ee5a8914120def205c8bd1b9f7d4 Mon Sep 17 00:00:00 2001 From: karstenmidthaug Date: Fri, 9 Oct 2026 19:36:25 +0200 Subject: [PATCH 09/17] =?UTF-8?q?Dokumenter=20grenser=20og=20mellomlagring?= =?UTF-8?q?=20for=20fjellomr=C3=A5dene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d6f98f0..21c212f 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,10 @@ GraphQL-spørringen `routes` støtter tekstsøk, minimums- og maksimumslengde, k Rensede data som appen henter fra, ligger i skjemaet `silver`. SQL-filene som lager dem ligger i `server/sql/silver/`, og originaldataene endres ikke. -- `silver.osm_points_of_interest` er en materialisert visning med turisthytter, buer, gapahuker, teltplasser, topper, utsiktspunkter, turparkering, toaletter og drikkevann fra OpenStreetMap. Busskur, private steder og vanlig parkering er filtrert bort, og høyde og antall sengeplasser er gjort om til tall. Ved ny OSM-import oppdateres den med `REFRESH MATERIALIZED VIEW silver.osm_points_of_interest;`. +- `silver.osm_points_of_interest` er en materialisert visning med turisthytter, buer, gapahuker, teltplasser, topper, utsiktspunkter, turparkering, toaletter og drikkevann fra OpenStreetMap. Busskur, private steder og vanlig parkering er filtrert bort, og høyde og antall sengeplasser er gjort om til tall. Ved ny OSM-import oppdateres den med `REFRESH MATERIALIZED VIEW silver.osm_points_of_interest;`. Backend husker tallene for fjellområdene i 10 minutter, så start backend på nytt for å se endringer med en gang. - `silver.mountain_areas` inneholder 15 fjellområder, blant annet Jotunheimen, Rondane og Hardangervidda. -Grensene for fjellområdene er foreløpig omtrentlige. Det finnes ingen offisiell avgrensning av landskapsområder som «Jotunheimen», så hvert område er et håndlaget rektangel. Rektanglene er kontrollert mot punktene Kartverket har for området i stedsnavnregisteret (SSR), og kolonnen `ssr_id` peker til Kartverkets offisielle navn der området er registrert. Vi planlegger å erstatte rektanglene med faktiske grenser senere, for eksempel nasjonalpark- og verneområdegrenser fra Miljødirektoratet. Grensen ligger i én kolonne, så backend og frontend trenger ikke endres når den byttes ut. +Grensene for fjellområdene er foreløpig omtrentlige. Det finnes ingen offisiell avgrensning av landskapsområder som «Jotunheimen», så hvert område er et håndlaget rektangel. Rektanglene er kontrollert mot punktene Kartverket har for området i stedsnavnregisteret (SSR), og kolonnen `ssr_id` peker til Kartverkets offisielle navn der området er registrert. Vi planlegger å erstatte rektanglene med faktiske grenser senere, for eksempel nasjonalpark- og verneområdegrenser fra Miljødirektoratet. Grensen ligger i én kolonne, og både opptellingen og turpunktene på kartet hentes med `ST_Intersects` mot den, så backend og frontend trenger ikke endres når den byttes ut. Består en ny grense av flere deler, må kolonnetypen endres fra `Polygon` til `MultiPolygon`. ## Start databaseversjonen lokalt From c45e208d4db271efdd230277ce3990b87786b660 Mon Sep 17 00:00:00 2001 From: karstenmidthaug Date: Fri, 9 Oct 2026 19:36:34 +0200 Subject: [PATCH 10/17] =?UTF-8?q?Lenk=20til=20fjellomr=C3=A5dene=20fra=20t?= =?UTF-8?q?oppmenyen=20og=20fjern=20rest=20etter=20merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.css | 2 +- src/App.tsx | 3 +-- src/Profile.tsx | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/App.css b/src/App.css index d90ef13..beae444 100644 --- a/src/App.css +++ b/src/App.css @@ -3,4 +3,4 @@ header{height:90px;padding:0 5%;display:flex;align-items:center;justify-content: .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}} .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}} -.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} +.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} diff --git a/src/App.tsx b/src/App.tsx index 363b5ce..ddaf3dd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -199,8 +199,7 @@ export default function App() { return <> Hopp til ruteinformasjon -profil-og-database -
△ Norwegian Explorer
+
△ Norwegian Explorer

OPPDAG NORGE TIL FOTS

Ut på nye stier.

Utforsk registrerte turruter og bli kjent med veien videre.

FINN DIN NESTE TUR

Utforsk etter kategori

{(difficulty || routeType || county || municipality || minLength != null || maxLength != null) && }
diff --git a/src/Profile.tsx b/src/Profile.tsx index d798e00..9af2b31 100644 --- a/src/Profile.tsx +++ b/src/Profile.tsx @@ -47,7 +47,7 @@ export function AuthGate({ onAuthenticated }: { onAuthenticated: () => void }) { } return
-
△ Norwegian Explorer
+
△ Norwegian ExplorerFjellområder

OPPDAG NORGE TIL FOTS

{checking ? 'Velkommen tilbake.' : mode === 'login' ? 'Logg inn for å utforske.' : 'Opprett din profil.'}

Lagre favoritter og finn registrerte turruter fra hele Norge.

From 7b1e1f08a7346a4439302aec3047f73d75dd55d2 Mon Sep 17 00:00:00 2001 From: karstenmidthaug Date: Fri, 9 Oct 2026 19:44:11 +0200 Subject: [PATCH 11/17] =?UTF-8?q?Endret=20tittel=20p=C3=A5=20siden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Fjellomrade.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Fjellomrade.tsx b/src/Fjellomrade.tsx index e32da4e..e251d3f 100644 --- a/src/Fjellomrade.tsx +++ b/src/Fjellomrade.tsx @@ -179,7 +179,7 @@ function Overview({ areas, error, notFound, onOpen }: OverviewProps) { Min profil
-

Norges fjell

+

Fjellområder i Norge

Velg et fjellområde for å åpne kartet, med alle hyttene, buene, gapahukene og toppene på ett sted.

From d6ee617eddad461c39308ae71668d7765ee4d694 Mon Sep 17 00:00:00 2001 From: karstenmidthaug Date: Fri, 9 Oct 2026 19:46:06 +0200 Subject: [PATCH 12/17] Endret skriftstype --- src/Fjellomrade.css | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Fjellomrade.css b/src/Fjellomrade.css index 794390d..1f29bd3 100644 --- a/src/Fjellomrade.css +++ b/src/Fjellomrade.css @@ -1,5 +1,3 @@ -@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300..700&family=DM+Sans:opsz,wght@9..40,400..700&display=swap'); - /* Fargene er de samme som i resten av appen (index.css og App.css) */ :root { --bg: #f5f6f0; @@ -9,10 +7,12 @@ --soft: #edf2e9; --green: #126b51; --copper: #c27730; - --serif: 'Fraunces', Georgia, serif; + /* Skrifttyper som allerede finnes på maskinen, så ingenting må lastes ned */ + --serif: 'Iowan Old Style', 'Palatino Linotype', Georgia, serif; + --sans: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; --shadow: 0 12px 36px -14px rgba(35, 58, 48, 0.45); } -body { margin: 0; background: var(--bg); color: var(--text); font-family: 'DM Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; } +body { margin: 0; background: var(--bg); color: var(--text); font-family: var(--sans); -webkit-font-smoothing: antialiased; } .fo-page *, .fo-mapview * { box-sizing: border-box; } .fo-page a, .fo-mapview a { color: inherit; text-decoration: none; } .fo-page button, .fo-page input, .fo-mapview button { font: inherit; color: inherit; } @@ -95,7 +95,7 @@ body { margin: 0; background: var(--bg); color: var(--text); font-family: 'DM Sa .fo-peak .fo-kicker { margin: 0; } .fo-peak strong { font-family: var(--serif); font-weight: 400; font-size: 20px; } .fo-peak-elev { font-family: var(--serif); font-size: 24px; color: var(--green); white-space: nowrap; } -.fo-peak-elev small { font-family: 'DM Sans', sans-serif; font-size: 12px; color: var(--muted); } +.fo-peak-elev small { font-family: var(--sans); font-size: 12px; color: var(--muted); } .fo-tabs { display: flex; gap: 4px; padding: 4px; border-radius: 12px; background: var(--soft); } .fo-tabs button { flex: 1; min-height: 34px; border: 0; border-radius: 9px; background: none; font-weight: 600; font-size: 12px; color: var(--muted); cursor: pointer; } .fo-tabs button span { opacity: 0.6; font-weight: 500; } @@ -122,7 +122,7 @@ body { margin: 0; background: var(--bg); color: var(--text); font-family: 'DM Sa /* Boksen som vises når musepekeren er over et punkt */ .fo-hover { pointer-events: none; } -.fo-hover .maplibregl-popup-content { padding: 8px 12px; border-radius: 10px; box-shadow: var(--shadow); font-family: 'DM Sans', system-ui, sans-serif; } +.fo-hover .maplibregl-popup-content { padding: 8px 12px; border-radius: 10px; box-shadow: var(--shadow); font-family: var(--sans); } .fo-hover strong { display: block; font-size: 14px; color: var(--text); } .fo-hover small { font-size: 12px; color: var(--muted); } From ccd921f0cda1826f908c20790ca9edd90500608e Mon Sep 17 00:00:00 2001 From: karstenmidthaug Date: Fri, 9 Oct 2026 19:53:37 +0200 Subject: [PATCH 13/17] =?UTF-8?q?oppdaterte=20graphql-yoga=20etter=20s?= =?UTF-8?q?=C3=A5rbarhet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 115 ++++++++++++++++++++-------------------------- 1 file changed, 49 insertions(+), 66 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6f2e021..c225cea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -475,16 +475,16 @@ "license": "MIT" }, "node_modules/@graphql-tools/executor": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/executor/-/executor-2.0.1.tgz", - "integrity": "sha512-rvlOaoTUXUUHlu7090e94566SeBnU9PV6xMztIt2s8StXy5HbP0p+tYSfg3N/gCdEzJ78ZQLjXJz4UFgWGZ/7A==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor/-/executor-2.0.3.tgz", + "integrity": "sha512-0qUCtalTDdanDuvW4EiHnNMct8QHYKTuGjI2lXU388LrpoV+5tlqp/xPqdYbrDtl3OBpZHToOEBcQunWz1UDig==", "license": "MIT", "dependencies": { - "@graphql-tools/utils": "^12.0.1", + "@graphql-tools/utils": "^12.0.3", "@graphql-typed-document-node/core": "^3.2.0", "@repeaterjs/repeater": "^3.1.0", - "@whatwg-node/disposablestack": "^0.0.6", - "@whatwg-node/promise-helpers": "^1.0.0", + "@whatwg-node/disposablestack": "^0.1.0", + "@whatwg-node/promise-helpers": "^2.0.0", "tslib": "^2.4.0" }, "engines": { @@ -494,49 +494,38 @@ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-tools/executor/node_modules/@graphql-tools/utils": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-12.0.1.tgz", - "integrity": "sha512-8YC6jn4xYDS6YTY6xDAgQAs/nGgvzRaIGHS8GeSfVItQzNK7Ms24CQtE3EJY+amvR+tBnhRSX0N83aGj+V/RIg==", + "node_modules/@graphql-tools/executor/node_modules/@whatwg-node/disposablestack": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.1.0.tgz", + "integrity": "sha512-ok0A9+D+mdeb3w1GNCpVr+2gVVLRWKo9Ywm/whNsoWSNfNg3jNxS0/wUF6NSUYbfOtFGu2wZgJnLjasbaQEfvA==", "license": "MIT", "dependencies": { - "@graphql-typed-document-node/core": "^3.1.1", - "@whatwg-node/promise-helpers": "^1.0.0", - "cross-inspect": "1.0.1", - "tslib": "^2.4.0" + "@whatwg-node/promise-helpers": "^2.0.0", + "tslib": "^2.6.3" }, "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + "node": ">=22.15.0" } }, - "node_modules/@graphql-tools/merge": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.2.4.tgz", - "integrity": "sha512-vV+8uWNWn0+OsqT0r22lZuoT0cTe6fBqBtpLHre2rriLjI/ZTrsOHmabLPTUOyzgFnRrS2PzFSYGL3zKL1Wj4Q==", + "node_modules/@graphql-tools/executor/node_modules/@whatwg-node/promise-helpers": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-2.0.0.tgz", + "integrity": "sha512-/i9CS0Z96o+cPwaYoAyVxAThDiUX1wV3tDmNWPJbn+vtdR+JftuCDg1NGCXBrzIYVDGkJ8uT6ctwmrBOUzOpKQ==", "license": "MIT", "dependencies": { - "@graphql-tools/utils": "^12.0.1", - "tslib": "^2.4.0" + "tslib": "^2.6.3" }, "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + "node": ">=22.15.0" } }, - "node_modules/@graphql-tools/merge/node_modules/@graphql-tools/utils": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-12.0.1.tgz", - "integrity": "sha512-8YC6jn4xYDS6YTY6xDAgQAs/nGgvzRaIGHS8GeSfVItQzNK7Ms24CQtE3EJY+amvR+tBnhRSX0N83aGj+V/RIg==", + "node_modules/@graphql-tools/merge": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.2.6.tgz", + "integrity": "sha512-M7iI/qoCnks6iITkimtdZgI2SrWOInr+QRWuhEsInaumzQGPd0pnWbRAVTRVcFN2Gu9hM2aS5kYzASC93d9epw==", "license": "MIT", "dependencies": { - "@graphql-typed-document-node/core": "^3.1.1", - "@whatwg-node/promise-helpers": "^1.0.0", - "cross-inspect": "1.0.1", + "@graphql-tools/utils": "^12.0.3", "tslib": "^2.4.0" }, "engines": { @@ -547,13 +536,13 @@ } }, "node_modules/@graphql-tools/schema": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.1.1.tgz", - "integrity": "sha512-24jJghRxEW+SG1lbJ45Zg9HEZ8ZKS923ihbFjOicspFLpryxJkWp6gZJ7D8tCVaSAhli4x1+1wDPAfhT8mLqxg==", + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.1.3.tgz", + "integrity": "sha512-N1ZpzguxzqTy5fpurqWQ5BXyjirlrtvVLeSafsu+SCDNjDeRnVxf9n5PVbdgzwFPAmP/7NK9sq9UB/kyR8oFiw==", "license": "MIT", "dependencies": { - "@graphql-tools/merge": "^9.2.4", - "@graphql-tools/utils": "^12.0.1", + "@graphql-tools/merge": "^9.2.6", + "@graphql-tools/utils": "^12.0.3", "tslib": "^2.4.0" }, "engines": { @@ -563,14 +552,14 @@ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-tools/schema/node_modules/@graphql-tools/utils": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-12.0.1.tgz", - "integrity": "sha512-8YC6jn4xYDS6YTY6xDAgQAs/nGgvzRaIGHS8GeSfVItQzNK7Ms24CQtE3EJY+amvR+tBnhRSX0N83aGj+V/RIg==", + "node_modules/@graphql-tools/utils": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-12.0.3.tgz", + "integrity": "sha512-M+04bHvI1SKoug7W2EkwfKjig2mdtgIIlFY8p2jLHmxPNVs5bC/3R+58JrD+7ZXWgPILWTwuqJ9AqTjPlahroA==", "license": "MIT", "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", - "@whatwg-node/promise-helpers": "^1.0.0", + "@whatwg-node/promise-helpers": "^2.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, @@ -581,22 +570,16 @@ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-tools/utils": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.2.2.tgz", - "integrity": "sha512-Do2A/t6ayqOma8m7PvpYMsCBswL+NB35BQSng4GWSBqafL2/BnfAZy/NSENPwV721Rm2fG0BHETFC0+FJJZmLw==", + "node_modules/@graphql-tools/utils/node_modules/@whatwg-node/promise-helpers": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-2.0.0.tgz", + "integrity": "sha512-/i9CS0Z96o+cPwaYoAyVxAThDiUX1wV3tDmNWPJbn+vtdR+JftuCDg1NGCXBrzIYVDGkJ8uT6ctwmrBOUzOpKQ==", "license": "MIT", "dependencies": { - "@graphql-typed-document-node/core": "^3.1.1", - "@whatwg-node/promise-helpers": "^1.0.0", - "cross-inspect": "1.0.1", - "tslib": "^2.4.0" + "tslib": "^2.6.3" }, "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + "node": ">=22.15.0" } }, "node_modules/@graphql-typed-document-node/core": { @@ -2282,16 +2265,16 @@ } }, "node_modules/graphql-yoga": { - "version": "5.24.1", - "resolved": "https://registry.npmjs.org/graphql-yoga/-/graphql-yoga-5.24.1.tgz", - "integrity": "sha512-NA2aQ4sZvR/bniCCLOlMG6OSZf5hPg3mUf3Phm33ObqRMZEq6WtZrj2hTcHfT2ZEFVV5FRCbcMb4NB1w7BIKaA==", + "version": "5.24.4", + "resolved": "https://registry.npmjs.org/graphql-yoga/-/graphql-yoga-5.24.4.tgz", + "integrity": "sha512-jnpLm2g9BfnP18VKL+Yao8YRQYgDjXdAkFgZso/tnKFcRcAtOeUKLDVc1XnYiE3uooXzfIQzsvpKR+fC3we4QA==", "license": "MIT", "dependencies": { "@envelop/core": "^5.6.1", "@envelop/instrumentation": "^1.0.1", - "@graphql-tools/executor": "^2.0.0", - "@graphql-tools/schema": "^10.0.11", - "@graphql-tools/utils": "^11.2.0", + "@graphql-tools/executor": "^2.0.3", + "@graphql-tools/schema": "^10.1.3", + "@graphql-tools/utils": "^12.0.3", "@graphql-yoga/logger": "^2.0.2", "@graphql-yoga/subscription": "^5.1.1", "@whatwg-node/fetch": "^0.10.6", @@ -3319,9 +3302,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { From 9e31cf09153e5c49c91fbac746142f06339e3d24 Mon Sep 17 00:00:00 2001 From: karstenmidthaug Date: Sat, 10 Oct 2026 10:57:16 +0200 Subject: [PATCH 14/17] byttet POI med ikoner --- src/Fjellomrade.tsx | 44 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/src/Fjellomrade.tsx b/src/Fjellomrade.tsx index e251d3f..ddcc25e 100644 --- a/src/Fjellomrade.tsx +++ b/src/Fjellomrade.tsx @@ -26,16 +26,38 @@ type PointOfInterest = { capacity: number | null } -// Navn og farge for hver type. Rekkefølgen her er rekkefølgen i fanene. -const KINDS: Record = { - HUT: { short: 'Hytter', one: 'Turisthytte', color: '#c0392b' }, - CABIN: { short: 'Buer', one: 'Bu / koie', color: '#c27730' }, - SHELTER: { short: 'Gapahuker', one: 'Gapahuk', color: '#2f8f5b' }, - PEAK: { short: 'Topper', one: 'Topp', color: '#203b32' }, +// Navn, farge og ikon for hver type. Rekkefølgen her er rekkefølgen i fanene. +// Ikonet er en SVG-sti i et rutenett på 24 × 24 som tegnes med hvit strek i en farget sirkel på kartet. +const KINDS: Record = { + HUT: { short: 'Hytter', one: 'Turisthytte', color: '#c0392b', icon: 'M5 12 12 6l7 6M7 11v7h10v-7M11 18v-4h2v4' }, + CABIN: { short: 'Buer', one: 'Bu / koie', color: '#c27730', icon: 'M6 18v-6l6-5 6 5v6zM10 18v-3h4v3' }, + SHELTER: { short: 'Gapahuker', one: 'Gapahuk', color: '#2f8f5b', icon: 'M5 18 12 6l7 12M9 18l3-5 3 5' }, + PEAK: { short: 'Topper', one: 'Topp', color: '#203b32', icon: 'm4 18 6-10 3 5 2-3 5 8z' }, } const ALL_KINDS = Object.keys(KINDS) as PoiKind[] const COLOR_BY_KIND = ['match', ['get', 'kind'], ...ALL_KINDS.flatMap(k => [k, KINDS[k].color]), '#999'] as unknown as ExpressionSpecification +// Tegner kartikonet for en type: farget sirkel med hvit kant og hvitt symbol. Tegnes dobbelt så stort +// som det vises (pixelRatio 2 i addImage), så det blir skarpt på skjermer med høy oppløsning. +function drawIcon(kind: PoiKind) { + const size = 48 + const ctx = document.createElement('canvas').getContext('2d')! + ctx.canvas.width = ctx.canvas.height = size + ctx.beginPath() + ctx.arc(size / 2, size / 2, size / 2 - 3, 0, Math.PI * 2) + ctx.fillStyle = KINDS[kind].color + ctx.fill() + ctx.lineWidth = 4 + ctx.strokeStyle = '#fff' + ctx.stroke() + // Symbolet: skaler 24 × 24-rutenettet ned til midten av sirkelen + ctx.setTransform(1.4, 0, 0, 1.4, 7, 7) + ctx.lineWidth = 2 + ctx.lineCap = ctx.lineJoin = 'round' + ctx.stroke(new Path2D(KINDS[kind].icon)) + return ctx.getImageData(0, 0, size, size) +} + // Antallet som allerede er talt opp i databasen for hvert område, per type const countOf = (area: MountainArea, kind: PoiKind) => ({ HUT: area.hutCount, CABIN: area.cabinCount, SHELTER: area.shelterCount, PEAK: area.peakCount })[kind] @@ -358,9 +380,15 @@ function AreaMap({ area, points, selected, panelOpen, onSelect }: MapProps) { map.addSource('pois', { type: 'geojson', data: pointsToGeoJson([]) }) // Ring rundt det valgte punktet. Filteret settes når et punkt velges. map.addLayer({ id: 'pois-selected', type: 'circle', source: 'pois', filter: ['==', ['get', 'id'], ''], paint: { 'circle-radius': 16, 'circle-color': COLOR_BY_KIND, 'circle-opacity': 0.25 } }) + // Ett ikon per type (poi-HUT, poi-CABIN …), som vokser litt når man zoomer inn + for (const kind of ALL_KINDS) map.addImage(`poi-${kind}`, drawIcon(kind), { pixelRatio: 2 }) map.addLayer({ - id: 'pois', type: 'circle', source: 'pois', - paint: { 'circle-color': COLOR_BY_KIND, 'circle-radius': ['interpolate', ['linear'], ['zoom'], 8, 4, 13, 8], 'circle-stroke-color': '#fff', 'circle-stroke-width': 2 }, + id: 'pois', type: 'symbol', source: 'pois', + layout: { + 'icon-image': ['concat', 'poi-', ['get', 'kind']], + 'icon-size': ['interpolate', ['linear'], ['zoom'], 8, 0.7, 13, 1.1], + 'icon-allow-overlap': true, + }, }) // Kartet mister tomme verdier i egenskapene, så vi slår opp punktet på id i stedet map.on('click', 'pois', event => onSelectRef.current(String(event.features?.[0]?.properties.id))) From acf29c3a157348bac073caab3f992d84771ad54c Mon Sep 17 00:00:00 2001 From: karstenmidthaug Date: Sat, 10 Oct 2026 11:37:07 +0200 Subject: [PATCH 15/17] =?UTF-8?q?Bruk=20grenser=20fra=20OSM=20og=20Milj?= =?UTF-8?q?=C3=B8direktoratet=20for=20fjellomr=C3=A5dene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/sql/silver/mountain_areas.sql | 67 +++++++++++++++++++--------- 1 file changed, 46 insertions(+), 21 deletions(-) diff --git a/server/sql/silver/mountain_areas.sql b/server/sql/silver/mountain_areas.sql index be485ce..04348bf 100644 --- a/server/sql/silver/mountain_areas.sql +++ b/server/sql/silver/mountain_areas.sql @@ -1,8 +1,13 @@ -- Fjellområder i Norge, brukt til oversikten over fjellområder og til å zoome kartet inn på ett område. -- --- Det finnes ingen offisielle grenser for f.eks. «Jotunheimen», så grensene er håndlagde rektangler. --- De er kontrollert mot punktene Kartverket har for området i stedsnavnregisteret (SSR), --- og ssr_id peker til Kartverkets offisielle navn der det finnes (api.kartverket.no/stedsnavn). +-- Det finnes ingen offisielle grenser for f.eks. «Jotunheimen», så vi bruker det nærmeste som finnes: +-- * OpenStreetMap (natural=mountain_range) for Jotunheimen, Rondane, Hardangervidda og Dovrefjell. +-- © OpenStreetMap-bidragsytere, ODbL. Hentet via nominatim.openstreetmap.org. +-- * Nasjonalparker og landskapsvernområder fra Miljødirektoratet (Naturvernområder, NLOD) for de fleste andre. +-- * Et håndlaget rektangel der ingen av kildene har en grense som passer. +-- Mange hytter ligger like utenfor vernegrensene, så grensene fra OSM og Miljødirektoratet utvides med 3 km +-- nederst i fila. Grensene er forenklet til omtrent 100 m nøyaktighet, så fila holder seg liten. +-- ssr_id peker til Kartverkets offisielle navn der det finnes (api.kartverket.no/stedsnavn). -- -- Kjør HELE filen i DBeaver med Option+X («Execute SQL Script»), ikke Cmd+Enter. -- Filen er kilden til dataene: endre her og kjør på nytt, ikke rediger tabellen direkte. @@ -11,29 +16,49 @@ DROP TABLE IF EXISTS silver.mountain_areas; CREATE TABLE silver.mountain_areas ( - id text PRIMARY KEY, -- f.eks. 'jotunheimen', brukes i URL-en + id text PRIMARY KEY, -- f.eks. 'jotunheimen', brukes i URL-en name text NOT NULL, description text, - ssr_id int, -- Kartverkets stedsnummer, NULL hvis Kartverket ikke har området - area geometry(Polygon, 4326) NOT NULL -- rektangel: ST_MakeEnvelope(vest, sør, øst, nord, 4326) + ssr_id int, -- Kartverkets stedsnummer, NULL hvis Kartverket ikke har området + area geometry(MultiPolygon, 4326) NOT NULL -- MultiPolygon fordi noen grenser består av flere deler ); INSERT INTO silver.mountain_areas (id, name, description, ssr_id, area) VALUES - ('jotunheimen', 'Jotunheimen', 'Nord-Europas høyeste fjell, med Galdhøpiggen og Glittertind og et tett nett av DNT-hytter.', 328584, ST_MakeEnvelope(7.9, 61.35, 9.1, 61.8, 4326)), - ('rondane', 'Rondane', 'Norges første nasjonalpark. Runde, karrige topper over 2000 moh. og lange dalfører.', 435681, ST_MakeEnvelope(9.55, 61.75, 10.1, 62.1, 4326)), - ('hardangervidda', 'Hardangervidda', 'Europas største høyfjellsplatå med villrein, vann og lange hytte-til-hytte-ruter.', 85109, ST_MakeEnvelope(6.9, 59.9, 8.3, 60.6, 4326)), - ('dovrefjell', 'Dovrefjell', 'Snøhetta og moskus. Høyfjell mellom Østlandet og Trøndelag.', 450696, ST_MakeEnvelope(8.85, 62.15, 9.9, 62.45, 4326)), - ('trollheimen', 'Trollheimen', 'Alpine topper og klassiske DNT-ruter mellom Trollheimshytta, Gjevilvasshytta og Jøldalshytta.', 814547, ST_MakeEnvelope(8.7, 62.6, 9.4, 62.95, 4326)), - ('romsdalsalpane', 'Romsdalsalpane', 'Bratte tinder rundt Romsdalen, Romsdalseggen og Trollveggen.', NULL, ST_MakeEnvelope(7.4, 62.35, 8.1, 62.65, 4326)), - ('sunnmorsalpane', 'Sunnmørsalpane', 'Spisse tinder som stuper i fjordene. Kjent for toppturer.', NULL, ST_MakeEnvelope(6.2, 62.0, 7.1, 62.4, 4326)), - ('breheimen', 'Breheimen', 'Breer og villmark mellom Jotunheimen og Jostedalsbreen.', 572299, ST_MakeEnvelope(7.3, 61.55, 8.1, 61.95, 4326)), - ('skarvheimen', 'Skarvheimen', 'Høyfjell mellom Hallingskarvet og Aurlandsdalen.', 324756, ST_MakeEnvelope(7.5, 60.6, 8.4, 61.0, 4326)), - ('setesdalsheiane', 'Setesdalsheiane', 'Store heiområder med villrein og mange ubetjente hytter.', 1094317, ST_MakeEnvelope(7.0, 58.95, 7.9, 59.8, 4326)), - ('femundsmarka', 'Femundsmarka', 'Skog, myr og vann. Villmark ved svenskegrensen.', NULL, ST_MakeEnvelope(11.6, 62.0, 12.3, 62.45, 4326)), - ('blafjella-skjaekerfjella', 'Blåfjella–Skjækerfjella', 'Stor og lite besøkt villmark i Trøndelag.', NULL, ST_MakeEnvelope(13.0, 64.0, 13.9, 64.5, 4326)), - ('saltfjellet', 'Saltfjellet', 'Høyfjell og isbreer rundt polarsirkelen.', 622606, ST_MakeEnvelope(14.8, 66.5, 15.9, 66.95, 4326)), - ('lofoten', 'Lofoten', 'Tinder rett opp fra havet og korte, bratte turer.', 26064, ST_MakeEnvelope(12.8, 67.85, 15.5, 68.45, 4326)), - ('lyngsalpan', 'Lyngsalpan', 'Alpine tinder og breer i Troms, kjent for toppturer.', 444653, ST_MakeEnvelope(19.6, 69.2, 20.5, 70.0, 4326)); + -- OpenStreetMap, natural=mountain_range (relasjon 8512564) + ('jotunheimen', 'Jotunheimen', 'Nord-Europas høyeste fjell, med Galdhøpiggen og Glittertind og et tett nett av DNT-hytter.', 328584, ST_Multi(ST_GeomFromGeoJSON('{"type":"MultiPolygon","coordinates":[[[[7.73878,61.43101],[7.74283,61.4127],[7.79351,61.41191],[7.82111,61.42118],[7.83627,61.40654],[7.8356,61.39843],[7.86419,61.39613],[7.88274,61.37668],[7.9128,61.39125],[7.90769,61.39952],[7.87764,61.41001],[7.867,61.42897],[7.91657,61.41337],[7.93896,61.417],[7.95656,61.42577],[7.96228,61.43437],[7.95932,61.45599],[7.97791,61.45812],[7.98034,61.46933],[7.92918,61.48463],[8.00367,61.50315],[8.00713,61.52135],[8.024,61.52621],[8.03729,61.52178],[8.05237,61.4999],[8.16773,61.53043],[8.15208,61.50058],[8.1513,61.47838],[8.14275,61.46816],[8.13974,61.46144],[8.12217,61.44451],[8.11049,61.42307],[8.12319,61.4024],[8.1562,61.39808],[8.18614,61.40118],[8.21151,61.40798],[8.27022,61.40116],[8.32986,61.40308],[8.38108,61.41607],[8.40991,61.41816],[8.46619,61.39774],[8.51161,61.4028],[8.55406,61.39635],[8.57984,61.38906],[8.6207,61.39273],[8.64648,61.39143],[8.69944,61.39515],[8.68722,61.4236],[8.72563,61.44251],[8.71884,61.46198],[8.74579,61.45997],[8.75452,61.46153],[8.7576,61.46311],[8.76376,61.47538],[8.75526,61.47998],[8.79917,61.48689],[8.80848,61.51337],[8.80861,61.52491],[8.79198,61.55677],[8.83702,61.59774],[8.85363,61.64391],[8.84918,61.65384],[8.73929,61.65978],[8.67427,61.69807],[8.63805,61.70641],[8.6038,61.73402],[8.5571,61.7171],[8.49658,61.71013],[8.4719,61.66605],[8.44779,61.66208],[8.44006,61.63972],[8.42099,61.61451],[8.40514,61.60892],[8.39451,61.61233],[8.38548,61.63574],[8.35242,61.65673],[8.33465,61.6591],[8.33639,61.66685],[8.25645,61.65786],[8.21243,61.62991],[8.20586,61.60126],[8.22876,61.58672],[8.26958,61.57078],[8.26759,61.56415],[8.26784,61.54452],[8.26452,61.54117],[8.26255,61.53386],[8.19531,61.54767],[8.17461,61.57214],[8.11915,61.57161],[8.06847,61.56098],[7.99775,61.54906],[7.90379,61.5268],[7.84651,61.51649],[7.8927,61.50643],[7.89598,61.49882],[7.85054,61.48706],[7.84158,61.47823],[7.80627,61.47964],[7.79548,61.46443],[7.77561,61.46804],[7.75808,61.46615],[7.7507,61.44847],[7.76048,61.44157],[7.73878,61.43101]]]]}'))), + -- OpenStreetMap, natural=mountain_range (relasjon 8512563) + ('rondane', 'Rondane', 'Norges første nasjonalpark. Runde, karrige topper over 2000 moh. og lange dalfører.', 435681, ST_Multi(ST_GeomFromGeoJSON('{"type":"MultiPolygon","coordinates":[[[[9.37878,61.93398],[9.45244,61.91473],[9.50001,61.89608],[9.56677,61.89896],[9.58572,61.88793],[9.56636,61.88243],[9.51779,61.87997],[9.5265,61.86866],[9.49925,61.86058],[9.49944,61.85458],[9.53059,61.84584],[9.52622,61.84221],[9.53159,61.84069],[9.52112,61.83585],[9.54168,61.82765],[9.56088,61.82731],[9.61254,61.83455],[9.60824,61.83672],[9.63815,61.84394],[9.64628,61.8387],[9.64177,61.83497],[9.63593,61.83375],[9.64018,61.82471],[9.64233,61.82412],[9.64513,61.82469],[9.64416,61.82626],[9.64545,61.8278],[9.65697,61.82852],[9.6685,61.82296],[9.67438,61.83448],[9.69157,61.83367],[9.71059,61.82756],[9.72584,61.83739],[9.73737,61.83475],[9.71965,61.82456],[9.74558,61.80949],[9.74436,61.80686],[9.7476,61.80311],[9.74669,61.79608],[9.74925,61.78992],[9.74742,61.785],[9.78967,61.77576],[9.81175,61.76826],[9.83869,61.76613],[9.9265,61.73523],[9.9555,61.75351],[9.98706,61.74793],[10.02712,61.72271],[10.05941,61.68988],[10.08493,61.69104],[10.12471,61.71164],[10.13979,61.71668],[10.14071,61.71776],[10.13847,61.7172],[10.13774,61.71866],[10.13953,61.72074],[10.13809,61.72473],[10.14037,61.72639],[10.13943,61.72814],[10.14059,61.72927],[10.13721,61.73279],[10.13893,61.73479],[10.1551,61.73824],[10.16241,61.75356],[10.1915,61.78656],[10.19198,61.81021],[10.19506,61.82034],[10.1776,61.82812],[10.15495,61.84336],[10.16826,61.85283],[10.16528,61.87878],[10.15593,61.8815],[10.11923,61.88126],[10.1133,61.88624],[10.07954,61.90487],[10.05178,61.91331],[10.04945,61.91033],[10.04074,61.90779],[10.01723,61.90837],[10.02154,61.89142],[10.01588,61.88445],[10.01757,61.88269],[10.0103,61.88374],[10.01126,61.88522],[9.97871,61.91593],[9.95873,61.92551],[9.9616,61.92944],[9.97644,61.92886],[9.98887,61.9332],[10.00434,61.93509],[10.00533,61.94537],[10.00899,61.95022],[10.01483,61.95332],[10.00698,61.95349],[10.00427,61.95508],[9.99295,61.95389],[9.99887,61.98338],[9.99686,61.99214],[9.97193,62.0087],[9.92802,62.00634],[9.91018,62.01265],[9.8417,62.00565],[9.82149,61.99959],[9.8144,61.99929],[9.80679,61.99612],[9.80596,61.99439],[9.79863,62.00028],[9.81826,62.00515],[9.84014,62.01346],[9.86628,62.01579],[9.92752,62.04316],[9.95739,62.04036],[9.94816,62.04778],[9.91685,62.06279],[9.91068,62.06337],[9.91035,62.06073],[9.89405,62.05869],[9.88535,62.06],[9.86219,62.05969],[9.8521,62.0637],[9.83327,62.06647],[9.83136,62.0695],[9.82945,62.06783],[9.82354,62.06926],[9.81859,62.06819],[9.80641,62.07207],[9.80041,62.07635],[9.77455,62.07569],[9.75268,62.08455],[9.72433,62.08023],[9.72105,62.08176],[9.71066,62.08165],[9.69776,62.08561],[9.69285,62.08434],[9.69729,62.07621],[9.68176,62.07346],[9.64739,62.07528],[9.63785,62.08029],[9.6322,62.07823],[9.62117,62.06365],[9.61838,62.06379],[9.6169,62.06133],[9.61104,62.0608],[9.60937,62.05809],[9.60263,62.05702],[9.58371,62.05929],[9.57399,62.06238],[9.55213,62.05849],[9.54672,62.05984],[9.54691,62.06094],[9.53034,62.06002],[9.52632,62.05575],[9.51081,62.04741],[9.49342,62.04171],[9.49422,62.03955],[9.47647,62.03292],[9.44751,62.03023],[9.45431,62.0071],[9.55784,62.01697],[9.55677,62.0263],[9.56138,62.03035],[9.57098,62.03644],[9.58444,62.03875],[9.59907,62.03884],[9.62569,62.03437],[9.64515,62.03686],[9.68207,62.03595],[9.68251,62.03491],[9.66962,62.03171],[9.66098,62.02662],[9.63155,62.01613],[9.59988,62.01291],[9.55744,62.01346],[9.51973,62.00637],[9.46007,61.98989],[9.37878,61.93398]]]]}'))), + -- OpenStreetMap, natural=mountain_range (relasjon 8512565) + ('hardangervidda', 'Hardangervidda', 'Europas største høyfjellsplatå med villrein, vann og lange hytte-til-hytte-ruter.', 85109, ST_Multi(ST_GeomFromGeoJSON('{"type":"MultiPolygon","coordinates":[[[[6.68391,60.28677],[6.71416,60.25731],[6.74561,60.25362],[6.80716,60.21776],[6.88417,60.17941],[6.92385,60.13499],[6.96537,60.12391],[6.97791,60.12271],[6.99139,60.10938],[6.93545,60.09791],[6.89995,60.08601],[6.89515,60.06086],[6.87175,60.04724],[6.77216,60.04103],[6.73218,60.01856],[6.73321,60.0084],[6.74285,59.98438],[6.76458,59.96745],[6.79997,59.93206],[6.86251,59.92028],[6.89954,59.94346],[6.92294,59.96025],[6.97087,59.96794],[7.00506,59.95973],[7.01583,59.92866],[7.0361,59.90181],[7.11427,59.88508],[7.19473,59.88093],[7.23008,59.88409],[7.24101,59.89274],[7.26532,59.92817],[7.35554,59.93349],[7.4415,59.93148],[7.62371,59.88506],[7.69608,59.8562],[7.72521,59.85767],[7.74753,59.8657],[7.77562,59.8842],[7.78766,59.88783],[7.79661,59.92424],[7.83747,59.93401],[7.83779,59.94164],[7.80714,59.97852],[7.81669,60.03959],[7.91154,60.0399],[8.04377,60.0757],[8.13262,60.13112],[8.11954,60.14778],[8.06888,60.17686],[8.19283,60.21403],[8.26828,60.27462],[8.28645,60.29822],[8.29844,60.34361],[8.13162,60.3614],[8.10131,60.3941],[8.04865,60.40904],[7.94745,60.39509],[7.85168,60.38441],[7.85397,60.31733],[7.68887,60.31527],[7.60892,60.30137],[7.5636,60.30107],[7.56209,60.287],[7.5574,60.28688],[7.55675,60.28894],[7.55071,60.29066],[7.54645,60.28986],[7.54618,60.29114],[7.53402,60.29564],[7.53293,60.29804],[7.48614,60.29596],[7.38245,60.37193],[7.32576,60.39329],[7.26877,60.37747],[7.19077,60.34642],[7.14792,60.3429],[7.09539,60.34578],[6.99799,60.36225],[6.93887,60.37464],[6.83187,60.36373],[6.79809,60.36422],[6.71589,60.33061],[6.70524,60.30373],[6.68391,60.28677]]]]}'))), + -- OpenStreetMap, natural=mountain_range (relasjon 8512562) + ('dovrefjell', 'Dovrefjell', 'Snøhetta og moskus. Høyfjell mellom Østlandet og Trøndelag.', 450696, ST_Multi(ST_GeomFromGeoJSON('{"type":"MultiPolygon","coordinates":[[[[8.51721,62.55438],[8.58254,62.50012],[8.60938,62.45616],[8.6435,62.39213],[8.65176,62.37461],[8.65144,62.37183],[8.7201,62.33977],[8.73761,62.29933],[8.77776,62.27142],[8.82936,62.25308],[8.8769,62.22861],[8.91031,62.21553],[8.93651,62.2133],[8.93698,62.2175],[8.93909,62.22024],[8.9461,62.22549],[8.96312,62.2325],[8.95985,62.23698],[8.96171,62.23758],[8.98938,62.24216],[8.99967,62.23803],[8.95394,62.21828],[8.95264,62.21651],[8.94636,62.16756],[8.9848,62.15432],[9.04833,62.16244],[9.07348,62.16364],[9.12803,62.13478],[9.15492,62.14124],[9.224,62.17985],[9.2244,62.19822],[9.24316,62.20478],[9.26503,62.20542],[9.28041,62.19955],[9.30554,62.19754],[9.32033,62.19435],[9.37319,62.19015],[9.39458,62.19358],[9.40412,62.19357],[9.41512,62.1963],[9.44701,62.19781],[9.45584,62.2015],[9.46535,62.20221],[9.45885,62.22241],[9.45108,62.22332],[9.45326,62.226],[9.46217,62.22915],[9.47397,62.23592],[9.45374,62.24706],[9.45577,62.24954],[9.45333,62.25093],[9.45407,62.25208],[9.47393,62.25619],[9.46591,62.26376],[9.45077,62.26988],[9.42054,62.2717],[9.41837,62.27277],[9.42023,62.27558],[9.4183,62.27887],[9.41296,62.28171],[9.40089,62.2826],[9.38748,62.28787],[9.34978,62.29334],[9.3487,62.2955],[9.37998,62.29746],[9.39391,62.29545],[9.41322,62.29049],[9.43446,62.27932],[9.46451,62.27031],[9.47498,62.26352],[9.49606,62.2693],[9.51259,62.26804],[9.54043,62.26274],[9.54998,62.26368],[9.55495,62.26815],[9.57564,62.2803],[9.58446,62.28036],[9.59025,62.28394],[9.59083,62.28717],[9.58903,62.29037],[9.57858,62.29611],[9.57803,62.30117],[9.58503,62.30942],[9.60523,62.32172],[9.59935,62.34253],[9.61434,62.35216],[9.62432,62.36593],[9.62422,62.36851],[9.61451,62.38581],[9.59076,62.396],[9.50931,62.40817],[9.49324,62.44342],[9.49821,62.45378],[9.50978,62.46469],[9.47601,62.45597],[9.46824,62.45523],[9.45412,62.45739],[9.42605,62.45294],[9.38454,62.44238],[9.37562,62.442],[9.37157,62.44331],[9.38056,62.44971],[9.36682,62.45105],[9.40336,62.45902],[9.41615,62.4729],[9.44291,62.47264],[9.52026,62.47957],[9.54197,62.48412],[9.5484,62.48923],[9.55717,62.50701],[9.57058,62.5177],[9.57548,62.52573],[9.57235,62.52769],[9.55604,62.53118],[9.55874,62.53409],[9.58155,62.53864],[9.588,62.54423],[9.59022,62.54959],[9.58924,62.55325],[9.57352,62.56113],[9.49548,62.57559],[9.47536,62.57492],[9.45414,62.57109],[9.43543,62.55845],[9.36269,62.52595],[9.32421,62.51657],[9.27054,62.49833],[9.23902,62.48959],[9.18339,62.48057],[9.10379,62.47972],[9.04755,62.48542],[8.96512,62.4879],[8.92005,62.42537],[8.88746,62.41079],[8.87311,62.438],[8.90573,62.4632],[8.92705,62.48684],[8.90784,62.49303],[8.84834,62.49422],[8.83745,62.51373],[8.93209,62.51236],[8.95647,62.52245],[8.90491,62.55528],[8.71241,62.59672],[8.66798,62.62757],[8.57753,62.65179],[8.55317,62.64118],[8.51721,62.55438]]],[[[9.19454,62.05337],[9.30224,62.02726],[9.31047,62.02298],[9.31686,62.01576],[9.51148,62.05992],[9.53011,62.07115],[9.60163,62.06735],[9.63928,62.09805],[9.74356,62.10124],[9.79734,62.11205],[9.83073,62.11459],[9.7537,62.15533],[9.74065,62.16642],[9.6702,62.17415],[9.60629,62.18934],[9.51371,62.17283],[9.45943,62.16003],[9.44164,62.15086],[9.36631,62.13527],[9.30232,62.09386],[9.20261,62.07216],[9.19454,62.05337]]],[[[9.5903,62.46746],[9.59189,62.45955],[9.59699,62.45289],[9.64014,62.42702],[9.64845,62.41715],[9.66044,62.38964],[9.69471,62.3537],[9.64674,62.34075],[9.63021,62.34105],[9.63016,62.33518],[9.6329,62.32884],[9.6302,62.31521],[9.643,62.3165],[9.62617,62.29514],[9.66815,62.27909],[9.68516,62.27548],[9.71807,62.28279],[9.68206,62.25754],[9.7243,62.26073],[9.75523,62.25432],[9.77876,62.24688],[9.8226,62.23801],[9.86848,62.20843],[9.9187,62.19385],[9.99835,62.17976],[10.0326,62.1824],[10.05218,62.19294],[10.02564,62.21207],[10.00982,62.22726],[10.05329,62.23346],[10.06199,62.22687],[10.06071,62.22117],[10.08984,62.22034],[10.16273,62.23083],[10.16147,62.23736],[10.16984,62.24226],[10.16068,62.25602],[10.12279,62.26563],[10.09369,62.27591],[10.02183,62.29334],[9.9992,62.30585],[9.92569,62.30496],[9.88232,62.30704],[9.87607,62.32809],[9.86174,62.32031],[9.86525,62.31046],[9.84923,62.30912],[9.84077,62.30607],[9.83423,62.3065],[9.83489,62.30959],[9.83291,62.31147],[9.83928,62.31498],[9.83265,62.32282],[9.83158,62.32769],[9.83442,62.33179],[9.83636,62.33221],[9.83561,62.33386],[9.84621,62.33565],[9.84407,62.33414],[9.84797,62.33393],[9.8584,62.33937],[9.87599,62.34507],[9.88917,62.34612],[9.89215,62.34899],[9.89333,62.35359],[9.89769,62.35689],[9.89689,62.35976],[9.90935,62.3689],[9.90782,62.37037],[9.9106,62.37341],[9.90866,62.37658],[9.8952,62.3842],[9.88981,62.38513],[9.88837,62.3981],[9.88527,62.40341],[9.88772,62.41429],[9.89182,62.41577],[9.88877,62.422],[9.88304,62.42608],[9.88808,62.4249],[9.89606,62.41871],[9.89656,62.41407],[9.89462,62.41307],[9.89666,62.39591],[9.90495,62.39091],[9.91555,62.37831],[9.91496,62.37713],[9.91842,62.37585],[9.91658,62.3734],[9.91337,62.3734],[9.91727,62.37318],[9.91913,62.37361],[9.918,62.37171],[9.9201,62.37143],[9.91915,62.36986],[9.92052,62.36812],[9.91656,62.3662],[9.91926,62.36384],[9.91708,62.36193],[9.91928,62.36035],[9.90688,62.34626],[9.9084,62.34313],[9.90663,62.33644],[9.91038,62.33446],[9.91,62.32988],[9.91644,62.32471],[9.93103,62.32966],[9.95312,62.33154],[9.97691,62.34694],[10.03871,62.36217],[10.04651,62.37104],[10.05338,62.36969],[10.05983,62.363],[10.06417,62.36271],[10.06716,62.36506],[10.07042,62.36406],[10.06847,62.36076],[10.06913,62.35839],[10.07121,62.35926],[10.07249,62.35681],[10.07862,62.3535],[10.07484,62.34924],[10.06564,62.34627],[10.04842,62.336],[10.06051,62.3197],[10.14396,62.31101],[10.23604,62.28123],[10.27196,62.28475],[10.32907,62.33112],[10.33423,62.33852],[10.31139,62.36573],[10.32618,62.39643],[10.31153,62.43526],[10.32101,62.47889],[10.30927,62.51868],[10.2465,62.51665],[10.23711,62.51978],[10.2291,62.51949],[10.22243,62.52205],[10.22379,62.52304],[10.20867,62.53144],[10.20544,62.53079],[10.19651,62.53352],[10.18729,62.53387],[10.13092,62.54641],[10.1255,62.54457],[10.11842,62.54503],[10.11428,62.54071],[10.10237,62.55265],[10.03719,62.56732],[10.00573,62.55813],[10.00418,62.54159],[9.95698,62.53074],[9.9401,62.52219],[9.91728,62.50668],[9.90207,62.49192],[9.85975,62.48119],[9.83934,62.49707],[9.84113,62.50891],[9.81021,62.53584],[9.78809,62.54521],[9.764,62.54646],[9.75266,62.55035],[9.75247,62.55679],[9.74653,62.57086],[9.74232,62.57648],[9.70494,62.56565],[9.68911,62.55213],[9.69504,62.52954],[9.70157,62.51806],[9.68338,62.50555],[9.67303,62.47545],[9.69432,62.45149],[9.70309,62.43645],[9.63476,62.46416],[9.63403,62.49013],[9.62293,62.50186],[9.61685,62.50108],[9.60513,62.49109],[9.5917,62.47441],[9.5903,62.46746]]]]}'))), + -- Miljødirektoratet, Trollheimen landskapsvernområde (VV00000745) + ('trollheimen', 'Trollheimen', 'Alpine topper og klassiske DNT-ruter mellom Trollheimshytta, Gjevilvasshytta og Jøldalshytta.', 814547, ST_Multi(ST_GeomFromGeoJSON('{"type":"MultiPolygon","coordinates":[[[[9.51124,62.93779],[9.37978,62.9649],[9.29811,62.97742],[9.19598,62.95479],[9.15342,62.93659],[9.14103,62.92679],[9.14357,62.92375],[9.15252,62.91862],[9.15519,62.91486],[9.17529,62.90691],[9.18195,62.90252],[9.20535,62.89818],[9.21602,62.88986],[9.24061,62.87741],[9.23934,62.8764],[9.24234,62.87292],[9.24032,62.87047],[9.24285,62.86948],[9.24448,62.86499],[9.24666,62.86362],[9.26411,62.86549],[9.26906,62.84317],[9.27249,62.84472],[9.27985,62.84211],[9.31405,62.83915],[9.33792,62.84018],[9.35177,62.84242],[9.35576,62.83622],[9.33533,62.83323],[9.27915,62.8319],[9.24888,62.83358],[9.24931,62.825],[9.22635,62.82257],[9.21285,62.83343],[9.21123,62.84878],[9.22862,62.85042],[9.22809,62.85293],[9.22424,62.85393],[9.22293,62.85777],[9.21787,62.85894],[9.21715,62.86248],[9.20673,62.87182],[9.20499,62.8803],[9.20039,62.88584],[9.18544,62.89535],[9.15782,62.90652],[9.14664,62.90942],[9.14536,62.90999],[9.14784,62.91087],[9.13868,62.91416],[9.11881,62.91041],[9.11295,62.91327],[9.10868,62.91929],[9.07845,62.92423],[9.03877,62.92031],[9.03383,62.92245],[8.98424,62.91504],[8.9613,62.91567],[8.93398,62.91869],[8.86512,62.90694],[8.83808,62.90683],[8.83608,62.90843],[8.83731,62.911],[8.82213,62.91067],[8.81249,62.91366],[8.80401,62.91258],[8.7373,62.89039],[8.72025,62.88208],[8.70645,62.88004],[8.74303,62.87175],[8.74602,62.86108],[8.74196,62.85602],[8.74924,62.85397],[8.75619,62.82093],[8.79007,62.80722],[8.80644,62.80286],[8.87109,62.79678],[8.90998,62.78258],[8.90313,62.76337],[8.89275,62.76382],[8.86857,62.76896],[8.82244,62.76677],[8.75156,62.75566],[8.75162,62.75292],[8.75977,62.74961],[8.76381,62.74527],[8.78291,62.7404],[8.7991,62.74304],[8.80809,62.7386],[8.81602,62.73691],[8.82648,62.73882],[8.83438,62.73755],[8.84036,62.74041],[8.85203,62.73323],[8.8655,62.72774],[8.88751,62.7264],[8.89059,62.72486],[8.89054,62.72219],[8.90757,62.71366],[8.91996,62.71177],[8.98118,62.66217],[8.96006,62.65932],[8.94562,62.66098],[8.9404,62.66529],[8.9263,62.66313],[8.91145,62.66735],[8.89999,62.66686],[8.85976,62.67256],[8.83455,62.68137],[8.80739,62.68827],[8.74661,62.68088],[8.7422,62.68354],[8.73181,62.68596],[8.72697,62.69097],[8.72614,62.69834],[8.7278,62.70165],[8.71817,62.7053],[8.71678,62.70742],[8.71832,62.71971],[8.65052,62.73221],[8.58808,62.69431],[8.59038,62.69285],[8.59061,62.68959],[8.58875,62.6862],[8.64441,62.67332],[8.76114,62.65825],[8.823,62.64846],[8.94875,62.62361],[8.96254,62.61497],[9.04058,62.5987],[9.06284,62.58328],[9.08849,62.57308],[9.14043,62.58211],[9.24226,62.60853],[9.23599,62.64339],[9.06068,62.68072],[9.0122,62.70348],[9.016,62.71234],[9.02645,62.71646],[9.11582,62.702],[9.16705,62.70388],[9.18779,62.71407],[9.19923,62.71139],[9.20286,62.71251],[9.28457,62.7122],[9.33398,62.70423],[9.40793,62.71269],[9.41255,62.70426],[9.42397,62.70169],[9.43438,62.70251],[9.43572,62.70361],[9.434,62.70581],[9.43527,62.70987],[9.45707,62.72579],[9.51506,62.76076],[9.5645,62.77715],[9.56428,62.77896],[9.57197,62.78656],[9.56678,62.78301],[9.55752,62.78291],[9.55829,62.78109],[9.55283,62.78119],[9.55438,62.78184],[9.54254,62.78593],[9.53978,62.78833],[9.53675,62.79111],[9.53879,62.79324],[9.53746,62.7951],[9.54504,62.79665],[9.56698,62.79254],[9.59374,62.80427],[9.59889,62.80946],[9.60363,62.81148],[9.62097,62.81452],[9.64809,62.81326],[9.64695,62.81479],[9.64894,62.8153],[9.65635,62.83297],[9.67445,62.84326],[9.65931,62.86463],[9.61389,62.89438],[9.60619,62.91114],[9.59781,62.92279],[9.59236,62.92639],[9.58001,62.92248],[9.57201,62.94063],[9.55259,62.942],[9.54301,62.93898],[9.51124,62.93779]]],[[[9.56801,62.7904],[9.56274,62.79091],[9.57032,62.78876],[9.56801,62.7904]]],[[[9.23723,62.82926],[9.24234,62.82839],[9.23994,62.83017],[9.23723,62.82926]]]]}'))), + -- Ingen avgrensning å hente, så området er et rektangel (vest, sør, øst, nord) + ('romsdalsalpane', 'Romsdalsalpane', 'Bratte tinder rundt Romsdalen, Romsdalseggen og Trollveggen.', NULL, ST_Multi(ST_MakeEnvelope(7.4, 62.35, 8.1, 62.65, 4326))), + -- Ingen avgrensning å hente, så området er et rektangel (vest, sør, øst, nord) + ('sunnmorsalpane', 'Sunnmørsalpane', 'Spisse tinder som stuper i fjordene. Kjent for toppturer.', NULL, ST_Multi(ST_MakeEnvelope(6.2, 62.0, 7.1, 62.4, 4326))), + -- Miljødirektoratet, Breheimen nasjonalpark (VV00002830) + ('breheimen', 'Breheimen', 'Breer og villmark mellom Jotunheimen og Jostedalsbreen.', 572299, ST_Multi(ST_GeomFromGeoJSON('{"type":"MultiPolygon","coordinates":[[[[7.65492,62.00729],[7.64195,62.00471],[7.64156,62.00268],[7.63475,62.00097],[7.62965,61.99606],[7.62613,61.98914],[7.6172,61.98666],[7.61949,61.98589],[7.61751,61.98296],[7.61109,61.98241],[7.61105,61.98376],[7.6079,61.98407],[7.59845,61.98218],[7.58013,61.98182],[7.57947,61.98034],[7.57051,61.98028],[7.55029,61.97399],[7.52272,61.97095],[7.51229,61.97464],[7.50973,61.97585],[7.51027,61.97682],[7.50266,61.97741],[7.50359,61.97891],[7.49957,61.9813],[7.49001,61.98172],[7.48708,61.98336],[7.48273,61.98229],[7.48085,61.98384],[7.47295,61.98267],[7.4762,61.9816],[7.47137,61.98023],[7.4712,61.97881],[7.46395,61.97791],[7.44753,61.97213],[7.44787,61.971],[7.44505,61.96978],[7.43888,61.96912],[7.43706,61.96791],[7.43816,61.96754],[7.405,61.95904],[7.39465,61.95894],[7.39387,61.95569],[7.41783,61.95186],[7.42129,61.94984],[7.42281,61.94601],[7.41625,61.94037],[7.41655,61.92692],[7.38916,61.93242],[7.4141,61.89153],[7.41237,61.85746],[7.38239,61.85301],[7.41737,61.84448],[7.42946,61.82959],[7.42883,61.8171],[7.42028,61.81063],[7.43497,61.78656],[7.48833,61.7767],[7.49055,61.75213],[7.48343,61.73938],[7.43359,61.7272],[7.39244,61.72349],[7.39203,61.71937],[7.40782,61.71327],[7.38493,61.7046],[7.36208,61.70246],[7.32125,61.68829],[7.41145,61.6844],[7.41371,61.66821],[7.40428,61.65014],[7.31488,61.64758],[7.30858,61.6373],[7.3163,61.63229],[7.3581,61.62382],[7.4099,61.63345],[7.4098,61.61577],[7.39729,61.60586],[7.34432,61.60031],[7.38914,61.59664],[7.3892,61.59264],[7.36747,61.58585],[7.33227,61.58161],[7.33812,61.57601],[7.33844,61.56645],[7.35339,61.55822],[7.37298,61.5642],[7.40642,61.5789],[7.42572,61.57975],[7.43628,61.56768],[7.4277,61.55367],[7.42874,61.53802],[7.46999,61.51447],[7.48319,61.51697],[7.50035,61.52472],[7.50564,61.53915],[7.54209,61.54911],[7.56579,61.5605],[7.56138,61.56566],[7.55025,61.56937],[7.53263,61.57148],[7.53619,61.57587],[7.5664,61.57491],[7.5916,61.56364],[7.59375,61.55826],[7.60585,61.55483],[7.63449,61.58547],[7.58862,61.6172],[7.60823,61.62238],[7.62152,61.61865],[7.63491,61.60817],[7.65027,61.60629],[7.65907,61.57611],[7.65817,61.56635],[7.64818,61.54808],[7.67423,61.52925],[7.69857,61.55184],[7.7225,61.55613],[7.73004,61.55238],[7.7398,61.55668],[7.73846,61.56431],[7.75228,61.58728],[7.75626,61.59037],[7.76939,61.63963],[7.72362,61.64305],[7.71419,61.65094],[7.71914,61.65547],[7.73677,61.65628],[7.75023,61.69508],[7.78812,61.70025],[7.78008,61.71557],[7.7614,61.73395],[7.77702,61.74954],[7.79926,61.7433],[7.81602,61.71509],[7.78454,61.67521],[7.83544,61.69014],[7.853,61.70792],[7.88778,61.70189],[7.90714,61.70963],[7.90736,61.71376],[7.9654,61.70942],[7.97144,61.68732],[7.94154,61.6837],[7.8989,61.6835],[7.87873,61.66299],[7.8969,61.65471],[7.88193,61.63819],[7.85338,61.63744],[7.78501,61.6237],[7.79881,61.61136],[7.77919,61.59379],[7.80244,61.58016],[7.82543,61.58284],[7.84727,61.5683],[7.88337,61.58456],[7.92514,61.60771],[7.94242,61.62242],[7.95006,61.63541],[7.983,61.64167],[7.99875,61.64034],[7.99293,61.62633],[7.98461,61.62132],[7.9695,61.60396],[7.9564,61.5743],[7.95983,61.56138],[8.01463,61.56934],[8.02848,61.57397],[8.03111,61.5813],[8.03585,61.60776],[8.04965,61.63005],[8.0487,61.63552],[8.03254,61.63663],[8.0168,61.63988],[8.01194,61.64412],[8.01749,61.64769],[8.01964,61.65226],[8.01937,61.65484],[8.01615,61.6578],[8.01795,61.65838],[8.01466,61.65963],[7.99552,61.65581],[7.97527,61.65815],[7.95202,61.65672],[7.95089,61.6551],[7.95127,61.65611],[7.94493,61.65494],[7.94481,61.65334],[7.93641,61.65287],[7.93634,61.65178],[7.91812,61.65082],[7.92595,61.65655],[7.93505,61.65822],[7.97951,61.66294],[7.99193,61.66212],[8.03736,61.67045],[8.0735,61.6684],[8.13388,61.67495],[8.13839,61.68171],[8.15157,61.69097],[8.173,61.69948],[8.17994,61.70488],[8.19874,61.71354],[8.19145,61.71802],[8.21559,61.72311],[8.22761,61.72098],[8.22932,61.72531],[8.23561,61.72926],[8.26995,61.73203],[8.27585,61.73414],[8.31797,61.72904],[8.34555,61.73279],[8.3573,61.73705],[8.36178,61.7418],[8.359,61.74442],[8.3593,61.7527],[8.3685,61.76094],[8.37856,61.76186],[8.37749,61.76731],[8.39087,61.77181],[8.41879,61.77606],[8.42628,61.79806],[8.43329,61.80781],[8.44861,61.82009],[8.53355,61.83591],[8.53324,61.8391],[8.52968,61.84005],[8.4681,61.85223],[8.46318,61.85069],[8.4066,61.85769],[8.32386,61.84984],[8.23912,61.82876],[8.22952,61.82169],[8.21718,61.82597],[8.22246,61.82965],[8.21904,61.83083],[8.21737,61.83601],[8.25675,61.85571],[8.2182,61.87475],[8.20963,61.8835],[8.20656,61.8846],[8.05319,61.88751],[8.03579,61.88141],[8.01906,61.86825],[7.99818,61.84816],[7.98817,61.84971],[7.99735,61.85743],[7.99909,61.85705],[8.00812,61.86974],[8.01633,61.87792],[8.01739,61.87935],[8.01576,61.87982],[8.01836,61.88229],[8.02991,61.88571],[8.03263,61.89161],[8.03904,61.89733],[8.03868,61.89868],[8.02739,61.89404],[8.02176,61.89038],[8.02183,61.88841],[8.01192,61.88757],[8.01253,61.89147],[8.00729,61.89371],[7.96162,61.89704],[7.92926,61.89365],[7.86006,61.88142],[7.82178,61.88107],[7.77742,61.86844],[7.76372,61.85086],[7.7609,61.8448],[7.74416,61.83346],[7.73951,61.82817],[7.73762,61.81671],[7.71863,61.80082],[7.70989,61.80535],[7.70937,61.80724],[7.70601,61.80538],[7.70081,61.80562],[7.67084,61.79784],[7.65333,61.7974],[7.63193,61.79943],[7.62638,61.79605],[7.61113,61.79174],[7.60821,61.79544],[7.58845,61.8069],[7.60279,61.81282],[7.64889,61.80283],[7.67148,61.80253],[7.69521,61.81174],[7.71159,61.82134],[7.72473,61.83549],[7.75791,61.86572],[7.76762,61.87165],[7.77216,61.8803],[7.76632,61.88212],[7.78309,61.88995],[7.82536,61.90399],[7.83218,61.91237],[7.76942,61.90334],[7.74023,61.90174],[7.70933,61.90397],[7.60219,61.89693],[7.595,61.898],[7.59276,61.90194],[7.59525,61.90508],[7.63121,61.90572],[7.70904,61.9129],[7.77929,61.91163],[7.79153,61.91896],[7.84633,61.93662],[7.8472,61.94079],[7.84178,61.94615],[7.82472,61.95669],[7.81085,61.96301],[7.79295,61.9741],[7.79301,61.97865],[7.80005,61.98806],[7.80889,61.99231],[7.81199,62.00261],[7.79504,62.0047],[7.75945,61.99854],[7.74335,62.00282],[7.72389,62.00151],[7.71894,61.99782],[7.71196,61.99816],[7.71348,61.99735],[7.70543,61.99632],[7.70201,61.99721],[7.70341,61.99951],[7.7004,62.00104],[7.6887,62.00127],[7.68315,62.00331],[7.67662,62.00183],[7.67667,62.00442],[7.67543,62.00311],[7.66173,62.00364],[7.6578,62.0046],[7.65492,62.00729]]],[[[7.50939,62.01932],[7.48326,62.02473],[7.48207,62.02269],[7.46982,62.01979],[7.4536,62.01964],[7.45036,62.01783],[7.44816,62.01927],[7.44506,62.01638],[7.44271,62.01681],[7.4435,62.01878],[7.44135,62.01858],[7.44021,62.01586],[7.43624,62.01464],[7.40855,62.01452],[7.40536,62.00894],[7.37134,62.00895],[7.36044,62.01085],[7.35104,62.01443],[7.34911,62.00768],[7.36741,62.00292],[7.38088,62.00296],[7.39006,61.99634],[7.39339,61.99253],[7.39892,61.97847],[7.39656,61.96727],[7.48408,61.98916],[7.50459,61.98904],[7.53301,61.98016],[7.55889,61.98816],[7.59821,61.98951],[7.62005,61.99261],[7.61594,61.99583],[7.6133,62.00334],[7.60934,62.00524],[7.57071,62.00746],[7.53136,62.01763],[7.50939,62.01932]]]]}'))), + -- Ingen avgrensning å hente, så området er et rektangel (vest, sør, øst, nord) + ('skarvheimen', 'Skarvheimen', 'Høyfjell mellom Hallingskarvet og Aurlandsdalen.', 324756, ST_Multi(ST_MakeEnvelope(7.5, 60.6, 8.4, 61.0, 4326))), + -- Miljødirektoratet, Setesdal Vesthei Ryfylkeheiane landskapsvernområde (VV00001937) + ('setesdalsheiane', 'Setesdalsheiane', 'Store heiområder med villrein og mange ubetjente hytter.', 1094317, ST_Multi(ST_GeomFromGeoJSON('{"type":"MultiPolygon","coordinates":[[[[6.90113,59.326],[6.87617,59.32207],[6.87434,59.32136],[6.87626,59.31968],[6.87478,59.31925],[6.86717,59.31962],[6.86404,59.31778],[6.86685,59.31685],[6.86374,59.31561],[6.86162,59.31574],[6.86398,59.31674],[6.86045,59.3176],[6.85417,59.31611],[6.85502,59.31525],[6.85038,59.31396],[6.85043,59.31221],[6.84765,59.31034],[6.84549,59.31338],[6.83234,59.31163],[6.83118,59.31097],[6.83212,59.30955],[6.83008,59.30972],[6.83243,59.30813],[6.83024,59.30836],[6.82453,59.26988],[6.84075,59.25102],[6.8377,59.19259],[6.84981,59.18328],[6.90637,59.18606],[6.93156,59.18344],[6.92669,59.18826],[6.91412,59.19135],[6.91734,59.19377],[6.9279,59.19615],[6.93716,59.20368],[6.93933,59.2086],[6.93932,59.21363],[6.94214,59.21694],[6.94279,59.22399],[6.94481,59.22504],[6.943,59.23112],[6.94993,59.24297],[6.94486,59.24696],[6.93557,59.25016],[6.91805,59.24704],[6.90933,59.24845],[6.90729,59.24724],[6.90608,59.24948],[6.90331,59.24731],[6.89234,59.25],[6.89408,59.25363],[6.89754,59.25516],[6.91318,59.25713],[6.91347,59.25899],[6.91181,59.25796],[6.91049,59.25922],[6.91428,59.26084],[6.91369,59.26242],[6.91561,59.2634],[6.92491,59.2629],[6.92679,59.25781],[6.93202,59.25821],[6.93684,59.2546],[6.95448,59.25212],[6.95357,59.25032],[6.95493,59.2438],[6.95276,59.24378],[6.95341,59.24223],[6.95087,59.239],[6.95577,59.24106],[6.96699,59.23511],[6.96391,59.23218],[6.96179,59.2329],[6.96367,59.23395],[6.96222,59.23501],[6.95687,59.23455],[6.9499,59.22751],[6.95095,59.22558],[6.94787,59.22143],[6.9491,59.21711],[6.94712,59.21399],[6.94767,59.21129],[6.94976,59.21085],[6.94631,59.20263],[6.94876,59.19646],[6.95918,59.19784],[6.96143,59.19682],[6.95818,59.19317],[6.97226,59.19036],[6.97736,59.19175],[6.9791,59.19087],[6.98356,59.19514],[6.9847,59.19389],[6.98657,59.19479],[6.98606,59.20074],[6.98897,59.20334],[6.98654,59.20398],[6.98648,59.20705],[6.98283,59.21104],[6.98238,59.2131],[6.98515,59.21016],[6.9865,59.21295],[6.99342,59.2113],[7.00011,59.21285],[7.00026,59.21108],[7.00541,59.21103],[7.00454,59.20942],[7.00686,59.20802],[7.01073,59.20875],[7.01482,59.21179],[7.01589,59.21106],[7.01691,59.2145],[7.03088,59.21631],[7.0301,59.21958],[7.03949,59.21995],[7.04981,59.21468],[7.04565,59.21261],[7.04074,59.21351],[7.04166,59.21223],[7.03889,59.21021],[7.02201,59.20581],[7.02535,59.20584],[7.01108,59.20117],[7.01069,59.1997],[7.01405,59.19675],[7.00813,59.19471],[7.00719,59.19166],[6.99541,59.18765],[6.99239,59.18423],[6.99626,59.18471],[6.99248,59.18123],[6.99475,59.18214],[6.99908,59.17995],[6.97871,59.17815],[6.96607,59.1745],[6.96343,59.17303],[6.96489,59.17232],[6.94988,59.16615],[6.94462,59.16682],[6.92631,59.16345],[6.91471,59.15887],[6.90829,59.15821],[6.90728,59.15642],[6.89497,59.1511],[6.89364,59.14988],[6.89451,59.14815],[6.89278,59.14717],[6.90127,59.14725],[6.90431,59.14454],[6.90339,59.14201],[6.89826,59.13787],[6.90225,59.13355],[6.89992,59.13283],[6.90253,59.13056],[6.89869,59.12688],[6.9004,59.12407],[6.89859,59.12196],[6.90241,59.12023],[6.90089,59.1192],[6.83549,59.11086],[6.81077,59.09511],[6.76951,59.09492],[6.73584,59.08495],[6.72587,59.07813],[6.69475,59.07242],[6.70277,59.0569],[6.75613,59.04706],[6.77205,59.04886],[6.77919,59.04444],[6.7811,59.04032],[6.75733,59.03196],[6.72164,59.0125],[6.68751,58.9929],[6.68702,58.99149],[6.6898,58.98944],[6.68731,58.98907],[6.68766,58.98741],[6.70412,58.97447],[6.72813,58.9684],[6.73934,58.95899],[6.79884,58.93165],[6.82592,58.93669],[6.82848,58.94196],[6.83448,58.94135],[6.83398,58.94291],[6.84226,58.941],[6.84897,58.94531],[6.85861,58.94663],[6.86418,58.94933],[6.86664,58.94855],[6.87887,58.95376],[6.88197,58.95294],[6.88681,58.95522],[6.88631,58.96025],[6.87798,58.97126],[6.87453,58.97242],[6.86858,58.98626],[6.8719,59.01155],[6.87611,59.0157],[6.8768,59.02003],[6.86659,59.02368],[6.86345,59.02825],[6.8653,59.03162],[6.86427,59.03356],[6.86605,59.03305],[6.85966,59.03593],[6.86363,59.03531],[6.86477,59.03724],[6.86926,59.03808],[6.8691,59.04082],[6.87174,59.04149],[6.87189,59.04307],[6.86801,59.04639],[6.87234,59.04737],[6.87984,59.05266],[6.88178,59.05578],[6.88641,59.05832],[6.88689,59.06259],[6.89264,59.06365],[6.8842,59.07051],[6.88525,59.07154],[6.90014,59.06959],[6.90454,59.06691],[6.94623,59.06548],[6.99951,59.04226],[7.00458,59.03366],[7.01516,59.02835],[7.03477,59.02724],[7.04911,59.03178],[7.06131,59.03219],[7.06832,59.03062],[7.06921,59.02474],[7.08204,59.0259],[7.08786,59.02425],[7.08657,59.02153],[7.08971,59.01772],[7.08892,59.01561],[7.09402,59.01084],[7.10216,59.01045],[7.10659,59.00771],[7.11961,59.00787],[7.1203,59.00562],[7.12365,59.00552],[7.11726,59.00016],[7.11944,58.99775],[7.11775,58.99683],[7.12035,58.99661],[7.12479,58.99082],[7.12012,58.98765],[7.1206,58.98554],[7.12343,58.98199],[7.12696,58.98046],[7.12707,58.97845],[7.13112,58.97831],[7.12725,58.97804],[7.12673,58.97621],[7.13164,58.97514],[7.13361,58.97593],[7.13339,58.97734],[7.13593,58.97456],[7.1408,58.97418],[7.13094,58.9743],[7.13412,58.96957],[7.13924,58.96907],[7.14466,58.9645],[7.14609,58.96086],[7.14493,58.95864],[7.14726,58.95343],[7.1515,58.95418],[7.15455,58.95147],[7.15311,58.94877],[7.15688,58.94699],[7.15936,58.94176],[7.16561,58.94079],[7.15841,58.93644],[7.16002,58.93419],[7.15579,58.92765],[7.1553,58.92164],[7.15953,58.91678],[7.15853,58.91336],[7.15546,58.9073],[7.14364,58.89944],[7.13999,58.89384],[7.12989,58.88868],[7.07027,58.88217],[7.06903,58.88045],[7.07014,58.87955],[7.07563,58.87957],[7.07643,58.87826],[7.07531,58.87421],[7.06925,58.87156],[7.06922,58.86963],[7.08441,58.87009],[7.09464,58.87348],[7.09654,58.87152],[7.09316,58.86955],[7.09743,58.86715],[7.10616,58.86837],[7.10706,58.8683],[7.10799,58.86788],[7.10045,58.86386],[7.10216,58.86231],[7.09546,58.86304],[7.09377,58.86049],[7.09105,58.86014],[7.09606,58.85726],[7.09493,58.85585],[7.09692,58.85358],[7.09456,58.85313],[7.0967,58.85034],[7.09082,58.84978],[7.08363,58.84394],[7.08136,58.84378],[7.08435,58.84203],[7.08279,58.84058],[7.06718,58.83719],[7.06923,58.83507],[7.05792,58.82924],[7.06221,58.82675],[7.05864,58.82618],[7.05865,58.82451],[7.05626,58.82628],[7.04899,58.82591],[7.03459,58.82146],[7.03322,58.81993],[7.03641,58.8193],[7.02951,58.81616],[7.03511,58.81629],[7.03378,58.815],[7.03444,58.81281],[7.02755,58.81176],[7.02967,58.80938],[7.02822,58.80727],[7.03119,58.80821],[7.03191,58.8062],[7.03353,58.80752],[7.03375,58.80615],[7.03719,58.80511],[7.04817,58.80474],[7.04966,58.80243],[7.05724,58.80471],[7.06881,58.78383],[7.06529,58.76881],[7.02799,58.7595],[7.06419,58.73976],[7.07358,58.70032],[7.10114,58.67848],[7.10787,58.66162],[7.08028,58.64517],[7.08068,58.63651],[7.08573,58.62088],[7.08477,58.60346],[7.08584,58.60003],[7.08252,58.58384],[7.08098,58.58202],[7.08307,58.57955],[7.1426,58.57285],[7.14433,58.57338],[7.14323,58.57344],[7.14487,58.57643],[7.14824,58.57664],[7.14882,58.57475],[7.15895,58.57784],[7.19882,58.5771],[7.22558,58.57446],[7.27519,58.58779],[7.30108,58.58758],[7.30374,58.59196],[7.30231,58.59732],[7.30688,58.59799],[7.31044,58.59596],[7.31096,58.59851],[7.30908,58.60094],[7.3186,58.60546],[7.32324,58.61262],[7.3266,58.61341],[7.32716,58.61561],[7.32311,58.62042],[7.32287,58.62309],[7.32619,58.625],[7.32495,58.62637],[7.32728,58.6289],[7.32197,58.63304],[7.32923,58.63242],[7.33645,58.63546],[7.3356,58.6393],[7.34074,58.64402],[7.34037,58.6453],[7.34154,58.64488],[7.33993,58.64833],[7.34551,58.65056],[7.3451,58.65218],[7.34876,58.65363],[7.35236,58.65758],[7.35282,58.66074],[7.35652,58.6659],[7.35509,58.6686],[7.35885,58.67001],[7.342,58.67365],[7.32773,58.67265],[7.32789,58.67483],[7.33236,58.67431],[7.3328,58.67821],[7.33439,58.67605],[7.33349,58.67494],[7.33736,58.67491],[7.33319,58.68031],[7.33299,58.69054],[7.32447,58.70339],[7.32428,58.71714],[7.31915,58.71751],[7.31886,58.7196],[7.31555,58.72126],[7.32436,58.72238],[7.29449,58.74231],[7.28839,58.75462],[7.26638,58.77689],[7.26679,58.79427],[7.26303,58.7944],[7.26414,58.79689],[7.25915,58.79865],[7.26559,58.80324],[7.26861,58.80252],[7.28371,58.82573],[7.34241,58.84388],[7.35785,58.85293],[7.35413,58.85745],[7.3564,58.85805],[7.35678,58.86016],[7.40006,58.85871],[7.42764,58.85264],[7.46009,58.85534],[7.47875,58.85966],[7.47475,58.87062],[7.49221,58.919],[7.46103,58.93465],[7.45324,58.95303],[7.47609,58.99272],[7.46228,59.01742],[7.43126,59.02116],[7.36842,59.03863],[7.33928,59.064],[7.30781,59.07183],[7.30433,59.07767],[7.32355,59.08293],[7.33189,59.08857],[7.35709,59.11098],[7.4115,59.13208],[7.46794,59.15679],[7.41849,59.18655],[7.4256,59.19566],[7.4158,59.21247],[7.32677,59.23782],[7.30637,59.23723],[7.29795,59.24223],[7.27386,59.24573],[7.25441,59.2619],[7.22223,59.2555],[7.22031,59.25232],[7.23749,59.24072],[7.2502,59.22474],[7.21524,59.22896],[7.19621,59.23539],[7.19186,59.2393],[7.19954,59.25473],[7.16383,59.26474],[7.15957,59.27077],[7.17258,59.28333],[7.17734,59.28514],[7.17719,59.28894],[7.18,59.29145],[7.18478,59.29378],[7.1944,59.29287],[7.19088,59.29697],[7.20048,59.2999],[7.17638,59.30475],[7.12868,59.30766],[7.06453,59.2949],[7.01648,59.30157],[6.97751,59.29466],[6.95977,59.28846],[6.94335,59.28839],[6.92324,59.30179],[6.92572,59.30498],[6.91993,59.30781],[6.91743,59.30713],[6.91862,59.30945],[6.91584,59.3121],[6.91635,59.31447],[6.91069,59.32006],[6.91305,59.32348],[6.90894,59.32587],[6.90657,59.32368],[6.90351,59.32365],[6.90113,59.326]],[[7.17846,59.13767],[7.19092,59.14214],[7.18882,59.13943],[7.17683,59.13629],[7.18348,59.13602],[7.18191,59.13367],[7.18633,59.13293],[7.18776,59.12907],[7.18591,59.12724],[7.18823,59.12495],[7.19455,59.12436],[7.19963,59.12619],[7.20263,59.12517],[7.20292,59.12353],[7.21091,59.1228],[7.21717,59.12523],[7.21653,59.12747],[7.21521,59.12732],[7.21683,59.12891],[7.21986,59.12964],[7.22514,59.12929],[7.21735,59.12747],[7.21874,59.12577],[7.21608,59.12451],[7.21725,59.12172],[7.21393,59.11544],[7.20505,59.11368],[7.20279,59.11168],[7.19817,59.10396],[7.19882,59.10073],[7.19616,59.09839],[7.19684,59.09686],[7.18944,59.09973],[7.18484,59.09867],[7.18498,59.09609],[7.1927,59.09483],[7.17475,59.08809],[7.17326,59.0864],[7.17391,59.08274],[7.17601,59.08159],[7.17757,59.08332],[7.18498,59.07999],[7.18861,59.08066],[7.20639,59.08599],[7.21057,59.09039],[7.21567,59.09111],[7.21507,59.09259],[7.22185,59.08928],[7.2184,59.08226],[7.21926,59.0811],[7.2262,59.0808],[7.22707,59.07947],[7.21415,59.07386],[7.21106,59.07488],[7.20782,59.07344],[7.20699,59.06823],[7.19943,59.06766],[7.20482,59.07006],[7.19619,59.07419],[7.19362,59.07369],[7.19343,59.07254],[7.19605,59.07278],[7.19283,59.07026],[7.19561,59.06898],[7.19172,59.06807],[7.19482,59.06292],[7.20657,59.06015],[7.19697,59.06117],[7.19881,59.06003],[7.19642,59.0596],[7.19654,59.05731],[7.18721,59.05788],[7.17922,59.05645],[7.17514,59.05409],[7.17417,59.05504],[7.17196,59.05089],[7.16968,59.05032],[7.17311,59.04682],[7.16932,59.04677],[7.16728,59.0439],[7.16174,59.04393],[7.15762,59.04589],[7.15844,59.04649],[7.15431,59.04717],[7.15529,59.04819],[7.15246,59.04813],[7.14501,59.04521],[7.15355,59.04332],[7.13979,59.04286],[7.13965,59.04477],[7.12671,59.04986],[7.12411,59.05255],[7.12793,59.05345],[7.12337,59.05353],[7.12531,59.05465],[7.12384,59.05558],[7.12551,59.0569],[7.12322,59.05887],[7.13091,59.05713],[7.12795,59.05946],[7.12946,59.06098],[7.12695,59.06162],[7.13643,59.0593],[7.13926,59.06245],[7.13146,59.06505],[7.13452,59.0699],[7.12934,59.07076],[7.13584,59.07094],[7.13762,59.07387],[7.12882,59.07564],[7.13666,59.07748],[7.13854,59.08188],[7.14093,59.08125],[7.13966,59.0803],[7.14049,59.07896],[7.14182,59.07944],[7.143,59.08162],[7.14212,59.08704],[7.14591,59.08986],[7.14419,59.09453],[7.14696,59.09804],[7.15142,59.09915],[7.15321,59.1027],[7.14122,59.10848],[7.14393,59.10843],[7.14761,59.11295],[7.15167,59.11438],[7.15075,59.11653],[7.15206,59.11876],[7.16058,59.11764],[7.16605,59.11927],[7.1559,59.1229],[7.14875,59.12751],[7.13826,59.1287],[7.1402,59.12937],[7.14028,59.13168],[7.1443,59.13434],[7.15286,59.13644],[7.16907,59.13868],[7.17846,59.13767]]],[[[7.23682,59.66524],[7.21467,59.67269],[7.21112,59.66901],[7.19876,59.66693],[7.13839,59.66227],[7.09872,59.65129],[7.10145,59.6441],[7.0891,59.62118],[7.06176,59.60346],[7.04858,59.58683],[7.01272,59.59645],[6.99184,59.59703],[6.97841,59.54133],[6.98023,59.52341],[6.96292,59.51645],[6.96803,59.51227],[6.97677,59.50924],[6.99974,59.50474],[7.00483,59.50202],[7.00554,59.50056],[7.00107,59.49607],[7.01525,59.49134],[7.01633,59.48728],[7.02278,59.48276],[7.02284,59.4812],[7.01046,59.4755],[7.00009,59.46694],[6.9977,59.4614],[6.95669,59.44471],[6.94954,59.44583],[6.94637,59.44315],[6.92705,59.43699],[6.9033,59.43244],[6.89452,59.42924],[6.89986,59.42949],[6.90171,59.42724],[6.89847,59.42674],[6.90069,59.42434],[6.9093,59.41974],[6.91586,59.42003],[6.91757,59.41629],[6.92536,59.41695],[6.92905,59.41279],[6.9307,59.41312],[6.92865,59.41477],[6.94034,59.4119],[6.94019,59.41046],[6.93469,59.41028],[6.93092,59.40763],[6.93437,59.40319],[6.93933,59.40201],[6.94128,59.39997],[6.93802,59.40037],[6.93674,59.3989],[6.94214,59.39669],[6.93832,59.39647],[6.93755,59.39478],[6.94202,59.39287],[6.94032,59.39121],[6.93547,59.39392],[6.93457,59.39188],[6.92794,59.38954],[6.92818,59.38721],[6.93382,59.38434],[6.9348,59.38571],[6.93688,59.38522],[6.93737,59.38245],[6.93615,59.38201],[6.95318,59.38128],[6.95026,59.38032],[6.95283,59.37889],[6.95057,59.37648],[6.95604,59.37616],[6.95627,59.37394],[6.95801,59.37307],[6.95896,59.37718],[6.96011,59.37504],[6.96417,59.37708],[6.96725,59.37522],[6.96845,59.37618],[6.96578,59.37899],[6.96577,59.3818],[6.96364,59.38273],[6.96468,59.38376],[6.97039,59.38369],[6.97367,59.38049],[6.97751,59.38092],[6.97731,59.38346],[6.97996,59.38022],[6.98195,59.38061],[6.97946,59.38297],[6.98092,59.38466],[6.9827,59.38378],[6.98504,59.37818],[6.98826,59.3777],[6.98907,59.3757],[6.99396,59.373],[6.98233,59.37721],[6.98415,59.37783],[6.98097,59.37924],[6.97315,59.37871],[6.97501,59.37683],[6.97414,59.37599],[6.9759,59.37632],[6.9749,59.37417],[6.97597,59.37532],[6.97686,59.37518],[6.97226,59.37093],[6.97327,59.36974],[6.97207,59.36802],[6.97334,59.36753],[6.97028,59.36597],[6.9733,59.36433],[6.97106,59.36192],[6.97142,59.35811],[6.96941,59.35631],[6.97099,59.35578],[6.96824,59.35373],[6.96991,59.35259],[6.9683,59.35124],[6.97067,59.34879],[6.97409,59.34999],[6.97312,59.34778],[6.97764,59.34725],[6.98272,59.34884],[6.98334,59.35123],[6.9872,59.35023],[6.98362,59.34759],[6.97142,59.34563],[6.96774,59.34364],[6.96808,59.34252],[6.966,59.34273],[6.96449,59.34031],[6.96936,59.33467],[6.96505,59.33528],[6.96024,59.33382],[6.9665,59.33064],[6.97126,59.33307],[6.97092,59.33039],[6.96896,59.32996],[6.96722,59.32696],[6.98084,59.32662],[6.98457,59.3248],[6.98496,59.32169],[6.98192,59.32103],[6.98282,59.32058],[6.98948,59.32124],[6.99191,59.32289],[6.99199,59.32577],[6.99656,59.32719],[6.99486,59.32272],[6.99223,59.3205],[6.99293,59.31942],[6.99084,59.31683],[6.98411,59.31692],[6.98222,59.31551],[6.98184,59.3172],[6.97747,59.31872],[6.97182,59.31805],[6.96728,59.31516],[6.95566,59.31617],[6.95155,59.3125],[6.95394,59.3113],[6.9551,59.30723],[6.98693,59.31178],[7.01759,59.31408],[7.03898,59.31808],[7.07639,59.32036],[7.10836,59.31457],[7.15961,59.34504],[7.2693,59.36155],[7.25986,59.37901],[7.25655,59.39823],[7.28681,59.41034],[7.31926,59.42834],[7.35141,59.44257],[7.35284,59.44906],[7.34857,59.45968],[7.34201,59.46127],[7.32537,59.45608],[7.32388,59.45737],[7.31884,59.4569],[7.31967,59.45837],[7.30941,59.45816],[7.31091,59.46083],[7.31277,59.46114],[7.31194,59.46174],[7.30476,59.46081],[7.30215,59.46237],[7.29871,59.46147],[7.29188,59.46298],[7.2798,59.46086],[7.27456,59.46169],[7.27111,59.46019],[7.26206,59.46162],[7.23074,59.45998],[7.21485,59.46146],[7.21735,59.4624],[7.21534,59.46479],[7.216,59.46668],[7.21171,59.46845],[7.21193,59.47073],[7.18747,59.47582],[7.18562,59.47875],[7.18753,59.47879],[7.18292,59.48014],[7.18067,59.48576],[7.17444,59.4867],[7.17683,59.48992],[7.17029,59.49206],[7.17442,59.49283],[7.17119,59.49365],[7.17191,59.4947],[7.16962,59.49767],[7.17175,59.49874],[7.17032,59.50028],[7.17525,59.50045],[7.18733,59.49513],[7.19781,59.48833],[7.22224,59.48005],[7.23468,59.47276],[7.25721,59.46937],[7.26731,59.46823],[7.2984,59.47197],[7.32036,59.46933],[7.33389,59.47018],[7.33804,59.46806],[7.343,59.46821],[7.33129,59.48427],[7.32595,59.50665],[7.33332,59.5219],[7.29402,59.52904],[7.27039,59.52875],[7.2318,59.54207],[7.22264,59.55384],[7.21069,59.55028],[7.19588,59.55118],[7.17871,59.5489],[7.16482,59.55112],[7.15124,59.55705],[7.14979,59.55974],[7.18735,59.55539],[7.19943,59.55778],[7.20668,59.55709],[7.2082,59.55849],[7.21088,59.557],[7.21285,59.55778],[7.21183,59.55925],[7.21322,59.56012],[7.21772,59.56026],[7.20484,59.57751],[7.25189,59.59176],[7.27392,59.58557],[7.32655,59.5854],[7.34151,59.58406],[7.34309,59.58475],[7.33438,59.58797],[7.33035,59.59392],[7.32568,59.59714],[7.31893,59.59966],[7.31522,59.59842],[7.3122,59.59966],[7.3159,59.6003],[7.32688,59.60679],[7.33921,59.60911],[7.35601,59.60926],[7.35312,59.60782],[7.35545,59.60554],[7.37572,59.60525],[7.38635,59.60714],[7.38472,59.60467],[7.40337,59.60869],[7.41298,59.60918],[7.42376,59.61497],[7.44245,59.62081],[7.44369,59.62284],[7.4357,59.62233],[7.43293,59.62025],[7.42596,59.62074],[7.42387,59.62362],[7.41743,59.62531],[7.41603,59.62808],[7.4001,59.6331],[7.39134,59.63211],[7.36784,59.63475],[7.36925,59.637],[7.34015,59.64453],[7.33771,59.64563],[7.33728,59.64787],[7.33208,59.64698],[7.33018,59.64424],[7.32667,59.6437],[7.31731,59.64721],[7.31874,59.65079],[7.30073,59.65811],[7.29285,59.65848],[7.28565,59.65555],[7.24411,59.66688],[7.23682,59.66524]],[[7.06131,59.47814],[7.06837,59.47831],[7.08246,59.47381],[7.08334,59.47221],[7.08922,59.47146],[7.09,59.4686],[7.09687,59.46629],[7.10303,59.46209],[7.10525,59.46283],[7.11469,59.45584],[7.12155,59.45418],[7.12072,59.45066],[7.12361,59.45121],[7.1232,59.45324],[7.12635,59.4528],[7.12557,59.45455],[7.13282,59.45583],[7.132,59.45827],[7.13327,59.45923],[7.14521,59.46181],[7.15008,59.45946],[7.15524,59.46067],[7.1607,59.45855],[7.16363,59.46094],[7.17074,59.46217],[7.17879,59.46131],[7.18689,59.45801],[7.18256,59.45622],[7.1587,59.45555],[7.14911,59.45332],[7.14124,59.44936],[7.1429,59.44786],[7.14191,59.44687],[7.15325,59.44475],[7.15188,59.44327],[7.1454,59.44401],[7.14338,59.44112],[7.14953,59.437],[7.15545,59.43591],[7.1598,59.43138],[7.15015,59.42986],[7.15121,59.42591],[7.14083,59.42925],[7.1424,59.42719],[7.1398,59.42694],[7.13646,59.42872],[7.13945,59.4294],[7.13651,59.43108],[7.13869,59.43145],[7.13834,59.43282],[7.12764,59.43523],[7.12583,59.4379],[7.12181,59.4383],[7.11928,59.44034],[7.10647,59.43703],[7.10978,59.43273],[7.11922,59.43052],[7.12073,59.42656],[7.11863,59.42592],[7.11775,59.42795],[7.1079,59.43085],[7.0984,59.43592],[7.08416,59.43301],[7.07078,59.43266],[7.06333,59.43005],[7.05576,59.43229],[7.04763,59.43185],[7.04695,59.4302],[7.04263,59.43037],[7.03709,59.42779],[7.03758,59.42654],[7.03503,59.42639],[7.03381,59.42707],[7.03633,59.42857],[7.03022,59.42937],[7.03248,59.42971],[7.02965,59.43021],[7.03086,59.432],[7.01842,59.4339],[7.03075,59.43523],[7.0311,59.43716],[7.03335,59.43522],[7.03781,59.43494],[7.03737,59.43606],[7.03953,59.43675],[7.03798,59.43769],[7.04066,59.43869],[7.04331,59.43687],[7.05981,59.43531],[7.06736,59.43688],[7.06916,59.43564],[7.08016,59.43547],[7.08096,59.43823],[7.08474,59.44],[7.09726,59.43708],[7.1042,59.43954],[7.10836,59.43872],[7.10611,59.44269],[7.0992,59.44383],[7.10064,59.44554],[7.09812,59.44694],[7.10157,59.45029],[7.09762,59.45184],[7.10106,59.4545],[7.09992,59.45525],[7.08252,59.45736],[7.07739,59.45628],[7.06795,59.45724],[7.06546,59.45687],[7.06507,59.45463],[7.05815,59.45578],[7.05441,59.45467],[7.05131,59.45858],[7.04515,59.46034],[7.04881,59.46171],[7.0578,59.46013],[7.07145,59.46199],[7.07004,59.46068],[7.07531,59.46016],[7.08137,59.46179],[7.08059,59.46353],[7.0872,59.46641],[7.08513,59.46762],[7.07869,59.46663],[7.0717,59.46974],[7.07062,59.47161],[7.07766,59.47267],[7.07064,59.47478],[7.06647,59.47461],[7.06969,59.47242],[7.05803,59.47244],[7.04801,59.47581],[7.05241,59.47478],[7.05058,59.47567],[7.06181,59.47581],[7.0637,59.47703],[7.06131,59.47814]]]]}'))), + -- Miljødirektoratet, Femundsmarka nasjonalpark (VV00001943) + ('femundsmarka', 'Femundsmarka', 'Skog, myr og vann. Villmark ved svenskegrensen.', NULL, ST_Multi(ST_GeomFromGeoJSON('{"type":"MultiPolygon","coordinates":[[[[11.99762,62.32764],[12.00492,62.29454],[11.98168,62.27533],[11.99746,62.2424],[12.01498,62.24004],[12.02354,62.23734],[12.03228,62.22615],[12.03281,62.21566],[11.97731,62.19567],[12.00919,62.17019],[12.01063,62.14459],[12.04537,62.11066],[12.0283,62.10745],[12.05072,62.09068],[12.0645,62.0924],[12.11091,62.09006],[12.13992,62.09854],[12.22327,62.08739],[12.24461,62.08625],[12.26323,62.14338],[12.22134,62.15244],[12.21037,62.18308],[12.22214,62.20295],[12.27772,62.19642],[12.29911,62.26729],[12.08969,62.56379],[12.00496,62.50031],[12.03939,62.48682],[11.94363,62.48872],[11.91954,62.48162],[11.92284,62.47803],[11.93693,62.47165],[11.93384,62.46949],[11.94639,62.45362],[11.95288,62.43831],[11.97052,62.40993],[11.97402,62.40655],[11.97168,62.406],[11.973,62.40482],[11.9714,62.40258],[11.97478,62.40005],[11.97537,62.39823],[11.97398,62.39736],[11.9798,62.394],[11.97633,62.39332],[11.98143,62.39317],[11.9793,62.38611],[11.98173,62.38582],[11.98059,62.38198],[11.98286,62.37745],[11.98223,62.37466],[11.97958,62.37372],[11.98684,62.36597],[11.97987,62.3635],[11.98393,62.36037],[11.98182,62.3594],[11.98232,62.35205],[11.97226,62.34457],[11.96889,62.33725],[11.96919,62.3306],[11.97851,62.32901],[11.98255,62.33057],[11.99086,62.33],[11.99762,62.32764]]]]}'))), + -- Miljødirektoratet, Blåfjella-Skjækerfjella nasjonalpark (VV00001562) + ('blafjella-skjaekerfjella', 'Blåfjella–Skjækerfjella', 'Stor og lite besøkt villmark i Trøndelag.', NULL, ST_Multi(ST_GeomFromGeoJSON('{"type":"MultiPolygon","coordinates":[[[[13.42644,64.37824],[13.40757,64.39208],[13.32542,64.41911],[13.18336,64.42734],[13.17822,64.47013],[13.13651,64.4834],[13.08886,64.48007],[12.96928,64.46615],[12.92033,64.46209],[12.55501,64.43733],[12.54244,64.41919],[12.58239,64.40901],[12.60318,64.39542],[12.65852,64.36799],[12.70283,64.3684],[12.78571,64.37855],[12.81546,64.37671],[12.77225,64.36345],[12.77888,64.36092],[12.78718,64.3611],[12.78636,64.35923],[12.7903,64.35685],[12.81824,64.35977],[12.99163,64.34494],[13.04913,64.30934],[13.01814,64.29925],[12.9552,64.29728],[12.93752,64.29082],[12.94488,64.28786],[12.94734,64.2859],[12.94492,64.2852],[12.94953,64.28349],[12.9434,64.28318],[12.94483,64.28137],[12.93329,64.28134],[12.93096,64.28009],[12.9372,64.2784],[12.93767,64.27601],[12.93287,64.27509],[12.92808,64.27197],[12.92558,64.27298],[12.92525,64.27503],[12.91627,64.27657],[12.91751,64.27832],[12.91524,64.27886],[12.91569,64.27688],[12.91379,64.27588],[12.91977,64.27348],[12.91707,64.27357],[12.92129,64.27081],[12.90773,64.27513],[12.90575,64.27709],[12.9046,64.27701],[12.90503,64.2737],[12.89138,64.27801],[12.88748,64.28064],[12.8802,64.28186],[12.87537,64.27907],[12.87037,64.27887],[12.85753,64.27458],[12.80184,64.24629],[12.76981,64.24019],[12.76504,64.23749],[12.76941,64.23335],[12.7623,64.23228],[12.76326,64.23049],[12.74678,64.23007],[12.74356,64.23171],[12.74737,64.23248],[12.74556,64.23246],[12.73821,64.23099],[12.7366,64.22821],[12.73052,64.22796],[12.73087,64.22422],[12.73407,64.22316],[12.73318,64.22184],[12.7353,64.21978],[12.7334,64.21766],[12.75968,64.21243],[12.84533,64.20315],[12.9219,64.18462],[12.93441,64.15918],[12.93097,64.13941],[12.92742,64.13274],[12.92763,64.12327],[12.93046,64.11985],[12.93298,64.11935],[12.93121,64.11879],[12.93173,64.11776],[12.94012,64.11358],[12.93415,64.11195],[12.93329,64.11308],[12.926,64.11503],[12.93285,64.11209],[12.92641,64.11139],[12.91223,64.09267],[12.88251,64.0837],[12.84924,64.07106],[12.83754,64.09162],[12.82195,64.10253],[12.79736,64.11677],[12.74092,64.1314],[12.71066,64.16268],[12.61669,64.15362],[12.58434,64.16083],[12.5935,64.14788],[12.57671,64.14033],[12.55456,64.1388],[12.51822,64.144],[12.5404,64.13462],[12.56569,64.1271],[12.47418,64.10595],[12.48174,64.11807],[12.47303,64.11893],[12.46961,64.12292],[12.46387,64.12161],[12.46444,64.11736],[12.45185,64.10836],[12.44691,64.09966],[12.41493,64.09229],[12.34837,64.08546],[12.34807,64.07352],[12.35454,64.05056],[12.39,64.03765],[12.35894,64.024],[12.36442,64.00673],[12.38875,64.00123],[12.38312,63.99838],[12.37788,63.99051],[12.37419,63.98897],[12.37256,63.98616],[12.35792,63.98109],[12.35356,63.97838],[12.35379,63.97621],[12.34914,63.97592],[12.35244,63.97503],[12.35386,63.97285],[12.35261,63.96976],[12.34586,63.96802],[12.34769,63.96698],[12.34653,63.9649],[12.34757,63.96429],[12.33385,63.96023],[12.33232,63.95768],[12.31409,63.95467],[12.31291,63.9531],[12.22935,63.94821],[12.13117,63.9246],[12.05394,63.88335],[12.08598,63.88463],[12.0976,63.88684],[12.09665,63.88856],[12.12066,63.89943],[12.13439,63.89376],[12.10979,63.88826],[12.11329,63.87704],[12.11185,63.8759],[12.10158,63.87671],[12.1034,63.86405],[12.13894,63.85926],[12.14317,63.84258],[12.19298,63.84173],[12.21789,63.83597],[12.22667,63.83659],[12.22404,63.83374],[12.23915,63.82919],[12.24206,63.81886],[12.25088,63.82147],[12.25605,63.81868],[12.25992,63.8198],[12.25857,63.8219],[12.26601,63.82442],[12.25744,63.82368],[12.26886,63.83213],[12.27187,63.83691],[12.28103,63.83802],[12.28719,63.83302],[12.29331,63.83507],[12.29438,63.83818],[12.3012,63.83461],[12.31086,63.83771],[12.31306,63.84209],[12.33286,63.84114],[12.35989,63.81958],[12.48023,63.81876],[12.68357,63.97422],[12.76965,64.00401],[12.76851,64.00489],[12.77561,64.01469],[12.76375,64.01983],[12.75778,64.02058],[12.74233,64.01522],[12.72767,64.01304],[12.72005,64.0076],[12.70479,64.00373],[12.69951,64.00042],[12.69962,63.99912],[12.68119,64.00661],[12.67832,64.01422],[12.66699,64.01418],[12.66418,64.01383],[12.66266,64.01085],[12.64167,64.00145],[12.63874,63.99708],[12.63075,63.99888],[12.62822,64.00526],[12.62169,64.00509],[12.603,64.00252],[12.5936,63.99559],[12.5866,63.99545],[12.57931,64.00626],[12.58407,64.01271],[12.59393,64.0178],[12.60573,64.01982],[12.62037,64.01966],[12.63279,64.01736],[12.66053,64.01981],[12.66668,64.02097],[12.67683,64.02939],[12.68383,64.02762],[12.68336,64.02099],[12.68145,64.01898],[12.7132,64.02137],[12.73784,64.027],[12.79861,64.04589],[12.83425,64.02628],[12.92672,64.05795],[13.2111,64.09537],[13.53772,64.06552],[13.71622,64.04616],[13.72987,64.06275],[13.69617,64.08821],[13.69026,64.09847],[13.59694,64.12001],[13.55822,64.12097],[13.58253,64.18019],[13.41131,64.18521],[13.38371,64.18873],[13.38009,64.19556],[13.38387,64.21146],[13.39557,64.2271],[13.38961,64.22741],[13.39168,64.22906],[13.38777,64.22917],[13.388,64.23106],[13.38609,64.23246],[13.37654,64.23376],[13.3765,64.23598],[13.36796,64.23679],[13.36835,64.23947],[13.37523,64.23937],[13.39175,64.24601],[13.41002,64.27512],[13.44763,64.32034],[13.4839,64.3249],[13.51125,64.33721],[13.42644,64.37824]],[[12.87126,64.45794],[12.89986,64.45297],[12.89624,64.44693],[12.92002,64.4405],[12.89066,64.42728],[12.87225,64.42721],[12.84105,64.43591],[12.85731,64.45019],[12.85548,64.45398],[12.85984,64.45598],[12.85928,64.45717],[12.87126,64.45794]],[[12.95343,64.11365],[12.95127,64.13684],[12.95618,64.1439],[12.94737,64.16341],[13.07651,64.13017],[13.07723,64.12686],[13.09195,64.12447],[13.08848,64.1203],[13.09176,64.11628],[13.10559,64.11404],[13.10834,64.10997],[13.08699,64.10181],[13.10193,64.09901],[13.11045,64.09904],[13.09943,64.08839],[13.09176,64.08694],[13.08261,64.09217],[13.08558,64.09419],[13.08062,64.09427],[13.08085,64.09668],[13.07255,64.09857],[13.07802,64.10166],[13.07748,64.1045],[13.08273,64.1056],[13.07966,64.10715],[13.03448,64.11174],[12.99667,64.11257],[12.98525,64.11518],[12.98039,64.11806],[12.97497,64.11584],[12.95343,64.11365]]]]}'))), + -- Miljødirektoratet, Saltfjellet-Svartisen nasjonalpark (VV00000235) + ('saltfjellet', 'Saltfjellet', 'Høyfjell og isbreer rundt polarsirkelen.', 622606, ST_Multi(ST_GeomFromGeoJSON('{"type":"MultiPolygon","coordinates":[[[[14.95537,66.93637],[14.96346,66.91121],[14.94745,66.89737],[14.97616,66.87443],[14.93679,66.86842],[14.91846,66.87155],[14.86243,66.86318],[14.84057,66.87639],[14.82844,66.88712],[14.79706,66.86456],[14.76478,66.85078],[14.74624,66.83626],[14.71918,66.82613],[14.66309,66.7822],[14.59383,66.75076],[14.53293,66.70354],[14.46295,66.67757],[14.41597,66.70316],[14.41679,66.73221],[14.32847,66.75329],[14.28153,66.71902],[14.22104,66.66768],[14.14995,66.64903],[13.95684,66.71688],[13.85849,66.69844],[13.8597,66.70217],[13.80112,66.70781],[13.75447,66.7095],[13.80869,66.69151],[13.81484,66.68637],[13.80658,66.6628],[13.72184,66.66393],[13.64714,66.62812],[13.59485,66.62382],[13.57574,66.61075],[13.57518,66.59941],[13.57708,66.58937],[13.58054,66.58395],[13.6237,66.57193],[13.63047,66.56799],[13.64728,66.5737],[13.65698,66.57262],[13.66141,66.56958],[13.66098,66.56712],[13.66723,66.56538],[13.68271,66.56589],[13.69424,66.57172],[13.70837,66.57469],[13.70356,66.56874],[13.70399,66.56395],[13.68854,66.557],[13.70251,66.55018],[13.68576,66.53291],[13.69356,66.51949],[13.70064,66.51611],[13.71816,66.51269],[13.73661,66.51163],[13.77538,66.50481],[13.78957,66.49878],[13.79353,66.49475],[13.79061,66.49412],[13.79061,66.49097],[13.82428,66.48157],[13.83188,66.48344],[13.85462,66.48439],[13.86364,66.48715],[13.86482,66.49034],[13.8729,66.49478],[13.88191,66.49561],[13.88362,66.49757],[13.91021,66.49564],[13.92125,66.50128],[14.03186,66.51586],[14.02278,66.51719],[14.02633,66.52006],[14.02257,66.52149],[14.02315,66.52393],[14.03131,66.52544],[14.07205,66.52171],[14.09137,66.51709],[14.08879,66.51401],[14.08576,66.51353],[14.12279,66.51313],[14.21919,66.49174],[14.23613,66.48353],[14.24157,66.48268],[14.25113,66.47658],[14.27457,66.46836],[14.32983,66.47671],[14.31986,66.49376],[14.33197,66.50192],[14.34523,66.53456],[14.38141,66.56737],[14.4174,66.58194],[14.50899,66.62599],[14.54865,66.63568],[14.54819,66.63156],[14.52752,66.62071],[14.47031,66.58294],[14.42242,66.57181],[14.37229,66.53944],[14.33965,66.47974],[14.33373,66.47378],[14.36156,66.46504],[14.43225,66.46404],[14.55374,66.46706],[14.61938,66.46484],[14.70054,66.45056],[14.85127,66.49647],[14.8823,66.50395],[14.88615,66.50546],[14.88515,66.50598],[14.88488,66.50691],[14.88911,66.51002],[14.8894,66.51263],[14.8969,66.51875],[14.89646,66.52024],[14.8971,66.51824],[14.88981,66.51268],[14.88975,66.51002],[14.88535,66.50611],[14.89253,66.50507],[14.88655,66.5067],[14.90019,66.51778],[14.90033,66.51997],[14.91219,66.51911],[14.92227,66.51145],[14.96351,66.51751],[14.97352,66.51657],[14.97378,66.51505],[14.96955,66.50997],[15.00378,66.51492],[15.0807,66.51298],[15.19093,66.54344],[15.24307,66.57098],[15.27334,66.65694],[15.29741,66.7005],[15.30097,66.75692],[15.28594,66.79018],[15.28948,66.83782],[15.27028,66.85108],[15.25701,66.87047],[15.22456,66.90203],[15.26874,66.91877],[15.26397,66.92335],[15.25061,66.92547],[15.2455,66.92777],[15.23989,66.93417],[15.22539,66.93883],[15.20085,66.93838],[15.1823,66.93695],[15.17694,66.93456],[15.15539,66.93413],[15.14333,66.93664],[15.11335,66.9377],[15.08988,66.94105],[14.97086,66.9357],[14.95537,66.93637]]]]}'))), + -- Ingen avgrensning å hente, så området er et rektangel (vest, sør, øst, nord) + ('lofoten', 'Lofoten', 'Tinder rett opp fra havet og korte, bratte turer.', 26064, ST_Multi(ST_MakeEnvelope(12.8, 67.85, 15.5, 68.45, 4326))), + -- Miljødirektoratet, Lyngsalpan landskapsvernområde (VV00002271) + ('lyngsalpan', 'Lyngsalpan', 'Alpine tinder og breer i Troms, kjent for toppturer.', 444653, ST_Multi(ST_GeomFromGeoJSON('{"type":"MultiPolygon","coordinates":[[[[19.82315,69.59504],[19.79237,69.59065],[19.78253,69.5743],[19.77944,69.55578],[19.78196,69.54033],[19.76101,69.52724],[19.78554,69.50405],[19.76874,69.50029],[19.74654,69.50496],[19.7433,69.49353],[19.73071,69.47586],[19.74118,69.44759],[19.72538,69.4358],[19.70185,69.4536],[19.69722,69.45353],[19.68075,69.43689],[19.69199,69.41959],[19.69087,69.40842],[19.6735,69.39622],[19.6652,69.39243],[19.66515,69.38418],[19.6878,69.37866],[19.66129,69.37077],[19.70378,69.35295],[19.69942,69.34042],[19.65366,69.34896],[19.62147,69.33359],[19.60861,69.33187],[19.59942,69.32246],[19.60869,69.31265],[19.57754,69.31563],[19.55343,69.30068],[19.56385,69.29061],[19.51975,69.27911],[19.50952,69.27794],[19.50352,69.2714],[19.48733,69.24408],[19.52618,69.23422],[19.5571,69.23307],[19.57061,69.23732],[19.62827,69.2426],[19.62994,69.24069],[19.6287,69.23842],[19.65045,69.22426],[19.68926,69.20713],[19.72178,69.20747],[19.76921,69.2229],[19.80613,69.2401],[19.8152,69.25165],[19.80955,69.25853],[19.86907,69.27485],[19.88707,69.28582],[19.86487,69.29484],[19.87583,69.29876],[19.89763,69.30074],[19.93993,69.32796],[19.92382,69.32976],[19.88773,69.32614],[19.88056,69.33077],[19.89721,69.33427],[19.89871,69.33977],[20.00532,69.37166],[20.00585,69.37784],[20.02844,69.38629],[20.05416,69.3856],[20.07605,69.39449],[20.11491,69.42757],[20.05778,69.4476],[20.07227,69.46079],[20.08697,69.46368],[20.11144,69.44986],[20.15297,69.45643],[20.1617,69.48546],[20.16838,69.49902],[20.1613,69.51674],[20.16734,69.52328],[20.17532,69.52494],[20.18458,69.54444],[20.12429,69.54705],[20.08762,69.5817],[20.02959,69.58048],[19.97213,69.58204],[19.91889,69.59135],[19.88934,69.58644],[19.8724,69.59194],[19.83714,69.59223],[19.82315,69.59504]]],[[[20.28544,69.97715],[20.27255,69.97508],[20.26197,69.97619],[20.25992,69.9737],[20.26823,69.96194],[20.27898,69.96115],[20.2536,69.94449],[20.25935,69.94043],[20.26327,69.92635],[20.21965,69.92627],[20.21661,69.91387],[20.20126,69.89357],[20.2263,69.85763],[20.22123,69.84341],[20.16545,69.85194],[20.15205,69.8709],[20.14354,69.87704],[20.11435,69.87254],[20.11444,69.86525],[20.07317,69.83415],[20.06459,69.80772],[20.05493,69.80511],[20.0478,69.79287],[20.02124,69.78531],[20.01356,69.76446],[19.98865,69.75477],[19.98172,69.75699],[19.94269,69.75002],[19.94983,69.74412],[19.97683,69.73961],[19.98196,69.73783],[19.98253,69.73583],[19.98178,69.73443],[19.96713,69.73406],[19.9642,69.72973],[19.96293,69.71962],[19.92722,69.72098],[19.91853,69.71873],[19.91511,69.71589],[19.91679,69.71206],[19.92467,69.70937],[19.92543,69.70525],[19.91804,69.69751],[19.91733,69.69602],[19.92004,69.69492],[19.91634,69.69131],[19.9066,69.68964],[19.91989,69.68441],[19.94019,69.67157],[19.91924,69.64984],[19.91187,69.63836],[19.91532,69.61886],[19.93271,69.61336],[19.97832,69.60379],[19.98812,69.59836],[20.0673,69.59032],[20.11785,69.59255],[20.15524,69.58936],[20.19235,69.60742],[20.18964,69.61001],[20.19548,69.61201],[20.19604,69.61501],[20.20002,69.61536],[20.19889,69.63949],[20.21815,69.65939],[20.2359,69.66265],[20.23811,69.67183],[20.22452,69.6762],[20.23903,69.68054],[20.24694,69.68089],[20.26387,69.6871],[20.27857,69.69514],[20.28416,69.69999],[20.28638,69.70783],[20.26484,69.7126],[20.26404,69.71396],[20.26972,69.71531],[20.26521,69.71668],[20.28307,69.71616],[20.28624,69.71747],[20.28581,69.72097],[20.29032,69.73133],[20.28947,69.73225],[20.29187,69.73346],[20.29662,69.74229],[20.29786,69.75174],[20.3,69.7534],[20.2985,69.75613],[20.30205,69.75959],[20.30371,69.765],[20.3102,69.77345],[20.3107,69.77642],[20.31502,69.78198],[20.31504,69.78477],[20.32241,69.78971],[20.3219,69.79171],[20.32954,69.798],[20.33229,69.80347],[20.33724,69.80683],[20.33431,69.8097],[20.33947,69.80954],[20.34321,69.81134],[20.348,69.81595],[20.34971,69.81871],[20.34861,69.81949],[20.35406,69.82169],[20.35703,69.82964],[20.35512,69.83081],[20.35789,69.8337],[20.35571,69.84047],[20.35736,69.84388],[20.36043,69.84646],[20.3597,69.84766],[20.36189,69.85107],[20.36552,69.85308],[20.3651,69.85474],[20.36734,69.85755],[20.38531,69.86688],[20.39207,69.87817],[20.39186,69.88483],[20.39477,69.88789],[20.39464,69.89038],[20.39145,69.89428],[20.38434,69.8949],[20.37299,69.89964],[20.36908,69.90474],[20.36919,69.90842],[20.36237,69.91353],[20.35442,69.9228],[20.35407,69.92787],[20.35,69.93369],[20.3491,69.93846],[20.35035,69.94007],[20.33937,69.94393],[20.33998,69.94596],[20.33418,69.94956],[20.3361,69.95231],[20.32823,69.95523],[20.32964,69.95995],[20.31752,69.96148],[20.31435,69.96375],[20.31581,69.96468],[20.3144,69.96665],[20.30384,69.96801],[20.29815,69.97323],[20.28964,69.97429],[20.28544,69.97715]]]]}'))); + +-- Utvid grensene fra OSM og Miljødirektoratet med 3 km (regnet i meter via geography), så hyttene i kanten kommer med +UPDATE silver.mountain_areas +SET area = ST_Multi(ST_Buffer(area::geography, 3000)::geometry) +WHERE id NOT IN ('romsdalsalpane', 'sunnmorsalpane', 'skarvheimen', 'lofoten'); -- Romlig indeks, så det går raskt å finne hytter og topper innenfor et område CREATE INDEX mountain_areas_area_idx ON silver.mountain_areas USING gist (area); From 2382fc3e6df83819d652994b1fb683d78cc874d2 Mon Sep 17 00:00:00 2001 From: karstenmidthaug Date: Sat, 10 Oct 2026 11:38:53 +0200 Subject: [PATCH 16/17] =?UTF-8?q?Vis=20turpunkter=20utenfor=20omr=C3=A5det?= =?UTF-8?q?,=20rydd=20i=20toppene=20og=20legg=20til=20s=C3=B8k=20etter=20h?= =?UTF-8?q?ytter=20og=20topper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/pointsOfInterest.ts | 42 +++++++++++-- server/server.ts | 15 ++++- src/Fjellomrade.css | 4 ++ src/Fjellomrade.tsx | 125 +++++++++++++++++++++++++++---------- 4 files changed, 147 insertions(+), 39 deletions(-) diff --git a/server/pointsOfInterest.ts b/server/pointsOfInterest.ts index 7a54ed8..dd86b61 100644 --- a/server/pointsOfInterest.ts +++ b/server/pointsOfInterest.ts @@ -12,21 +12,53 @@ interface PointOfInterest { lat: number elevation: number | null capacity: number | null + /** Om punktet ligger innenfor grensen. Punktene utenfor vises bare svakt på kartet. */ + inside: boolean } -/** Turpunktene innenfor grensen til ett fjellområde (silver.mountain_areas) */ +/** Turpunktene i ett fjellområde (silver.mountain_areas), og de som ligger opptil 10 km utenfor grensen */ export async function getAreaPointsOfInterest(areaId: string): Promise { if (!areaId.trim() || areaId.length > 100) throw new GraphQLError('Ugyldig id for fjellområde.') - // Samme ST_Intersects som når punktene telles i server/mountainAreas.ts, så lista og tallene stemmer - // også når grensen ikke er et rektangel + // inside bruker samme ST_Intersects som når punktene telles i server/mountainAreas.ts, så lista og tallene stemmer. + // && med ST_Expand bruker den romlige indeksen til å finne kandidatene raskt, og ST_DWithin på geography + // regner avstanden i meter. const { rows } = await pool.query(` SELECT p.id, p.kind, p.dnt_type AS "dntType", p.name, p.lon::float8 AS lon, p.lat::float8 AS lat, - p.elevation, p.capacity + p.elevation, p.capacity, + ST_Intersects(p.geom, a.area) AS inside FROM silver.osm_points_of_interest p - JOIN silver.mountain_areas a ON a.id = $1 AND ST_Intersects(p.geom, a.area) + JOIN silver.mountain_areas a ON a.id = $1 + AND p.geom && ST_Expand(a.area, 0.3) + AND ST_DWithin(p.geom::geography, a.area::geography, 10000) WHERE p.kind IN ('HUT', 'CABIN', 'SHELTER', 'PEAK') `, [areaId]) return rows } + +/** Et søketreff: punktet og fjellområdet det ligger i, så siden kan åpne riktig kart */ +interface PointSearchHit { + areaId: string + point: PointOfInterest +} + +/** Søker etter hytter, buer, gapahuker og topper med navn som inneholder søketeksten, i alle fjellområdene */ +export async function searchPointsOfInterest(query: string): Promise { + const text = query.trim().slice(0, 100) + if (text.length < 2) return [] + // % og _ betyr noe spesielt i ILIKE, så de må escapes for å søkes etter som vanlige tegn + const pattern = text.replace(/[\\%_]/g, '\\$&') + // Navn som starter med søketeksten kommer først, deretter hytter før buer, gapahuker og topper + const { rows } = await pool.query(` + SELECT a.id AS "areaId", p.id, p.kind, p.dnt_type AS "dntType", p.name, + p.lon::float8 AS lon, p.lat::float8 AS lat, + p.elevation, p.capacity, true AS inside + FROM silver.osm_points_of_interest p + JOIN silver.mountain_areas a ON ST_Intersects(p.geom, a.area) + WHERE p.kind IN ('HUT', 'CABIN', 'SHELTER', 'PEAK') AND p.name ILIKE '%' || $1 || '%' + ORDER BY p.name ILIKE $1 || '%' DESC, array_position(ARRAY['HUT', 'CABIN', 'SHELTER', 'PEAK'], p.kind::text), p.name + LIMIT 12 + `, [pattern]) + return rows.map(({ areaId, ...point }) => ({ areaId, point })) +} diff --git a/server/server.ts b/server/server.ts index 9336fa6..75326da 100644 --- a/server/server.ts +++ b/server/server.ts @@ -2,7 +2,7 @@ // Fila starter ikke noen server selv. Den kobles inn i backenden i server/index.mjs, som startes med // npm run dev:server, ved siden av rutesøket og innloggingen. import { getMountainAreas } from './mountainAreas.ts' -import { getAreaPointsOfInterest } from './pointsOfInterest.ts' +import { getAreaPointsOfInterest, searchPointsOfInterest } from './pointsOfInterest.ts' // «extend type Query» legger spørringene til i Query-typen som er definert i index.mjs export const mountainTypeDefs = /* GraphQL */ ` @@ -17,6 +17,14 @@ export const mountainTypeDefs = /* GraphQL */ ` lat: Float! elevation: Int capacity: Int + "Om punktet ligger innenfor grensen til fjellområdet" + inside: Boolean! + } + + "Et søketreff og fjellområdet det ligger i" + type PointSearchHit { + areaId: ID! + point: PointOfInterest! } type Peak { @@ -41,8 +49,10 @@ export const mountainTypeDefs = /* GraphQL */ ` extend type Query { mountainAreas: [MountainArea!]! - "Hyttene, buene, gapahukene og toppene innenfor grensen til ett fjellområde" + "Hyttene, buene, gapahukene og toppene i ett fjellområde, og de som ligger opptil 10 km utenfor" areaPointsOfInterest(areaId: ID!): [PointOfInterest!]! + "Hytter, buer, gapahuker og topper i alle fjellområdene med navn som inneholder søketeksten (maks 12)" + searchPointsOfInterest(query: String!): [PointSearchHit!]! } ` @@ -50,5 +60,6 @@ export const mountainResolvers = { Query: { mountainAreas: () => getMountainAreas(), areaPointsOfInterest: (_: unknown, { areaId }: { areaId: string }) => getAreaPointsOfInterest(areaId), + searchPointsOfInterest: (_: unknown, { query }: { query: string }) => searchPointsOfInterest(query), }, } diff --git a/src/Fjellomrade.css b/src/Fjellomrade.css index 1f29bd3..b9b653d 100644 --- a/src/Fjellomrade.css +++ b/src/Fjellomrade.css @@ -36,6 +36,10 @@ body { margin: 0; background: var(--bg); color: var(--text); font-family: var(-- .fo-filters { max-width: 1440px; margin: 0 auto; padding: 0 var(--gutter) 24px; display: flex; flex-wrap: wrap; gap: 14px; align-items: center; justify-content: space-between; } .fo-filters .fo-search input { width: 260px; margin: 0; } .fo-messages { max-width: 1440px; margin: 0 auto; padding: 0 var(--gutter); } +/* Søketreff blant hytter og topper, over kortene */ +.fo-hits { max-width: 1440px; margin: 0 auto; padding: 0 var(--gutter) 24px; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); gap: 8px; } +.fo-hits a { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border: 1px solid var(--line); border-radius: 12px; background: #fff; color: inherit; text-decoration: none; } +.fo-hits a:hover { border-color: var(--green); } .fo-grid { max-width: 1440px; margin: 0 auto; padding: 0 var(--gutter) 48px; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr)); gap: clamp(14px, 1.6vw, 24px); } .fo-grid .fo-card { padding: 0; gap: 0; background: #fff; border-radius: 22px; overflow: hidden; box-shadow: 0 20px 50px -34px rgba(35, 58, 48, 0.25); transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.45s, border-color 0.3s; } .fo-grid .fo-card:hover { transform: translateY(-5px); box-shadow: 0 34px 70px -30px rgba(18, 107, 81, 0.4); } diff --git a/src/Fjellomrade.tsx b/src/Fjellomrade.tsx index ddcc25e..e0f2f58 100644 --- a/src/Fjellomrade.tsx +++ b/src/Fjellomrade.tsx @@ -2,9 +2,10 @@ // åpnes kartet over hele skjermen med et panel oppå, som viser hyttene, buene, gapahukene og toppene i området. // Områdene kommer fra silver.mountain_areas og punktene fra silver.osm_points_of_interest. // Valgt område står i adressen (#/jotunheimen), så tilbakeknappen og delte lenker virker. +// Et søketreff legger også til punktet (#/jotunheimen/n/791106132), så kartet åpnes med punktet valgt. import { useEffect, useRef, useState } from 'react' import * as maplibregl from 'maplibre-gl' -import type { ExpressionSpecification, GeoJSONSource, StyleSpecification } from 'maplibre-gl' +import type { ExpressionSpecification, GeoJSONSource, StyleSpecification, SymbolLayerSpecification } from 'maplibre-gl' import type { Feature, FeatureCollection, MultiPolygon, Point, Polygon, Position } from 'geojson' import 'maplibre-gl/dist/maplibre-gl.css' import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url' @@ -24,6 +25,7 @@ type PointOfInterest = { lat: number elevation: number | null capacity: number | null + inside: boolean // false for punktene opptil 10 km utenfor grensen, som bare vises svakt på kartet } // Navn, farge og ikon for hver type. Rekkefølgen her er rekkefølgen i fanene. @@ -107,11 +109,18 @@ function tileUrl(area: MountainArea, z = 7) { const POI_QUERY = ` query AreaPointsOfInterest($areaId: ID!) { - areaPointsOfInterest(areaId: $areaId) { id kind dntType name lon lat elevation capacity } + areaPointsOfInterest(areaId: $areaId) { id kind dntType name lon lat elevation capacity inside } } ` -// Backend henter punktene innenfor selve grensen, med samme metode som når de telles +const SEARCH_QUERY = ` + query SearchPointsOfInterest($query: String!) { + searchPointsOfInterest(query: $query) { areaId point { id kind dntType name lon lat elevation capacity inside } } + } +` +type SearchHit = { areaId: string; point: PointOfInterest } + +// Backend henter punktene i området og like utenfor. inside er true for dem som er med i opptellingen. async function fetchPoints(areaId: string, signal: AbortSignal): Promise { const data = await graphql<{ areaPointsOfInterest: PointOfInterest[] }>(POI_QUERY, { areaId }, signal) return data.areaPointsOfInterest @@ -126,7 +135,7 @@ function pointsToGeoJson(points: PointOfInterest[]): FeatureCollection JSON.parse(area.geojson) as Polygon | MultiPolygon -// Et polygon som dekker hele verden, med området som hull, gjør at alt utenfor området blir blekere. +// Et polygon som dekker hele verden, med området som hull, brukes til et svakt slør over kartet utenfor området. // Hullene er ytterkanten av hver del av grensen, så det virker både for Polygon og MultiPolygon. function maskAround(area: MountainArea): Feature { const geometry = boundaryOf(area) @@ -153,31 +162,35 @@ function hoverContent(p: PointOfInterest) { return box } -const idFromHash = () => decodeURIComponent(window.location.hash.replace(/^#\/?/, '')) || null +// #/jotunheimen gir området, og #/jotunheimen/n/791106132 gir også punktet som skal velges +function readHash() { + const [areaId, ...point] = decodeURIComponent(window.location.hash.replace(/^#\/?/, '')).split('/') + return { areaId: areaId || null, pointId: point.join('/') || null } +} export default function Fjellomrade() { const [areas, setAreas] = useState(null) const [error, setError] = useState('') - const [selectedId, setSelectedId] = useState(idFromHash) + const [route, setRoute] = useState(readHash) // Hvor langt ned på oversikten man var, så man kommer tilbake til samme sted fra kartet const overviewScroll = useRef(0) useEffect(() => { fetchMountainAreas().then(setAreas).catch((e: unknown) => setError(e instanceof Error ? e.message : 'Noe gikk galt')) - const onHashChange = () => setSelectedId(idFromHash()) + const onHashChange = () => setRoute(readHash()) window.addEventListener('hashchange', onHashChange) return () => window.removeEventListener('hashchange', onHashChange) }, []) - const area = areas?.find(a => a.id === selectedId) ?? null + const area = areas?.find(a => a.id === route.areaId) ?? null useEffect(() => { if (!area) window.scrollTo(0, overviewScroll.current) }, [area]) - if (area) return + if (area) return return ( - { overviewScroll.current = window.scrollY }} /> ) } @@ -192,6 +205,22 @@ function Overview({ areas, error, notFound, onOpen }: OverviewProps) { .filter(a => (region === 'all' || regionOf(a) === region) && (!needle || `${a.name} ${a.description ?? ''}`.toLowerCase().includes(needle))) .sort((a, b) => (b.highestPeak?.elevation ?? 0) - (a.highestPeak?.elevation ?? 0)) + // Søket finner også hytter og topper. Backend spørres en kort stund etter at man har sluttet å skrive, + // og treffene huskes sammen med søketeksten, så gamle treff ikke vises for et nytt søk. + const [search, setSearch] = useState<{ needle: string; hits: SearchHit[] }>({ needle: '', hits: [] }) + useEffect(() => { + if (needle.length < 2) return + const controller = new AbortController() + const timer = setTimeout(() => { + graphql<{ searchPointsOfInterest: SearchHit[] }>(SEARCH_QUERY, { query: needle }, controller.signal) + .then(data => setSearch({ needle, hits: data.searchPointsOfInterest })) + .catch(() => { /* søket i hytter og topper er et tillegg, så feil vises ikke */ }) + }, 250) + return () => { clearTimeout(timer); controller.abort() } + }, [needle]) + const hits = search.needle === needle ? search.hits : [] + const areaName = (id: string) => areas?.find(a => a.id === id)?.name ?? id + return (
@@ -223,9 +252,23 @@ function Overview({ areas, error, notFound, onOpen }: OverviewProps) { {error &&

Fikk ikke hentet fjellområdene: {error}

} {!areas && !error &&

Henter fjellområder …

} {notFound &&

Fant ikke fjellområdet «{notFound}». Velg et av områdene under.

} - {areas && shown.length === 0 &&

Ingen fjellområder passer søket.

} + {areas && shown.length === 0 && hits.length === 0 &&

Ingen fjellområder, hytter eller topper passer søket.

}
+ {hits.length > 0 && ( +
+ {hits.map(({ areaId, point }) => ( + + + + {point.name} + {KINDS[point.kind].one} · {areaName(areaId)} · {describe(point)} + + + ))} +
+ )} +
{shown.map(a => ( @@ -253,7 +296,7 @@ function Overview({ areas, error, notFound, onOpen }: OverviewProps) { } // Ett område: kartet over hele skjermen, med et panel oppå som kan felles sammen -function AreaView({ area }: { area: MountainArea }) { +function AreaView({ area, pointId }: { area: MountainArea; pointId: string | null }) { const [points, setPoints] = useState(null) const [error, setError] = useState('') const [tab, setTab] = useState('HUT') @@ -263,14 +306,22 @@ function AreaView({ area }: { area: MountainArea }) { useEffect(() => { const controller = new AbortController() fetchPoints(area.id, controller.signal) - .then(setPoints) + .then(points => { + setPoints(points) + // Kom man fra et søketreff, velges punktet og fanen for typen dets + const fromSearch = points.find(p => p.id === pointId) + if (fromSearch) { + setSelected(fromSearch) + setTab(fromSearch.kind) + } + }) .catch((e: unknown) => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : 'Noe gikk galt') }) return () => controller.abort() - }, [area]) + }, [area, pointId]) // Toppene sorteres etter høyde, resten etter antall sengeplasser const rows = (points ?? []) - .filter(p => p.kind === tab) + .filter(p => p.kind === tab && p.inside) .sort((a, b) => (tab === 'PEAK' ? (b.elevation ?? 0) - (a.elevation ?? 0) : (b.capacity ?? 0) - (a.capacity ?? 0))) return ( @@ -349,7 +400,7 @@ type MapProps = { onSelect: (id: string) => void } -// Kartverkets kart over hele skjermen, zoomet inn på området. Alt utenfor grensen tones ned. +// Kartverkets kart over hele skjermen, zoomet inn på området. Området er svakt farget, og kartet utenfor har et tynt slør. function AreaMap({ area, points, selected, panelOpen, onSelect }: MapProps) { const container = useRef(null) const mapRef = useRef(null) @@ -372,35 +423,47 @@ function AreaMap({ area, points, selected, panelOpen, onSelect }: MapProps) { mapRef.current = map map.on('load', () => { - map.addSource('mask', { type: 'geojson', data: maskAround(area) }) - map.addLayer({ id: 'mask', type: 'fill', source: 'mask', paint: { 'fill-color': '#e3ebdf', 'fill-opacity': 0.75 } }) map.addSource('area', { type: 'geojson', data: { type: 'Feature', properties: {}, geometry: boundaryOf(area) } }) + map.addLayer({ id: 'area-fill', type: 'fill', source: 'area', paint: { 'fill-color': '#c27730', 'fill-opacity': 0.08 } }) + // Sløret er så svakt at kartet og punktene utenfor fortsatt er lette å lese + map.addSource('mask', { type: 'geojson', data: maskAround(area) }) + map.addLayer({ id: 'mask', type: 'fill', source: 'mask', paint: { 'fill-color': '#7d8a80', 'fill-opacity': 0.15 } }) map.addLayer({ id: 'area-line', type: 'line', source: 'area', paint: { 'line-color': '#c27730', 'line-width': 2.5, 'line-dasharray': [3, 2] } }) map.addSource('pois', { type: 'geojson', data: pointsToGeoJson([]) }) // Ring rundt det valgte punktet. Filteret settes når et punkt velges. map.addLayer({ id: 'pois-selected', type: 'circle', source: 'pois', filter: ['==', ['get', 'id'], ''], paint: { 'circle-radius': 16, 'circle-color': COLOR_BY_KIND, 'circle-opacity': 0.25 } }) - // Ett ikon per type (poi-HUT, poi-CABIN …), som vokser litt når man zoomer inn + // Ett ikon per type (poi-HUT, poi-CABIN …), som vokser litt når man zoomer inn. + // Punktene utenfor grensen er mindre og halvt gjennomsiktige. for (const kind of ALL_KINDS) map.addImage(`poi-${kind}`, drawIcon(kind), { pixelRatio: 2 }) + const icon: SymbolLayerSpecification['layout'] = { + 'icon-image': ['concat', 'poi-', ['get', 'kind']], + 'icon-size': ['interpolate', ['linear'], ['zoom'], 8, ['case', ['get', 'inside'], 0.7, 0.55], 13, ['case', ['get', 'inside'], 1.1, 0.85]], + } + const paint: SymbolLayerSpecification['paint'] = { 'icon-opacity': ['case', ['get', 'inside'], 1, 0.5] } + // Toppene får ikke ligge oppå hverandre. De høyeste plasseres først, så når man zoomer ut, + // vises bare de høyeste toppene, og flere kommer til når man zoomer inn. + map.addLayer({ + id: 'pois-peaks', type: 'symbol', source: 'pois', filter: ['==', ['get', 'kind'], 'PEAK'], paint, + layout: { ...icon, 'symbol-sort-key': ['-', ['coalesce', ['get', 'elevation'], 0]] }, + }) + // Hytter, buer og gapahuker vises alltid, også når de ligger tett map.addLayer({ - id: 'pois', type: 'symbol', source: 'pois', - layout: { - 'icon-image': ['concat', 'poi-', ['get', 'kind']], - 'icon-size': ['interpolate', ['linear'], ['zoom'], 8, 0.7, 13, 1.1], - 'icon-allow-overlap': true, - }, + id: 'pois', type: 'symbol', source: 'pois', filter: ['!=', ['get', 'kind'], 'PEAK'], paint, + layout: { ...icon, 'icon-allow-overlap': true }, }) + const poiLayers = ['pois', 'pois-peaks'] // Kartet mister tomme verdier i egenskapene, så vi slår opp punktet på id i stedet - map.on('click', 'pois', event => onSelectRef.current(String(event.features?.[0]?.properties.id))) + map.on('click', poiLayers, event => onSelectRef.current(String(event.features?.[0]?.properties.id))) // Vis navnet på punktet musepekeren er over const hover = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 12, className: 'fo-hover' }) - map.on('mousemove', 'pois', event => { + map.on('mousemove', poiLayers, event => { const point = pointsRef.current.find(p => p.id === String(event.features?.[0]?.properties.id)) if (!point) return map.getCanvas().style.cursor = 'pointer' hover.setLngLat([point.lon, point.lat]).setDOMContent(hoverContent(point)).addTo(map) }) - map.on('mouseleave', 'pois', () => { + map.on('mouseleave', poiLayers, () => { map.getCanvas().style.cursor = '' hover.remove() }) @@ -435,8 +498,6 @@ function AreaMap({ area, points, selected, panelOpen, onSelect }: MapProps) { // Bytt mellom Kartverkets kart og flyfoto ved å vise eller skjule flyfotolaget const toggleSatellite = () => { mapRef.current?.setLayoutProperty('satellitt', 'visibility', satellite ? 'none' : 'visible') - // Sløret utenfor området gjør flyfotoet tåkete, så det er svakere der - mapRef.current?.setPaintProperty('mask', 'fill-opacity', satellite ? 0.75 : 0.4) setSatellite(!satellite) } From 0439323cadc3a3e74078bc2df1cd276e8089d74c Mon Sep 17 00:00:00 2001 From: karstenmidthaug Date: Sat, 10 Oct 2026 11:39:19 +0200 Subject: [PATCH 17/17] =?UTF-8?q?Dokumenter=20backenden=20og=20hvor=20gren?= =?UTF-8?q?sene=20for=20fjellomr=C3=A5dene=20kommer=20fra?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 ++- server/README.md | 165 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 server/README.md diff --git a/README.md b/README.md index 21c212f..fb961e9 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,18 @@ GraphQL-spørringen `routes` støtter tekstsøk, minimums- og maksimumslengde, k ## Fjellområder og turpunkter -Rensede data som appen henter fra, ligger i skjemaet `silver`. SQL-filene som lager dem ligger i `server/sql/silver/`, og originaldataene endres ikke. +Rensede data som appen henter fra, ligger i skjemaet `silver`. SQL-filene som lager dem ligger i `server/sql/silver/`, og originaldataene endres ikke. Se [server/README.md](server/README.md) for hvordan backenden er satt opp og hvor hver grense kommer fra. - `silver.osm_points_of_interest` er en materialisert visning med turisthytter, buer, gapahuker, teltplasser, topper, utsiktspunkter, turparkering, toaletter og drikkevann fra OpenStreetMap. Busskur, private steder og vanlig parkering er filtrert bort, og høyde og antall sengeplasser er gjort om til tall. Ved ny OSM-import oppdateres den med `REFRESH MATERIALIZED VIEW silver.osm_points_of_interest;`. Backend husker tallene for fjellområdene i 10 minutter, så start backend på nytt for å se endringer med en gang. - `silver.mountain_areas` inneholder 15 fjellområder, blant annet Jotunheimen, Rondane og Hardangervidda. -Grensene for fjellområdene er foreløpig omtrentlige. Det finnes ingen offisiell avgrensning av landskapsområder som «Jotunheimen», så hvert område er et håndlaget rektangel. Rektanglene er kontrollert mot punktene Kartverket har for området i stedsnavnregisteret (SSR), og kolonnen `ssr_id` peker til Kartverkets offisielle navn der området er registrert. Vi planlegger å erstatte rektanglene med faktiske grenser senere, for eksempel nasjonalpark- og verneområdegrenser fra Miljødirektoratet. Grensen ligger i én kolonne, og både opptellingen og turpunktene på kartet hentes med `ST_Intersects` mot den, så backend og frontend trenger ikke endres når den byttes ut. Består en ny grense av flere deler, må kolonnetypen endres fra `Polygon` til `MultiPolygon`. +Det finnes ingen offisiell avgrensning av landskapsområder som «Jotunheimen», så grensene er hentet fra det nærmeste som finnes: + +- Jotunheimen, Rondane, Hardangervidda og Dovrefjell bruker fjellområdene fra OpenStreetMap (`natural=mountain_range`, © OpenStreetMap-bidragsytere, ODbL). +- Trollheimen, Breheimen, Femundsmarka, Blåfjella–Skjækerfjella, Saltfjellet, Lyngsalpan og Setesdalsheiane bruker nasjonalparken eller landskapsvernområdet fra Miljødirektoratets datasett Naturvernområder (NLOD). +- Romsdalsalpane, Sunnmørsalpane, Skarvheimen og Lofoten har ingen grense som passer i noen av kildene, så de er fortsatt håndlagde rektangler. + +Mange hytter ligger like utenfor vernegrensene, så grensene fra OSM og Miljødirektoratet er utvidet med 3 km. De er også forenklet til omtrent 100 m nøyaktighet, så SQL-fila holder seg liten, og hver rad har en kommentar om hvor grensen kommer fra. Kolonnen `ssr_id` peker til Kartverkets offisielle navn i stedsnavnregisteret (SSR) der området er registrert. Grensen ligger i én kolonne av typen `MultiPolygon`, og både opptellingen og turpunktene på kartet hentes med `ST_Intersects` mot den. ## Start databaseversjonen lokalt diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..c26ba58 --- /dev/null +++ b/server/README.md @@ -0,0 +1,165 @@ +# Backend + +Backenden er én Node.js-server med ett GraphQL-endepunkt, `/graphql`. Den henter alle data fra PostgreSQL-databasen `norwegian_explorer` med PostGIS på VM-en. Denne fila forklarer hvordan den er satt opp, med vekt på fjellområdene og turpunktene. + +## Oppsett + +| Fil | Hva den gjør | +|---|---| +| `index.mjs` | Starter serveren (GraphQL Yoga på `127.0.0.1:4000`). Inneholder rutesøket, innloggingen, profilene og favorittene, og kobler inn fjelldelen fra `server.ts`. | +| `server.ts` | GraphQL-typene og -spørringene for fjellområdesiden. Starter ingen server selv, men legges til i skjemaet i `index.mjs`. | +| `mountainAreas.ts` | Henter fjellområdene med nøkkeltall: antall hytter, buer, gapahuker og topper, og høyeste topp. | +| `pointsOfInterest.ts` | Henter turpunktene i ett fjellområde, og søker etter turpunkter på navn. | +| `db.ts` | Én felles tilkobling (`pg.Pool`) til databasen, som brukes av alle filene over. | +| `sql/silver/*.sql` | SQL-filene som lager de rensede dataene fjelldelen henter fra. Kjøres for hånd i DBeaver. | + +`index.mjs` er JavaScript, mens fjelldelen er skrevet i TypeScript. Node 24 kjører `.ts`-filene direkte, siden den fjerner typene ved oppstart, så backenden trenger ikke bygges. + +### Kjøre lokalt + +1. Kopier `.env.example` til `.env`. `.env` skal ikke committes. Lokalt peker `DATABASE_URL` til en SSH-tunnel til VM-en: + + ```bash + ssh -N -L 5433:/var/run/postgresql/.s.PGSQL.5432 @it2810-34.idi.ntnu.no + ``` + +2. Start backend og frontend i hver sin terminal: + + ```bash + npm run dev:server + ``` + + ```bash + npm run dev + ``` + +`dev:server` starter backenden på nytt når en fil endres. Vite sender `/graphql` videre til `127.0.0.1:4000` (se `vite.config.ts`). GraphiQL, et verktøy for å prøve spørringer i nettleseren, ligger på når `NODE_ENV` ikke er `production`. + +Uten `DATABASE_URL` prøver `db.ts` en lokal database via socket i `/tmp`. Tilkoblingen gir opp etter 5 sekunder, og hver spørring etter 10 sekunder, så en treg spørring ikke henger for alltid. + +## Databasen + +Databasen er delt i skjemaer etter det som kalles medallion-mønsteret. Originaldataene endres aldri. + +| Skjema | Innhold | +|---|---| +| `osm_import`, `turogfriluftsruter_…` (bronze) | Rådata slik de ble importert: OpenStreetMap-uttrekket og Kartverkets Tur- og friluftsruter. | +| `silver` | Rensede data som fjelldelen henter fra: `osm_points_of_interest` og `mountain_areas`. | +| `norwegian_explorer_app` | Appens egne tabeller: brukere, sesjoner, favoritter og rutekatalogen. `index.mjs` lager tabellene for brukere, sesjoner og favoritter ved oppstart. | + +Hvis databasebrukeren ikke har lov til å lage tabellene i `norwegian_explorer_app`, feiler bare innlogging og rutesøk. Resten av serveren, inkludert fjelldelen, virker som før. + +## GraphQL-spørringer for fjelldelen + +| Spørring | Svar | +|---|---| +| `mountainAreas` | Alle fjellområdene med navn, beskrivelse, grense (GeoJSON), `bbox` (rektangelet rundt området) og antall hytter, buer, gapahuker og topper, pluss høyeste topp. | +| `areaPointsOfInterest(areaId)` | Hyttene, buene, gapahukene og toppene i ett område, og dem som ligger opptil 10 km utenfor. Feltet `inside` sier om punktet er innenfor grensen. | +| `searchPointsOfInterest(query)` | Opptil 12 hytter, buer, gapahuker og topper i fjellområdene med navn som inneholder søketeksten, sammen med området hvert treff ligger i. | + +Eksempel: + +```graphql +{ + mountainAreas { id name hutCount peakCount highestPeak { name elevation } } + searchPointsOfInterest(query: "gjende") { areaId point { name kind capacity } } +} +``` + +Rutesøket, innloggingen og profilene er beskrevet i README-en i rotmappa. + +## Turpunktene: `silver.osm_points_of_interest` + +Lages av `sql/silver/osm_points_of_interest.sql` fra OSM-dataene i `osm_import.objects`. Det er en materialisert visning, altså et lagret spørringsresultat, så appen slipper å kjøre hele spørringen hver gang. + +OSM-taggene oversettes til egne typer. Fjelldelen bruker de fire første: + +| Type | OSM-tagg | Betydning | +|---|---|---| +| `HUT` | `tourism=alpine_hut` | Turisthytte | +| `CABIN` | `tourism=wilderness_hut`, eller `tourism=chalet` med `dnt:classification` | Bu eller koie | +| `SHELTER` | `amenity=shelter` | Gapahuk eller rasteplass | +| `PEAK` | `natural=peak` | Topp | +| `CAMPSITE`, `VIEWPOINT`, `PARKING`, `TOILETS`, `WATER` | `tourism=camp_site`, `tourism=viewpoint`, `amenity=parking` med `hiking=yes`, `amenity=toilets`, `amenity=drinking_water` | Brukes ikke på fjellområdesiden i dag | + +Private steder, busskur, vanlig parkering og utleiehytter er filtrert bort. Høyde (`ele`) og sengeplasser (`capacity`) er tekst i OSM og gjøres om til tall. Tall som er åpenbart feil, som høyder over Galdhøpiggens 2469 moh., blir `NULL`. + +Etter en ny OSM-import oppdateres visningen med: + +```sql +REFRESH MATERIALIZED VIEW silver.osm_points_of_interest; +``` + +## Fjellområdene: `silver.mountain_areas` + +Tabellen lages av `sql/silver/mountain_areas.sql`, og fila er kilden til dataene. Vil du endre noe, endrer du fila og kjører hele fila på nytt i DBeaver med Option+X. Ikke rediger tabellen direkte. + +| Kolonne | Innhold | +|---|---| +| `id` | Brukes i adressen, for eksempel `jotunheimen` i `fjellomrade.html#/jotunheimen` | +| `name`, `description` | Navn og kort beskrivelse | +| `ssr_id` | Kartverkets stedsnummer i stedsnavnregisteret (SSR), eller `NULL` hvis Kartverket ikke har området | +| `area` | Grensen, som `geometry(MultiPolygon, 4326)`. Det er en MultiPolygon fordi noen grenser består av flere deler. | + +### Hvor grensene kommer fra + +Det finnes ingen offisiell avgrensning av landskapsområder som «Jotunheimen». Hvert område har derfor fått grensen fra den kilden som passer best: + +| Område | Kilde | Grense | Areal* | +|---|---|---|---| +| Jotunheimen | OpenStreetMap | `natural=mountain_range`, relasjon 8512564 | 1 830 km² | +| Rondane | OpenStreetMap | `natural=mountain_range`, relasjon 8512563 | 1 496 km² | +| Hardangervidda | OpenStreetMap | `natural=mountain_range`, relasjon 8512565 | 4 381 km² | +| Dovrefjell | OpenStreetMap | `natural=mountain_range`, relasjon 8512562 | 4 550 km² | +| Trollheimen | Miljødirektoratet | Trollheimen landskapsvernområde (VV00000745) | 1 914 km² | +| Breheimen | Miljødirektoratet | Breheimen nasjonalpark (VV00002830) | 2 668 km² | +| Femundsmarka | Miljødirektoratet | Femundsmarka nasjonalpark (VV00001943) | 1 025 km² | +| Blåfjella–Skjækerfjella | Miljødirektoratet | Blåfjella-Skjækerfjella nasjonalpark (VV00001562) | 3 109 km² | +| Saltfjellet | Miljødirektoratet | Saltfjellet-Svartisen nasjonalpark (VV00000235) | 3 013 km² | +| Lyngsalpan | Miljødirektoratet | Lyngsalpan landskapsvernområde (VV00002271) | 1 661 km² | +| Setesdalsheiane | Miljødirektoratet | Setesdal Vesthei Ryfylkeheiane landskapsvernområde (VV00001937) | 3 732 km² | +| Romsdalsalpane | Håndlaget rektangel | 7,4–8,1 °Ø, 62,35–62,65 °N | 1 206 km² | +| Sunnmørsalpane | Håndlaget rektangel | 6,2–7,1 °Ø, 62,0–62,4 °N | 2 088 km² | +| Skarvheimen | Håndlaget rektangel | 7,5–8,4 °Ø, 60,6–61,0 °N | 2 184 km² | +| Lofoten | Håndlaget rektangel | 12,8–15,5 °Ø, 67,85–68,45 °N | 7 506 km² | + +\* Arealet slik det ligger i tabellen, altså med 3 km utvidelse for grensene fra OSM og Miljødirektoratet. + +Om kildene: + +- **OpenStreetMap, `natural=mountain_range`:** Fjellområder slik frivillige har tegnet dem i OSM. Bare fire av områdene våre finnes som flater der. For Jotunheimen, Rondane og Hardangervidda følger de nesten nøyaktig nasjonalparkgrensene. Grensene ble hentet med Nominatim (`nominatim.openstreetmap.org/lookup?osm_ids=R8512564&format=geojson&polygon_geojson=1`). +- **Miljødirektoratet, Naturvernområder:** De offisielle grensene for nasjonalparker og landskapsvernområder. De ble lastet ned som GeoJSON i EPSG:4326 fra Geonorge, og de sju flatene ble plukket ut på `identifikasjon.lokalId` (VV-nummeret). Hele datasettet er på rundt 200 MB og ligger ikke i repoet eller i databasen. Bare de sju grensene ligger i SQL-fila. +- **Håndlagde rektangler:** For Romsdalsalpane og Sunnmørsalpane finnes det ingen flate i noen av kildene. Skarvheimen (Hallingskarvet nasjonalpark) og Lofoten (Lofotodden nasjonalpark) har verneområder, men de dekker bare en liten del av det folk regner som området. Rektanglene er kontrollert mot punktene Kartverket har for områdene i SSR. + +Andre kilder som ble vurdert: + +- **Villreinområdene fra Miljødirektoratet:** Ofte mye større enn selve fjellområdet, og de finnes bare i Sør-Norge. +- **Kartverkets stedsnavnregister (SSR):** Har bare ett punkt per fjellområde, ingen flate. +- **Landformer fra NGU:** Geologiske former, ingen navngitte fjellområder. + +### Bearbeiding av grensene + +- **Forenklet:** Grensene fra OSM og Miljødirektoratet er forenklet til omtrent 100 m nøyaktighet med `ogr2ogr -simplify 0.001`, og koordinatene er rundet av til 5 desimaler. Det holder godt for et oversiktskart, og hele SQL-fila blir på rundt 65 kB. +- **Utvidet med 3 km:** Mange DNT-hytter ligger rett utenfor verne- og fjellgrensene, nede i dalen eller ved veien inn. Gjendesheim og Spiterstulen er eksempler. Derfor utvider SQL-fila disse grensene med 3 km med `ST_Buffer(area::geography, 3000)`, der `geography` gjør at avstanden regnes i meter. 3 km ble valgt etter å ha sammenlignet antall hytter: + + | | Gammelt rektangel | Grensen | + 2 km | + 3 km | + 5 km | + |---|---|---|---|---|---| + | Jotunheimen | 19 | 4 | 12 | 16 | 17 | + | Rondane | 7 | 1 | 11 | 11 | 11 | + | Hardangervidda | 26 | 9 | 12 | 15 | 18 | + + Hardangervidda har færre hytter enn før fordi det gamle rektangelet gikk langt utenfor selve vidda. + +### Slik brukes grensene + +All romlig logikk bruker samme grense med `ST_Intersects`, så tallene, lista og kartet stemmer overens: + +- **Opptelling** (`mountainAreas.ts`): For hvert område telles hytter, buer, gapahuker og topper innenfor grensen, og høyeste topp med navn og høyde finnes. Svaret huskes i 10 minutter, siden tallene bare endres når SQL-filene kjøres på nytt. Start backenden på nytt for å se endringer med en gang. +- **Turpunkter for ett område** (`pointsOfInterest.ts`): Henter punktene opptil 10 km utenfor grensen, så kartet også viser hva som ligger like ved. `&&` mot `ST_Expand(area, 0.3)` finner kandidatene raskt med den romlige indeksen, og `ST_DWithin` på `geography` sjekker de 10 km i meter. `inside` er true for punktene innenfor grensen, og det er bare de som telles og vises i lista i panelet. Spørringen tar rundt 90 ms for Jotunheimen. +- **Søk** (`pointsOfInterest.ts`): `ILIKE` mot navnet, bare for punkter innenfor et fjellområde, siden det er de siden kan åpne et kart for. Navn som starter med søketeksten kommer først, deretter hytter før buer, gapahuker og topper. `%` og `_` escapes, så de søkes etter som vanlige tegn. + +## Lisenser + +- **OpenStreetMap:** Turpunktene og fire av grensene. © OpenStreetMap-bidragsytere, lisensiert under [ODbL](https://www.openstreetmap.org/copyright), som krever at kilden oppgis. +- **Miljødirektoratet:** Naturvernområder, lisensiert under Norsk lisens for offentlige data (NLOD). +- **Kartverket:** Tur- og friluftsruter, stedsnavn (SSR) og kartflisene på fjellområdesiden.