Compare commits

...

1 Commits

Author SHA1 Message Date
Stig Rune Jensen
3e87d7067d wip: import site locations from fiskeridir 2026-01-19 17:13:41 +01:00
8 changed files with 473 additions and 12 deletions

View File

@@ -121,4 +121,5 @@ importSideEffects "@spectrum-web-components/icons-workflow/icons/sp-icon-checkma
importSideEffects "@spectrum-web-components/icons-workflow/icons/sp-icon-rotate-cc-w.js"
importSideEffects "@spectrum-web-components/icons-workflow/icons/sp-icon-align-bottom.js"
importSideEffects "@spectrum-web-components/icons-workflow/icons/sp-icon-transform-perspective.js"
importSideEffects "@spectrum-web-components/icons-workflow/icons/sp-icon-search.js"
importSideEffects "@spectrum-web-components/icons-workflow/icons/sp-icon-search.js"
importSideEffects "@spectrum-web-components/icons-workflow/icons/sp-icon-list-bulleted.js"

View File

@@ -312,6 +312,9 @@ let fiskeri (topic: InfoLayer) : Ol.Layer.Layer =
source.serverType Source.ServerType.Geoserver
source.params' !!{| layers = string topic |}
]
if topic = POs then
let parameters = source.getParams ()
console.log $"[Maps] POs WMS parameters: {parameters}"
Layer.imageLayer [ layer.source source; layer.opacity alpha ]

View File

@@ -9,6 +9,7 @@ open Fable.OpenLayers
open FsToolkit.ErrorHandling
open Proj4
open System.Text.RegularExpressions
proj4.defs ("EPSG:25832", "+proj=utm +zone=32 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs")
proj4.defs ("EPSG:25833", "+proj=utm +zone=33 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs")
@@ -25,12 +26,18 @@ let inline posToCoord (pos: 'a * 'a) : Coordinate = unbox pos
let inline coordToPos (coord: Coordinate) = coord[0], coord[1]
let inline tryMaxDefault d s = if Seq.isEmpty s then d else Seq.max s
let inline tryParseFloat (s: string) : float option =
s
|> Double.TryParse
|> function
| true, f -> Some f
| _ -> None
let inline tryParseFloat (s: string) : float option = s |> Double.TryParse |> function | true, f -> Some f | _ -> None
let inline tryParseInt (s: string) : int option = s |> Int32.TryParse |> function | true, i -> Some i | _ -> None
// Extracting first number in string together with its prefix: "Ab4cdef" -> Some ("Ab", 4)
let inline trySplitPrefixAndInt (input: string) : (string * int) option =
let m = Regex.Match(input, @"^([A-Za-z]+)(\d+)$")
if m.Success then
let prefix = m.Groups.[1].Value
let number = int m.Groups.[2].Value
Some (prefix, number)
else
None
let formatDigits n m =
{| minimumFractionDigits = n; maximumFractionDigits = m |} |> JS.JSON.stringify

View File

