diff --git a/.changeset/data-source-coverage.md b/.changeset/data-source-coverage.md new file mode 100644 index 00000000..1b0e4ff4 --- /dev/null +++ b/.changeset/data-source-coverage.md @@ -0,0 +1,16 @@ +--- +"ftw": patch +--- + +Every external data source now declares where in the world it works, and the +Settings map says so before you commit to a location. `GET /api/data-sources` +reports each source's kind, coverage area, countries and licence, plus an +advisory `covers` verdict for the configured site (or an explicit `?lat=&lon=` +preview). The Weather tab renders it under the location picker and refreshes +as the pin drags, so a site outside Europe learns up front that price-driven +planning has no source there instead of getting an empty price curve with no +explanation. + +The registry's European price-country list is held in lockstep with +`prices/zones.go` by a test, so a bidding zone added there cannot silently +go missing from the coverage answer. diff --git a/.changeset/vendored-maplibre.md b/.changeset/vendored-maplibre.md new file mode 100644 index 00000000..d6d62012 --- /dev/null +++ b/.changeset/vendored-maplibre.md @@ -0,0 +1,17 @@ +--- +"ftw": patch +--- + +The Settings location picker moves from Leaflet to MapLibre GL JS 6.9.0, +vendored on the box: the map keeps the same OpenStreetMap raster tiles and +attribution, but the UI now executes no third-party CDN JavaScript and the +picker loads even when the gateway cannot reach the internet — the same +policy as `/vendor/three` and `/vendor/ace`. Leaflet's now-unused copy is +removed. If WebGL is unavailable the numeric latitude/longitude fields stay +authoritative, exactly as before. + +Static assets are also served with pinned Content-Types instead of whatever +the host OS's MIME table says: on a Windows host whose registry maps `.mjs` +to text/plain, the browser (correctly, under `nosniff`) refuses the vendored +ES module and the map dies with "failed to fetch dynamically imported +module". diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 0353dc02..991d94e5 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -36,6 +36,7 @@ import ( "github.com/srcfl/ftw/go/internal/config" "github.com/srcfl/ftw/go/internal/configreload" "github.com/srcfl/ftw/go/internal/control" + "github.com/srcfl/ftw/go/internal/coverage" "github.com/srcfl/ftw/go/internal/driverrepo" "github.com/srcfl/ftw/go/internal/drivers" "github.com/srcfl/ftw/go/internal/evcloud" @@ -483,6 +484,7 @@ func (s *Server) routes() { s.handle("GET /api/prices", Read, s.handlePrices) s.handle("GET /api/prices/zones", Read, s.handlePriceZones) s.handle("GET /api/forecast", Read, s.handleForecast) + s.handle("GET /api/data-sources", Read, s.handleDataSources) s.handle("GET /api/mpc/plan", Read, s.handleMPCPlan) s.handle("POST /api/mpc/replan", Configure, s.handleMPCReplan) s.handle("GET /api/mpc/diagnose", Read, s.handleMPCDiagnose) @@ -2873,8 +2875,98 @@ func (s *Server) handlePVModelReset(w http.ResponseWriter, r *http.Request) { s.handleForecastLearningReset(w, r, "pv") } +// ---- /api/data-sources ---- +// +// Where each external data source works, and whether it covers this site. +// Response: {latitude, longitude, sources:[{id, kind, label, area, countries, +// worldwide, requires_key, license, note, covers}]}. `covers` is advisory: for +// a bounded source it is a lat/lon box test, so true means "worth trying". +// False is reliable — that location is definitely not served. +// +// This exists because some sources are regional (every price provider is +// European) and nothing previously said so: a site outside those areas got an +// empty result and no explanation. See #726. +func (s *Server) handleDataSources(w http.ResponseWriter, r *http.Request) { + var lat, lon float64 + var haveSite bool + // Weather is an optional config section, so it is nil on a site that has + // never configured one — which is exactly the site most likely to be + // looking at this endpoint. + if s.deps.CfgMu != nil { + s.deps.CfgMu.RLock() + if s.deps.Cfg != nil && s.deps.Cfg.Weather != nil { + lat, lon = s.deps.Cfg.Weather.Latitude, s.deps.Cfg.Weather.Longitude + haveSite = lat != 0 || lon != 0 + } + s.deps.CfgMu.RUnlock() + } + + // An explicit ?lat=&lon= overrides the configured site so the Weather tab + // can preview coverage for a pin the operator is still dragging around, + // before they save it. + if v := r.URL.Query().Get("lat"); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil { + lat, haveSite = f, true + } + } + if v := r.URL.Query().Get("lon"); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil { + lon, haveSite = f, true + } + } + + items := make([]map[string]any, 0, len(coverage.All())) + for _, src := range coverage.All() { + item := map[string]any{ + "id": src.ID, + "kind": string(src.Kind), + "label": src.Label, + "area": src.Area, + "worldwide": src.Worldwide(), + "requires_key": src.RequiresKey, + } + if len(src.Countries) > 0 { + item["countries"] = src.Countries + } + if src.License != "" { + item["license"] = src.License + } + if src.Note != "" { + item["note"] = src.Note + } + // Without a site location there is nothing to test against, so omit + // `covers` entirely rather than defaulting it to a misleading true. + if haveSite { + item["covers"] = src.Covers(lat, lon) + } + items = append(items, item) + } + resp := map[string]any{"sources": items} + if haveSite { + resp["latitude"], resp["longitude"] = lat, lon + } + writeJSON(w, 200, resp) +} + // ---- static ---- +// staticContentTypes pins the Content-Type of every asset kind the web tree +// ships. Without it, http.ServeFile asks the operating system's MIME table — +// the registry on Windows — and a host that maps .mjs (or .js) to text/plain +// does not merely mislabel the file: the app sends X-Content-Type-Options: +// nosniff, so the browser is required to refuse it, and the vendored MapLibre +// dies with "failed to fetch dynamically imported module". ES modules are the +// strictest case; the rest are pinned so no asset depends on host state. +var staticContentTypes = map[string]string{ + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", +} + func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) { path := r.URL.Path if path == "/" { @@ -2893,6 +2985,9 @@ func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) { } // Always-revalidate so version bumps land immediately w.Header().Set("Cache-Control", "no-cache, must-revalidate") + if ct, ok := staticContentTypes[strings.ToLower(filepath.Ext(clean))]; ok { + w.Header().Set("Content-Type", ct) + } http.ServeFile(w, r, clean) } diff --git a/go/internal/api/api_datasources_test.go b/go/internal/api/api_datasources_test.go new file mode 100644 index 00000000..481db33d --- /dev/null +++ b/go/internal/api/api_datasources_test.go @@ -0,0 +1,163 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/srcfl/ftw/go/internal/config" +) + +type dataSource struct { + ID string `json:"id"` + Kind string `json:"kind"` + Label string `json:"label"` + Area string `json:"area"` + Countries []string `json:"countries"` + Worldwide bool `json:"worldwide"` + RequiresKey bool `json:"requires_key"` + Note string `json:"note"` + Covers *bool `json:"covers"` +} + +type dataSourcesResp struct { + Latitude *float64 `json:"latitude"` + Longitude *float64 `json:"longitude"` + Sources []dataSource `json:"sources"` +} + +func getDataSources(t *testing.T, deps *Deps, query string) dataSourcesResp { + t.Helper() + srv := New(deps) + req := httptest.NewRequest(http.MethodGet, "/api/data-sources"+query, nil) + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + if rr.Code != 200 { + t.Fatalf("status = %d, want 200", rr.Code) + } + var resp dataSourcesResp + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + return resp +} + +func depsAt(lat, lon float64) *Deps { + cfg := &config.Config{Weather: &config.Weather{Latitude: lat, Longitude: lon}} + return &Deps{Cfg: cfg, CfgMu: &sync.RWMutex{}} +} + +func find(t *testing.T, resp dataSourcesResp, id string) dataSource { + t.Helper() + for _, s := range resp.Sources { + if s.ID == id { + return s + } + } + t.Fatalf("source %q missing from response", id) + return dataSource{} +} + +func TestDataSourcesListsEverySource(t *testing.T) { + resp := getDataSources(t, depsAt(59.33, 18.07), "") + for _, id := range []string{ + "met_no", "openweather", "open_meteo", "forecast_solar", + "sourceful", "elprisetjustnu", "entsoe", + } { + find(t, resp, id) // fails the test if absent + } +} + +// A Nordic site: the Swedish price feed and the European ones all apply. +func TestDataSourcesCoversNordicSite(t *testing.T) { + resp := getDataSources(t, depsAt(59.33, 18.07), "") + for _, id := range []string{"elprisetjustnu", "sourceful", "entsoe", "open_meteo"} { + s := find(t, resp, id) + if s.Covers == nil || !*s.Covers { + t.Errorf("%s: want covers=true for Stockholm", id) + } + } +} + +// The case that motivated this endpoint: outside Europe the forecast still +// works, but every price provider does not. +func TestDataSourcesExplainsWhySydneyIsLimited(t *testing.T) { + resp := getDataSources(t, depsAt(-33.87, 151.21), "") + + for _, id := range []string{"met_no", "openweather", "open_meteo", "forecast_solar"} { + s := find(t, resp, id) + if s.Covers == nil || !*s.Covers { + t.Errorf("%s: forecast providers are worldwide, want covers=true", id) + } + } + for _, id := range []string{"sourceful", "elprisetjustnu", "entsoe"} { + s := find(t, resp, id) + if s.Covers == nil || *s.Covers { + t.Errorf("%s: want covers=false in Sydney", id) + } + if s.Note == "" && s.Area == "" { + t.Errorf("%s: an uncovered source must still explain its area", id) + } + } +} + +// The Weather tab previews a pin before it is saved, so an explicit lat/lon +// must override the configured site. +func TestDataSourcesQueryOverridesConfiguredSite(t *testing.T) { + deps := depsAt(59.33, 18.07) // configured: Stockholm + resp := getDataSources(t, deps, "?lat=-33.87&lon=151.21") + if s := find(t, resp, "sourceful"); s.Covers == nil || *s.Covers { + t.Error("query lat/lon should override config and report not covered") + } + if resp.Latitude == nil || *resp.Latitude != -33.87 { + t.Errorf("latitude = %v, want the overridden -33.87", resp.Latitude) + } +} + +// With no location configured there is nothing to test against, so `covers` +// must be absent rather than defaulting to a misleading true. +func TestDataSourcesOmitsCoversWithoutASite(t *testing.T) { + resp := getDataSources(t, &Deps{}, "") + if len(resp.Sources) == 0 { + t.Fatal("sources should still be listed without a site") + } + for _, s := range resp.Sources { + if s.Covers != nil { + t.Errorf("%s: covers should be omitted when no site is known", s.ID) + } + } + if resp.Latitude != nil || resp.Longitude != nil { + t.Error("latitude/longitude should be omitted when no site is known") + } +} + +// Metadata is the whole point of the endpoint; assert it actually arrives. +func TestDataSourcesCarriesRegionMetadata(t *testing.T) { + resp := getDataSources(t, depsAt(59.33, 18.07), "") + + se := find(t, resp, "elprisetjustnu") + if se.Worldwide { + t.Error("elprisetjustnu must not be reported worldwide") + } + if se.Area == "" || len(se.Countries) == 0 { + t.Error("elprisetjustnu should carry an area and country list") + } + if se.RequiresKey { + t.Error("elprisetjustnu needs no API key") + } + + if sf := find(t, resp, "sourceful"); len(sf.Countries) == 0 { + t.Error("sourceful should carry the European country list") + } + if en := find(t, resp, "entsoe"); !en.RequiresKey { + t.Error("entsoe requires an API key") + } + if ow := find(t, resp, "openweather"); !ow.RequiresKey { + t.Error("openweather requires an API key") + } + if mn := find(t, resp, "met_no"); !mn.Worldwide { + t.Error("met_no is worldwide") + } +} diff --git a/go/internal/api/api_static_mime_test.go b/go/internal/api/api_static_mime_test.go new file mode 100644 index 00000000..b0801ce0 --- /dev/null +++ b/go/internal/api/api_static_mime_test.go @@ -0,0 +1,52 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// A browser must refuse an ES module served with a non-JavaScript +// Content-Type once X-Content-Type-Options: nosniff is set — and FTW sets it. +// http.ServeFile alone asks the operating system's MIME table (the registry +// on Windows), so a host that maps .mjs to text/plain would break the +// vendored MapLibre import outright. The served type must come from the app. +func TestStaticAssetsServeWithPinnedContentTypes(t *testing.T) { + dir := t.TempDir() + sub := filepath.Join(dir, "vendor", "maplibre") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + files := map[string]string{ + filepath.Join(sub, "maplibre-gl.mjs"): "export default {};", + filepath.Join(dir, "app.js"): "// classic script", + filepath.Join(dir, "style.css"): "body{}", + filepath.Join(dir, "logo.svg"): ``, + } + for p, content := range files { + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + srv := New(&Deps{Version: "test", WebDir: dir}) + + for path, want := range map[string]string{ + "/vendor/maplibre/maplibre-gl.mjs": "text/javascript", + "/app.js": "text/javascript", + "/style.css": "text/css", + "/logo.svg": "image/svg+xml", + } { + req := httptest.NewRequest(http.MethodGet, path, nil) + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + if rr.Code != 200 { + t.Fatalf("%s: status = %d, want 200", path, rr.Code) + } + if got := rr.Header().Get("Content-Type"); !strings.HasPrefix(got, want) { + t.Errorf("%s: Content-Type = %q, want prefix %q", path, got, want) + } + } +} diff --git a/go/internal/coverage/coverage.go b/go/internal/coverage/coverage.go new file mode 100644 index 00000000..c3baedec --- /dev/null +++ b/go/internal/coverage/coverage.go @@ -0,0 +1,172 @@ +// Package coverage records where each external data source FTW talks to +// actually returns usable data. +// +// FTW runs outside Europe, but several of its sources are regional: every +// price provider is European. Nothing in the code said so, so a site in +// Australia would get an empty price curve with no explanation. This package +// is that missing explanation, in one place, so the API and the UI can tell an +// operator *before* they select a source that it cannot serve their location. +// +// Bounds here are ADVISORY, and deliberately generous. Coverage is declared as +// a lat/lon box, which can only ever be a superset of a market's real shape. +// Read Covers()==false as "definitely not supported, do not bother asking" and +// Covers()==true as "worth trying" — the upstream API stays authoritative. +// Nothing here is a safety input; it only decides what we show and whether we +// skip a pointless fetch. +package coverage + +// Kind groups sources by what they supply, so the UI can present forecast and +// price coverage separately. +type Kind string + +const ( + KindForecast Kind = "forecast" + KindPrice Kind = "price" +) + +// BBox is an inclusive latitude/longitude bounding box in WGS84 degrees. +type BBox struct { + MinLat float64 `json:"min_lat"` + MinLon float64 `json:"min_lon"` + MaxLat float64 `json:"max_lat"` + MaxLon float64 `json:"max_lon"` +} + +// Contains reports whether (lat, lon) falls inside the box. Longitude is not +// wrapped: no source described here spans the antimeridian, and silently +// wrapping would turn a nonsense coordinate into a plausible-looking hit. +func (b BBox) Contains(lat, lon float64) bool { + return lat >= b.MinLat && lat <= b.MaxLat && lon >= b.MinLon && lon <= b.MaxLon +} + +// Source describes one external data source and where it works. +type Source struct { + ID string `json:"id"` + Kind Kind `json:"kind"` + Label string `json:"label"` + // Area is the human-readable coverage, shown in the UI. + Area string `json:"area"` + // Countries lists ISO 3166-1 alpha-2 codes when the source is bounded to a + // known set. Empty means either worldwide or "bounded by BBox, not by + // borders" — check Worldwide() rather than inferring from length. + Countries []string `json:"countries,omitempty"` + // BBox bounds the source geographically. nil means worldwide. + BBox *BBox `json:"bbox,omitempty"` + // RequiresKey is true when the operator must supply their own credential. + RequiresKey bool `json:"requires_key"` + License string `json:"license,omitempty"` + Note string `json:"note,omitempty"` +} + +// Worldwide reports whether the source is unbounded geographically. +func (s Source) Worldwide() bool { return s.BBox == nil } + +// Covers reports whether the source plausibly serves (lat, lon). Worldwide +// sources always do. See the package doc: a true result is advisory. +func (s Source) Covers(lat, lon float64) bool { + if s.BBox == nil { + return true + } + return s.BBox.Contains(lat, lon) +} + +// sources is the registry. Keep it ordered by kind then id so the API response +// is stable and diffs stay readable. +var sources = []Source{ + { + ID: "met_no", Kind: KindForecast, Label: "MET Norway", + Area: "Worldwide", + License: "NLOD / CC BY 4.0", + Note: "Cloud cover only — no irradiance, so PV is derived from a cloud-derated clear-sky prior.", + }, + { + ID: "openweather", Kind: KindForecast, Label: "OpenWeather", + Area: "Worldwide", + RequiresKey: true, + Note: "Cloud cover only — same cloud-derated prior as MET Norway.", + }, + { + ID: "open_meteo", Kind: KindForecast, Label: "Open-Meteo", + Area: "Worldwide", + License: "CC BY 4.0", + Note: "Publishes shortwave radiation, so PV is irradiance-derived rather than cloud-derated.", + }, + { + ID: "forecast_solar", Kind: KindForecast, Label: "Forecast.Solar", + Area: "Worldwide", + Note: "Returns site-calibrated watts from the configured array geometry; free tier is rate-limited.", + }, + { + ID: "sourceful", Kind: KindPrice, Label: "Sourceful (cached ENTSO-E)", + Area: "Europe", + Countries: europeanPriceCountries, + BBox: &BBox{MinLat: 34.0, MinLon: -25.0, MaxLat: 72.0, MaxLon: 45.0}, + Note: "European day-ahead bidding zones. No key required.", + }, + { + ID: "elprisetjustnu", Kind: KindPrice, Label: "Elpriset just nu", + Area: "Sweden", + Countries: []string{"SE"}, + BBox: &BBox{MinLat: 55.0, MinLon: 10.0, MaxLat: 69.5, MaxLon: 24.5}, + Note: "Swedish bidding zones SE1-SE4 only. No key required.", + }, + { + ID: "entsoe", Kind: KindPrice, Label: "ENTSO-E Transparency", + Area: "Europe", + Countries: europeanPriceCountries, + BBox: &BBox{MinLat: 34.0, MinLon: -25.0, MaxLat: 72.0, MaxLon: 45.0}, + RequiresKey: true, + Note: "All ENTSO-E member bidding zones.", + }, +} + +// europeanPriceCountries are the countries whose day-ahead bidding zones +// prices/zones.go lists — the same table the pickers and fetchers use. Shared +// by sourceful and entsoe because both resolve to the same underlying zones. +// TestEuropeanPriceCountriesMatchZoneTable holds the two in lockstep, so a +// zone added over there fails a test here instead of silently missing from +// the coverage answer. +var europeanPriceCountries = []string{ + "AT", "BE", "BG", "CH", "CZ", "DE", "DK", "EE", "ES", "FI", + "FR", "GR", "HR", "HU", "IT", "LT", "LU", "LV", "ME", "NL", + "NO", "PL", "PT", "RO", "RS", "SE", "SI", "SK", "UA", +} + +// All returns every known source. +func All() []Source { + out := make([]Source, len(sources)) + copy(out, sources) + return out +} + +// ByID returns the source with the given id. +func ByID(id string) (Source, bool) { + for _, s := range sources { + if s.ID == id { + return s, true + } + } + return Source{}, false +} + +// ForKind returns every source of one kind, in registry order. +func ForKind(k Kind) []Source { + var out []Source + for _, s := range sources { + if s.Kind == k { + out = append(out, s) + } + } + return out +} + +// Covers reports whether the named source plausibly serves (lat, lon). An +// unknown id returns false: callers ask about a source they intend to use, and +// answering "sure" for a source we know nothing about is the wrong default. +func Covers(id string, lat, lon float64) bool { + s, ok := ByID(id) + if !ok { + return false + } + return s.Covers(lat, lon) +} diff --git a/go/internal/coverage/coverage_test.go b/go/internal/coverage/coverage_test.go new file mode 100644 index 00000000..e62015b7 --- /dev/null +++ b/go/internal/coverage/coverage_test.go @@ -0,0 +1,177 @@ +package coverage + +import ( + "sort" + "testing" + + "github.com/srcfl/ftw/go/internal/prices" +) + +func TestForecastProvidersAreWorldwide(t *testing.T) { + for _, id := range []string{"met_no", "openweather", "open_meteo", "forecast_solar"} { + s, ok := ByID(id) + if !ok { + t.Fatalf("%s: not registered", id) + } + if !s.Worldwide() { + t.Errorf("%s: want worldwide", id) + } + // A worldwide source must cover anywhere, including the far south. + if !s.Covers(-33.87, 151.21) { + t.Errorf("%s: worldwide source must cover Sydney", id) + } + } +} + +// The whole point of #726: price data is Europe-only. If someone adds a global +// price provider this test should be updated deliberately, not incidentally. +func TestPriceProvidersAreEuropeOnly(t *testing.T) { + priceSources := ForKind(KindPrice) + if len(priceSources) == 0 { + t.Fatal("no price sources registered") + } + for _, s := range priceSources { + if s.Worldwide() { + t.Errorf("%s: price sources are not worldwide", s.ID) + } + if s.Covers(-33.87, 151.21) { + t.Errorf("%s: must not claim to cover Sydney", s.ID) + } + if s.Covers(40.71, -74.01) { + t.Errorf("%s: must not claim to cover New York", s.ID) + } + } +} + +// The country list the coverage answer carries must be the zone table the +// pickers and fetchers actually use — one registry, not two. A bidding zone +// added to prices/zones.go fails here until the coverage list follows, and a +// country invented here (Ireland once was) fails because no zone backs it. +func TestEuropeanPriceCountriesMatchZoneTable(t *testing.T) { + want := map[string]bool{} + for _, z := range prices.Zones() { + // A zone code starts with its ISO 3166-1 alpha-2 country: "SE3" is + // Sweden, "IT-SARDINIA" is Italy, "NO2NSL" is Norway. + code := "" + for _, r := range z.Code { + if r < 'A' || r > 'Z' { + break + } + code += string(r) + } + if len(code) != 2 { + t.Fatalf("zone %q: expected a 2-letter country prefix, got %q", z.Code, code) + } + want[code] = true + } + + got := map[string]bool{} + for _, c := range europeanPriceCountries { + if got[c] { + t.Errorf("%s: duplicated in europeanPriceCountries", c) + } + got[c] = true + } + + var missing, invented []string + for c := range want { + if !got[c] { + missing = append(missing, c) + } + } + for c := range got { + if !want[c] { + invented = append(invented, c) + } + } + sort.Strings(missing) + sort.Strings(invented) + if len(missing) > 0 { + t.Errorf("countries in prices/zones.go but not declared here: %v", missing) + } + if len(invented) > 0 { + t.Errorf("countries declared here that no bidding zone backs: %v", invented) + } + if !sort.StringsAreSorted(europeanPriceCountries) { + t.Error("europeanPriceCountries should stay sorted so diffs are readable") + } +} + +func TestSwedishPriceProviderIsNarrowerThanEuropean(t *testing.T) { + // Berlin: served by the European providers, not by the Swedish one. + if Covers("elprisetjustnu", 52.52, 13.40) { + t.Error("elprisetjustnu must not claim Berlin") + } + if !Covers("sourceful", 52.52, 13.40) { + t.Error("sourceful should cover Berlin") + } + if !Covers("elprisetjustnu", 59.33, 18.07) { + t.Error("elprisetjustnu should cover Stockholm") + } +} + +// An unknown id must not be treated as universally available. +func TestUnknownSourceIsNotCovered(t *testing.T) { + if Covers("does_not_exist", 59.33, 18.07) { + t.Error("unknown source must report not covered") + } + if _, ok := ByID("does_not_exist"); ok { + t.Error("unknown source must not resolve") + } +} + +func TestBBoxContainsIsInclusive(t *testing.T) { + b := BBox{MinLat: 10, MinLon: 20, MaxLat: 30, MaxLon: 40} + for _, c := range []struct { + lat, lon float64 + want bool + }{ + {10, 20, true}, // min corner + {30, 40, true}, // max corner + {20, 30, true}, // interior + {9.99, 30, false}, // just south + {20, 40.01, false}, // just east + } { + if got := b.Contains(c.lat, c.lon); got != c.want { + t.Errorf("Contains(%v,%v) = %v, want %v", c.lat, c.lon, got, c.want) + } + } +} + +// Longitude is intentionally not wrapped; a nonsense coordinate must stay a +// miss rather than being folded into range. +func TestBBoxDoesNotWrapLongitude(t *testing.T) { + b := BBox{MinLat: -90, MinLon: -180, MaxLat: 90, MaxLon: 180} + if b.Contains(0, 200) { + t.Error("lon 200 must not wrap to -160") + } +} + +func TestRegistryIsInternallyConsistent(t *testing.T) { + seen := map[string]bool{} + for _, s := range All() { + if s.ID == "" || s.Label == "" || s.Area == "" { + t.Errorf("%+v: id, label and area are all required", s) + } + if seen[s.ID] { + t.Errorf("%s: duplicate id", s.ID) + } + seen[s.ID] = true + if s.BBox != nil { + if s.BBox.MinLat > s.BBox.MaxLat || s.BBox.MinLon > s.BBox.MaxLon { + t.Errorf("%s: inverted bbox %+v", s.ID, *s.BBox) + } + } + } +} + +// All() must hand out a copy: a caller mutating the result must not corrupt the +// registry for everyone else in the process. +func TestAllReturnsACopy(t *testing.T) { + got := All() + original := got[0].ID + got[0].ID = "mutated" + if All()[0].ID != original { + t.Fatal("All() exposed the backing array") + } +} diff --git a/web/index.html b/web/index.html index 6cbb4f5e..541c1e3d 100644 --- a/web/index.html +++ b/web/index.html @@ -5,10 +5,10 @@
s&&(_=s-e)}if(i){let e=(c+l)/2,t=m;this._helper._renderWorldCopies&&(t=O(m,e-a/2,e+a/2));let n=f/2;t-no&&(o=n)}let l=[c.lng+a,c.lat+s,c.lng+i,c.lat+o];return this.isSurfacePointOnScreen([0,1,0])&&(l[3]=90,l[0]=-180,l[2]=180),this.isSurfacePointOnScreen([0,-1,0])&&(l[1]=-90,l[0]=-180,l[2]=180),new ya(l)}calculateCenterFromCameraLngLatAlt(e,t,n,r){return this._helper.calculateCenterFromCameraLngLatAlt(e,t,n,r)}calculateCameraOptionsFromTo(e,t,n,r){let i=z.convert(n),a=zc(z.convert(e));Tn(a,a,1+t/cr);let o=zc(i),s=Tn(A(),o,1+r/cr),c=Qt(A(),a,o),l=Yn(c);if(l=-_&&m<=_,y=g>=-_&&g<=_,b,x;if(v&&y){let e=this.center.lng*Math.PI/180,t=this.center.lat*Math.PI/180,n=Ve(d,e),r=Ve(m,t),i=Ve(f,e),a=Ve(g,t);n+r=0}isSurfacePointOnScreen(e){if(!this.isSurfacePointVisible(e))return!1;let t=Kn();return gt(t,[...e,1],this._globeViewProjMatrixF64),t[0]/=t[3],t[1]/=t[3],t[2]/=t[3],t[0]>-1&&t[0]<1&&t[1]>-1&&t[1]<1&&t[2]>-1&&t[2]<1}unprojectScreenPoint(e){let t=this._cameraPosition,n=this.getRayDirectionFromPixel(e),r=rl(t,n);if(r){let e=A();wt(e,t,[n[0]*r.tMin,n[1]*r.tMin,n[2]*r.tMin]);let i=A();return jt(i,e),Hc(i)}let i=this._cachedClippingPlane,a=i[0]*n[0]+i[1]*n[1]+i[2]*n[2],o=-Sr(i,t)/a,s=A();if(o>0)wt(s,t,[n[0]*o,n[1]*o,n[2]*o]);else{let e=A();wt(e,t,[n[0]*2,n[1]*2,n[2]*2]);let r=Sr(this._cachedClippingPlane,e);Wt(s,e,[this._cachedClippingPlane[0]*r,this._cachedClippingPlane[1]*r,this._cachedClippingPlane[2]*r])}let c=Yc(i);return Hc(Xc(c.center,c.radius,s))}getProjectionDataForCustomLayer(e=!0){let t=this.getProjectionData({overscaledTileID:new Ut(0,0,0,0,0),applyGlobeMatrix:e});return t.tileMercatorCoords=[0,0,1,1],t}getFastPathSimpleProjectionMatrix(e){}};function fl(e,t){let n=A();or(n,e.origin,e.direction,t);let r=Yn(n),i=A();Tn(i,n,1/r);let a=Hc(i),o=B.fromLngLat(a),s=new B(o.x,I(o.y,0,.999999999)),c=yc(e.index,e.exaggeration,s.x,s.y),l=Math.abs(a.lat)>85.051129?0:c.elevation;return{sample:{...c,elevation:l},radius:r,mercator:s}}function pl(e,t){let{sample:n,radius:r}=fl(e,t);return bc(n,(r-1)*cr)}var ml=class e{get pixelsToClipSpaceMatrix(){return this._helper.pixelsToClipSpaceMatrix}get clipSpaceToPixelsMatrix(){return this._helper.clipSpaceToPixelsMatrix}get pixelsToGLUnits(){return this._helper.pixelsToGLUnits}get centerOffset(){return this._helper.centerOffset}get size(){return this._helper.size}get rotationMatrix(){return this._helper.rotationMatrix}get centerPoint(){return this._helper.centerPoint}get pixelsPerMeter(){return this._helper.pixelsPerMeter}setMinZoom(e){this._helper.setMinZoom(e)}setMaxZoom(e){this._helper.setMaxZoom(e)}setMinPitch(e){this._helper.setMinPitch(e)}setMaxPitch(e){this._helper.setMaxPitch(e)}setRenderWorldCopies(e){this._helper.setRenderWorldCopies(e)}setBearing(e){this._helper.setBearing(e)}setPitch(e){this._helper.setPitch(e)}setRoll(e){this._helper.setRoll(e)}setFov(e){this._helper.setFov(e)}setZoom(e){this._helper.setZoom(e)}setCenter(e){this._helper.setCenter(e)}setElevation(e){this._helper.setElevation(e)}setMinElevationForCurrentTile(e){this._helper.setMinElevationForCurrentTile(e)}setPadding(e){this._helper.setPadding(e)}interpolatePadding(e,t,n){this._helper.interpolatePadding(e,t,n)}isPaddingEqual(e){return this._helper.isPaddingEqual(e)}resize(e,t,n=!0){this._helper.resize(e,t,n)}getMaxBounds(){return this._helper.getMaxBounds()}setMaxBounds(e){this._helper.setMaxBounds(e)}setConstrainOverride(e){this._helper.setConstrainOverride(e)}overrideNearFarZ(e,t){this._helper.overrideNearFarZ(e,t)}clearNearFarZOverride(){this._helper.clearNearFarZOverride()}getCameraQueryGeometry(e){return this._helper.getCameraQueryGeometry(this.getCameraPoint(),e)}get tileSize(){return this._helper.tileSize}get tileZoom(){return this._helper.tileZoom}get scale(){return this._helper.scale}get worldSize(){return this._helper.worldSize}get width(){return this._helper.width}get height(){return this._helper.height}get lngRange(){return this._helper.lngRange}get latRange(){return this._helper.latRange}get minZoom(){return this._helper.minZoom}get maxZoom(){return this._helper.maxZoom}get zoom(){return this._helper.zoom}get center(){return this._helper.center}get minPitch(){return this._helper.minPitch}get maxPitch(){return this._helper.maxPitch}get pitch(){return this._helper.pitch}get pitchInRadians(){return this._helper.pitchInRadians}get roll(){return this._helper.roll}get rollInRadians(){return this._helper.rollInRadians}get bearing(){return this._helper.bearing}get bearingInRadians(){return this._helper.bearingInRadians}get fov(){return this._helper.fov}get fovInRadians(){return this._helper.fovInRadians}get elevation(){return this._helper.elevation}get minElevationForCurrentTile(){return this._helper.minElevationForCurrentTile}get padding(){return this._helper.padding}get unmodified(){return this._helper.unmodified}get renderWorldCopies(){return this._helper.renderWorldCopies}get cameraToCenterDistance(){return this._helper.cameraToCenterDistance}get constrainOverride(){return this._helper.constrainOverride}get nearZ(){return this._helper.nearZ}get farZ(){return this._helper.farZ}get autoCalculateNearFarZ(){return this._helper.autoCalculateNearFarZ}get isGlobeRendering(){return this._globeness>0}setTransitionState(e){this._globeness=e,this._calcMatrices(),this._verticalPerspectiveTransform.getCoveringTilesDetailsProvider().prepareNextFrame(),this._mercatorTransform.getCoveringTilesDetailsProvider().prepareNextFrame()}get currentTransform(){return this.isGlobeRendering?this._verticalPerspectiveTransform:this._mercatorTransform}constructor(e){this._globeness=1,this.defaultConstrain=(e,t)=>this.currentTransform.defaultConstrain(e,t),this.applyConstrain=(e,t)=>this._helper.applyConstrain(e,t),this._helper=new fc({calcMatrices:()=>this._calcMatrices(),defaultConstrain:(e,t)=>this.defaultConstrain(e,t)},e),this._globeness=1,this._mercatorTransform=new Sc,this._verticalPerspectiveTransform=new dl}clone(){let t=new e;return t._globeness=this._globeness,t.apply(this,!1),t}apply(e,t){this._helper.apply(e,t),this._mercatorTransform.apply(this,!1),this._verticalPerspectiveTransform.apply(this,!1)}get projectionMatrix(){return this.currentTransform.projectionMatrix}get modelViewProjectionMatrix(){return this.currentTransform.modelViewProjectionMatrix}get inverseProjectionMatrix(){return this.currentTransform.inverseProjectionMatrix}get cameraPosition(){return this.currentTransform.cameraPosition}getProjectionData(e){let t=this._mercatorTransform.getProjectionData(e),n=this._verticalPerspectiveTransform.getProjectionData(e);return{mainMatrix:this.isGlobeRendering?n.mainMatrix:t.mainMatrix,clippingPlane:n.clippingPlane,tileMercatorCoords:n.tileMercatorCoords,projectionTransition:e.applyGlobeMatrix?this._globeness:0,fallbackMatrix:t.fallbackMatrix,clipAntimeridian:n.clipAntimeridian}}isLocationOccluded(e){return this.currentTransform.isLocationOccluded(e)}transformLightDirection(e){return this.currentTransform.transformLightDirection(e)}getPixelScale(){return dn(this._mercatorTransform.getPixelScale(),this._verticalPerspectiveTransform.getPixelScale(),this._globeness)}getCircleRadiusCorrection(){return dn(this._mercatorTransform.getCircleRadiusCorrection(),this._verticalPerspectiveTransform.getCircleRadiusCorrection(),this._globeness)}getPitchedTextCorrection(e,t,n){let r=this._mercatorTransform.getPitchedTextCorrection(e,t,n),i=this._verticalPerspectiveTransform.getPitchedTextCorrection(e,t,n);return dn(r,i,this._globeness)}projectTileCoordinates(e,t,n,r){return this.currentTransform.projectTileCoordinates(e,t,n,r)}_calcMatrices(){this._helper._width&&this._helper._height&&(this._verticalPerspectiveTransform.apply(this,!1),this._helper._nearZ=this._verticalPerspectiveTransform.nearZ,this._helper._farZ=this._verticalPerspectiveTransform.farZ,this._mercatorTransform.apply(this,!0,this.isGlobeRendering),this._helper._nearZ=this._mercatorTransform.nearZ,this._helper._farZ=this._mercatorTransform.farZ)}calculateFogMatrix(e){return this.currentTransform.calculateFogMatrix(e)}getVisibleUnwrappedCoordinates(e){return this.currentTransform.getVisibleUnwrappedCoordinates(e)}getCameraFrustum(){return this.currentTransform.getCameraFrustum()}getClippingPlane(){return this.currentTransform.getClippingPlane()}getCoveringTilesDetailsProvider(){return this.currentTransform.getCoveringTilesDetailsProvider()}recalculateZoomAndCenter(e){this.currentTransform.recalculateZoomAndCenter(e)}maxPitchScaleFactor(){return this._mercatorTransform.maxPitchScaleFactor()}getCameraPoint(){return this._helper.getCameraPoint()}getCameraAltitude(){return this.currentTransform.getCameraAltitude()}getCameraLngLat(){return this.currentTransform.getCameraLngLat()}lngLatToCameraDepth(e,t){return this.currentTransform.lngLatToCameraDepth(e,t)}populateCache(e){this._mercatorTransform.populateCache(e),this._verticalPerspectiveTransform.populateCache(e)}getBounds(){return this.currentTransform.getBounds()}calculateCenterFromCameraLngLatAlt(e,t,n,r){return this._helper.calculateCenterFromCameraLngLatAlt(e,t,n,r)}calculateCameraOptionsFromTo(e,t,n,r){return this.currentTransform.calculateCameraOptionsFromTo(e,t,n,r)}setLocationAtPoint(e,t,n){if(!this.isGlobeRendering){this._mercatorTransform.setLocationAtPoint(e,t,n),this.apply(this._mercatorTransform,!1);return}this._verticalPerspectiveTransform.setLocationAtPoint(e,t,n),this.apply(this._verticalPerspectiveTransform,!1)}locationToScreenPoint(e,t){return this.currentTransform.locationToScreenPoint(e,t)}screenPointToMercatorCoordinate(e,t){return this.currentTransform.screenPointToMercatorCoordinate(e,t)}screenTerrainPointToMercatorCoordinate(e,t){return this.currentTransform.screenTerrainPointToMercatorCoordinate(e,t)}screenPointToLocation(e,t){return this.currentTransform.screenPointToLocation(e,t)}screenPointToLocationAtElevation(e,t){return this.currentTransform.screenPointToLocationAtElevation(e,t)}isPointOnMapSurface(e,t){return this.currentTransform.isPointOnMapSurface(e,t)}getRayDirectionFromPixel(e){return this._verticalPerspectiveTransform.getRayDirectionFromPixel(e)}getProjectionDataForCustomLayer(e=!0){let t=this._mercatorTransform.getProjectionDataForCustomLayer(e);if(!this.isGlobeRendering)return t;let n=this._verticalPerspectiveTransform.getProjectionDataForCustomLayer(e);return n.fallbackMatrix=t.mainMatrix,n.projectionTransition=this._globeness,n}getFastPathSimpleProjectionMatrix(e){return this.currentTransform.getFastPathSimpleProjectionMatrix(e)}},hl=class e{get useGlobeControls(){return!0}handlePanInertia(e,t){let n=el(e,t);return Math.abs(n.lng-t.center.lng)>180&&(n.lng=t.center.lng+179.5*Math.sign(n.lng-t.center.lng)),{easingCenter:n,easingOffset:new P(0,0)}}handleMapControlsRollPitchBearingZoom(e,t){let n=e.around,r=t.screenPointToLocation(n);e.bearingDelta&&t.setBearing(t.bearing+e.bearingDelta),e.pitchDelta&&t.setPitch(t.pitch+e.pitchDelta),e.rollDelta&&t.setRoll(t.roll+e.rollDelta);let i=t.zoom;e.zoomDelta&&t.setZoom(t.zoom+e.zoomDelta);let a=t.zoom-i;if(a===0)return;let o=Ct(t.center.lng,r.lng),s=o/(Math.abs(o/180)+1),c=Ct(t.center.lat,r.lat),l=t.getRayDirectionFromPixel(n),u=t.cameraPosition,d=nr(u,l)*-1,f=A();wt(f,u,[l[0]*d,l[1]*d,l[2]*d]);let p=Yn(f),m=p-1,h=Math.exp(-Math.max(m-.3,0)*.5),g=tt(p,.95,.999,0,1),_=Bc(t.worldSize,t.center.lat)/Math.min(t.width,t.height),v=tt(_,.9,.5,1,.25),y=Math.min(h,dn(1,v,g)),b=(1-ue(-a))*y,x=t.center.lat,S=t.zoom,C=new z(t.center.lng+s*b,I(t.center.lat+c*b,-le,le));t.setLocationAtPoint(r,n);let w=t.center,T=tt(Math.abs(o),45,85,0,1),E=Math.max(T,g)**.25,ee=Ct(w.lng,C.lng),D=Ct(w.lat,C.lat);t.setCenter(new z(w.lng+ee*E,w.lat+D*E).wrap()),t.setZoom(S+Qc(x,t.center.lat))}handleMapControlsPan(e,t,n){e.panDelta&&qc(t,n,t.isPointOnMapSurface(e.around)?e.around:t.centerPoint,e.panDelta)}cameraForBoxAndBearing(t,n,r,i,a){let o=Dc(t,n,r,i,a),s=n.left/a.width*2-1,c=(a.width-n.right)/a.width*2-1,l=n.top/a.height*-2+1,u=(a.height-n.bottom)/a.height*-2+1,d=Ct(r.getWest(),r.getEast())<0,f=d?r.getEast():r.getWest(),p=d?r.getWest():r.getEast(),m=Math.max(r.getNorth(),r.getSouth()),h=Math.min(r.getNorth(),r.getSouth()),g=f+Ct(f,p)*.5,_=m+Ct(m,h)*.5,v=a.clone();v.setCenter(o.center),v.setBearing(o.bearing),v.setPitch(0),v.setRoll(0),v.setZoom(o.zoom);let y=v.modelViewProjectionMatrix,b=[zc(r.getNorthWest()),zc(r.getNorthEast()),zc(r.getSouthWest()),zc(r.getSouthEast()),zc(new z(p,_)),zc(new z(f,_)),zc(new z(g,m)),zc(new z(g,h))],x=zc(o.center),S=1/0;for(let t of b)s<0&&(S=e.getLesserNonNegativeNonNull(S,e.solveVectorScale(t,x,y,`x`,s))),c>0&&(S=e.getLesserNonNegativeNonNull(S,e.solveVectorScale(t,x,y,`x`,c))),l>0&&(S=e.getLesserNonNegativeNonNull(S,e.solveVectorScale(t,x,y,`y`,l))),u<0&&(S=e.getLesserNonNegativeNonNull(S,e.solveVectorScale(t,x,y,`y`,u)));if(!Number.isFinite(S)||S===0){Tc();return}return o.zoom=Math.min(v.zoom+Pe(S),t.maxZoom),o}handleJumpToCenterZoom(e,t){let n=e.center.lat,r=e.applyConstrain(t.center?z.convert(t.center):e.center,e.zoom).center;e.setCenter(r.wrap());let i=t.zoom===void 0?e.zoom+Qc(n,r.lat):+t.zoom;e.zoom!==i&&e.setZoom(i)}handleEaseTo(e,t){let n=e.zoom,r=e.center,i=e.padding,a={roll:e.roll,pitch:e.pitch,bearing:e.bearing},o={roll:t.roll===void 0?e.roll:t.roll,pitch:t.pitch===void 0?e.pitch:t.pitch,bearing:t.bearing===void 0?e.bearing:t.bearing},s=t.zoom!==void 0,c=!e.isPaddingEqual(t.padding),l=!1,u=t.center?z.convert(t.center):r,d=e.applyConstrain(u,n).center;uc(e,d);let f=e.clone();f.setCenter(d),f.setZoom(s?+t.zoom:n+Qc(r.lat,u.lat)),f.setBearing(t.bearing);let p=new P(I(e.centerPoint.x+t.offsetAsPoint.x,0,e.width),I(e.centerPoint.y+t.offsetAsPoint.y,0,e.height));f.setLocationAtPoint(d,p);let m=(t.offset&&t.offsetAsPoint.mag())>0?f.center:d,h=s?+t.zoom:n+Qc(r.lat,m.lat),g=n+Qc(r.lat,0),_=h+Qc(m.lat,0),v=Ct(r.lng,m.lng),y=Ct(r.lat,m.lat),b=ue(_-g);return l=h!==n,{easeFunc:n=>{if(T(a,o)||Ec({startEulerAngles:a,endEulerAngles:o,tr:e,k:n,useSlerp:a.roll!=o.roll}),c&&e.interpolatePadding(i,t.padding,n),t.around)N(`Easing around a point is not supported under globe projection.`),e.setLocationAtPoint(t.around,t.aroundPoint);else{let t=n*(_>g?Math.min(2,b):Math.max(.5,b))**(1-n),i=nl(r,v,y,t);e.setCenter(i.wrap())}if(l){let t=Gt.number(g,_,n)+Qc(0,e.center.lat);e.setZoom(t)}},isZooming:l,elevationCenter:m}}handleFlyTo(e,t){let n=t.zoom!==void 0,r=e.center,i=e.zoom,a=e.padding,o=!e.isPaddingEqual(t.padding),s=e.applyConstrain(z.convert(t.center||t.locationAtOffset),i).center,c=n?+t.zoom:e.zoom+Qc(e.center.lat,s.lat),l=e.clone();l.setCenter(s),l.setZoom(c),l.setBearing(t.bearing);let u=new P(I(e.centerPoint.x+t.offsetAsPoint.x,0,e.width),I(e.centerPoint.y+t.offsetAsPoint.y,0,e.height));l.setLocationAtPoint(s,u);let d=l.center;uc(e,d);let f=Fc(e,r,d),p=i+Qc(r.lat,0),m=c+Qc(d.lat,0),h=ue(m-p),g=typeof t.minZoom==`number`?+t.minZoom:e.minZoom,_=Math.max(g,e.minZoom)+Qc(d.lat,0),v=Math.min(_,p,m)+Qc(0,d.lat),y=e.applyConstrain(d,v).zoom+Qc(d.lat,0),b=ue(y-p),x=Ct(r.lng,d.lng),S=Ct(r.lat,d.lat);return{easeFunc:(n,i,s,l)=>{let u=nl(r,x,S,s);o&&e.interpolatePadding(a,t.padding,n);let f=n===1?d:u;e.setCenter(f.wrap());let m=p+Pe(i);e.setZoom(n===1?c:m+Qc(0,f.lat))},scaleOfZoom:h,targetCenter:d,scaleOfMinZoom:b,pixelPathLength:f}}static solveVectorScale(e,t,n,r,i){let a=i,o=r===`x`?[n[0],n[4],n[8],n[12]]:[n[1],n[5],n[9],n[13]],s=[n[3],n[7],n[11],n[15]],c=e[0]*o[0]+e[1]*o[1]+e[2]*o[2],l=e[0]*s[0]+e[1]*s[1]+e[2]*s[2],u=t[0]*o[0]+t[1]*o[1]+t[2]*o[2],d=t[0]*s[0]+t[1]*s[1]+t[2]*s[2],f=(u+o[3]-a*d-a*s[3])/(u-c-a*d+a*l);return u+a*l===c+a*d||s[3]*(c-u)+o[3]*(d-l)+c*d===u*l?null:f}static getLesserNonNegativeNonNull(e,t){return t!==null&&t>=0&&t
s;this._popup?.isOpen()&&d&&this._popup.remove(),this._element.style.opacity=d?this._opacityWhenCovered:this._opacity,this._element.classList.toggle(`maplibregl-marker-covered`,d)}getOffset(){return this._offset}setOffset(e){return this._offset=P.convert(e),this._update(),this}addClassName(e){this._element.classList.add(e)}removeClassName(e){this._element.classList.remove(e)}toggleClassName(e){return this._element.classList.toggle(e)}setDraggable(e){return this._draggable=!!e,this._element.classList.toggle(`maplibregl-marker-draggable`,this._draggable),this._map&&(e?(this._map.on(`mousedown`,this._addDragHandler),this._map.on(`touchstart`,this._addDragHandler)):(this._map.off(`mousedown`,this._addDragHandler),this._map.off(`touchstart`,this._addDragHandler))),this._defaultMarker&&(this._draggable?(this._element.addEventListener(`keydown`,this._onKeyDown),this._element.addEventListener(`keyup`,this._onKeyUp),this._element.addEventListener(`blur`,this._onBlur)):(this._element.removeEventListener(`keydown`,this._onKeyDown),this._element.removeEventListener(`keyup`,this._onKeyUp),this._element.removeEventListener(`blur`,this._onBlur),this._endKeyboardDrag())),this._updateTabIndex(),this._updateAccessibilityRole(),this}isDraggable(){return this._draggable}_updateTabIndex(){this._popup||this._defaultMarker&&this._draggable?this._element.hasAttribute(`tabindex`)||(this._element.setAttribute(`tabindex`,`0`),this._tabIndexManaged=!0):this._tabIndexManaged&&=(this._element.getAttribute(`tabindex`)===`0`&&this._element.removeAttribute(`tabindex`),!1)}_updateAccessibilityRole(){if(!this._defaultMarker||this._element.hasAttribute(`role`)&&!this._roleManaged)return;let e=this._draggable||this._popup?`button`:`img`;this._element.setAttribute(`role`,e),this._roleManaged=!0}setRotation(e){return this._rotation=e||0,this._update(),this}getRotation(){return this._rotation}setRotationAlignment(e){return this._rotationAlignment=e||`auto`,this._update(),this}getRotationAlignment(){return this._rotationAlignment}setPitchAlignment(e){return this._pitchAlignment=e&&e!==`auto`?e:this._rotationAlignment,this._update(),this}getPitchAlignment(){return this._pitchAlignment}setOpacity(e,t){return(this._opacity===void 0||e===void 0&&t===void 0)&&(this._opacity=`1`,this._opacityWhenCovered=`0.2`),e!==void 0&&(this._opacity=String(e)),t!==void 0&&(this._opacityWhenCovered=String(t)),this._map&&this._updateOpacity(!0),this}};const Wm={positionOptions:{enableHighAccuracy:!1,maximumAge:0,timeout:6e3},fitBoundsOptions:{maxZoom:15},trackUserLocation:!1,showAccuracyCircle:!0,showUserLocation:!0};let Gm=0,Km=!1;var qm=class extends dr{},Jm=class extends dr{},Ym=class extends dr{},Xm=class extends Er{constructor(e){super(),this._onSuccess=e=>{if(this._map){if(this._isOutOfMapMaxBounds(e)){this._setErrorState(),this.fire(new Jm(`outofmaxbounds`,e)),this._updateMarker(),this._finish();return}if(this.options.trackUserLocation)switch(this._lastKnownPosition=e,this._watchState){case`WAITING_ACTIVE`:case`ACTIVE_LOCK`:case`ACTIVE_ERROR`:this._watchState=`ACTIVE_LOCK`,this._geolocateButton.classList.remove(`maplibregl-ctrl-geolocate-waiting`),this._geolocateButton.classList.remove(`maplibregl-ctrl-geolocate-active-error`),this._geolocateButton.classList.add(`maplibregl-ctrl-geolocate-active`);break;case`BACKGROUND`:case`BACKGROUND_ERROR`:this._watchState=`BACKGROUND`,this._geolocateButton.classList.remove(`maplibregl-ctrl-geolocate-waiting`),this._geolocateButton.classList.remove(`maplibregl-ctrl-geolocate-background-error`),this._geolocateButton.classList.add(`maplibregl-ctrl-geolocate-background`);break;default:throw Error(`Unexpected watchState ${this._watchState}`)}this.options.showUserLocation&&this._watchState!==`OFF`&&this._updateMarker(e),(!this.options.trackUserLocation||this._watchState===`ACTIVE_LOCK`)&&this._updateCamera(e),this.options.showUserLocation&&this._dotElement.classList.remove(`maplibregl-user-location-dot-stale`),this.fire(new Jm(`geolocate`,e)),this._finish()}},this._updateCamera=e=>{let t=new z(e.coords.longitude,e.coords.latitude),n=e.coords.accuracy,r=this._map.getBearing(),i=H({bearing:r},this.options.fitBoundsOptions),a=ya.fromLngLat(t,n);this._map.fitBounds(a,i,{geolocateSource:!0})},this._updateMarker=e=>{if(e){let t=new z(e.coords.longitude,e.coords.latitude);this._accuracyCircleMarker.setLngLat(t).addTo(this._map),this._userLocationDotMarker.setLngLat(t).addTo(this._map),this._accuracy=e.coords.accuracy,this._updateCircleRadiusIfNeeded()}else this._userLocationDotMarker.remove(),this._accuracyCircleMarker.remove()},this._onUpdate=()=>{this._updateCircleRadiusIfNeeded()},this._onError=e=>{if(this._map){if(e.code===1){this._watchState=`OFF`,this._geolocateButton.classList.remove(`maplibregl-ctrl-geolocate-waiting`),this._geolocateButton.classList.remove(`maplibregl-ctrl-geolocate-active`),this._geolocateButton.classList.remove(`maplibregl-ctrl-geolocate-active-error`),this._geolocateButton.classList.remove(`maplibregl-ctrl-geolocate-background`),this._geolocateButton.classList.remove(`maplibregl-ctrl-geolocate-background-error`),this._geolocateButton.disabled=!0;let e=this._map._getUIString(`GeolocateControl.LocationNotAvailable`);this._geolocateButton.title=e,this._geolocateButton.setAttribute(`aria-label`,e),this._geolocationWatchID!==void 0&&this._clearWatch()}else if(e.code===3&&Km)return;else this._setErrorState();this._watchState!==`OFF`&&this.options.showUserLocation&&this._dotElement.classList.add(`maplibregl-user-location-dot-stale`),this.fire(new Ym(`error`,e)),this._finish()}},this._finish=()=>{this._timeoutId&&clearTimeout(this._timeoutId),this._timeoutId=void 0},this._onMoveStart=e=>{if(!this._map)return;let t=e?.[0]instanceof ResizeObserverEntry;!e.geolocateSource&&this._watchState===`ACTIVE_LOCK`&&!t&&!this._map.isZooming()&&(this._watchState=`BACKGROUND`,this._geolocateButton.classList.add(`maplibregl-ctrl-geolocate-background`),this._geolocateButton.classList.remove(`maplibregl-ctrl-geolocate-active`),this.fire(new qm(`trackuserlocationend`)),this.fire(new qm(`userlocationlostfocus`)))},this._setupUI=()=>{this._map&&(this._container.addEventListener(`contextmenu`,e=>{e.preventDefault()}),this._geolocateButton=W.create(`button`,`maplibregl-ctrl-geolocate`,this._container),W.create(`span`,`maplibregl-ctrl-icon`,this._geolocateButton).setAttribute(`aria-hidden`,`true`),this._geolocateButton.type=`button`,this._geolocateButton.disabled=!0)},this._finishSetupUI=e=>{if(this._map){if(e===!1){N(`Geolocation support is not available so the GeolocateControl will be disabled.`);let e=this._map._getUIString(`GeolocateControl.LocationNotAvailable`);this._geolocateButton.disabled=!0,this._geolocateButton.title=e,this._geolocateButton.setAttribute(`aria-label`,e)}else{let e=this._map._getUIString(`GeolocateControl.FindMyLocation`);this._geolocateButton.disabled=!1,this._geolocateButton.title=e,this._geolocateButton.setAttribute(`aria-label`,e)}this.options.trackUserLocation&&(this._geolocateButton.setAttribute(`aria-pressed`,`false`),this._watchState=`OFF`),this.options.showUserLocation&&(this._dotElement=W.create(`div`,`maplibregl-user-location-dot`),this._userLocationDotMarker=new Um({element:this._dotElement}),this._circleElement=W.create(`div`,`maplibregl-user-location-accuracy-circle`),this._accuracyCircleMarker=new Um({element:this._circleElement,pitchAlignment:`map`}),this.options.trackUserLocation&&(this._watchState=`OFF`),this._map.on(`zoom`,this._onUpdate),this._map.on(`move`,this._onUpdate),this._map.on(`rotate`,this._onUpdate),this._map.on(`pitch`,this._onUpdate)),this._geolocateButton.addEventListener(`click`,()=>this.trigger()),this._setup=!0,this.options.trackUserLocation&&this._map.on(`movestart`,this._onMoveStart)}},this.options=H({},Wm,e)}onAdd(e){return this._map=e,this._container=W.create(`div`,`maplibregl-ctrl maplibregl-ctrl-group`),this._setupUI(),jm().then(e=>this._finishSetupUI(e)),this._container}onRemove(){this._geolocationWatchID!==void 0&&(window.navigator.geolocation.clearWatch(this._geolocationWatchID),this._geolocationWatchID=void 0),this.options.showUserLocation&&this._userLocationDotMarker&&this._userLocationDotMarker.remove(),this.options.showAccuracyCircle&&this._accuracyCircleMarker&&this._accuracyCircleMarker.remove(),this._container.remove(),this._map.off(`movestart`,this._onMoveStart),this._map.off(`zoom`,this._onUpdate),this._map.off(`move`,this._onUpdate),this._map.off(`rotate`,this._onUpdate),this._map.off(`pitch`,this._onUpdate),this._map=void 0,Gm=0,Km=!1}_isOutOfMapMaxBounds(e){let t=this._map.getMaxBounds(),n=e.coords;return t&&(n.longitude