fpv-airspace 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -205,9 +205,9 @@ Which UK open category rules apply to a consumer drone. Give `model` (looked up
205
205
 
206
206
  The hosted server serves `GET /map?lat=&lon=[&radius=][&route=lon,lat;lon,lat]` as a standalone Leaflet map, `GET /api/view` as the JSON behind it, and `GET /api/wind?bbox=w,s,e,n&z=` for the wind field (one Open-Meteo request per view, snapped to a fixed lattice of at most 64 points and cached per point). It also publishes an MCP App resource (`ui://fpv-airspace/map`) attached to `check_location`, `check_takeoff_site`, `check_route` and `find_parking`, so hosts that support MCP Apps show the map inline with the answer; `preflight_briefing`, `check_terrain` and `find_takeoff_spots` carry it too, the last with its spots numbered on the map. Claude web, desktop and mobile render it; Claude Code shows the text only.
207
207
 
208
- The layers panel (top left) offers Map or Satellite base layers and a checkbox for every overlay, grouped into Airspace (each zone class and NOTAMs), On the ground (rights of way, landowner land, parking, route) and Weather. Prohibited and restricted areas and aerodrome FRZs are always drawn and cannot be switched off. Satellite is Esri World Imagery with a place-name overlay; add `basemap=satellite` to the `/map` URL to open in that view. Weather has three toggles: Conditions now (a badge with the Open-Meteo flyability rating for the coming hour, including the wind at 120 m), Wind flow (animated streamlines over the visible map, as on a forecast chart, coloured by the advisory thresholds; a still frame when the browser prefers reduced motion) and Rain radar (the latest RainViewer frame, coarse at about 600 m per pixel on the free tier). `weather=0` on `/api/view` skips the forecast. Base layer, overlay and panel choices are remembered per browser.
208
+ The layers button (bottom left) opens a panel with Map or Satellite base layers and a checkbox for every overlay, grouped into Airspace (each zone class and NOTAMs), On the ground (rights of way, landowner land, parking, route), Ground hazards (one row per kind with its icon and count, and All or None shortcuts) and Weather. Prohibited and restricted areas and aerodrome FRZs are always drawn and cannot be switched off. Satellite is Esri World Imagery with a place-name overlay; add `basemap=satellite` to the `/map` URL to open in that view. Weather has three toggles: Conditions now (a badge with the Open-Meteo flyability rating for the coming hour, including the wind at 120 m), Wind flow (animated streamlines over the visible map, as on a forecast chart, coloured by the advisory thresholds; a still frame when the browser prefers reduced motion) and Rain radar (the latest RainViewer frame, coarse at about 600 m per pixel on the free tier). `weather=0` on `/api/view` skips the forecast. Base layer, overlay and panel choices are remembered per browser. On a phone the search bar spans the top, the location card is a collapsible sheet at the bottom, the layers panel opens as a sheet from the same button, and pinch replaces the zoom buttons; a page opened without a location invites a search or offers to use the phone's location.
209
209
 
210
- The search box (top left) takes a place name, a postcode or `lat, lon` and calls `GET /api/geocode?q=`, which runs the same resolver as the tools: a confident match moves the map, an ambiguous name shows the candidates to pick from, and nothing is ever guessed. The page URL updates as you search, so it can be shared.
210
+ The search box (top left) takes a place name, a postcode or `lat, lon` and calls `GET /api/geocode?q=`, which runs the same resolver as the tools: a confident match moves the map, an ambiguous name shows the candidates to pick from, and nothing is ever guessed. The page URL updates as you search, so it can be shared. Tapping or clicking anywhere on the map checks that point, and the locate button uses the browser's location.
211
211
 
212
212
  The location card has a Your drone picker fed by `GET /api/drones` (the catalogue with each model's rules summary and a drawn silhouette). Choosing a model makes it the location marker and the key swatch, and shows its subcategory and overflight rule in the card. Add `drone=<catalogue id>` to the `/map` URL to preselect one; `check_takeoff_site` called with a `drone` does this for the MCP App. The silhouettes are original drawings, one per family (palm, mini, air, mavic, fpv, phantom), because manufacturer photographs are copyrighted.
213
213
 
@@ -238,11 +238,13 @@ No arguments. Reports pack tag, AIRAC effective dates, per-source fetch dates an
238
238
  | Drone rules | [CAA class marks](https://www.caa.co.uk/drones/getting-started-with-drones-and-model-aircraft/class-marks/) and the [Drone Code](https://register-drones.caa.co.uk/drone-code), summarised in `src/services/drones/rules.ts`; drone catalogue curated from manufacturer specifications | With the code | Crown copyright, OGL v3; the CAA pages are authoritative |
239
239
  | Open access land | [Natural England CRoW Access Layer](https://naturalengland-defra.opendata.arcgis.com/datasets/Defra::crow-act-2000-access-layer/about), [NRW open country and common land](https://datamap.gov.wales/) | With the pack | OGL v3, see [licences/natural-england.md](licences/natural-england.md) and [licences/natural-resources-wales.md](licences/natural-resources-wales.md) |
240
240
  | SSSI and National Parks | Natural England and Natural Resources Wales designation boundaries | With the pack | OGL v3, advisory only |
241
+ | Northern Ireland designations | [ASSI](https://www.opendatani.gov.uk/dataset/areas-of-special-scientific-interest), [AONB](https://www.opendatani.gov.uk/dataset/areas-of-outstanding-natural-beauty) and [National Nature Reserves](https://www.opendatani.gov.uk/dataset/national-nature-reserves) from the Northern Ireland Environment Agency on OpenDataNI | With the pack | OGL v3, advisory only, see [licences/northern-ireland.md](licences/northern-ireland.md) |
241
242
  | Forestry England land | [Forestry England Legal Boundary](https://data-forestry.opendata.arcgis.com/) | With the pack | OGL v3 with acknowledgement, see [licences/forestry-england.md](licences/forestry-england.md); byelaws need a permit for drones |
243
+ | Northern Ireland rights of way | Asserted public rights of way published by councils, so far [Mid Ulster](https://www.opendatani.gov.uk/dataset/mid-ulster-council-public-rights-of-way); there is no definitive map | Weekly | OGL v3 per council, see [licences/northern-ireland.md](licences/northern-ireland.md) |
242
244
  | Scottish core paths | [Core Paths - Scotland, Improvement Service Spatial Hub](https://data.spatialhub.scot/dataset/core_paths-is) (needs a free account key, `SPATIALHUB_AUTHKEY`) | Weekly | OGL v3 per council, see [licences/improvement-service-core-paths.md](licences/improvement-service-core-paths.md) |
243
245
  | Local authorities | [ONS Local Authority Districts (May 2026) BSC](https://geoportal.statistics.gov.uk/) | With the pack | OGL v3, see [licences/ONS.md](licences/ONS.md) |
244
- | Ground hazards | OpenStreetMap via the Geofabrik extract: railways, motorways and trunk roads, power lines and minor lines, pylons, substations, generators, helipads, masts, military land, schools, nurseries, hospitals, fire and fuel stations, parks, cemeteries | Weekly | ODbL, see [licences/openstreetmap-ODbL.md](licences/openstreetmap-ODbL.md); advisory only |
245
- | Parking and laybys | OpenStreetMap via the [Geofabrik Great Britain extract](https://download.geofabrik.de/europe/great-britain.html) (`amenity=parking`, `highway=rest_area`) | Weekly | ODbL |
246
+ | Ground hazards | OpenStreetMap via the Geofabrik Great Britain and Ireland extracts (the latter clipped to Northern Ireland): railways, motorways and trunk roads, bridges, power lines and minor lines, pylons, substations, generators, helipads, masts, military land, schools, nurseries, hospitals, fire and fuel stations, parks, cemeteries | Weekly | ODbL, see [licences/openstreetmap-ODbL.md](licences/openstreetmap-ODbL.md); advisory only |
247
+ | Parking and laybys | OpenStreetMap via the [Geofabrik Great Britain extract](https://download.geofabrik.de/europe/great-britain.html) plus the Ireland extract clipped to Northern Ireland (`amenity=parking`, `highway=rest_area`) | Weekly | ODbL |
246
248
  | Country boundaries | ONS Countries (December 2024) BUC | Yearly | OGL v3 |
247
249
  | Geocoding | postcodes.io, OS Names API (optional), Nominatim | Live, cached 30 days | OGL v3; ODbL |
248
250
  | Weather | [Open-Meteo](https://open-meteo.com/) forecast API | Live, cached 15 min | CC BY 4.0 |
@@ -261,7 +263,7 @@ The permanent layers are assembled into a data pack by [`.github/workflows/build
261
263
  | `ELEVATION_CACHE_TTL_SECONDS` | `2592000` | Terrain does not change |
262
264
  | `NOAA_KP_URL` | `https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json` | |
263
265
  | `SPACE_WEATHER_CACHE_TTL_SECONDS` | `900` | |
264
- | `OSM_PBF_URL`, `NE_CROW_URL`, `NE_SSSI_URL`, `NE_NATIONAL_PARKS_URL`, `NRW_WFS_URL`, `FE_LEGAL_BOUNDARY_URL`, `ONS_LAD_URL`, `SPATIALHUB_WFS_URL` | (upstream defaults) | Build only: override a source endpoint when a publisher moves it; the `NRW_*_TYPENAME` and `SPATIALHUB_TYPENAME` variables do the same for WFS layer names |
266
+ | `OSM_PBF_URL`, `OSM_PBF_URL_IRELAND`, `NE_CROW_URL`, `NE_SSSI_URL`, `NE_NATIONAL_PARKS_URL`, `NRW_WFS_URL`, `FE_LEGAL_BOUNDARY_URL`, `ONS_LAD_URL`, `SPATIALHUB_WFS_URL` | (upstream defaults) | Build only: override a source endpoint when a publisher moves it; the `NRW_*_TYPENAME` and `SPATIALHUB_TYPENAME` variables do the same for WFS layer names |
265
267
  | `SPATIALHUB_AUTHKEY` | (unset) | Build only: Spatial Hub account key for Scottish core paths; without it the pack has none |
266
268
  | `NOMINATIM_URL` | `https://nominatim.openstreetmap.org/search` | Self-host to lift the 1 req/s limit |
267
269
  | `POSTCODES_IO_URL` | `https://api.postcodes.io` | |
package/dist/index.js CHANGED
@@ -61987,7 +61987,7 @@ function date4(params) {
61987
61987
  // src/version.ts
61988
61988
  import { createRequire } from "node:module";
61989
61989
  function readVersion() {
61990
- if (true) return "0.4.0";
61990
+ if (true) return "0.5.0";
61991
61991
  try {
61992
61992
  const require2 = createRequire(import.meta.url);
61993
61993
  return require2("../package.json").version;
@@ -62378,6 +62378,7 @@ var HAZARD_KINDS = [
62378
62378
  "railway",
62379
62379
  "motorway",
62380
62380
  "trunk_road",
62381
+ "bridge",
62381
62382
  "power_line",
62382
62383
  "minor_power_line",
62383
62384
  "pylon",
@@ -62440,7 +62441,7 @@ var DDL = [
62440
62441
  `CREATE TABLE authorities (
62441
62442
  code TEXT PRIMARY KEY,
62442
62443
  name TEXT NOT NULL,
62443
- country TEXT NOT NULL CHECK (country IN ('england','wales','scotland')),
62444
+ country TEXT NOT NULL CHECK (country IN ('england','wales','scotland','northern_ireland')),
62444
62445
  attribution TEXT NOT NULL,
62445
62446
  fetched_at TEXT,
62446
62447
  feature_count INTEGER
@@ -64798,11 +64799,12 @@ var QueryPackRepository = class {
64798
64799
  }
64799
64800
  return out;
64800
64801
  }
64801
- async hazardsInBbox(bbox, limit = 300) {
64802
+ async hazardsInBbox(bbox, limit = 300, kinds) {
64803
+ const kindFilter = kinds && kinds.length > 0 ? ` AND h.kind IN (${kinds.map(() => "?").join(",")})` : "";
64802
64804
  const rows = await this.q.all(
64803
64805
  `SELECT h.* FROM hazards_rtree r JOIN hazards h ON h.id = r.id
64804
- WHERE r.min_lon <= ? AND r.max_lon >= ? AND r.min_lat <= ? AND r.max_lat >= ? LIMIT ?`,
64805
- [bbox[2], bbox[0], bbox[3], bbox[1], limit]
64806
+ WHERE r.min_lon <= ? AND r.max_lon >= ? AND r.min_lat <= ? AND r.max_lat >= ?${kindFilter} LIMIT ?`,
64807
+ [bbox[2], bbox[0], bbox[3], bbox[1], ...kinds && kinds.length > 0 ? kinds : [], limit]
64806
64808
  );
64807
64809
  const out = [];
64808
64810
  for (const row of rows) {
@@ -74909,6 +74911,9 @@ var SOURCE_LABEL = {
74909
74911
  nrw_common_land: "Registered common land",
74910
74912
  ne_sssi: "SSSI",
74911
74913
  nrw_sssi: "SSSI",
74914
+ niea_assi: "ASSI",
74915
+ niea_aonb: "AONB",
74916
+ niea_nnr: "National Nature Reserve",
74912
74917
  ne_national_parks: "National Park",
74913
74918
  nrw_national_parks: "National Park"
74914
74919
  };
@@ -74924,7 +74929,7 @@ function restrictionLabel(r) {
74924
74929
  case "access_land":
74925
74930
  return "Open access land";
74926
74931
  case "designation":
74927
- return r.accessClass === "sssi" ? "SSSI" : "Designation";
74932
+ return r.accessClass === "sssi" ? "SSSI" : r.accessClass === "assi" ? "ASSI" : r.accessClass === "aonb" ? "AONB" : r.accessClass === "nnr" ? "National Nature Reserve" : "Designation";
74928
74933
  default:
74929
74934
  return r.owner;
74930
74935
  }
@@ -74948,7 +74953,7 @@ function accessLine(restrictions) {
74948
74953
  function advisoryLine(restrictions) {
74949
74954
  const des = restrictions.filter((r) => r.kind === "designation");
74950
74955
  if (des.length === 0) return null;
74951
- const parts = des.slice(0, 2).map((r) => r.accessClass === "sssi" ? `${r.name} (SSSI): do not disturb protected wildlife` : `${r.name}: follow the park authority's drone guidance`);
74956
+ const parts = des.slice(0, 2).map((r) => r.accessClass === "sssi" || r.accessClass === "assi" || r.accessClass === "nnr" ? `${r.name} (${r.accessClass === "sssi" ? "SSSI" : r.accessClass === "assi" ? "ASSI" : "NNR"}): do not disturb protected wildlife` : `${r.name}: follow the ${r.accessClass === "aonb" ? "AONB partnership" : "park authority"}'s drone guidance`);
74952
74957
  const extra = des.length > 2 ? ` (+${des.length - 2} more)` : "";
74953
74958
  return `Designation: ${parts.join("; ")}${extra}.`;
74954
74959
  }
@@ -75046,7 +75051,7 @@ var PACK_SOURCE_IDS = {
75046
75051
  hazards: ["osm_hazards"],
75047
75052
  lad: ["ons_lad"],
75048
75053
  access_land: ["ne_crow_access", "nrw_open_country", "nrw_common_land"],
75049
- designations: ["ne_sssi", "nrw_sssi", "ne_national_parks", "nrw_national_parks"],
75054
+ designations: ["ne_sssi", "nrw_sssi", "ne_national_parks", "nrw_national_parks", "niea_assi", "niea_aonb", "niea_nnr"],
75050
75055
  forestry: ["fe_legal_boundary"]
75051
75056
  };
75052
75057
  var SHORT = {
@@ -75532,6 +75537,47 @@ var SILHOUETTES = {
75532
75537
  };
75533
75538
 
75534
75539
  // src/map/view-data.ts
75540
+ var DAYLIGHT = { from: 6, to: 20 };
75541
+ var RANK = { good: 0, caution: 1, poor: 2 };
75542
+ var LINE_HAZARD_KINDS = ["railway", "motorway", "trunk_road", "power_line", "military"];
75543
+ var OTHER_HAZARD_KINDS = HAZARD_KINDS.filter((k) => !LINE_HAZARD_KINDS.includes(k));
75544
+ var LINE_HAZARD_LIMIT = 1500;
75545
+ var POINT_HAZARD_LIMIT = 600;
75546
+ async function viewHazards(pack, bbox, lon, lat) {
75547
+ const lines = await pack.hazardsInBbox(bbox, LINE_HAZARD_LIMIT, LINE_HAZARD_KINDS);
75548
+ const others = await pack.hazardsInBbox(bbox, 4e3, OTHER_HAZARD_KINDS);
75549
+ const nearest = others.map((h) => ({ h, d: (h.lon - lon) ** 2 + ((h.lat - lat) * 1.6) ** 2 })).sort((a, b) => a.d - b.d).slice(0, POINT_HAZARD_LIMIT).map((x) => x.h);
75550
+ return [...lines, ...nearest];
75551
+ }
75552
+ function weatherTimeline(hourly, fromKey) {
75553
+ const hours = [];
75554
+ const byDay = /* @__PURE__ */ new Map();
75555
+ for (const h of hourly) {
75556
+ if (h.time < fromKey) continue;
75557
+ const a = assessHour(h);
75558
+ hours.push({
75559
+ time: h.time,
75560
+ flyability: a.flyability,
75561
+ summary: describeWeatherCode(h.weatherCode),
75562
+ reasons: a.reasons,
75563
+ windMs: h.windMs,
75564
+ gustMs: h.gustMs,
75565
+ wind120Ms: h.wind120Ms,
75566
+ windFrom: compassPoint(h.windDirectionDeg),
75567
+ windDirectionDeg: h.windDirectionDeg,
75568
+ temperatureC: h.temperatureC,
75569
+ precipitationProbability: h.precipitationProbability
75570
+ });
75571
+ const date5 = h.time.slice(0, 10);
75572
+ const hourOfDay = Number(h.time.slice(11, 13));
75573
+ if (hourOfDay < DAYLIGHT.from || hourOfDay >= DAYLIGHT.to) continue;
75574
+ const day = byDay.get(date5) ?? { date: date5, flyability: "poor", good: 0, caution: 0, poor: 0 };
75575
+ day[a.flyability] += 1;
75576
+ if (RANK[a.flyability] < RANK[day.flyability]) day.flyability = a.flyability;
75577
+ byDay.set(date5, day);
75578
+ }
75579
+ return { hours, days: [...byDay.values()] };
75580
+ }
75535
75581
  function windLevelOf(h) {
75536
75582
  if ((h.gustMs ?? 0) >= LIMITS.gustNoFlyMs || (h.windMs ?? 0) >= LIMITS.windStrongMs) return "poor";
75537
75583
  if ((h.windMs ?? 0) >= LIMITS.windCautionMs || (h.wind120Ms ?? 0) >= LIMITS.windStrongMs) return "caution";
@@ -75552,7 +75598,7 @@ async function buildViewData(deps, req) {
75552
75598
  pack.nearestRightsOfWay(req.lon, req.lat, radiusM, 40),
75553
75599
  pack.landRestrictionsInBbox(bbox, 300),
75554
75600
  pack.nearestParking(req.lon, req.lat, Math.max(radiusM, 2e3), 15, false),
75555
- pack.hazardsInBbox(bbox, 300),
75601
+ viewHazards(pack, bbox, req.lon, req.lat),
75556
75602
  pack.meta()
75557
75603
  ]);
75558
75604
  let notams = [];
@@ -75583,7 +75629,8 @@ async function buildViewData(deps, req) {
75583
75629
  windFrom: compassPoint(hour.windDirectionDeg),
75584
75630
  windDirectionDeg: hour.windDirectionDeg,
75585
75631
  windLevel: windLevelOf(hour),
75586
- temperatureC: hour.temperatureC
75632
+ temperatureC: hour.temperatureC,
75633
+ ...weatherTimeline(forecast.hourly, key)
75587
75634
  };
75588
75635
  }
75589
75636
  } catch {
@@ -75761,6 +75808,7 @@ function buildDroneIndex(now) {
75761
75808
  var CAVEAT_NOT_BRIEFING = "Informational only. Not a substitute for a NATS pre-flight briefing, the CAA Drone Code, or permission from the landowner and any relevant aerodrome.";
75762
75809
  var CAVEAT_PROW_INTERPRETATION = "Rights-of-way data is an interpretation of each council's Definitive Map, not the Definitive Map itself, and covers England and Wales only.";
75763
75810
  var CAVEAT_NO_PROW_HERE = "No public rights-of-way data here: Scotland has no definitive map (access rights apply under the Land Reform (Scotland) Act 2003, and this pack has no core paths) and Northern Ireland has very few recorded rights of way.";
75811
+ var CAVEAT_NI_PROW = "Northern Ireland: councils assert public rights of way under the Access to the Countryside (Northern Ireland) Order 1983 and there is no definitive map. Only councils that publish their asserted paths appear here (Mid Ulster so far), so absence means unknown, not none.";
75764
75812
  var CAVEAT_SCOTLAND_ACCESS = "Scotland: responsible access rights under the Land Reform (Scotland) Act 2003 apply to most land, not only paths. Core paths come from each council's core path plan via the Improvement Service, not a definitive map.";
75765
75813
  var CAVEAT_BAN_LAYER_INCOMPLETE = "The landowner rule layer is incomplete: it holds National Trust and Forestry England land and a hand-curated list of council byelaws and policies. Absence of a rule here does not mean take-off is permitted, and open access land is shown as context, not as permission.";
75766
75814
  var CAVEAT_NOTAM_SCHEDULE = "One or more NOTAMs carry a daily schedule (item D); check the times before flying.";
@@ -76422,6 +76470,7 @@ var HAZARD_LABEL = {
76422
76470
  railway: "railway",
76423
76471
  motorway: "motorway",
76424
76472
  trunk_road: "trunk road",
76473
+ bridge: "bridge",
76425
76474
  power_line: "power line",
76426
76475
  minor_power_line: "minor power line",
76427
76476
  pylon: "pylon",
@@ -76447,6 +76496,7 @@ var HAZARD_GROUP = {
76447
76496
  railway: "transport",
76448
76497
  motorway: "transport",
76449
76498
  trunk_road: "transport",
76499
+ bridge: "transport",
76450
76500
  helipad: "aviation",
76451
76501
  tower: "aviation",
76452
76502
  military: "aviation",
@@ -76458,7 +76508,7 @@ var HAZARD_GROUP = {
76458
76508
  park: "sites",
76459
76509
  cemetery: "sites"
76460
76510
  };
76461
- var DANGER_KINDS = /* @__PURE__ */ new Set(["railway", "motorway", "trunk_road", "power_line", "minor_power_line", "pylon", "substation", "power_generator", "helipad", "tower", "military", "fuel_station"]);
76511
+ var DANGER_KINDS = /* @__PURE__ */ new Set(["railway", "motorway", "trunk_road", "bridge", "power_line", "minor_power_line", "pylon", "substation", "power_generator", "helipad", "tower", "military", "fuel_station"]);
76462
76512
  var SITE_KINDS = /* @__PURE__ */ new Set(["school", "kindergarten", "hospital", "fire_station", "park", "cemetery"]);
76463
76513
  function isDangerHazard(h) {
76464
76514
  return DANGER_KINDS.has(h.kind);
@@ -76628,7 +76678,8 @@ function createCheckTakeoffSiteHandler(deps) {
76628
76678
  const verdict = buildVerdict(relevant, restrictions);
76629
76679
  const coverage = await deps.rightsOfWay.coverageAt(loc.lon, loc.lat);
76630
76680
  const scotlandPaths = coverage === "scotland" && await deps.rightsOfWay.hasCorePaths();
76631
- const paths = coverage === "northern_ireland" || coverage === "scotland" && !scotlandPaths ? [] : await deps.rightsOfWay.nearest(loc.lon, loc.lat, radiusM, maxPaths);
76681
+ const niPaths = coverage === "northern_ireland" && await deps.rightsOfWay.hasNorthernIrelandPaths();
76682
+ const paths = coverage === "northern_ireland" && !niPaths || coverage === "scotland" && !scotlandPaths ? [] : await deps.rightsOfWay.nearest(loc.lon, loc.lat, radiusM, maxPaths);
76632
76683
  const takeoffBanned = land.rules.some((r) => r.takeoffBanned);
76633
76684
  const parking = await pack.nearestParking(loc.lon, loc.lat, 2e3, 3, false);
76634
76685
  const hazardRadiusM = Math.min(radiusM, 1e3);
@@ -76645,7 +76696,7 @@ function createCheckTakeoffSiteHandler(deps) {
76645
76696
  const attribution = attributionLines(used, await pack.meta(), landPackIds(land));
76646
76697
  for (const a of new Set(paths.map((p) => p.attribution))) attribution.push(a);
76647
76698
  const caveats = [
76648
- scotlandPaths ? CAVEAT_SCOTLAND_ACCESS : coverage === "scotland" || coverage === "northern_ireland" ? CAVEAT_NO_PROW_HERE : CAVEAT_PROW_INTERPRETATION,
76699
+ scotlandPaths ? CAVEAT_SCOTLAND_ACCESS : niPaths ? CAVEAT_NI_PROW : coverage === "scotland" || coverage === "northern_ireland" ? CAVEAT_NO_PROW_HERE : CAVEAT_PROW_INTERPRETATION,
76649
76700
  CAVEAT_BAN_LAYER_INCOMPLETE,
76650
76701
  CAVEAT_NOTAMS_NOT_INCLUDED,
76651
76702
  CAVEAT_HAZARDS,
@@ -76679,7 +76730,7 @@ function createCheckTakeoffSiteHandler(deps) {
76679
76730
  parking,
76680
76731
  groundHazards: hazards.map(hazardToJson),
76681
76732
  hazardRadiusM,
76682
- coverage: coverage === "england_wales" ? "england_wales" : coverage === "unknown" ? "unknown" : scotlandPaths ? "scotland_core_paths" : "no_prow_data",
76733
+ coverage: coverage === "england_wales" ? "england_wales" : coverage === "unknown" ? "unknown" : scotlandPaths ? "scotland_core_paths" : niPaths ? "northern_ireland_asserted" : "no_prow_data",
76683
76734
  searchRadiusM: radiusM,
76684
76735
  drone: droneInfo ? { query: droneArg, label: droneInfo.label, assessment: droneInfo.assessment } : null,
76685
76736
  caveats,
@@ -76710,7 +76761,7 @@ function createCheckTakeoffSiteHandler(deps) {
76710
76761
  parking.length > 0 ? `Nearest parking: ${parkingSentence(parking[0])}` : null,
76711
76762
  hazards.length > 0 && hazards[0].distanceM < 150 ? `Ground hazard close by: ${renderHazard(hazards[0])}` : null,
76712
76763
  droneInfo ? droneInfo.lines[0] : null,
76713
- scotlandPaths ? "In Scotland responsible access rights apply to most land, not only the core paths listed" : coverage === "scotland" || coverage === "northern_ireland" ? "There is no rights-of-way data for this area" : "Rights of way are an interpretation of the council definitive map",
76764
+ scotlandPaths ? "In Scotland responsible access rights apply to most land, not only the core paths listed" : niPaths ? "Northern Ireland has no definitive map; only paths published by councils are listed" : coverage === "scotland" || coverage === "northern_ireland" ? "There is no rights-of-way data for this area" : "Rights of way are an interpretation of the council definitive map",
76714
76765
  "Check NOTAMs separately and remember the landowner rule layer is incomplete",
76715
76766
  attributionSentence(used)
76716
76767
  ),
@@ -76951,7 +77002,7 @@ function createCheckWeatherHandler(deps) {
76951
77002
  }
76952
77003
 
76953
77004
  // src/services/briefing/status.ts
76954
- var RANK = { go: 0, caution: 1, no_go: 2 };
77005
+ var RANK2 = { go: 0, caution: 1, no_go: 2 };
76955
77006
  var NO_GO_ZONE_TYPES = /* @__PURE__ */ new Set(["prohibited", "prison"]);
76956
77007
  function title(z2) {
76957
77008
  return [z2.designator, z2.name].filter((p) => !!p && p.trim() !== "").join(" ");
@@ -76961,7 +77012,7 @@ function deriveBriefingStatus(i) {
76961
77012
  let status = "go";
76962
77013
  const add = (level, code, text2) => {
76963
77014
  reasons.push({ level, code, text: text2 });
76964
- if (level !== "note" && RANK[level] > RANK[status]) status = level;
77015
+ if (level !== "note" && RANK2[level] > RANK2[status]) status = level;
76965
77016
  };
76966
77017
  for (const z2 of i.zones) {
76967
77018
  const t = z2.zoneType;
@@ -77007,7 +77058,7 @@ function deriveBriefingStatus(i) {
77007
77058
  const access = i.restrictions.filter((r) => r.kind === "access_land");
77008
77059
  if (access.length > 0) add("note", "access_land", "Open access land: the public may walk here off paths; that is not itself permission to take off, so check the landowner rules above.");
77009
77060
  const des = i.restrictions.filter((r) => r.kind === "designation");
77010
- if (des.length > 0) add("note", "designation", `${des.map((r) => r.name).slice(0, 2).join(" and ")}: ${des.some((r) => r.accessClass === "sssi") ? "protected wildlife, do not disturb it" : "follow the park authority's drone guidance"}.`);
77061
+ if (des.length > 0) add("note", "designation", `${des.map((r) => r.name).slice(0, 2).join(" and ")}: ${des.some((r) => r.accessClass === "sssi" || r.accessClass === "assi" || r.accessClass === "nnr") ? "protected wildlife, do not disturb it" : "follow the park authority's drone guidance"}.`);
77011
77062
  if (i.droneSubcategory === "A3") add("note", "a3_separation", "Your drone flies in A3: keep 150 m from residential, commercial, industrial and recreational areas; the sites listed here are cues, not a full built-up-area layer.");
77012
77063
  if (status === "go") add("go", "clear", `No permanent restriction at this point, no NOTAM covering it${i.weather ? `, weather ${i.weather} for the window` : ""}.`);
77013
77064
  return { status, reasons };
@@ -77054,7 +77105,8 @@ function createPreflightBriefingHandler(deps) {
77054
77105
  pack.hazardsNear(loc.lon, loc.lat, 500, 10)
77055
77106
  ]);
77056
77107
  const scotlandPaths = coverage === "scotland" && await deps.rightsOfWay.hasCorePaths();
77057
- const paths = coverage === "northern_ireland" || coverage === "scotland" && !scotlandPaths ? [] : await deps.rightsOfWay.nearest(loc.lon, loc.lat, 1e3, maxPaths);
77108
+ const niPaths = coverage === "northern_ireland" && await deps.rightsOfWay.hasNorthernIrelandPaths();
77109
+ const paths = coverage === "northern_ireland" && !niPaths || coverage === "scotland" && !scotlandPaths ? [] : await deps.rightsOfWay.nearest(loc.lon, loc.lat, 1e3, maxPaths);
77058
77110
  const { relevant, above } = splitByRelevance(zones);
77059
77111
  const land = splitLandRestrictions(restrictions);
77060
77112
  const verdict = buildVerdict(relevant, restrictions);
@@ -77107,7 +77159,7 @@ function createPreflightBriefingHandler(deps) {
77107
77159
  outages.push(`space_weather: ${spaceR.error}`);
77108
77160
  caveats.push(CAVEAT_SPACE_WEATHER);
77109
77161
  }
77110
- caveats.push(scotlandPaths ? CAVEAT_SCOTLAND_ACCESS : coverage === "scotland" || coverage === "northern_ireland" ? CAVEAT_NO_PROW_HERE : CAVEAT_PROW_INTERPRETATION);
77162
+ caveats.push(scotlandPaths ? CAVEAT_SCOTLAND_ACCESS : niPaths ? CAVEAT_NI_PROW : coverage === "scotland" || coverage === "northern_ireland" ? CAVEAT_NO_PROW_HERE : CAVEAT_PROW_INTERPRETATION);
77111
77163
  if (droneInfo) caveats.push(CAVEAT_DRONE_RULES);
77112
77164
  const windowTo = window2.length > 0 ? window2[window2.length - 1].hour.time : null;
77113
77165
  const headlineReasons = reasons.filter((r) => r.level === status && r.code !== "clear");
@@ -77496,6 +77548,7 @@ function createFindTakeoffSpotsHandler(deps) {
77496
77548
  const pack = deps.pack.require();
77497
77549
  const coverage = await deps.rightsOfWay.coverageAt(loc.lon, loc.lat);
77498
77550
  const scotlandPaths = coverage === "scotland" && await deps.rightsOfWay.hasCorePaths();
77551
+ const niPaths = coverage === "northern_ireland" && await deps.rightsOfWay.hasNorthernIrelandPaths();
77499
77552
  const { dLat, dLon } = metresToDegrees(radiusM, loc.lat);
77500
77553
  const bbox = [loc.lon - dLon, loc.lat - dLat, loc.lon + dLon, loc.lat + dLat];
77501
77554
  const [paths, parking, land, hazards, notamR] = await Promise.all([
@@ -77555,7 +77608,7 @@ function createFindTakeoffSpotsHandler(deps) {
77555
77608
  const attribution = attributionLines(used, await pack.meta(), landPackIds(landSplit));
77556
77609
  const usedPathAuthorities = new Set(paths.filter((p) => spots.some((sp) => sp.candidate.source === "prow" && sp.candidate.label.includes(p.routeNo ?? "\0"))).map((p) => p.attribution));
77557
77610
  for (const a of usedPathAuthorities) attribution.push(a);
77558
- const caveats = [CAVEAT_SPOTS, scotlandPaths ? CAVEAT_SCOTLAND_ACCESS : coverage === "scotland" || coverage === "northern_ireland" ? CAVEAT_NO_PROW_HERE : CAVEAT_PROW_INTERPRETATION, CAVEAT_BAN_LAYER_INCOMPLETE, CAVEAT_AIRSPACE_ONLY_BELOW_120M];
77611
+ const caveats = [CAVEAT_SPOTS, scotlandPaths ? CAVEAT_SCOTLAND_ACCESS : niPaths ? CAVEAT_NI_PROW : coverage === "scotland" || coverage === "northern_ireland" ? CAVEAT_NO_PROW_HERE : CAVEAT_PROW_INTERPRETATION, CAVEAT_BAN_LAYER_INCOMPLETE, CAVEAT_AIRSPACE_ONLY_BELOW_120M];
77559
77612
  if (!notamR.ok) caveats.push(`NOTAMs could not be fetched (${notamR.error}); spots are scored without them, run check_notams before flying.`);
77560
77613
  else if (notams.some((n) => n.schedule)) caveats.push(CAVEAT_NOTAM_SCHEDULE);
77561
77614
  if (a3 && !hazards.some(isSiteHazard)) caveats.push("No schools, parks or similar sites are mapped here; as an A3 pilot keep 150 m from residential, commercial, industrial and recreational areas yourself.");
@@ -77570,7 +77623,7 @@ function createFindTakeoffSpotsHandler(deps) {
77570
77623
  candidatesConsidered: candidates.length,
77571
77624
  spots: spots.map((s2, i) => ({ rank: i + 1, lat: s2.candidate.lat, lon: s2.candidate.lon, score: s2.score, distanceM: s2.candidate.distanceFromCentreM, via: { source: s2.candidate.source, label: s2.candidate.label }, reasons: s2.reasons, nearestPathM: s2.nearestPathM, nearestParkingM: s2.nearestParkingM, notamIds: s2.notamIds, zoneNames: s2.zoneNames })),
77572
77625
  excluded,
77573
- coverage: coverage === "england_wales" ? "england_wales" : coverage === "unknown" ? "unknown" : scotlandPaths ? "scotland_core_paths" : "no_prow_data",
77626
+ coverage: coverage === "england_wales" ? "england_wales" : coverage === "unknown" ? "unknown" : scotlandPaths ? "scotland_core_paths" : niPaths ? "northern_ireland_asserted" : "no_prow_data",
77574
77627
  drone: droneInfo ? { query: droneArg, label: droneInfo.label, assessment: droneInfo.assessment } : null,
77575
77628
  caveats,
77576
77629
  attribution
@@ -77802,9 +77855,33 @@ var RADAR = {
77802
77855
  var SECTIONS = [
77803
77856
  ["airspace", "Airspace"],
77804
77857
  ["ground", "On the ground"],
77858
+ ["hazards", "Ground hazards"],
77805
77859
  ["weather", "Weather"]
77806
77860
  ];
77807
- var OVERLAYS = [
77861
+ var HAZARD_GROUP_COLOUR = { power: "#e65100", transport: "#5d4037", aviation: "#6a1b9a", sites: "#c6a700" };
77862
+ var HAZARD_KIND_ROWS = [
77863
+ ["power_line", "Power line", "power"],
77864
+ ["minor_power_line", "Minor power line", "power"],
77865
+ ["pylon", "Pylon", "power"],
77866
+ ["substation", "Substation", "power"],
77867
+ ["power_generator", "Power generator", "power"],
77868
+ ["railway", "Railway", "transport"],
77869
+ ["motorway", "Motorway", "transport"],
77870
+ ["trunk_road", "Trunk road", "transport"],
77871
+ ["bridge", "Bridge", "transport"],
77872
+ ["helipad", "Helipad", "aviation"],
77873
+ ["tower", "Mast or tower", "aviation"],
77874
+ ["military", "Military land", "aviation"],
77875
+ ["school", "School", "sites"],
77876
+ ["kindergarten", "Nursery", "sites"],
77877
+ ["hospital", "Hospital", "sites"],
77878
+ ["fire_station", "Fire station", "sites"],
77879
+ ["fuel_station", "Fuel station", "sites"],
77880
+ ["park", "Park", "sites"],
77881
+ ["cemetery", "Cemetery", "sites"]
77882
+ ];
77883
+ var HAZARD_OVERLAYS = HAZARD_KIND_ROWS.map(([kind, label, group]) => ({ key: `hz_${kind}`, label, colour: HAZARD_GROUP_COLOUR[group], section: "hazards", shape: "icon", icon: kind }));
77884
+ var BASE_OVERLAYS = [
77808
77885
  { key: "prohibited", label: "Prohibited / restricted", colour: "#c62828", section: "airspace", shape: "area", locked: true },
77809
77886
  { key: "frz", label: "Aerodrome FRZ", colour: "#ef6c00", section: "airspace", shape: "area", locked: true },
77810
77887
  { key: "prison", label: "Prison (no-fly)", colour: "#6d4c41", section: "airspace", shape: "area", locked: true },
@@ -77816,16 +77893,13 @@ var OVERLAYS = [
77816
77893
  { key: "access", label: "Open access land", colour: "#7cb342", section: "ground", shape: "area" },
77817
77894
  { key: "designation", label: "Nature designations", colour: "#9e9d24", section: "ground", shape: "area" },
77818
77895
  { key: "parking", label: "Parking / layby", colour: "#1a56c4", section: "ground", shape: "parking" },
77819
- { key: "power", label: "Power lines and pylons", colour: "#e65100", section: "ground", shape: "icon", icon: "pylon" },
77820
- { key: "transport", label: "Railways and major roads", colour: "#5d4037", section: "ground", shape: "icon", icon: "railway" },
77821
- { key: "aviation", label: "Helipads and military", colour: "#6a1b9a", section: "ground", shape: "icon", icon: "helipad" },
77822
- { key: "sites", label: "Schools and parks", colour: "#c6a700", section: "ground", shape: "icon", icon: "school" },
77823
77896
  { key: "route", label: "Route and location", colour: "#2a81cb", section: "ground", shape: "pin" },
77824
77897
  { key: "spots", label: "Take-off spots", colour: "#2a81cb", section: "ground", shape: "spot" },
77825
77898
  { key: "conditions", label: "Conditions now", colour: "#4fc3f7", section: "weather", shape: "badge" },
77826
77899
  { key: "wind", label: "Wind flow", colour: "#4fc3f7", section: "weather", shape: "flow" },
77827
77900
  { key: "radar", label: "Rain radar", colour: "#4fc3f7", section: "weather", shape: "radar" }
77828
77901
  ];
77902
+ var OVERLAYS = [...BASE_OVERLAYS, ...HAZARD_OVERLAYS];
77829
77903
  function mapHtml(opts) {
77830
77904
  const apiBase = JSON.stringify(opts.apiBase ?? "");
77831
77905
  const mode = JSON.stringify(opts.mode);
@@ -77864,6 +77938,9 @@ function mapHtml(opts) {
77864
77938
  #search input::-webkit-search-cancel-button { -webkit-appearance: none; }
77865
77939
  #search button { flex: none; width: 28px; height: 28px; border: 0; border-radius: 8px; background: var(--accent); color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; }
77866
77940
  #search button svg { width: 14px; height: 14px; }
77941
+ #search button.locate { background: rgba(128,140,152,.16); color: var(--ink); margin-right: 2px; }
77942
+ #search button.locate svg { width: 15px; height: 15px; }
77943
+ #search button.locate.busy { opacity: .5; }
77867
77944
  #search .results { border-top: 1px solid var(--line); max-height: 240px; overflow: auto; }
77868
77945
  #search .results:empty { display: none; }
77869
77946
  #search .results div { padding: 8px 12px; font-size: 13px; line-height: 1.3; cursor: pointer; }
@@ -77883,6 +77960,15 @@ function mapHtml(opts) {
77883
77960
  #info.closed .pill.mini.on { display: inline-block; }
77884
77961
  #info.closed .chips, #info.closed #weather { display: none; }
77885
77962
  #info .chips { display: flex; flex-wrap: wrap; gap: 5px; padding: 2px 14px 11px; }
77963
+ #info .empty { display: none; padding: 0 14px 12px; }
77964
+ #info.empty .empty { display: block; }
77965
+ #info.empty .chips, #info.empty .drone-row, #info.empty .drone-summary, #info.empty #weather, #info.empty .place .chev { display: none; }
77966
+ #info.empty .place { cursor: default; }
77967
+ #info .empty p { margin: 0 0 10px; font-size: 13px; line-height: 1.4; color: var(--muted); }
77968
+ #info .empty button { font: inherit; font-size: 13.5px; font-weight: 600; color: #fff; background: var(--accent); border: 0; border-radius: 9px; padding: 9px 14px; cursor: pointer; width: 100%; }
77969
+ #info .empty small { display: block; margin-top: 8px; font-size: 12px; color: var(--poor); }
77970
+ #info .empty small:empty { display: none; }
77971
+ #place .retry { font: inherit; font-size: 12px; font-weight: 600; color: #fff; background: var(--accent); border: 0; border-radius: 7px; padding: 4px 9px; margin-left: 8px; cursor: pointer; }
77886
77972
  .chip { font-size: 11.5px; line-height: 1; padding: 5px 8px; border-radius: 999px; background: rgba(128,140,152,.16); color: var(--ink); white-space: nowrap; }
77887
77973
  .chip b { font-weight: 600; }
77888
77974
  .chip.zero { color: var(--muted); }
@@ -77911,25 +77997,41 @@ function mapHtml(opts) {
77911
77997
  .wx-foot { margin-top: 9px; font-size: 11.5px; color: var(--muted); line-height: 1.35; }
77912
77998
  .wx-foot .why { color: var(--caution); }
77913
77999
  .wx-foot .why.poor { color: var(--poor); }
78000
+ .wx-days { display: flex; gap: 4px; margin-top: 10px; overflow-x: auto; scrollbar-width: none; }
78001
+ .wx-days::-webkit-scrollbar { display: none; }
78002
+ .wx-d { flex: none; font: inherit; text-align: left; border: 1.5px solid transparent; border-radius: 8px; padding: 4px 7px; background: rgba(128,140,152,.16); color: var(--ink); cursor: pointer; }
78003
+ .wx-d b { display: block; font-size: 11.5px; font-weight: 600; line-height: 1.2; }
78004
+ .wx-d small { display: block; font-size: 10px; color: var(--muted); }
78005
+ .wx-d.good { border-color: var(--good); } .wx-d.caution { border-color: var(--caution); } .wx-d.poor { border-color: var(--poor); }
78006
+ .wx-d.sel { background: var(--accent); color: #fff; } .wx-d.sel small { color: rgba(255,255,255,.8); }
78007
+ .wx-strip { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 2px; margin-top: 6px; }
78008
+ .wx-h { font: inherit; border: 0; border-radius: 3px; padding: 0; height: 22px; cursor: pointer; opacity: .85; position: relative; }
78009
+ .wx-h.good { background: var(--good); } .wx-h.caution { background: var(--caution); } .wx-h.poor { background: var(--poor); }
78010
+ .wx-h i { position: absolute; left: 0; right: 0; top: 24px; font-size: 9px; font-style: normal; color: var(--muted); display: none; text-align: center; }
78011
+ .wx-h:nth-child(3n+1) i { display: block; }
78012
+ .wx-h.sel { opacity: 1; outline: 2px solid var(--ink); outline-offset: 1px; z-index: 1; }
78013
+ .wx-strip { margin-bottom: 14px; }
77914
78014
 
77915
78015
  /* Sources: collapsed credit line that expands to the full attribution */
77916
- #sources { position: absolute; right: 10px; bottom: 10px; z-index: 1000; max-width: min(380px, calc(100vw - 20px)); font-size: 11.5px; }
77917
- #sources button { all: unset; display: flex; align-items: center; gap: 6px; cursor: pointer; padding: 6px 11px; color: var(--muted); white-space: nowrap; }
77918
- #sources button .chev { width: 11px; height: 11px; transition: transform .15s; }
77919
- #sources.open button .chev { transform: rotate(180deg); }
77920
- #sources ul { display: none; list-style: none; margin: 0; padding: 0 12px 8px; color: var(--ink); }
78016
+ #sources { position: absolute; right: 10px; bottom: 10px; z-index: 1001; width: 44px; height: 44px; font-size: 11.5px; }
78017
+ #sources button { all: unset; display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; cursor: pointer; color: var(--ink); }
78018
+ #sources button svg { width: 24px; height: 24px; }
78019
+ #sources.open button { color: #fff; background: var(--accent); border-radius: 12px; }
78020
+ #sources ul { display: none; position: absolute; right: 0; bottom: 54px; box-sizing: border-box; width: min(380px, calc(100vw - 20px)); list-style: none; margin: 0; padding: 4px 12px 8px; color: var(--ink); background: var(--panel); border-radius: 12px; box-shadow: var(--shadow); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }
77921
78021
  #sources.open ul { display: block; }
77922
78022
  #sources li { padding: 5px 0; border-top: 1px solid var(--line); line-height: 1.35; }
78023
+ #sources li:first-child { border-top: 0; }
78024
+ .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
77923
78025
 
77924
78026
  /* Layers panel */
77925
- .layers { width: 256px; max-height: calc(100vh - 30px); overflow: auto; font-size: 13px; margin: 0 0 10px 10px !important; }
78027
+ .layers { display: none; position: absolute; left: 10px; bottom: 64px; z-index: 1000; width: 256px; max-height: calc(100vh - 90px); overflow: auto; font-size: 13px; }
78028
+ body.layers-open .layers { display: block; }
77926
78029
  .layers h2 { padding: 9px 12px 9px 14px; font-size: 13.5px; display: flex; align-items: center; justify-content: space-between; cursor: pointer; user-select: none; }
77927
78030
  .layers h2 .chev { width: 14px; height: 14px; transition: transform .15s; color: var(--muted); }
77928
- .layers.closed h2 .chev { transform: rotate(-90deg); }
77929
- .layers.closed .body { display: none; }
77930
78031
  .layers .body { padding: 0 8px 10px; }
77931
78032
  .layers h3 { display: flex; align-items: center; gap: 8px; margin: 8px 6px 4px; font-size: 10px; letter-spacing: .09em; text-transform: uppercase; color: var(--muted); }
77932
78033
  .layers h3::after { content: ''; flex: 1; border-top: 1px solid var(--line); }
78034
+ .layers h3 button.mini { order: 2; font: inherit; font-size: 10px; letter-spacing: .06em; text-transform: uppercase; color: var(--accent); background: none; border: 0; padding: 0 2px; cursor: pointer; }
77933
78035
  .layers .seg { display: flex; margin: 2px 6px 4px; padding: 3px; background: rgba(128,140,152,.16); border-radius: 9px; }
77934
78036
  .layers .seg label { flex: 1; cursor: pointer; }
77935
78037
  .layers .seg input { display: none; }
@@ -77984,7 +78086,30 @@ function mapHtml(opts) {
77984
78086
  .leaflet-tooltip-top::before { border-top-color: var(--panel); }
77985
78087
  .wind-canvas { position: absolute; left: 0; top: 0; pointer-events: none; }
77986
78088
  .leaflet-zoom-anim .wind-canvas { visibility: hidden; }
77987
- @media (max-width: 720px) { #search { width: calc(100vw - 66px); } #info { top: 58px; } }
78089
+ .leaflet-popup-content hr.sep { border: 0; border-top: 1px solid var(--line); margin: 7px 0; }
78090
+ #bottom { display: contents; }
78091
+ #layers-fab { position: absolute; left: 10px; bottom: 10px; z-index: 1001; display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; border: 0; padding: 0; color: var(--ink); cursor: pointer; }
78092
+ #layers-fab svg { width: 24px; height: 24px; }
78093
+ body.layers-open #layers-fab { background: var(--accent); color: #fff; }
78094
+
78095
+ /* Phones: search on top, a bottom stack of credit pill + layers button, the layers sheet and the location sheet.
78096
+ Pinch replaces the zoom buttons; the location sheet starts collapsed so the map stays visible. */
78097
+ @media (max-width: 720px) {
78098
+ .leaflet-control-zoom { display: none; }
78099
+ #search { left: 10px; right: 10px; width: auto; max-width: none; }
78100
+ #search .results { max-height: 40vh; }
78101
+ #search input { font-size: 16px; }
78102
+ #bottom { display: flex; flex-direction: column; gap: 8px; position: absolute; left: 10px; right: 10px; bottom: 10px; z-index: 1000; max-height: calc(100vh - 80px); }
78103
+ #bottom .fabrow { display: flex; align-items: flex-end; justify-content: space-between; gap: 8px; }
78104
+ #sources { position: relative; right: auto; bottom: auto; flex: none; }
78105
+ #sources ul { right: 0; left: auto; width: calc(100vw - 20px); }
78106
+ #layers-fab { position: static; flex: none; order: -1; }
78107
+ #info, #info.closed { position: static; width: auto; max-width: none; overflow: auto; min-height: 0; }
78108
+ #info.closed .place { padding-bottom: 10px; }
78109
+ .drone-summary { font-size: 12.5px; }
78110
+ .layers { position: static; width: auto; max-height: none; min-height: 0; }
78111
+ body.layers-open #info { display: none; }
78112
+ }
77988
78113
  </style>
77989
78114
  </head>
77990
78115
  <body>
@@ -77993,20 +78118,31 @@ function mapHtml(opts) {
77993
78118
  <div class="bar">
77994
78119
  <svg class="ico" viewBox="0 0 16 16"><circle cx="6.5" cy="6.5" r="4.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M10 10l4 4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
77995
78120
  <input id="search-input" type="search" placeholder="Search a place, postcode or lat, lon" aria-label="Search for a place" enterkeyhint="search">
78121
+ <button type="button" class="locate" id="locate-btn" aria-label="Use my location" title="Use my location"><svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="4" fill="none" stroke="currentColor" stroke-width="1.8"/><circle cx="8" cy="8" r="1.3" fill="currentColor"/><path d="M8 1v3M8 12v3M1 8h3M12 8h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg></button>
77996
78122
  <button type="submit" aria-label="Search"><svg viewBox="0 0 16 16"><path d="M3 8h9M8 3l5 5-5 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
77997
78123
  </div>
77998
78124
  <div class="results" id="search-results"></div>
77999
78125
  </form>
78126
+ <div id="bottom">
78127
+ <div class="fabrow">
78128
+ <div id="sources" class="card">
78129
+ <button type="button" id="sources-btn" aria-label="Map credits and data sources"><svg viewBox="0 0 20 20"><circle cx="10" cy="10" r="8" fill="none" stroke="currentColor" stroke-width="1.7"/><circle cx="10" cy="6.4" r="1.1" fill="currentColor"/><path d="M8.6 9h1.9v5.2M8.6 14.2h3" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg><span id="sources-label" class="sr-only">Sources</span></button>
78130
+ <ul id="sources-list"></ul>
78131
+ </div>
78132
+ <button type="button" id="layers-fab" class="card" aria-label="Layers"><svg viewBox="0 0 20 20"><path d="M10 3l7 4-7 4-7-4z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="M3 11l7 4 7-4M3 14.5l7 4 7-4" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round" opacity=".65"/></svg></button>
78133
+ </div>
78000
78134
  <div id="info" class="card">
78001
78135
  <div class="place" id="info-head"><span id="place">Loading\u2026</span><span class="pill mini" id="info-pill"></span><svg class="chev" viewBox="0 0 16 16"><path d="M3 6l5 5 5-5" fill="none" stroke="currentColor" stroke-width="2"/></svg></div>
78136
+ <div class="empty" id="empty">
78137
+ <p>Search for a place, a postcode or coordinates above, tap anywhere on the map, or start from where you are.</p>
78138
+ <button type="button" id="locate-btn-2">Use my location</button>
78139
+ <small id="empty-note"></small>
78140
+ </div>
78002
78141
  <div class="chips" id="chips"></div>
78003
78142
  <div class="drone-row" id="drone-row"><span class="lbl">Your drone</span><select id="drone-select" aria-label="Your drone"><option value="">None</option></select></div>
78004
78143
  <div class="drone-summary" id="drone-summary"></div>
78005
78144
  <div id="weather"></div>
78006
78145
  </div>
78007
- <div id="sources" class="card">
78008
- <button type="button" id="sources-btn"><span id="sources-label">Sources</span><svg class="chev" viewBox="0 0 16 16"><path d="M3 10l5-5 5 5" fill="none" stroke="currentColor" stroke-width="2"/></svg></button>
78009
- <ul id="sources-list"></ul>
78010
78146
  </div>
78011
78147
  <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
78012
78148
  <script>
@@ -78021,10 +78157,11 @@ function mapHtml(opts) {
78021
78157
  var sourcesEl = document.getElementById('sources'), sourcesLabel = document.getElementById('sources-label'), sourcesList = document.getElementById('sources-list');
78022
78158
  var loaded = false, dataSources = [];
78023
78159
  function setStatus(text) { placeEl.textContent = text; }
78024
- document.getElementById('sources-btn').onclick = function () { sourcesEl.classList.toggle('open'); };
78160
+ document.getElementById('sources-btn').onclick = function () { var open = !sourcesEl.classList.contains('open'); sourcesEl.classList.toggle('open', open); if (open) setLayersSheet(false); };
78025
78161
  var infoEl = document.getElementById('info'), infoPill = document.getElementById('info-pill');
78026
- if (!recall('infoOpen', true)) infoEl.classList.add('closed');
78027
- document.getElementById('info-head').onclick = function () { infoEl.classList.toggle('closed'); remember('infoOpen', !infoEl.classList.contains('closed')); };
78162
+ var SMALL = window.innerWidth <= 720;
78163
+ if (!recall('infoOpen', !SMALL)) infoEl.classList.add('closed');
78164
+ document.getElementById('info-head').onclick = function () { if (infoEl.classList.contains('empty')) return; infoEl.classList.toggle('closed'); if (!SMALL) remember('infoOpen', !infoEl.classList.contains('closed')); };
78028
78165
  L.DomEvent.disableClickPropagation(document.getElementById('info'));
78029
78166
  L.DomEvent.disableClickPropagation(sourcesEl);
78030
78167
  L.DomEvent.disableScrollPropagation(document.getElementById('info'));
@@ -78048,6 +78185,7 @@ function mapHtml(opts) {
78048
78185
  function updateSources() {
78049
78186
  var sat = map.hasLayer(satellite);
78050
78187
  sourcesLabel.textContent = sat ? '\xA9 Esri' : '\xA9 OpenStreetMap';
78188
+ document.getElementById('sources-btn').title = (sat ? 'Imagery \xA9 Esri' : 'Map \xA9 OpenStreetMap contributors') + '; tap for all sources';
78051
78189
  var items = [sat ? BASEMAPS.satellite.attribution : 'Map tiles ' + BASEMAPS.map.attribution].concat(dataSources);
78052
78190
  if (map.hasLayer(groups.radar)) items.push(RADAR.attribution);
78053
78191
  items.push('Built with Leaflet');
@@ -78072,19 +78210,20 @@ function mapHtml(opts) {
78072
78210
  // Layers panel: base map switch plus a checkbox per overlay, grouped by section.
78073
78211
  // Ground hazards: a white tile with a glyph per kind, in the colour of its group (like a road sign).
78074
78212
  var HAZARD_GROUP = { power_line: 'power', minor_power_line: 'power', pylon: 'power', substation: 'power', power_generator: 'power',
78075
- railway: 'transport', motorway: 'transport', trunk_road: 'transport', helipad: 'aviation', tower: 'aviation', military: 'aviation',
78213
+ railway: 'transport', motorway: 'transport', trunk_road: 'transport', bridge: 'transport', helipad: 'aviation', tower: 'aviation', military: 'aviation',
78076
78214
  school: 'sites', kindergarten: 'sites', hospital: 'sites', fire_station: 'sites', fuel_station: 'sites', park: 'sites', cemetery: 'sites' };
78077
- var HAZARD_LABEL = { railway: 'Railway', motorway: 'Motorway', trunk_road: 'Trunk road', power_line: 'Power line', minor_power_line: 'Minor power line', pylon: 'Pylon', substation: 'Substation', power_generator: 'Power generator',
78215
+ var HAZARD_LABEL = { railway: 'Railway', motorway: 'Motorway', trunk_road: 'Trunk road', bridge: 'Bridge', power_line: 'Power line', minor_power_line: 'Minor power line', pylon: 'Pylon', substation: 'Substation', power_generator: 'Power generator',
78078
78216
  helipad: 'Helipad', tower: 'Mast or tower', military: 'Military land', school: 'School', kindergarten: 'Nursery', hospital: 'Hospital', fire_station: 'Fire station', fuel_station: 'Fuel station', park: 'Park', cemetery: 'Cemetery' };
78079
78217
  var GLYPH = {
78080
78218
  pylon: ['M8 21L12 3l4 18M9.3 15h5.4M10.4 9.5h3.2M5 7.5h14', 0],
78081
78219
  substation: ['M13 2L5 13h6l-1 9 9-12h-6z', 1],
78082
78220
  power_generator: ['M12 21V10m0 0V3m0 7l6 3.5M12 10l-6 3.5', 0],
78083
- power_line: ['M4 6h16M4 18h16M12 6v12', 0],
78221
+ power_line: ['M8 21L12 3l4 18M9.3 15h5.4M10.4 9.5h3.2M5 7.5h14', 0],
78084
78222
  minor_power_line: ['M4 12h16', 0],
78085
78223
  railway: ['M7 4h10v12H7zM8 8h8M9 16l-2 4m8-4l2 4M10 12.5h.5M13.5 12.5h.5', 0],
78086
78224
  motorway: ['M8 3l-4 18M16 3l4 18M12 3v4m0 4v4m0 4v2', 0],
78087
78225
  trunk_road: ['M8 3l-4 18M16 3l4 18M12 3v4m0 4v4m0 4v2', 0],
78226
+ bridge: ['M2 18h20M4 18v-5a8 8 0 0 1 16 0v5M9 18v-4a3 3 0 0 1 6 0v4', 0],
78088
78227
  helipad: ['M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0-18 0M8.5 7v10m7-10v10m-7-5h7', 0],
78089
78228
  tower: ['M12 21V4m-5 3a7 7 0 0 1 10 0M9 10a4 4 0 0 1 6 0', 0],
78090
78229
  military: ['M12 2l8 3v6c0 5-4 9-8 11-4-2-8-6-8-11V5z', 0],
@@ -78103,23 +78242,59 @@ function mapHtml(opts) {
78103
78242
  }
78104
78243
  var hazardIcons = {};
78105
78244
  function hazardIcon(kind) {
78106
- if (!hazardIcons[kind]) hazardIcons[kind] = L.divIcon({ className: 'hz', iconSize: [22, 22], iconAnchor: [11, 11], popupAnchor: [0, -12], html: hazardSvg(kind, COLOUR[HAZARD_GROUP[kind] || 'sites']) });
78245
+ if (!hazardIcons[kind]) hazardIcons[kind] = L.divIcon({ className: 'hz', iconSize: [22, 22], iconAnchor: [11, 11], popupAnchor: [0, -12], html: hazardSvg(kind, COLOUR['hz_' + kind] || COLOUR.hz_school) });
78107
78246
  return hazardIcons[kind];
78108
78247
  }
78248
+ // Icons along line hazards (railways, roads, power lines): one every LINE_ICON_GAP screen pixels of
78249
+ // visible line, re-laid on every move so they thin out zoomed out and fill in zoomed in.
78250
+ var LINE_ICON_GAP = 180, LINE_ICON_MAX = 900, lineHazards = [], lineIconMarkers = [];
78251
+ // Lines and areas draw on one canvas: far cheaper than SVG when a wide view carries a thousand features.
78252
+ var vectorRenderer = L.canvas({ padding: 0.4 });
78253
+ function placeLineIcons() {
78254
+ lineIconMarkers.forEach(function (m) { m.group.removeLayer(m.marker); });
78255
+ lineIconMarkers = [];
78256
+ if (!lineHazards.length) return;
78257
+ var bounds = map.getBounds().pad(0.1), placed = 0;
78258
+ for (var li = 0; li < lineHazards.length && placed < LINE_ICON_MAX; li++) {
78259
+ var line = lineHazards[li];
78260
+ if (line.kind === 'minor_power_line') continue; // too dense, and the pylons mark them
78261
+ var pts = line.coords.map(function (c) { return map.latLngToLayerPoint([c[1], c[0]]); });
78262
+ var total = 0, i;
78263
+ for (i = 1; i < pts.length; i++) total += pts[i].distanceTo(pts[i - 1]);
78264
+ if (total < 60) continue;
78265
+ var next = Math.min(total / 2, LINE_ICON_GAP / 2), walked = 0;
78266
+ for (i = 1; i < pts.length && placed < LINE_ICON_MAX; i++) {
78267
+ var seg = pts[i].distanceTo(pts[i - 1]);
78268
+ while (next <= walked + seg && seg > 0) {
78269
+ var t = (next - walked) / seg;
78270
+ var pt = L.point(pts[i - 1].x + (pts[i].x - pts[i - 1].x) * t, pts[i - 1].y + (pts[i].y - pts[i - 1].y) * t);
78271
+ var ll = map.layerPointToLatLng(pt);
78272
+ if (bounds.contains(ll)) {
78273
+ var marker = L.marker(ll, { icon: hazardIcon(line.kind), interactive: false, keyboard: false }).addTo(groups[line.key]);
78274
+ lineIconMarkers.push({ marker: marker, group: groups[line.key] });
78275
+ placed++;
78276
+ }
78277
+ next += LINE_ICON_GAP;
78278
+ }
78279
+ walked += seg;
78280
+ }
78281
+ }
78282
+ }
78283
+ var lineIconTimer = null;
78284
+ map.on('moveend zoomend', function () { clearTimeout(lineIconTimer); lineIconTimer = setTimeout(placeLineIcons, 120); });
78109
78285
  function hazardLineStyle(kind, colour) {
78110
78286
  if (kind === 'minor_power_line') return { color: colour, weight: 1.5, opacity: 0.8, dashArray: '3 5' };
78111
- if (kind === 'power_line') return { color: colour, weight: 2, opacity: 0.9, dashArray: '6 4' };
78112
- if (kind === 'railway') return { color: colour, weight: 3, opacity: 0.85, dashArray: '8 5' };
78113
- return { color: colour, weight: 3, opacity: 0.7 };
78114
- }
78115
- var LayersPanel = L.Control.extend({
78116
- onAdd: function () {
78117
- var el = L.DomUtil.create('div', 'layers card' + (recall('layersOpen', window.innerWidth > 600) ? '' : ' closed'));
78118
- L.DomEvent.disableClickPropagation(el);
78119
- L.DomEvent.disableScrollPropagation(el);
78287
+ if (kind === 'power_line') return { color: colour, weight: 2.5, opacity: 0.95, dashArray: '7 5' };
78288
+ if (kind === 'railway') return { color: colour, weight: 3.5, opacity: 0.9, dashArray: '9 6' };
78289
+ return { color: colour, weight: 4, opacity: 0.75 };
78290
+ }
78291
+ // Layers panel: one mechanism everywhere. The round button toggles it; on a desktop it pops up
78292
+ // above the button, on a phone it is a sheet in the bottom stack. The header closes it.
78293
+ function buildLayersPanel() {
78294
+ var el = L.DomUtil.create('div', 'layers card');
78120
78295
  var h = L.DomUtil.create('h2', '', el);
78121
- h.innerHTML = 'Layers <svg class="chev" viewBox="0 0 16 16"><path d="M3 6l5 5 5-5" fill="none" stroke="currentColor" stroke-width="2"/></svg>';
78122
- h.onclick = function () { el.classList.toggle('closed'); remember('layersOpen', !el.classList.contains('closed')); };
78296
+ h.innerHTML = 'Layers <svg class="chev" viewBox="0 0 16 16"><path d="M4 4l8 8M12 4l-8 8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
78297
+ h.onclick = function () { setLayersSheet(false); };
78123
78298
  var body = L.DomUtil.create('div', 'body', el);
78124
78299
  var b = L.DomUtil.create('h3', '', body); b.textContent = 'Base map';
78125
78300
  var seg = L.DomUtil.create('div', 'seg', body);
@@ -78137,6 +78312,19 @@ function mapHtml(opts) {
78137
78312
  });
78138
78313
  SECTIONS.forEach(function (sec) {
78139
78314
  var hd = L.DomUtil.create('h3', '', body); hd.textContent = sec[1];
78315
+ if (sec[0] === 'hazards') {
78316
+ [['All', true], ['None', false]].forEach(function (opt) {
78317
+ var b = L.DomUtil.create('button', 'mini', hd); b.type = 'button'; b.textContent = opt[0];
78318
+ b.onclick = function (ev) {
78319
+ ev.preventDefault();
78320
+ OVERLAYS.filter(function (o) { return o.section === 'hazards'; }).forEach(function (o) {
78321
+ setOverlay(o.key, opt[1]);
78322
+ var row = body.querySelector('.row[data-key="' + o.key + '"]');
78323
+ if (row) { row.classList.toggle('off', !opt[1]); var cb = row.querySelector('input'); if (cb) cb.checked = opt[1]; }
78324
+ });
78325
+ };
78326
+ });
78327
+ }
78140
78328
  OVERLAYS.filter(function (o) { return o.section === sec[0]; }).forEach(function (o) {
78141
78329
  var row = L.DomUtil.create('label', 'row' + (map.hasLayer(groups[o.key]) ? '' : ' off'), body);
78142
78330
  row.dataset.key = o.key;
@@ -78158,9 +78346,19 @@ function mapHtml(opts) {
78158
78346
  });
78159
78347
  });
78160
78348
  return el;
78161
- }
78162
- });
78163
- new LayersPanel({ position: 'bottomleft' }).addTo(map);
78349
+ }
78350
+ function setLayersSheet(open) {
78351
+ document.body.classList.toggle('layers-open', open);
78352
+ if (open) sourcesEl.classList.remove('open');
78353
+ if (open && SMALL) infoEl.classList.add('closed');
78354
+ if (!SMALL) remember('layersOpen', open);
78355
+ }
78356
+ var layersEl = buildLayersPanel();
78357
+ document.getElementById('bottom').insertBefore(layersEl, infoEl);
78358
+ L.DomEvent.disableClickPropagation(document.getElementById('bottom'));
78359
+ L.DomEvent.disableScrollPropagation(document.getElementById('bottom'));
78360
+ document.getElementById('layers-fab').onclick = function () { setLayersSheet(!document.body.classList.contains('layers-open')); };
78361
+ if (!SMALL && recall('layersOpen', false)) setLayersSheet(true);
78164
78362
  updateSources();
78165
78363
  function setCounts(counts) {
78166
78364
  Array.prototype.forEach.call(document.querySelectorAll('.layers .row[data-key]'), function (row) {
@@ -78170,7 +78368,7 @@ function mapHtml(opts) {
78170
78368
  }
78171
78369
 
78172
78370
  // Weather: rain radar tiles from RainViewer's latest frame, plus the conditions badge.
78173
- var radarLayer = null, radarTime = null, currentWeather = null, currentCentre = null, pendingSpots = [];
78371
+ var radarLayer = null, radarTime = null, currentWeather = null, currentCentre = null, currentName = null, currentRoute = null, loadedBounds = null, pendingSpots = [], selectedHour = null;
78174
78372
  function spotIcon(rank) { return L.divIcon({ className: 'spot', iconSize: [26, 26], iconAnchor: [13, 13], popupAnchor: [0, -14], html: '<div>' + rank + '</div>' }); }
78175
78373
  var WIND_COLOUR = { good: '#2e7d32', caution: '#ef6c00', poor: '#c62828' };
78176
78374
 
@@ -78219,12 +78417,15 @@ function mapHtml(opts) {
78219
78417
  this._ctx = this._canvas.getContext('2d');
78220
78418
  this._particles = [];
78221
78419
  this._reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
78222
- map.on('move', this._reset, this);
78420
+ // Pause while the map is dragged or zoomed (a reset every frame looks like static) and start afresh when it settles.
78421
+ map.on('movestart zoomstart', this._pause, this);
78422
+ map.on('moveend zoomend', this._reset, this);
78223
78423
  map.on('resize', this._resize, this);
78224
78424
  this._resize();
78225
78425
  },
78226
78426
  onRemove: function (map) {
78227
- map.off('move', this._reset, this);
78427
+ map.off('movestart zoomstart', this._pause, this);
78428
+ map.off('moveend zoomend', this._reset, this);
78228
78429
  map.off('resize', this._resize, this);
78229
78430
  this._stop();
78230
78431
  L.DomUtil.remove(this._canvas);
@@ -78235,6 +78436,10 @@ function mapHtml(opts) {
78235
78436
  this._canvas.width = size.x; this._canvas.height = size.y;
78236
78437
  this._reset();
78237
78438
  },
78439
+ _pause: function () {
78440
+ this._stop();
78441
+ this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
78442
+ },
78238
78443
  _reset: function () {
78239
78444
  // Keep the canvas glued to the viewport and start the flow afresh for the new view.
78240
78445
  L.DomUtil.setPosition(this._canvas, this._map.containerPointToLayerPoint([0, 0]));
@@ -78336,6 +78541,40 @@ function mapHtml(opts) {
78336
78541
  showWeather();
78337
78542
  }).catch(function () { /* radar is optional */ });
78338
78543
  }
78544
+ var DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
78545
+ function dayLabel(date, i) {
78546
+ if (i === 0) return 'Today';
78547
+ if (i === 1) return 'Tomorrow';
78548
+ var d = new Date(date + 'T12:00:00');
78549
+ return DAY_NAMES[d.getDay()] + ' ' + d.getDate();
78550
+ }
78551
+ // Hour cells for the coming days, coloured by rating, and a pill per day with its count of good daylight hours.
78552
+ function weatherTimelineHtml(w) {
78553
+ if (!w || !w.hours || !w.hours.length) return '';
78554
+ var sel = selectedHour === null ? 0 : selectedHour;
78555
+ var selDate = w.hours[sel] ? w.hours[sel].time.slice(0, 10) : w.hours[0].time.slice(0, 10);
78556
+ var days = (w.days || []).map(function (d, i) {
78557
+ return '<button type="button" class="wx-d ' + esc(d.flyability) + (d.date === selDate ? ' sel' : '') + '" data-date="' + esc(d.date) + '"><b>' + esc(dayLabel(d.date, i)) + '</b><small>' + d.good + 'h good</small></button>';
78558
+ }).join('');
78559
+ var cells = '';
78560
+ w.hours.forEach(function (h, i) {
78561
+ if (h.time.slice(0, 10) !== selDate) return;
78562
+ var hh = h.time.slice(11, 13);
78563
+ cells += '<button type="button" class="wx-h ' + esc(h.flyability) + (i === sel ? ' sel' : '') + '" data-i="' + i + '" title="' + esc(hh + ':00 ' + h.flyability + (h.reasons.length ? ', ' + h.reasons.join(', ') : '')) + '"><i>' + esc(hh) + '</i></button>';
78564
+ });
78565
+ return '<div class="wx-days">' + days + '</div><div class="wx-strip">' + cells + '</div>';
78566
+ }
78567
+ weatherBox.addEventListener('click', function (e) {
78568
+ var h = e.target.closest('.wx-h'), d = e.target.closest('.wx-d');
78569
+ if (h) { selectedHour = Number(h.dataset.i); showWeather(); }
78570
+ else if (d && currentWeather && currentWeather.hours) {
78571
+ // Jump to the first good daylight hour of that day, or its first hour.
78572
+ var idx = -1;
78573
+ currentWeather.hours.forEach(function (x, i) { if (idx < 0 && x.time.slice(0, 10) === d.dataset.date && x.flyability === 'good' && Number(x.time.slice(11, 13)) >= 6) idx = i; });
78574
+ if (idx < 0) currentWeather.hours.forEach(function (x, i) { if (idx < 0 && x.time.slice(0, 10) === d.dataset.date) idx = i; });
78575
+ if (idx >= 0) { selectedHour = idx; showWeather(); }
78576
+ }
78577
+ });
78339
78578
  function showWeather() {
78340
78579
  if (map.hasLayer(groups.radar)) ensureRadar();
78341
78580
  showWind();
@@ -78345,6 +78584,9 @@ function mapHtml(opts) {
78345
78584
  if (!map.hasLayer(groups.conditions)) { weatherBox.className = ''; return; }
78346
78585
  var w = currentWeather, html = '';
78347
78586
  function num(v, unit) { return v == null ? '<b>\u2013</b>' : '<b>' + Math.round(v) + '<i>' + unit + '</i></b>'; }
78587
+ // A picked hour from the timeline replaces the "now" figures.
78588
+ var picked = w && w.hours && selectedHour !== null ? w.hours[selectedHour] : null;
78589
+ if (picked) w = Object.assign({}, w, picked);
78348
78590
  if (w) {
78349
78591
  var dir = w.windDirectionDeg == null ? '' : '<svg class="dir" viewBox="0 0 10 10" style="transform:rotate(' + ((w.windDirectionDeg + 180) % 360).toFixed(0) + 'deg)"><path d="M5 0.5 L9 6 L5 4.5 L1 6 Z" fill="currentColor"/></svg>';
78350
78592
  html = '<div class="wx-head"><span class="pill ' + esc(w.flyability) + '">' + esc(w.flyability) + '</span><span class="wx-when">' + esc(w.time.slice(11, 16)) + ' \xB7 ' + esc(w.summary) + '</span></div>' +
@@ -78358,6 +78600,7 @@ function mapHtml(opts) {
78358
78600
  if (w.reasons.length) foot.push('<span class="why ' + esc(w.flyability) + '">' + esc(w.reasons.join(', ')) + '</span>');
78359
78601
  if (map.hasLayer(groups.radar)) foot.push(map.getZoom() > RADAR.maxZoom ? 'Radar hidden at this zoom' : radarTime ? 'Radar ' + radarTime.toISOString().slice(11, 16) + ' UTC' : 'Radar loading\u2026');
78360
78602
  if (foot.length) html += '<div class="wx-foot">' + foot.join(' \xB7 ') + '</div>';
78603
+ html += weatherTimelineHtml(currentWeather);
78361
78604
  } else {
78362
78605
  html = '<div class="wx-foot">' + (loaded ? 'No forecast for this point' : 'Loading conditions\u2026') + (map.hasLayer(groups.radar) && radarTime ? ' \xB7 Radar ' + radarTime.toISOString().slice(11, 16) + ' UTC' : '') + '</div>';
78363
78606
  }
@@ -78432,40 +78675,59 @@ function mapHtml(opts) {
78432
78675
  // If the pane is resized just after the first render (embedded hosts lay out late), fit the view again.
78433
78676
  var viewBounds = null, viewFittedAt = 0;
78434
78677
  window.addEventListener('resize', function () { if (viewBounds && Date.now() - viewFittedAt < 3000) { map.invalidateSize(); map.fitBounds(viewBounds, { padding: [20, 20] }); } });
78435
- function render(view) {
78678
+ function render(view, opts) {
78679
+ opts = opts || {};
78680
+ infoEl.classList.remove('empty');
78681
+ hitTargets = [];
78682
+ lineHazards = [];
78683
+ if (!opts.silent && SMALL) setLayersSheet(false);
78436
78684
  OVERLAYS.forEach(function (o) { if (o.section !== 'weather') groups[o.key].clearLayers(); });
78437
- var counts = { prohibited: 0, frz: 0, prison: 0, danger: 0, other: 0, notam: view.notams.length, prow: view.rightsOfWay.length, land: 0, access: 0, designation: 0, parking: view.parking.length, power: 0, transport: 0, aviation: 0, sites: 0, spots: 0 };
78685
+ var counts = { prohibited: 0, frz: 0, prison: 0, danger: 0, other: 0, notam: view.notams.length, prow: view.rightsOfWay.length, land: 0, access: 0, designation: 0, parking: view.parking.length, spots: 0 };
78686
+ OVERLAYS.forEach(function (o) { if (o.section === 'hazards') counts[o.key] = 0; });
78438
78687
  var b = view.bbox;
78439
- viewBounds = [[b[1], b[0]], [b[3], b[2]]]; viewFittedAt = Date.now();
78440
- map.fitBounds(viewBounds, { padding: [20, 20] });
78688
+ loadedBounds = L.latLngBounds([b[1], b[0]], [b[3], b[2]]);
78689
+ if (!opts.silent) { viewBounds = [[b[1], b[0]], [b[3], b[2]]]; viewFittedAt = Date.now(); map.fitBounds(viewBounds, { padding: [20, 20] }); }
78441
78690
  var seenLand = {};
78442
78691
  view.landRestrictions.forEach(function (f) {
78443
78692
  var key = f.properties.kind === 'access_land' ? 'access' : f.properties.kind === 'designation' ? 'designation' : 'land';
78444
78693
  var entryKey = key + ':' + (f.properties.entryId || f.properties.name);
78445
78694
  if (!seenLand[entryKey]) { seenLand[entryKey] = 1; counts[key]++; }
78446
- L.geoJSON(f, { style: { color: COLOUR[key], weight: 1, fillOpacity: key === 'land' ? 0.18 : 0.1 } }).bindPopup('<b>' + esc(f.properties.name) + '</b><br>' + esc(f.properties.owner) + (f.properties.takeoffBanned ? '<br>Take-off not permitted' : key === 'access' ? '<br>Open access land: not a take-off permission' : key === 'designation' ? '<br>Advisory designation' : '')).addTo(groups[key]);
78695
+ L.geoJSON(f, { style: { color: COLOUR[key], weight: 1, fillOpacity: key === 'land' ? 0.18 : 0.1 } }).addTo(groups[key]);
78696
+ hitTargets.push({ key: key, geometry: f.geometry, html: '<b>' + esc(f.properties.name) + '</b><br>' + esc(f.properties.owner) + (f.properties.takeoffBanned ? '<br>Take-off not permitted' : key === 'access' ? '<br>Open access land: not a take-off permission' : key === 'designation' ? '<br>Advisory designation' : '') });
78447
78697
  });
78698
+ // Over a wide view the dense point kinds would bury the map; they are counted in the key but drawn only closer in.
78699
+ var wide = map.distance([b[1], b[0]], [b[1], b[2]]) > 6000;
78700
+ var DENSE = { substation: 1, pylon: 1, minor_power_line: 1, school: 1, kindergarten: 1, cemetery: 1, park: 1, fuel_station: 1, fire_station: 1, bridge: 1, tower: 1, power_generator: 1 };
78448
78701
  (view.hazards || []).forEach(function (f) {
78449
- var kind = f.properties.kind, key = HAZARD_GROUP[kind] || 'sites', colour = COLOUR[key];
78702
+ var kind = f.properties.kind, key = 'hz_' + kind, colour = COLOUR[key] || COLOUR.hz_school;
78703
+ if (!groups[key]) return; // a kind this page does not know yet
78450
78704
  counts[key]++;
78705
+ if (wide && DENSE[kind]) return;
78451
78706
  var label = '<b>' + esc(HAZARD_LABEL[kind] || kind) + '</b>' + (f.properties.name ? '<br>' + esc(f.properties.name) : '') + (f.properties.operator ? '<br>' + esc(f.properties.operator) : '');
78452
78707
  if (f.geometry.type === 'Point') L.marker([f.geometry.coordinates[1], f.geometry.coordinates[0]], { icon: hazardIcon(kind) }).bindPopup(label).addTo(groups[key]);
78453
- else if (f.geometry.type === 'LineString' || f.geometry.type === 'MultiLineString') L.geoJSON(f, { style: hazardLineStyle(kind, colour) }).bindPopup(label).addTo(groups[key]);
78708
+ else if (f.geometry.type === 'LineString' || f.geometry.type === 'MultiLineString') {
78709
+ L.geoJSON(f, { renderer: vectorRenderer, style: hazardLineStyle(kind, colour) }).addTo(groups[key]);
78710
+ hitTargets.push({ key: key, geometry: f.geometry, html: label });
78711
+ (f.geometry.type === 'LineString' ? [f.geometry.coordinates] : f.geometry.coordinates).forEach(function (c) { lineHazards.push({ kind: kind, key: key, coords: c }); });
78712
+ }
78454
78713
  else {
78455
- var poly = L.geoJSON(f, { style: { color: colour, weight: 1.5, opacity: 0.9, fillOpacity: 0.12 } }).bindPopup(label).addTo(groups[key]);
78714
+ var poly = L.geoJSON(f, { renderer: vectorRenderer, style: { color: colour, weight: 1.5, opacity: 0.9, fillOpacity: 0.12 } }).addTo(groups[key]);
78715
+ hitTargets.push({ key: key, geometry: f.geometry, html: label });
78456
78716
  L.marker(poly.getBounds().getCenter(), { icon: hazardIcon(kind), interactive: false }).addTo(groups[key]);
78457
78717
  }
78458
78718
  });
78459
78719
  view.zones.forEach(function (f) {
78460
78720
  var key = zoneGroup(f.properties);
78461
- L.geoJSON(f, { style: { color: COLOUR[key], weight: 2, fillOpacity: f.properties.relevant ? 0.22 : 0.06, dashArray: f.properties.relevant ? null : '6 6' } })
78462
- .bindPopup('<b>' + esc(f.properties.designator || '') + ' ' + esc(f.properties.name) + '</b><br>' + esc(f.properties.label) + '<br>' + esc(f.properties.limits) + (f.properties.relevant ? '' : '<br><i>Above 400 ft only</i>')).addTo(groups[key]);
78721
+ L.geoJSON(f, { style: { color: COLOUR[key], weight: 2, fillOpacity: f.properties.relevant ? 0.22 : 0.06, dashArray: f.properties.relevant ? null : '6 6' } }).addTo(groups[key]);
78722
+ hitTargets.push({ key: key, geometry: f.geometry, html: '<b>' + esc(f.properties.designator || '') + ' ' + esc(f.properties.name) + '</b><br>' + esc(f.properties.label) + '<br>' + esc(f.properties.limits) + (f.properties.relevant ? '' : '<br><i>Above 400 ft only</i>') });
78463
78723
  });
78464
78724
  view.notams.forEach(function (n) {
78465
- L.circle([n.lat, n.lon], { radius: n.radiusKm * 1000, color: COLOUR.notam, weight: 1.5, fillOpacity: 0.12 }).bindPopup('<b>NOTAM ' + esc(n.id) + '</b><br>' + esc(n.itemE)).addTo(groups.notam);
78725
+ L.circle([n.lat, n.lon], { radius: n.radiusKm * 1000, color: COLOUR.notam, weight: 1.5, fillOpacity: 0.12 }).addTo(groups.notam);
78726
+ hitTargets.push({ key: 'notam', circle: { lat: n.lat, lon: n.lon, radiusM: n.radiusKm * 1000 }, html: '<b>NOTAM ' + esc(n.id) + '</b><br>' + esc(n.itemE) });
78466
78727
  });
78467
78728
  view.rightsOfWay.forEach(function (f) {
78468
- L.geoJSON(f, { style: { color: COLOUR.prow, weight: 3, opacity: 0.9 } }).bindPopup('<b>Public ' + esc(f.properties.pathType.replace('_', ' ')) + (f.properties.routeNo ? ' ' + esc(f.properties.routeNo) : '') + '</b><br>' + esc(f.properties.authority) + '<br>' + f.properties.distanceM + ' m from the point').addTo(groups.prow);
78729
+ L.geoJSON(f, { renderer: vectorRenderer, style: { color: COLOUR.prow, weight: 3, opacity: 0.9 } }).addTo(groups.prow);
78730
+ hitTargets.push({ key: 'prow', geometry: f.geometry, html: '<b>Public ' + esc(f.properties.pathType.replace('_', ' ')) + (f.properties.routeNo ? ' ' + esc(f.properties.routeNo) : '') + '</b><br>' + esc(f.properties.authority) + '<br>' + f.properties.distanceM + ' m from the point' });
78469
78731
  });
78470
78732
  view.parking.forEach(function (p) {
78471
78733
  L.marker([p.lat, p.lon], { icon: parkingIcon, keyboard: false }).bindPopup('<b>' + esc(p.name || (p.kind === 'layby' ? 'Layby' : 'Car park')) + '</b><br>' + p.distanceM + ' m away' + (p.fee === 'yes' ? '<br>Pay to park' : p.fee === 'no' ? '<br>Free' : '')).addTo(groups.parking);
@@ -78473,19 +78735,29 @@ function mapHtml(opts) {
78473
78735
  if (view.route && view.route.length > 1) {
78474
78736
  L.polyline(view.route.map(function (p) { return [p[1], p[0]]; }), { color: COLOUR.route, weight: 3, dashArray: '8 6' }).addTo(groups.route);
78475
78737
  }
78476
- centreMarker = L.marker([view.centre.lat, view.centre.lon], { icon: currentPinIcon() }).bindPopup(esc(view.centre.name || 'Your location')).addTo(groups.route);
78738
+ if (opts.silent && currentCentre) {
78739
+ // An area load: the chosen point, its route and the spots stay where they were.
78740
+ if (currentRoute) L.polyline(currentRoute.map(function (p) { return [p[1], p[0]]; }), { color: COLOUR.route, weight: 3, dashArray: '8 6' }).addTo(groups.route);
78741
+ centreMarker = L.marker(currentCentre, { icon: currentPinIcon() }).bindPopup(esc(currentName || 'Your location')).addTo(groups.route);
78742
+ } else {
78743
+ centreMarker = L.marker([view.centre.lat, view.centre.lon], { icon: currentPinIcon() }).bindPopup(esc(view.centre.name || 'Your location')).addTo(groups.route);
78744
+ currentWeather = view.weather || null;
78745
+ selectedHour = null;
78746
+ currentCentre = [view.centre.lat, view.centre.lon];
78747
+ currentName = view.centre.name || null;
78748
+ currentRoute = view.route && view.route.length > 1 ? view.route : null;
78749
+ setStatus(view.centre.name || (view.route ? 'Your route' : 'Your location'));
78750
+ }
78477
78751
  pendingSpots.forEach(function (s) { L.marker([s.lat, s.lon], { icon: spotIcon(s.rank) }).bindPopup('<b>Spot ' + esc(s.rank) + '</b><br>' + esc(s.label)).addTo(groups.spots); });
78478
78752
  counts.spots = pendingSpots.length;
78479
- currentWeather = view.weather || null;
78480
- currentCentre = [view.centre.lat, view.centre.lon];
78481
78753
  loaded = true;
78482
- setStatus(view.centre.name || (view.route ? 'Your route' : 'Your location'));
78754
+ placeLineIcons();
78483
78755
  view.zones.forEach(function (f) { counts[zoneGroup(f.properties)]++; });
78484
78756
  setCounts(counts);
78485
78757
  var relevant = view.zones.filter(function (f) { return f.properties.relevant; }).length;
78486
78758
  chipsEl.innerHTML = [
78487
- [relevant, 'zone', 'zones', 'below 400 ft'], [view.notams.length, 'NOTAM', 'NOTAMs'], [view.rightsOfWay.length, 'path', 'paths'], [view.parking.length, 'parking spot', 'parking spots'], [counts.land, 'landowner rule', 'landowner rules'], [counts.access, 'access area', 'access areas'], [counts.power + counts.transport + counts.aviation + counts.sites, 'hazard', 'hazards']
78488
- ].map(function (c) { return '<span class="chip' + (c[0] ? '' : ' zero') + '"><b>' + c[0] + '</b> ' + (c[0] === 1 ? c[1] : c[2]) + (c[3] ? ' ' + c[3] : '') + '</span>'; }).join('');
78759
+ [relevant, 'zone', 'zones', 'below 400 ft'], [view.notams.length, 'NOTAM', 'NOTAMs'], [view.rightsOfWay.length, 'path', 'paths'], [view.parking.length, 'parking spot', 'parking spots'], [counts.land, 'landowner rule', 'landowner rules'], [counts.access, 'access area', 'access areas'], [OVERLAYS.reduce(function (n, o) { return n + (o.section === 'hazards' ? counts[o.key] : 0); }, 0), 'hazard', 'hazards']
78760
+ ].map(function (c) { return '<span class="chip' + (c[0] ? '' : ' zero') + '"><b>' + c[0] + '</b> ' + (c[0] === 1 ? c[1] : c[2]) + (c[3] ? ' ' + c[3] : '') + '</span>'; }).join('') + (wide ? '<span class="chip zero">zoom in for every hazard icon</span>' : '');
78489
78761
  dataSources = view.attribution || [];
78490
78762
  updateSources();
78491
78763
  showWeather();
@@ -78531,23 +78803,155 @@ function mapHtml(opts) {
78531
78803
  .catch(function () { showResults([{ name: 'Search failed, try again', msg: 1 }]); });
78532
78804
  };
78533
78805
  document.addEventListener('click', function (e) { if (!searchForm.contains(e.target)) searchResults.innerHTML = ''; });
78806
+ // Where you are: browser geolocation, then the same path as a search hit. Errors show in the card, never a guess.
78807
+ var locateBtn = document.getElementById('locate-btn'), emptyNote = document.getElementById('empty-note');
78808
+ function locateMe() {
78809
+ if (!navigator.geolocation) { showResults([{ name: 'This browser cannot share its location', msg: 1 }]); return; }
78810
+ if (!API_BASE) { showResults([{ name: 'Map data needs the hosted server', msg: 1 }]); return; }
78811
+ locateBtn.classList.add('busy');
78812
+ emptyNote.textContent = '';
78813
+ navigator.geolocation.getCurrentPosition(function (pos) {
78814
+ locateBtn.classList.remove('busy');
78815
+ var lat = Math.round(pos.coords.latitude * 1e5) / 1e5, lon = Math.round(pos.coords.longitude * 1e5) / 1e5;
78816
+ if (lat < 49 || lat > 62 || lon < -14 || lon > 5) { emptyNote.textContent = 'You seem to be outside the UK; this map only covers UK airspace.'; showResults([{ name: 'Outside the UK', msg: 1 }]); return; }
78817
+ goTo({ lat: lat, lon: lon, name: 'Your location' });
78818
+ }, function (err) {
78819
+ locateBtn.classList.remove('busy');
78820
+ var why = err && err.code === 1 ? 'Location permission was refused; search for a place instead.' : 'Could not get your location; search for a place instead.';
78821
+ emptyNote.textContent = why;
78822
+ showResults([{ name: why, msg: 1 }]);
78823
+ }, { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 });
78824
+ }
78825
+ locateBtn.onclick = locateMe;
78826
+ document.getElementById('locate-btn-2').onclick = locateMe;
78827
+ // Tap or click the map to check that point; markers, popups and controls keep their taps.
78828
+ // Zones and lines have no popups of their own (they often cover the whole view): a long press,
78829
+ // or a right click on a desktop, lists everything under the point instead. A short delay lets a
78830
+ // double tap zoom rather than load, and a long press never loads.
78831
+ var tapTimer = null, longPressed = false, hitTargets = [];
78832
+ map.getContainer().addEventListener('click', function (ev) {
78833
+ var t = ev.target;
78834
+ if (t && t.closest && t.closest('.leaflet-marker-icon, .leaflet-popup, .leaflet-control')) return;
78835
+ if (!API_BASE || longPressed || (map.dragging && map.dragging.moved && map.dragging.moved())) return;
78836
+ clearTimeout(tapTimer);
78837
+ var ll = map.mouseEventToLatLng(ev);
78838
+ var lat = Math.round(ll.lat * 1e5) / 1e5, lon = Math.round(ll.lng * 1e5) / 1e5;
78839
+ tapTimer = setTimeout(function () {
78840
+ if (lat < 49 || lat > 62 || lon < -14 || lon > 5) { showResults([{ name: 'Outside the UK', msg: 1 }]); return; }
78841
+ goTo({ lat: lat, lon: lon, name: lat.toFixed(4) + ', ' + lon.toFixed(4) });
78842
+ }, 280);
78843
+ });
78844
+ map.getContainer().addEventListener('dblclick', function () { clearTimeout(tapTimer); });
78845
+ function inRing(ring, x, y) {
78846
+ var inside = false;
78847
+ for (var i = 0, j = ring.length - 1; i < ring.length; j = i++) {
78848
+ var xi = ring[i][0], yi = ring[i][1], xj = ring[j][0], yj = ring[j][1];
78849
+ if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
78850
+ }
78851
+ return inside;
78852
+ }
78853
+ function inPolygon(coords, x, y) { // outer ring minus holes
78854
+ if (!inRing(coords[0], x, y)) return false;
78855
+ for (var k = 1; k < coords.length; k++) if (inRing(coords[k], x, y)) return false;
78856
+ return true;
78857
+ }
78858
+ function nearLine(coords, pt, tolPx) {
78859
+ for (var i = 1; i < coords.length; i++) {
78860
+ var a = map.latLngToLayerPoint([coords[i - 1][1], coords[i - 1][0]]), b = map.latLngToLayerPoint([coords[i][1], coords[i][0]]);
78861
+ if (L.LineUtil.pointToSegmentDistance(pt, a, b) <= tolPx) return true;
78862
+ }
78863
+ return false;
78864
+ }
78865
+ function hits(latlng) {
78866
+ var x = latlng.lng, y = latlng.lat, pt = map.latLngToLayerPoint(latlng), out = [];
78867
+ hitTargets.forEach(function (t) {
78868
+ if (!map.hasLayer(groups[t.key])) return;
78869
+ var g = t.geometry, hit = false;
78870
+ if (t.circle) hit = map.distance(latlng, [t.circle.lat, t.circle.lon]) <= t.circle.radiusM;
78871
+ else if (g.type === 'Polygon') hit = inPolygon(g.coordinates, x, y);
78872
+ else if (g.type === 'MultiPolygon') hit = g.coordinates.some(function (p) { return inPolygon(p, x, y); });
78873
+ else if (g.type === 'LineString') hit = nearLine(g.coordinates, pt, 14);
78874
+ else if (g.type === 'MultiLineString') hit = g.coordinates.some(function (c) { return nearLine(c, pt, 14); });
78875
+ if (hit) out.push(t.html);
78876
+ });
78877
+ return out;
78878
+ }
78879
+ map.on('contextmenu', function (e) {
78880
+ clearTimeout(tapTimer);
78881
+ longPressed = true;
78882
+ setTimeout(function () { longPressed = false; }, 600);
78883
+ var found = hits(e.latlng);
78884
+ var content = found.length ? found.join('<hr class="sep">') : '<i>Nothing drawn under this point</i>';
78885
+ L.popup({ maxWidth: 300, className: 'hits' }).setLatLng(e.latlng).setContent(content).openOn(map);
78886
+ });
78887
+ function startEmpty() {
78888
+ infoEl.classList.add('empty');
78889
+ infoEl.classList.remove('closed');
78890
+ setStatus('Where do you want to fly?');
78891
+ }
78534
78892
  searchInput.addEventListener('keydown', function (e) { if (e.key === 'Escape') { searchResults.innerHTML = ''; searchInput.blur(); } });
78535
78893
  searchInput.addEventListener('focus', function () { searchInput.select(); });
78536
78894
 
78537
78895
  function load(req) {
78538
- pendingSpots = Array.isArray(req.spots) ? req.spots.filter(function (s) { return typeof s.lat === 'number' && typeof s.lon === 'number'; }).map(function (s, i) { return { lat: s.lat, lon: s.lon, rank: s.rank || i + 1, label: s.label || 'Spot ' + (i + 1) }; }) : [];
78896
+ if (!req.silent) pendingSpots = Array.isArray(req.spots) ? req.spots.filter(function (s) { return typeof s.lat === 'number' && typeof s.lon === 'number'; }).map(function (s, i) { return { lat: s.lat, lon: s.lon, rank: s.rank || i + 1, label: s.label || 'Spot ' + (i + 1) }; }) : [];
78539
78897
  if (!API_BASE) { setStatus('Map data needs the hosted server'); if (typeof req.lat === 'number') { map.setView([req.lat, req.lon], 13); centreMarker = L.marker([req.lat, req.lon], { icon: currentPinIcon() }).addTo(groups.route); } return; }
78540
78898
  var q = new URLSearchParams();
78541
78899
  if (typeof req.lat === 'number' && typeof req.lon === 'number') { q.set('lat', req.lat); q.set('lon', req.lon); }
78542
78900
  if (req.place) q.set('place', req.place);
78543
78901
  if (req.name) q.set('name', req.name);
78544
- if (req.radiusM) { q.set('radius', req.radiusM); currentRadius = req.radiusM; }
78902
+ if (req.radiusM) { q.set('radius', req.radiusM); if (!req.silent) currentRadius = req.radiusM; }
78903
+ if (req.silent) q.set('weather', '0');
78545
78904
  if (req.route && req.route.length > 1) q.set('route', req.route.map(function (p) { return p[0] + ',' + p[1]; }).join(';'));
78546
78905
  if (req.waypoints && req.waypoints.length > 1) q.set('waypoints', req.waypoints.map(function (w) { return Array.isArray(w) ? w[0] + ',' + w[1] : w; }).join(';'));
78547
- setStatus('Loading\u2026');
78548
- fetch(API_BASE + '/api/view?' + q.toString()).then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }).then(render).catch(function (e) { setStatus('Could not load map data: ' + e.message); });
78906
+ if (!req.silent) setStatus('Loading\u2026');
78907
+ var url = API_BASE + '/api/view?' + q.toString();
78908
+ var seq = ++loadSeq;
78909
+ var attempt = function (n) {
78910
+ return fetch(url).then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
78911
+ .catch(function (e) { if (n > 0 && !/^HTTP/.test(e.message)) return new Promise(function (res) { setTimeout(res, 800); }).then(function () { return attempt(n - 1); }); throw e; });
78912
+ };
78913
+ attempt(1).then(function (view) { if (seq === loadSeq) render(view, { silent: !!req.silent }); if (req.silent) areaLoadDone(); }).catch(function (e) {
78914
+ if (req.silent) areaLoadDone();
78915
+ if (seq !== loadSeq) return;
78916
+ if (req.silent) return; // a quiet area load that failed just leaves the previous overlays
78917
+ setStatus('Could not load map data (' + e.message + ')');
78918
+ infoEl.classList.remove('closed');
78919
+ emptyNote.textContent = '';
78920
+ var retry = document.createElement('button'); retry.type = 'button'; retry.textContent = 'Retry'; retry.className = 'retry';
78921
+ retry.onclick = function () { retry.remove(); load(req); };
78922
+ placeEl.appendChild(retry);
78923
+ });
78549
78924
  }
78550
78925
 
78926
+ // Pan or zoom away from the loaded area and the overlays for the new view load on their own,
78927
+ // quietly: the chosen point, its weather and the framing stay put. Zoomed out beyond 10 km it waits.
78928
+ var loadSeq = 0, areaLoading = false, areaDirty = false, areaTimer = null;
78929
+ function setAreaHint(text) {
78930
+ var el = document.getElementById('area-hint');
78931
+ if (!text) { if (el) el.remove(); return; }
78932
+ if (!el) { el = document.createElement('span'); el.id = 'area-hint'; el.className = 'chip zero'; chipsEl.appendChild(el); }
78933
+ el.textContent = text;
78934
+ }
78935
+ function loadVisibleArea() {
78936
+ if (!loaded || !API_BASE || !currentCentre) return;
78937
+ if (areaLoading) { areaDirty = true; return; } // a load is in flight: run again when it lands
78938
+ var vb = map.getBounds();
78939
+ if (loadedBounds && loadedBounds.contains(vb)) return;
78940
+ var c = map.getCenter();
78941
+ var halfW = map.distance(vb.getNorthWest(), vb.getNorthEast()) / 2, halfH = map.distance(vb.getNorthWest(), vb.getSouthWest()) / 2;
78942
+ var radius = Math.round(Math.max(halfW, halfH) * 1.15);
78943
+ if (radius > 10000) { setAreaHint('zoom in to load this area'); return; }
78944
+ setAreaHint('loading this area\u2026');
78945
+ areaLoading = true;
78946
+ load({ lat: Math.round(c.lat * 1e5) / 1e5, lon: Math.round(c.lng * 1e5) / 1e5, radiusM: Math.max(300, radius), silent: true });
78947
+ }
78948
+ function areaLoadDone() {
78949
+ areaLoading = false;
78950
+ setAreaHint('');
78951
+ if (areaDirty) { areaDirty = false; setTimeout(loadVisibleArea, 50); }
78952
+ }
78953
+ map.on('moveend zoomend', function () { clearTimeout(areaTimer); areaTimer = setTimeout(loadVisibleArea, 650); });
78954
+
78551
78955
  if (MODE === 'page') {
78552
78956
  var qs = new URLSearchParams(location.search);
78553
78957
  var lat = parseFloat(qs.get('lat')), lon = parseFloat(qs.get('lon'));
@@ -78558,7 +78962,7 @@ function mapHtml(opts) {
78558
78962
  if (isFinite(lat) && isFinite(lon)) load({ lat: lat, lon: lon, name: qs.get('name') || undefined, radiusM: parseFloat(qs.get('radius')) || undefined, route: route.length > 1 ? route : undefined, spots: spots });
78559
78963
  else if (qs.get('place')) load({ place: qs.get('place'), radiusM: parseFloat(qs.get('radius')) || undefined });
78560
78964
  else if (wps.length > 1) load({ waypoints: wps });
78561
- else setStatus('Add ?lat=&lon= or ?place= to the URL');
78965
+ else startEmpty();
78562
78966
  } else {
78563
78967
  // MCP App: JSON-RPC over postMessage with the host (ui/initialize, then ui/notifications/tool-result).
78564
78968
  var nextId = 1;
@@ -83441,6 +83845,11 @@ var RightsOfWayService = class {
83441
83845
  const meta3 = await this.pack.meta();
83442
83846
  return meta3.sources.some((s) => s.id === "is_core_paths" && s.featureCount > 0);
83443
83847
  }
83848
+ /** True when the pack carries asserted rights of way from any Northern Ireland council. */
83849
+ async hasNorthernIrelandPaths() {
83850
+ const meta3 = await this.pack.meta();
83851
+ return meta3.sources.some((s) => s.id === "ni_prow" && s.featureCount > 0);
83852
+ }
83444
83853
  coverageAt(lon, lat) {
83445
83854
  return this.pack.prowCoverageAt(lon, lat);
83446
83855
  }
@@ -85968,7 +86377,7 @@ function buildDeps(config2, logger, overrides = {}) {
85968
86377
  nearestParking: (lon, lat, m, n, priv) => proxy.get().nearestParking(lon, lat, m, n, priv),
85969
86378
  landRestrictionsInBbox: (b, l) => proxy.get().landRestrictionsInBbox(b, l),
85970
86379
  hazardsNear: (lon, lat, m, n) => proxy.get().hazardsNear(lon, lat, m, n),
85971
- hazardsInBbox: (b, l) => proxy.get().hazardsInBbox(b, l),
86380
+ hazardsInBbox: (b, l, k) => proxy.get().hazardsInBbox(b, l, k),
85972
86381
  adminAreaAt: (lon, lat) => proxy.get().adminAreaAt(lon, lat),
85973
86382
  close: () => void 0
85974
86383
  };
@@ -0,0 +1,24 @@
1
+ # Northern Ireland open data
2
+
3
+ ## Designations (Northern Ireland Environment Agency, DAERA)
4
+
5
+ Areas of Special Scientific Interest, Areas of Outstanding Natural Beauty and
6
+ National Nature Reserves come from the Northern Ireland Environment Agency's
7
+ open data on OpenDataNI (https://www.opendatani.gov.uk/), published under the
8
+ UK Open Government Licence v3.0. The files are static GeoJSON last revised in
9
+ August 2020; the portal has no edit metadata, so the revision date in the file
10
+ name is recorded as the source version.
11
+
12
+ Attribution: "Contains public sector information licensed under the Open
13
+ Government Licence v3.0." The designations are advisory in this server: they
14
+ never change the take-off verdict.
15
+
16
+ ## Public rights of way (councils)
17
+
18
+ Northern Ireland has no definitive map. Councils assert public rights of way
19
+ under the Access to the Countryside (Northern Ireland) Order 1983 and only some
20
+ publish them. Mid Ulster District Council publishes its asserted paths as an
21
+ ArcGIS layer under the Open Government Licence v3.0
22
+ (https://www.opendatani.gov.uk/dataset/mid-ulster-council-public-rights-of-way).
23
+ Each path carries its council's attribution, as the OGL requires. Absence of a
24
+ path here means unknown, not that none exists.
@@ -1,5 +1,5 @@
1
1
  # OpenStreetMap
2
2
 
3
- Parking, laybys and ground hazards (railways, motorways and trunk roads, power lines and pylons, substations and generators, helipads, masts, military land, schools, nurseries, hospitals, fire and fuel stations, parks, cemeteries) come from the Geofabrik Great Britain extract of OpenStreetMap (https://download.geofabrik.de/europe/great-britain.html).
3
+ Parking, laybys and ground hazards (railways, motorways and trunk roads, bridges, power lines and pylons, substations and generators, helipads, masts, military land, schools, nurseries, hospitals, fire and fuel stations, parks, cemeteries) come from the Geofabrik Great Britain extract of OpenStreetMap (https://download.geofabrik.de/europe/great-britain.html) and, for Northern Ireland, the Ireland and Northern Ireland extract (https://download.geofabrik.de/europe/ireland-and-northern-ireland.html) clipped to the ONS boundary.
4
4
 
5
5
  Licence: Open Database Licence (ODbL) 1.0. Attribution: "© OpenStreetMap contributors". Ground hazards are advisory and incomplete; they do not change the legal verdict.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fpv-airspace",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "MCP server answering where a drone can legally take off and fly in the UK: NATS flight restriction zones, live NOTAMs, public rights of way and landowner rules",
5
5
  "type": "module",
6
6
  "bin": {