From 80d6203171de1badc7bbe6042c9b3a9e9be5c1d6 Mon Sep 17 00:00:00 2001 From: Sofus Lind Date: Wed, 7 Oct 2026 17:40:59 +0200 Subject: [PATCH] Implement API calls, map interactions and solar potential card Adds building, roof solar, ROS and route lookups, a mode switch between point exploration and routing, and a solar estimate with fun equivalents. Co-Authored-By: Claude Sonnet 5.5 --- ...ettings.local.json.tmp.119157.54e0e4f9c3fa | 8 + src/api/getBygningAtPunkt.ts | 14 +- src/api/getRosDataForBygning.ts | 13 +- src/api/getRuteMellomPunkter.ts | 14 +- src/api/getTakflateDataForBygning.ts | 13 +- src/api/getTakflateDataForPunkt.ts | 13 +- src/api/norkartFetch.ts | 23 ++ src/components/InfoCard.tsx | 93 ++++++++ src/components/MapLibreMap.tsx | 206 +++++++++++++++++- src/solar.ts | 56 +++++ 10 files changed, 390 insertions(+), 63 deletions(-) create mode 100644 .claude/settings.local.json.tmp.119157.54e0e4f9c3fa create mode 100644 src/api/norkartFetch.ts create mode 100644 src/components/InfoCard.tsx create mode 100644 src/solar.ts diff --git a/.claude/settings.local.json.tmp.119157.54e0e4f9c3fa b/.claude/settings.local.json.tmp.119157.54e0e4f9c3fa new file mode 100644 index 0000000..b53b4b6 --- /dev/null +++ b/.claude/settings.local.json.tmp.119157.54e0e4f9c3fa @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(git add *)", + "Bash(git commit -q -m 'Implement API calls, map interactions and solar potential card *)" + ] + } +} diff --git a/src/api/getBygningAtPunkt.ts b/src/api/getBygningAtPunkt.ts index a8211af..5c5f5ae 100644 --- a/src/api/getBygningAtPunkt.ts +++ b/src/api/getBygningAtPunkt.ts @@ -1,14 +1,8 @@ +import { norkartFetch } from './norkartFetch'; + export const getBygningAtPunkt = async (x: number, y: number) => { - const apiKey = import.meta.env.VITE_API_KEY; const query = `https://bygning.api.norkart.no/bygninger/byposition?x=${x}&y=${y}&MaxRadius=1&GeometryTextFormat=GeoJson&IncludeFkbData=true`; - // TODO: Fullfør/endre koden for hente og returnere bygningsdata på et punkt - - // Hint: Du kan se på getAdresseFromSearchText og getHoydeFromPunkt for å få en idé om hvordan - // dette kan gjøres. - - // Merk at dette er en GET request, og ikke en POST request! - - // Når du har fått til kallet til API-et kan du se i Network-taben i nettleseren eller i - // konsollen for å se hvordan responsen ser ut. + const data = await norkartFetch(query); + return data?.Bygninger?.[0]; }; diff --git a/src/api/getRosDataForBygning.ts b/src/api/getRosDataForBygning.ts index 229a356..7251952 100644 --- a/src/api/getRosDataForBygning.ts +++ b/src/api/getRosDataForBygning.ts @@ -1,14 +1,7 @@ +import { norkartFetch } from './norkartFetch'; + export const getRosDataForBygning = async (bygningsNr: number) => { - const apiKey = import.meta.env.VITE_API_KEY; const query = `https://ros.api.norkart.no/v2/ros/bygning/${bygningsNr}`; - // TODO: Fullfør/endre koden for hente og returnere risiko- og sårbarhetsdata (ROS-data) for en bygning - - // Hint: Du kan se på getAdresseFromSearchText og getHoydeFromPunkt for å få en idé om hvordan - // dette kan gjøres. - - // Merk at dette er en GET request, og ikke en POST request! - - // Når du har fått til kallet til API-et kan du se i Network-taben i nettleseren eller i - // konsollen for å se hvordan responsen ser ut. + return norkartFetch(query); }; diff --git a/src/api/getRuteMellomPunkter.ts b/src/api/getRuteMellomPunkter.ts index 1d807f2..9c44ab3 100644 --- a/src/api/getRuteMellomPunkter.ts +++ b/src/api/getRuteMellomPunkter.ts @@ -1,10 +1,11 @@ +import { norkartFetch } from './norkartFetch'; + export const getRuteMellomPunkter = async ( startX: number, startY: number, stoppX: number, stoppY: number ) => { - const apiKey = import.meta.env.VITE_API_KEY; const query = `https://ruteberegner.api.norkart.no/Route/Expanded`; const postData = { @@ -34,14 +35,5 @@ export const getRuteMellomPunkter = async ( ZoomLevel: 14, }; - // TODO: Fullfør/endre koden for å hente og returnere en kjørerute mellom to punkter - - // Hint: Du kan se på getHoydeFromPunkt for å få en idé om hvordan dette kan gjøres. - // Dette er en POST request, akkurat som getHoydeFromPunkt. - - // Responsen inneholder bl.a. RouteGeometry (en GeoJSON MultiLineString) som kan vises i - // kartet, og en liste med kostnader (CostList) for ruten. - - // Når du har fått til kallet til API-et kan du se i Network-taben i nettleseren eller i - // konsollen for å se hvordan responsen ser ut. + return norkartFetch(query, postData); }; diff --git a/src/api/getTakflateDataForBygning.ts b/src/api/getTakflateDataForBygning.ts index fc5346d..de57bf6 100644 --- a/src/api/getTakflateDataForBygning.ts +++ b/src/api/getTakflateDataForBygning.ts @@ -1,14 +1,7 @@ +import { norkartFetch } from './norkartFetch'; + export const getTakflateDataForBygning = async (bygningsNr: number) => { - const apiKey = import.meta.env.VITE_API_KEY; const query = `https://takflater.api.norkart.no/takflater/bygning/${bygningsNr}/utvidet`; - // TODO: Fullfør/endre koden for hente og returnere takflatedata for takene på en bygning - - // Hint: Du kan se på getAdresseFromSearchText og getHoydeFromPunkt for å få en idé om hvordan - // dette kan gjøres. - - // Merk at dette er en GET request, og ikke en POST request! - - // Når du har fått til kallet til API-et kan du se i Network-taben i nettleseren eller i - // konsollen for å se hvordan responsen ser ut. + return norkartFetch(query); }; diff --git a/src/api/getTakflateDataForPunkt.ts b/src/api/getTakflateDataForPunkt.ts index fb61e32..f6ff139 100644 --- a/src/api/getTakflateDataForPunkt.ts +++ b/src/api/getTakflateDataForPunkt.ts @@ -1,14 +1,7 @@ +import { norkartFetch } from './norkartFetch'; + export const getTakflateDataForPunkt = async (x: number, y: number) => { - const apiKey = import.meta.env.VITE_API_KEY; const query = `https://takflater.api.norkart.no/takflater/punkt/utvidet?x=${x}&y=${y}`; - // TODO: Fullfør/endre koden for hente og returnere takflatedata for et punkt - - // Hint: Du kan se på getAdresseFromSearchText og getHoydeFromPunkt for å få en idé om hvordan - // dette kan gjøres. - - // Merk at dette er en GET request, og ikke en POST request! - - // Når du har fått til kallet til API-et kan du se i Network-taben i nettleseren eller i - // konsollen for å se hvordan responsen ser ut. + return norkartFetch(query); }; diff --git a/src/api/norkartFetch.ts b/src/api/norkartFetch.ts new file mode 100644 index 0000000..c8b64c8 --- /dev/null +++ b/src/api/norkartFetch.ts @@ -0,0 +1,23 @@ +export const norkartFetch = async (url: string, body?: unknown) => { + const apiKey = import.meta.env.VITE_API_KEY; + + try { + const apiResult = await fetch(url, { + method: body ? 'POST' : 'GET', + headers: { + Accept: 'application/json', + ...(body ? { 'Content-Type': 'application/json' } : {}), + 'X-WAAPI-TOKEN': `${apiKey}`, + }, + body: body ? JSON.stringify(body) : undefined, + }); + + if (apiResult.ok) { + return await apiResult.json(); + } + console.error('API request failed with status:', apiResult.status); + } catch (error) { + console.error('An error occurred while fetching data:', error); + } + return undefined; +}; diff --git a/src/components/InfoCard.tsx b/src/components/InfoCard.tsx new file mode 100644 index 0000000..ade948f --- /dev/null +++ b/src/components/InfoCard.tsx @@ -0,0 +1,93 @@ +import { Box, Chip, Divider, Typography } from '@mui/material'; +import { MONTHS, summarize, type Takflate } from '../solar'; + +export type PointInfo = { + lng: number; + lat: number; + hoyde?: number; + bygning?: { + Bygningsnummer: number; + MatrikkelData?: { Bygningstype?: string; Bygningstatus?: string }; + }; + ros?: { Flom?: string; Kvikkleire?: string; AvstandBrannstasjon?: number }; + roofs: Takflate[]; +}; + +const nf = new Intl.NumberFormat('nb-NO', { maximumFractionDigits: 0 }); + +export const InfoCard = ({ info }: { info: PointInfo }) => { + const sol = info.roofs.length ? summarize(info.roofs) : undefined; + const max = sol ? Math.max(...sol.monthlyKwh) : 1; + + return ( + + + {info.lat.toFixed(5)}, {info.lng.toFixed(5)} + {info.hoyde !== undefined && ` · ${info.hoyde.toFixed(1)} moh.`} + + + {info.bygning && ( + + {info.bygning.MatrikkelData?.Bygningstype ?? 'Bygning'} ( + {info.bygning.MatrikkelData?.Bygningstatus}) · nr.{' '} + {info.bygning.Bygningsnummer} + + )} + + {info.ros && ( + + + + + + )} + + {sol && ( + <> + + + ☀️ Solcelle-orakelet + + + {nf.format(sol.areal)} m² tak kan gi ca.{' '} + {nf.format(sol.yearKwh)} kWh i året. + + + {sol.monthlyKwh.map((v, i) => ( + + ))} + + + Det tilsvarer: + + + 🏠 {sol.households.toFixed(1)} norske husholdninger +
+ 🚗 {nf.format(sol.evKm)} km med elbil +
☕ {nf.format(sol.coffees)} kopper kaffe +
+ + )} +
+ ); +}; diff --git a/src/components/MapLibreMap.tsx b/src/components/MapLibreMap.tsx index adf03b4..02538ca 100644 --- a/src/components/MapLibreMap.tsx +++ b/src/components/MapLibreMap.tsx @@ -1,12 +1,34 @@ import { LngLat, + type FillLayerSpecification, type MapLayerMouseEvent, type RequestTransformFunction, } from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; -import { RMap, useMap } from 'maplibre-react-components'; +import { + RLayer, + RMap, + RPopup, + RSource, + useMap, +} from 'maplibre-react-components'; import { getHoydeFromPunkt } from '../api/getHoydeFromPunkt'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import type { GeoJSON } from 'geojson'; +import { + Alert, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@mui/material'; +import { getBygningAtPunkt } from '../api/getBygningAtPunkt'; +import { getRosDataForBygning } from '../api/getRosDataForBygning'; +import { getTakflateDataForBygning } from '../api/getTakflateDataForBygning'; +import { getTakflateDataForPunkt } from '../api/getTakflateDataForPunkt'; +import { getRuteMellomPunkter } from '../api/getRuteMellomPunkter'; +import { SearchBar, type Address } from './SearchBar'; +import { InfoCard, type PointInfo } from './InfoCard'; +import { toFeatureCollection } from '../solar'; import { Overlay } from './Overlay'; import DrawComponent from './DrawComponent'; @@ -27,22 +49,114 @@ const NORKART_BASEMAP_VARIANT: NorkartBasemapVariant = 'standard'; const NORKART_BASEMAP_STYLE = `${KVP_BASE_URL}norkart-basemap/${NORKART_BASEMAP_VARIANT}/style.json`; +type Mode = 'utforsk' | 'rute'; + +const buildingStyle = { + 'fill-outline-color': 'rgba(0,0,0,0.1)', + 'fill-color': 'rgba(18, 94, 45, 0.41)', +}; + +// Tak fargelagt etter årlig solinnstråling (kWh/m²) +const roofStyle: FillLayerSpecification['paint'] = { + 'fill-outline-color': 'rgba(0,0,0,0.3)', + 'fill-color': [ + 'interpolate', + ['linear'], + ['get', 'sol'], + 0, + '#3b4cc0', + 400, + '#f7e27a', + 700, + '#f9a825', + 900, + '#d84315', + ], + 'fill-opacity': 0.8, +}; + +const lineStyle = { + 'line-color': '#1a73e8', + 'line-width': 4, +}; + export const MapLibreMap = () => { - const [pointHoyde, setPointHoydeAtPunkt] = useState( + const [mode, setMode] = useState('utforsk'); + const [info, setInfo] = useState(undefined); + const [bygningsOmriss, setBygningsOmriss] = useState( undefined ); - const [clickPoint, setClickPoint] = useState(undefined); + const [startPunkt, setStartPunkt] = useState(undefined); + const [rute, setRute] = useState(undefined); + const [reisetid, setReisetid] = useState(undefined); + const [address, setAddress] = useState
(null); + const latestRequest = useRef(0); - useEffect(() => { - console.log(pointHoyde, clickPoint); - }, [clickPoint, pointHoyde]); + const selectPoint = async (lng: number, lat: number) => { + const requestId = ++latestRequest.current; + const isStale = () => requestId !== latestRequest.current; + + const [hoyder, bygning, punktTak] = await Promise.all([ + getHoydeFromPunkt(lng, lat), + getBygningAtPunkt(lng, lat), + getTakflateDataForPunkt(lng, lat), + ]); + if (isStale()) return; + + // Hele bygningens tak om vi treffer en bygning, ellers taket under punktet + const [ros, bygningTak] = bygning + ? await Promise.all([ + getRosDataForBygning(bygning.Bygningsnummer), + getTakflateDataForBygning(bygning.Bygningsnummer), + ]) + : [undefined, undefined]; + if (isStale()) return; + + const roofs = bygningTak?.length ? bygningTak : (punktTak ?? []); + const omriss = bygning?.FkbData?.BygningsOmriss; + setBygningsOmriss(omriss ? JSON.parse(omriss) : undefined); + setInfo({ lng, lat, hoyde: hoyder?.[0]?.Z, bygning, ros, roofs }); + }; + + const planRoute = async (from: LngLat, to: LngLat) => { + const respons = await getRuteMellomPunkter( + from.lng, + from.lat, + to.lng, + to.lat + ); + if (respons?.RouteGeometry) { + setRute(respons.RouteGeometry); + setReisetid( + respons.CostList?.find((c: { Name: string }) => c.Name === 'time')?.Cost + ); + } + }; const onMapClick = async (e: MapLayerMouseEvent) => { - const hoyder = await getHoydeFromPunkt(e.lngLat.lng, e.lngLat.lat); - setPointHoydeAtPunkt(hoyder[0].Z); - setClickPoint(new LngLat(e.lngLat.lng, e.lngLat.lat)); + if (mode === 'utforsk') { + await selectPoint(e.lngLat.lng, e.lngLat.lat); + } else if (!startPunkt) { + setStartPunkt(e.lngLat); + setRute(undefined); + setReisetid(undefined); + } else { + const from = startPunkt; + setStartPunkt(undefined); + await planRoute(from, e.lngLat); + } }; + useEffect(() => { + if (address) { + selectPoint(address.PayLoad.Posisjon.X, address.PayLoad.Posisjon.Y); + } + }, [address]); + + const roofsGeoJson = info?.roofs.length + ? toFeatureCollection(info.roofs) + : undefined; + return ( { onClick={onMapClick} > -

Dette er et overlay

-

Legg til funksjonalitet knyttet til kartet.

+ + m && setMode(m)} + > + Utforsk punkt + Rute + + {mode === 'utforsk' && ( + + {info ? '' : 'Klikk i kartet eller søk etter en adresse.'} + + )} + {mode === 'utforsk' && info && } + {mode === 'rute' && ( + + {startPunkt + ? 'Klikk for å velge stoppested.' + : reisetid !== undefined + ? `Reisetid: ${Math.round(reisetid)} min` + : 'Klikk for å velge startpunkt.'} + + )} + {!import.meta.env.VITE_API_KEY && ( + Mangler VITE_API_KEY i .env + )}
+ + {address && ( + + )} + + {mode === 'utforsk' && info && ( + + {info.hoyde !== undefined + ? `${info.hoyde.toFixed(1)} m.o.h.` + : 'Ukjent høyde'} + + )} + + {mode === 'utforsk' && bygningsOmriss && ( + <> + + + + )} + + {mode === 'utforsk' && roofsGeoJson && ( + <> + + + + )} + + {mode === 'rute' && rute && ( + <> + + + + )}
); diff --git a/src/solar.ts b/src/solar.ts new file mode 100644 index 0000000..8a81ebe --- /dev/null +++ b/src/solar.ts @@ -0,0 +1,56 @@ +import type { Feature, FeatureCollection } from 'geojson'; + +export const MONTHS = [ + 'Januar', + 'Februar', + 'Mars', + 'April', + 'Mai', + 'Juni', + 'Juli', + 'August', + 'September', + 'Oktober', + 'November', + 'Desember', +] as const; + +export type Takflate = { + Id: string; + Solinnstraaling: number; // kWh/m² per år + Areal3D: number; + Geometri: string; +} & Record<(typeof MONTHS)[number], number>; + +// Antakelser for et typisk norsk solcelleanlegg +const USABLE_ROOF_SHARE = 0.6; +const PANEL_EFFICIENCY = 0.2; +const HOUSEHOLD_KWH_PER_YEAR = 16000; +const EV_KWH_PER_KM = 0.18; +const KWH_PER_COFFEE = 0.05; + +export const summarize = (roofs: Takflate[]) => { + const monthly = MONTHS.map((m) => + roofs.reduce((sum, r) => sum + r[m] * r.Areal3D, 0) + ); + const areal = roofs.reduce((sum, r) => sum + r.Areal3D, 0); + const kwh = monthly.map((v) => v * USABLE_ROOF_SHARE * PANEL_EFFICIENCY); + const yearKwh = kwh.reduce((a, b) => a + b, 0); + return { + areal, + monthlyKwh: kwh, + yearKwh, + households: yearKwh / HOUSEHOLD_KWH_PER_YEAR, + evKm: yearKwh / EV_KWH_PER_KM, + coffees: yearKwh / KWH_PER_COFFEE, + }; +}; + +export const toFeatureCollection = (roofs: Takflate[]): FeatureCollection => ({ + type: 'FeatureCollection', + features: roofs.map((r): Feature => ({ + type: 'Feature', + properties: { sol: r.Solinnstraaling }, + geometry: JSON.parse(r.Geometri), + })), +});