@@ -1,6 +1,7 @@
module Fiskeridir
open Browser
open Fable.Core
open Fable.OpenLayers
open Thoth.Fetch
open Thoth.Json
@@ -14,7 +15,7 @@ let api = "https://api.fiskeridir.no/pub-aqua/api/v1"
let bordersUrl id = sprintf "%s/sites/%i/borders" api id
/// See docs/fiskeridir-locality-borders-example-payload.json
type FiskeridirLocalityBorder = {
type LocalityBorder = {
Id : int
SiteVersionId : int
SiteNr : int
@@ -31,14 +32,14 @@ and Point = {
}
let fetchLocalityBorders (map: OlMap) (localityId: int) =
Fetch.tryGet<unit, FiskeridirLocalityBorder array>(
Fetch.tryGet<unit, LocalityBorder array>(
url = bordersUrl localityId,
headers = [
Fetch.Types.HttpRequestHeaders.Accept "application/json"
Fetch.Types.HttpRequestHeaders.AcceptCharset "UTF-8"
],
decoder =
Decode.Auto.generateDecoder<FiskeridirLocalityBorder array>(
Decode.Auto.generateDecoder<LocalityBorder array>(
caseStrategy = CaseStrategy.CamelCase
)
)
@@ -98,4 +99,205 @@ let fetchLocalityBorders (map: OlMap) (localityId: int) =
Layers.addFeatures map MapLayer.Aquaculture [| feature |]
())
type ProductionAreaData = {
DefaultCode: string
DefaultName: string
} with
static member empty = {
DefaultCode = ""
DefaultName = ""
}
member x.ToLabel() = $"PO{x.DefaultCode}: {x.DefaultName}"
type SiteData = {
// SiteId: int
// VersionId: int
SiteNr: int
Name: string
// PlacementType: string
// PlacementTypeValue: string
// WaterType: string
// WaterTypeValue: string
// FirstClearanceTime: string
// FirstClearanceType: string
// FirstClearanceTypeValue: string
Latitude: float
Longitude: float
Capacity: float
// TempCapacity: float
// CapacityUnitType: string
Placement: Placement
// SpeciesType: string
// SpeciesTypeValue: string
// SpeciesLimitations: SpeciesLimitations list
// Connections: Connections list
// ObsoleteConnections: Connections list
// Version: Version
// IsSlaughtery: bool
// HasCommercialActivity: bool
// HasColocation: bool
// HasJointOperation: bool
}
and Placement = {
// MunicipalityCode: string
MunicipalityName: string
// CountyCode: string
// CountyName: string
ProdAreaCode: string
ProdAreaName: string
// ProdAreaStatus: string
}
// and SpeciesLimitations = {
// Code: string
// LatinName: string
// NbNoName: string
// NnNoName: string
// EnGbName: string
// }
// and private Connections = {
// Key: string
// LicenseId: int
// LicenseNr: string
// SiteId: int
// SiteNr: string
// SiteName: string
// ValidFrom: string
// ValidUntil: string
// TemporaryUntil: string option
// RegisteredTime: string
// Active: bool
// Status: string
// StatusValue: string
// }
// and private Version = {
// ValidFrom: string
// ValidUntil: string
// TemporaryUntil: string option
// RegisteredTime: string
// VersionCauseType: string
// VersionCauseTypeValue: string
// Status: string
// StatusValue: string
// VersionableStatus: string
// VersionableStatusValue: string
// }
let fetchInfoPO (areaCode: int) : ProductionAreaData JS.Promise =
console.log $"Fetching Production Area data: PO{areaCode}"
Fetch.tryGet<unit, ProductionAreaData>(
url = $"https://api.fiskeridir.no/pub-aqua/api/v1/areas/production_area/{string areaCode}",
headers = [
Fetch.Types.HttpRequestHeaders.Accept "application/json"
Fetch.Types.HttpRequestHeaders.AcceptCharset "UTF-8"
],
decoder =
Decode.Auto.generateDecoder<ProductionAreaData>(
caseStrategy = CaseStrategy.CamelCase
)
)
|> Promise.map (fun res ->
match res with
| Error err ->
console.error("Could not fetch site data from api.fiskeridir.no:", err)
ProductionAreaData.empty
| Ok po ->
console.log $"Fetched PO{po.DefaultCode} : {po.DefaultName}"
po)
let fetchSitesPO (range: int*int) (po: int) : SiteData [] JS.Promise =
console.log $"Fetching Aquaculture Sites : PO{po} [{fst range} - {snd range}]"
Fetch.tryGet<unit, SiteData[]>(
url = $"https://api.fiskeridir.no/pub-aqua/api/v1/sites?production-area-code={string po}&range={fst range}-{snd range}",
headers = [
Fetch.Types.HttpRequestHeaders.Accept "application/json"
Fetch.Types.HttpRequestHeaders.AcceptCharset "UTF-8"
],
decoder =
Decode.Auto.generateDecoder<SiteData[]>(
caseStrategy = CaseStrategy.CamelCase
)
)
|> Promise.map (fun res ->
match res with
| Error err ->
console.error("Could not fetch site data from api.fiskeridir.no:", err)
Array.empty
| Ok sites ->
console.log $"Fetched {sites.Length} sites in PO{po}"
sites)
let fetchSiteData (map: OlMap) (localityId: int) =
console.log $"Fetching Aquaculture Site : {localityId}"
Fetch.tryGet<unit, SiteData>(
url = $"https://api.fiskeridir.no/pub-aqua/api/v1/sites/{localityId}",
headers = [
Fetch.Types.HttpRequestHeaders.Accept "application/json"
Fetch.Types.HttpRequestHeaders.AcceptCharset "UTF-8"
],
decoder =
Decode.Auto.generateDecoder<SiteData>(
caseStrategy = CaseStrategy.CamelCase
)
)
|> Promise.map (fun res ->
match res with
| Error err -> console.error("Could not fetch site data from api.fiskeridir.no:", err)
| Ok site ->
console.log $"SITE : {site}"
// let points =
// borders
// |> Array.collect (fun border ->
// border.Points)
//
// if points.Length < 1 then
// // TODO: Add some default geom for sites without bordes
// console.info("This site does not have borders")
// ()
// else
// let feature =
// let coords : Coordinate array =
// let coords =
// points
// |> Array.map (fun point ->
// posToCoord (point.Longitude, point.Latitude)
// |> coordToEpsg3857)
// let first = Array.head coords
// let last = coords[coords.Length - 1]
//
// if first |> Coord.equals last then
// coords
// else
// let first = coords |> Array.head |> Array.singleton
// let linearRing = first |> Array.append coords
// linearRing
//
// let polygon =
// Geometry.polygon [
// geometry.coordinates [| coords |]
// geometry.layout GeometryLayout.XY
// ]
// Feature.feature [
// feature.geometryOrProperties polygon
// ]
//
// feature.setId localityId
// feature.set("type", "polygon")
//
// // NOTE: Fly to feature
// let geom = feature.getGeometry()
// let extent = geom.getExtent ()
// let center = extent |> Extent.extent.getCenter
// let view = map.getView()
// let res = view.getResolutionForExtent extent
// let zoom = view.getZoomForResolution res
// let clamped = System.Double.Clamp(zoom, 7.0, 14.0)
// console.debug("Flying to aquaculture area", zoom, clamped)
// Maps.flyTo map clamped center
//
// Layers.addFeatures map MapLayer.Aquaculture [| feature |]
())

View File

@@ -0,0 +1,213 @@
module LocalitiesDialog
open System
open Browser
open Fable.Core
open Fable.Core.JsInterop
open Lit
open Atlantis
open Atlantis.Types
open Archmaester.Dto
open Maps
open Model
open Utils
type private TableItem = {
id: int
name: string
// species: string
capacity: float
latitude: float
longitude: float
municipality: string
prodAreaName: string
prodAreaCode: int
} with
static member empty = {
id = 0
name = ""
// species = ""
capacity = 0.0
latitude = 0.0
longitude = 0.0
municipality = ""
prodAreaName = ""
prodAreaCode = 0
}
let private siteDataToTableItems (site: Fiskeridir.SiteData) : TableItem =
{
id = site.SiteNr
name = site.Name
latitude = site.Latitude
longitude = site.Longitude
capacity = site.Capacity
municipality = site.Placement.MunicipalityName
prodAreaName = site.Placement.ProdAreaName
prodAreaCode = site.Placement.ProdAreaCode |> tryParseInt |> Option.defaultValue 0
}
let private fetchProductionAreaInfo onFetch (poOpt: int option) =
promise {
match poOpt with
| Some po ->
let! data = Fiskeridir.fetchInfoPO po
data |> onFetch
| None ->
Fiskeridir.ProductionAreaData.empty
|> onFetch
}
let private fetchAquacultureSites onFetch (poOpt: int option) =
promise {
match poOpt with
| Some po ->
let! sites =
[| for i in 0..9 do
// the api accepts only 100 sites at a time, so batching up the requests
// don't know how many sites there are, so fetching up to 1000...
let range = (i*100, i*100 + 99)
Fiskeridir.fetchSitesPO range po
|]
|> Array.map (Promise.map (Array.map siteDataToTableItems))
|> Promise.all
|> Promise.map Array.concat
sites
|> onFetch
| None -> ()
}
[<HookComponent>]
let localitiesDialog
(arg: {|
onClose: unit -> unit
prodArea: int option
|}) =
let items, setItems = Hook.useState<TableItem array> [||]
let modelData, setModelData = Hook.useState Fiskeridir.ProductionAreaData.empty
// let updateItems = Array.map siteDataToTableItems >> setItems
let appendItems = Array.append items >> setItems
Hook.useEffectOnce (Utils.handleKeyPress "Escape" arg.onClose)
// Hook.useEffectOnChange (arg.localities, updateItems)
Hook.useEffectOnChange(items, fun _ ->
let table = document.getElementById "localities-table"
table?selected <-
items
|> Array.choose (fun item ->
Some item.id)
table?items <- items)
// NOTE(simkir): To enable a virtualized table, we do not create the rows by iterating over drifters, we instead
// programatically add the rows to the table. In this useEffect, we wait for the table to be created, and then we
// add all the items.
// sp-table api: https://opensource.adobe.com/spectrum-web-components/components/table/api/
Hook.useEffectOnce (fun () ->
arg.prodArea
|> fetchProductionAreaInfo setModelData
|> Promise.start
arg.prodArea
|> fetchAquacultureSites setItems
|> Promise.start
let table = document.getElementById "localities-table"
table?selected <- items
// NOTE: These are members of the spectrum table that we get
// TODO: Make a small class for type safety
table?items <- items
// NOTE: Function to get an id for each row
table?itemValue <- (fun (item : TableItem) -> $"{item.id}")
// NOTE: Function to render each row. Creating the cell elements that corresponds to the header. Calls
// this function on each item that we have sent into it.
table?renderItem <- (fun (item: TableItem) ->
html $"""
<sp-table-cell style="max-width: 100px"><div style="padding-top: 5px;">{item.id}</div></sp-table-cell>
<sp-table-cell style="max-width: 200px"><div style="padding-top: 5px;">{item.name}</div></sp-table-cell>
<sp-table-cell style="max-width: 130px"><div style="padding-top: 5px;">{item.latitude |> sprintf "%.6f"}</div></sp-table-cell>
<sp-table-cell style="max-width: 130px"><div style="padding-top: 5px;">{item.longitude |> sprintf "%.6f"}</div></sp-table-cell>
<sp-table-cell style="max-width: 130px"><div style="padding-top: 5px;">{item.capacity |> sprintf "%.0f"}</div></sp-table-cell>
<sp-table-cell style="max-width: 200px"><div style="padding-top: 5px;">{item.municipality}</div></sp-table-cell>
""")
// NOTE: The table fires a "sorted" event when you click on the table headers
table.addEventListener("sorted", fun ev ->
// NOTE: The event has a detail member with these two members
let sortDir: string = ev?detail?sortDirection
let sortKey: string = ev?detail?sortKey // This comes from the sort-key given to the headers below
let sortFn = if sortDir = "asc" then Array.sortBy else Array.sortByDescending
table?items
|> sortFn (fun item ->
// NOTE(simkir): WARNING! Hack to access an objects members :) Everything is an object in
// JS, right!?
JS.expr_js $"{item}[{sortKey}]")
|> setItems))
let setupSimButton =
html $"""
<sp-action-menu size="m">
<sp-icon-social-network slot="icon"></sp-icon-social-network>
<span slot="label">Network analysis</span>
<sp-menu-item>
Lice
</sp-menu-item>
<sp-menu-item>
Water Contact
</sp-menu-item>
</sp-action-menu>
"""
let table =
html $"""
<sp-table
id="localities-table"
size="s"
selects="multiple"
scroller="true"
style="height: 92%%; "
@change={Ev(fun _ -> console.log $"CHANGE TABLE : {items.Length}")}
>
<sp-table-head>
<sp-table-head-cell sortable sort-key="id" style="max-width: 100px">
SiteId
</sp-table-head-cell>
<sp-table-head-cell sortable sort-key="name" style="max-width: 200px">
Name
</sp-table-head-cell>
<sp-table-head-cell sortable sort-key="latitude" style="max-width: 130px">
Latitude
</sp-table-head-cell>
<sp-table-head-cell sortable sort-key="longitude" style="max-width: 130px">
Longitude
</sp-table-head-cell>
<sp-table-head-cell sortable sort-key="capacity" style="max-width: 130px">
Capacity
</sp-table-head-cell>
<sp-table-head-cell sortable sort-key="municipality" style="max-width: 200px">
Municipality
</sp-table-head-cell>
</sp-table-head>
</sp-table>
"""
html $"""
<div class="archive-dialog">
<sp-underlay ?open={true} @click={Ev(ignore >> arg.onClose)}></sp-underlay>
<sp-dialog size="l" no-divider dismissable @close={Ev(fun _ -> arg.onClose ())}>
<h1 slot="heading">{modelData.ToLabel()}</h1>
{table}
<div style="padding-top: 10px">
<sp-action-group horizontal>
{setupSimButton}
</sp-action-group>
</div>
</sp-dialog>
</div>
"""

View File

@@ -11,6 +11,7 @@ open FsToolkit.ErrorHandling
open Lit
open Lit.Elmish
open Thoth.Json
open System.Text.RegularExpressions
open Archmaester.Dto
open Atlantis.Shared
@@ -2063,6 +2064,7 @@ let MapAppElement () =
|> Program.withSubscription inert
let (model: Model), (dispatch: Msg -> unit) = Hook.useElmish program
let localitiesOpen, setLocalitiesOpen = Hook.useState false
let archivesOpen, setArchivesOpen = Hook.useState false
let inboxOpen, setInboxOpen = Hook.useState false
@@ -2209,6 +2211,21 @@ let MapAppElement () =
selectArchive = SetSelectedDrifter >> dispatch
|}
let tryGetPoNumber (name: string) : int option =
name.Split('-')
|> Array.head
|> trySplitPrefixAndInt
|> Option.bind (function
| "PO", number -> Some number
| _ -> None)
let localitiesDialogArgs = {|
onClose = fun () -> setLocalitiesOpen false
prodArea =
model.archive.name
|> tryGetPoNumber
|}
let selectInboxItem (id, type': MessageType) =
Hub.Action.Inbox (Hub.InboxMsg.MarkRead id) |> (HubMsg >> dispatch)
match type' with
@@ -2380,7 +2397,7 @@ let MapAppElement () =
class="flex-row"
style="height: inherit"
>
{Navigation.toolboxNav setArchivesOpen setInboxOpen model dispatch}
{Navigation.toolboxNav setLocalitiesOpen setArchivesOpen setInboxOpen model dispatch}
<div class="box grow full-box">
<sp-split-view
@@ -2406,6 +2423,10 @@ let MapAppElement () =
</div>
</sp-split-view>
{notificationToast model.notification}
{if localitiesOpen then
LocalitiesDialog.localitiesDialog localitiesDialogArgs
else
Lit.nothing}
{if archivesOpen then
ArchiveDialog.archiveDialog archiveDialogArgs
else

View File

@@ -45,6 +45,7 @@
<Compile Include="ProbingControls.fs" />
<Compile Include="PlotBox.fs" />
<Compile Include="ArchiveDialog.fs" />
<Compile Include="LocalitiesDialog.fs" />
<Compile Include="Inbox.fs" />
<Compile Include="Navigation.fs" />
<Compile Include="Mapster.fs" />

View File

@@ -1225,7 +1225,7 @@ let sideNav (dispatch: Msg -> unit) (model: Model) =
"""
[<HookComponent>]
let toolboxNav setArchivesOpen setInboxOpen model dispatch =
let toolboxNav setLocalitiesOpen setArchivesOpen setInboxOpen model dispatch =
Hook.useHmr hmr
let statsDisabled = false
@@ -1292,6 +1292,7 @@ let toolboxNav setArchivesOpen setInboxOpen model dispatch =
else
Lit.nothing
let openLocalities ev = setLocalitiesOpen true
let openArchives ev = setArchivesOpen true
let openInbox ev = setInboxOpen true
@@ -1350,6 +1351,18 @@ let toolboxNav setArchivesOpen setInboxOpen model dispatch =
</div>
<div class="toolbox-bottom">
<overlay-trigger id="trigger" placement="right" offset="6" triggered-by="hover">
<sp-tooltip slot="hover-content">Akvakulturregisteret</sp-tooltip>
<div slot="trigger" class="toolbox-control">
<div
class="toolbox-icon"
@click={Ev(openLocalities)}
>
<i class="fas fa-fish"></i>
</div>
</div>
</overlay-trigger>
<overlay-trigger id="trigger" placement="right" offset="6" triggered-by="hover">
<sp-tooltip slot="hover-content">Inbox</sp-tooltip>
<div slot="trigger" class="toolbox-control">