wip: fetch localities from fiskeridir

This commit is contained in:
Stig Rune Jensen
2025-07-01 09:28:51 +02:00
parent 9fdc22f99f
commit 494e4445be
8 changed files with 588 additions and 5 deletions

View File

@@ -107,3 +107,5 @@ importSideEffects "@spectrum-web-components/icons-workflow/icons/sp-icon-rotate-
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-location.js"
importSideEffects "@spectrum-web-components/icons-workflow/icons/sp-icon-list-bulleted.js"

View File

@@ -290,6 +290,10 @@ 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 $"PARAMS : %A{parameters}"
Layer.imageLayer [
layer.source source
layer.opacity alpha

View File

@@ -6,6 +6,7 @@ open Fable.Core
open Fable.Core.JsInterop
open Fable.OpenLayers
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")
@@ -23,6 +24,18 @@ 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 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
let formatPercent n = {| style = "percent"; minimumFractionDigits = n; maximumFractionDigits = n |} |> 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
@@ -8,9 +9,91 @@ open Thoth.Json
open Atlantis.Types
open Utils
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
// }
/// See docs/fiskeridir-locality-borders-example-payload.json
type FiskeridirLocalityBorder = {
type LocalityBorder = {
Id : int
SiteVersionId : int
SiteNr : int
@@ -27,14 +110,15 @@ and Point = {
}
let fetchLocalityBorders (map: OlMap) (localityId: int) =
Fetch.tryGet<unit, FiskeridirLocalityBorder array>(
console.log $"FETCHING LOCALITY BORDERS"
Fetch.tryGet<unit, LocalityBorder array>(
url = $"https://api.fiskeridir.no/pub-aqua/api/v1/sites/{localityId}/borders",
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
)
)
@@ -42,6 +126,8 @@ let fetchLocalityBorders (map: OlMap) (localityId: int) =
match res with
| Error err -> console.error("Could not fetch locality borders from api.fiskeridir.no:", err)
| Ok borders ->
console.log $"BORDERS LENGTH : {borders.Length}"
console.log $"BORDER[0] : {borders[0]}"
let points =
borders
|> Array.collect (fun border ->
@@ -95,3 +181,121 @@ let fetchLocalityBorders (map: OlMap) (localityId: int) =
Layers.addFeatures map MapLayer.Aquaculture [| feature |]
())
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,329 @@
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
liceCoverage: SimCoverage
wcCoverage: SimCoverage
} with
static member empty = {
id = 0
name = ""
species = ""
capacity = 0.0
latitude = 0.0
longitude = 0.0
municipality = ""
prodAreaName = ""
prodAreaCode = 0
liceCoverage = SimCoverage.empty
wcCoverage = SimCoverage.empty
}
and private SimCoverage = {
progress: float
state: SimState
eta: float
} with
static member empty = {
progress = 0.0
state = SimState.Idle
eta = 0.0
}
static member random =
let p = Random().Next(0,100)
let s =
if Random().Next(0,100) > 70 then
SimState.Running
else
SimState.Idle
{
progress = p
state = s
eta = 0.0
}
and private SimState =
| Idle
| Running
| Completed
let private siteDataToTableItems (site: Fiskeridir.SiteData) : TableItem =
{
id = site.SiteNr
name = site.Name
species = site.SpeciesType
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
liceCoverage = SimCoverage.random
wcCoverage = SimCoverage.random
}
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 -> ()
}
// [<LitElement("archive-name")>]
// let archiveName () =
// let host, props =
// LitElement.init (fun cfg ->
// private cfg.useShadowDom <- true
// cfg.props <- {|
// item = Prop.Of(defaultValue = TableItem.empty , attribute = "")
// |}
// cfg.styles <- [ unsafeCSS "" ]
// )
//
// let item = props.item.Value
// let onChange (name: string) = host.dispatchCustomEvent("rename", name)
//
// if item.editing then
// html $"""
// <sp-textfield id="a-{item.id}" value="{item.name}" @change={EvVal(onChange)}></sp-textfield>
// """
// else
// html $"{item.name}"
//
[<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) ->
let color (coverage: SimCoverage) =
match coverage.state with
| Idle -> "rgb(0,0,0,0.0)"
| Running -> "rgb(30,144,255,0.7)"
| Completed -> "rgb(0,128,0,0.4)"
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>
<sp-table-cell style="max-width: 250px">
<div
style="
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-around;
background-color: {color item.liceCoverage};
">
<sp-progress-bar over-background side-label label="Lice" progress={item.liceCoverage.progress}></sp-progress-bar>
</div>
</sp-table-cell>
<sp-table-cell style="max-width: 250px">
<div
style="
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-around;
background-color: {color item.wcCoverage};
">
<sp-progress-bar side-label label="WC" progress={item.wcCoverage.progress}></sp-progress-bar>
</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))
// table.addEventListener("change", fun ev ->
// const selected = event.target.nextElementSibling
// selected.textContent = "Selected: ${JSON.stringify(event.target.selected.Length";
// ))
// let fetchSitesPO (poOpt: int option) _ =
// poOpt
// |> Option.map (fun po ->
// promise {
// let! sites = Fiskeridir.fetchSitesPO po
// sites
// |> Array.map siteDataToTableItems
// |> setItems
// } |> Promise.start)
// |> Option.defaultValue()
// let fetchSitesButton =
// html $"""
// <sp-field-group horizontal>
// <sp-action-button
// ?disabled={arg.prodArea.IsNone}
// @click={Ev(fetchSitesPO arg.prodArea)}>
// <sp-icon-location slot="icon"></sp-icon-location>
// Fetch sites
// </sp-action-button>
// </sp-field-group>
// """
//
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-cell sortable sort-key="coverage" style="max-width: 500px">
Simulation Coverage
</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>
"""
// {fetchSitesButton}

View File

@@ -8,6 +8,7 @@ open Fable.Core.JsInterop
open Fable.OpenLayers
open Lit
open Lit.Elmish
open System.Text.RegularExpressions
open Atlantis.Shared
open Atlantis.Shared.Notification
@@ -1830,6 +1831,7 @@ let MapAppElement () =
// TODO: I don't know why I'm getting errors here in rider VVVVVVV
let (model: Model), (dispatch: Msg -> unit) = Hook.useElmish(program)
let archivesOpen, setArchivesOpen = Hook.useState false
let localitiesOpen, setLocalitiesOpen = Hook.useState false
let inboxOpen, setInboxOpen = Hook.useState false
// let (simPolicies: DriftersPolicy[]), setSimPolicies = Hook.useState [||]
@@ -2039,6 +2041,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
@@ -2123,7 +2140,7 @@ let MapAppElement () =
html
$"""
<sp-theme system="spectrum-two" scale="medium" color="light" style="height: inherit">
{Navigation.toolboxNav setArchivesOpen setInboxOpen model dispatch}
{Navigation.toolboxNav setLocalitiesOpen setArchivesOpen setInboxOpen model dispatch}
<div class="box" style="padding-left: 46px; height=100%%">
<sp-split-view
primary-size="350px"
@@ -2148,6 +2165,7 @@ let MapAppElement () =
</sp-split-view>
{notificationToast model.notification}
{if archivesOpen then ArchiveDialog.archiveDialog archiveDialogArgs else Lit.nothing}
{if localitiesOpen then LocalitiesDialog.localitiesDialog localitiesDialogArgs else Lit.nothing}
{if inboxOpen then Inbox.inboxDialog inboxArgs else Lit.nothing}
<div style="display: none">
{aquaculturePopup}

View File

@@ -28,6 +28,7 @@
<Compile Include="Stats.fs" />
<Compile Include="DriftersPlots.fs" />
<Compile Include="ArchiveDialog.fs" />
<Compile Include="LocalitiesDialog.fs" />
<Compile Include="Inbox.fs" />
<Compile Include="Navigation.fs" />
<Compile Include="Mapster.fs" />

View File

@@ -729,7 +729,7 @@ let sideNav (dispatch: Msg -> unit) (model: Model) (navMode: SideNavMode) =
</div>
"""
let toolboxNav setArchivesOpen setInboxOpen model dispatch =
let toolboxNav setLocalitiesOpen setArchivesOpen setInboxOpen model dispatch =
let statsDisabled = false
let canSubmit = true
let isActive x = if model.sideNavMode = x then "active" else ""
@@ -794,6 +794,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
@@ -851,6 +852,17 @@ let toolboxNav setArchivesOpen setInboxOpen model dispatch =
{cropButton}
</div>
<div class="toolboxBottom">
<overlay-trigger id="trigger" placement="right" offset="6">
<sp-tooltip slot="hover-content">Akvakulturregisteret</sp-tooltip>
<div slot="trigger" class="toolbox-control">
<div
class="toolboxIcon"
@click={Ev(openLocalities)}
>
<i class="fas fa-fish"></i>
</div>
</div>
</overlay-trigger>
<overlay-trigger id="trigger" placement="right" offset="6">
<sp-tooltip slot="hover-content">Inbox</sp-tooltip>
<div slot="trigger" class="toolbox-control">