Skip to content
Snippets Groups Projects
Commit bcc41eb5 authored by Sara Savanovic Djordjevic's avatar Sara Savanovic Djordjevic
Browse files

Merge branch 'clhp_map' into 'main'

Clhp map

See merge request !14
parents 1fe08901 c4434a5d
No related branches found
No related tags found
1 merge request!14Clhp map
Showing
with 691 additions and 71 deletions
......@@ -10,9 +10,10 @@ const String serverURI = "https://127.0.0.1:$port/";
const String mapEndpoint = "update_map";
// Map variables
String selectedLake = 'Skumsjøen'; // NB should be initialised to last selected lake
String selectedLake = 'Mjøsa'; // NB should be initialised to last selected lake
Uint8List selectedRelation = Uint8List(0);
List<Measurement> selectedMarkerList = [];
List<Measurement> selectedMeasurements = [];
List<SubDiv> selectedSubdivisions = [];
SubDiv? selectedSubDiv;
LatLng mapCenter = LatLng(60.8000, 10.8471); // NB may not be necessary
......
......@@ -20,9 +20,6 @@ class Measurement {
});
factory Measurement.fromJson(Map<String, dynamic> json) {
if (json == null) {
throw const FormatException('Error parsing Measurement: JSON data is null');
}
try {
return Measurement(
measurementID: json['MeasurementID'] ?? 0,
......
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:app/server_requests/init_state.dart';
import 'package:liquid_pull_to_refresh/liquid_pull_to_refresh.dart';
import '../consts.dart';
import '../widgets/main_layout.dart';
import '../widgets/choropleth_map.dart';
import '../utils/custom_search_delegate.dart';
class DefaultPage extends StatefulWidget {
......@@ -64,12 +62,10 @@ class _DefaultPageState extends State<DefaultPage> {
delegate: CustomSearchDelegate((String result) {
// Make request only if the selected lake is different from the current selected lake
if (result != selectedLake) {
initialiseState(false);
setState(() {
print("SetState called!");
selectedLake = result;
// NB update lastLake persistent variable
//fetchNewLake(context);
});
}
}),
......@@ -90,7 +86,7 @@ class _DefaultPageState extends State<DefaultPage> {
child: ListView(
children: [
MapContainerWidget(
measurements: selectedMarkerList,
measurements: selectedMeasurements,
relation: selectedRelation,
serverConnection: serverConnection,
),
......@@ -102,3 +98,37 @@ class _DefaultPageState extends State<DefaultPage> {
);
}
}
void fetchNewLake(BuildContext context) {
showDialog(
context: context,
barrierDismissible: false, // Prevent dismissal by user
builder: (BuildContext dialogContext) {
bool initialized = false;
// Display CircularProgressIndicator
Future.delayed(const Duration(milliseconds: 500), () {
if (!initialized) {
showDialog(
context: dialogContext,
builder: (BuildContext _) => const Center(
child: CircularProgressIndicator(),
),
);
}
});
initialiseState(false).then((_) {
// Mark initialization as complete
initialized = true;
Navigator.of(dialogContext, rootNavigator: true).pop();
});
// Return a placeholder widget for the dialog
return const SizedBox.shrink();
},
);
}
......@@ -5,9 +5,8 @@ import 'dart:typed_data';
import 'package:app/consts.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../consts.dart';
import '../data_classes.dart';
import '../server_requests/fetch_markers.dart';
import '../server_requests/fetch_measurements.dart';
import '../server_requests/fetch_relation.dart';
/// initialiseState makes three requests to the server, one requesting
......@@ -25,7 +24,9 @@ Future<void> initialiseState(bool fetchSearchOptions) async {
FetchResult fetchResult = await loadMeasurements();
List<Measurement> measurements = fetchResult.measurements;
selectedMarkerList = measurements;
selectedMeasurements = measurements;
print("Loaded from files: Meas.len: ${selectedMeasurements.length}, rel.len: ${selectedRelation.length}");
} else { // Try to fetch measurement data from server
markerListFuture = fetchMeasurements().then((fetchResult) {
......@@ -53,7 +54,7 @@ Future<void> initialiseState(bool fetchSearchOptions) async {
//selectedRelation = await relationFuture;
selectedRelation = await relationFuture; // NB update once fixed
selectedMarkerList = await markerListFuture;
selectedMeasurements = await markerListFuture;
}
} catch (e) {
// Handle any errors that occur during the initialization process
......
import 'dart:io';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import '../../consts.dart';
// Saves all measurements to a file on the users mobile device
Future<void> _exportIceData() async {
final directory = await getExternalStorageDirectory();
final file = File('${directory?.path}/ice_data_$selectedLake.json');
// Convert JSON data to string
final jsonString = jsonEncode(selectedMeasurements);
// Write JSON data to file
await file.writeAsString(jsonString);
}
// Display a progress indicator while JSON data is being downloaded
void showProgressIndicator(BuildContext context) {
BuildContext? dialogContext;
showDialog(
context: context,
builder: (BuildContext context) {
dialogContext = context;
return WillPopScope(
onWillPop: () async => false, // Prevent dialog from being closed by user
child: const AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(), // Progress indicator
SizedBox(height: 20),
Text('Exporting JSON data...'),
],
),
),
);
},
);
// Ensure that the progress indicator runs for at lest 1 second
Future.delayed(const Duration(seconds: 1), () {
try { // Download JSON data
_exportIceData();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Downloaded ice data as JSON')),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to export JSON data: $e')),
);
} finally {
if (dialogContext != null) {
// Add 2 second delay before closing dialog
Future.delayed(const Duration(seconds: 2), () {
Navigator.of(dialogContext!).pop();
Navigator.of(context).pop();
});
}
}
});
}
......@@ -8,8 +8,7 @@ import 'stat_charts.dart';
import '../../consts.dart';
import 'choropleth_map.dart';
import '../data_classes.dart';
import 'satellite_layer.dart';
import 'quick_view_chart.dart';
import '../utils/export_data.dart';
import '../utils/format_month.dart';
/// MapContainerWidget is the main widget that contains the map with all
......@@ -38,7 +37,7 @@ class _MapContainerWidgetState extends State<MapContainerWidget> {
bool isSatTapped = false; // Satellite button tap state tracker
bool isMapTapped = false; // OSM button tap state tracker
Measurement? selectedMeasurement = selectedMarkerList[0];
Measurement? selectedMeasurement = selectedMeasurements[0];
// Initialise lastUpdate variable from persistent storage if server fetch fails
Future<void> checkAndSetLastUpdate() async {
......@@ -162,34 +161,53 @@ class _MapContainerWidgetState extends State<MapContainerWidget> {
),
),
),
Positioned( // No wifi icon
top: 80,
Positioned( // Export button
top: 90,
right: 10,
child: GestureDetector(
onTapDown: (_) {
setState(() {
// Add functionality
});
onTap: () {
showModalBottomSheet(
context: context,
builder: (BuildContext context) {
return SizedBox(
height: 200,
width: screenWidth,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"Export ice data for $selectedLake",
style: const TextStyle(fontSize: 20),
),
const SizedBox(height: contPadding),
ElevatedButton(
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all<Color>(Colors.white24),
),
child: const Text("Export JSON"),
onPressed: () {
// Close bottom sheet before displaying progress bar
showProgressIndicator(context);
},
onTapUp: (_) {
setState(() {
// Add functionality
});
)
],
),
),
);
},
onTapCancel: () {
setState(() {
// Add functionality
});
);
},
child: Visibility( // Show icon only when no server connection
visible: !widget.serverConnection,
child: Container(
padding: const EdgeInsets.all(8),
decoration: isSatTapped ? const BoxDecoration(
shape: BoxShape.circle,
color: Colors.blue,
) : null,
child: const Icon(Icons.perm_scan_wifi, color: Color(0xFF5B0000)),
child: const Icon(
Icons.share,
color: Colors.white54,
),
),
),
......
......@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import '../../consts.dart';
import '../data_classes.dart';
class OSM extends StatelessWidget {
......@@ -27,7 +26,7 @@ class OSM extends StatelessWidget {
),
children: [
TileLayer(
urlTemplate: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
urlTemplate: "https://{s}.tile.openstreetmap.org/{z/{x}/{y}.png",
subdomains: const ['a', 'b', 'c'],
),
PolygonLayer(
......
This diff is collapsed.
......@@ -100,6 +100,17 @@ class IceHTTP(BaseHTTPRequestHandler):
lake_name = query_params.get('lake', [None])[0]
if lake_name is not None:
cell_size = query_params.get('cell_size', [''])[0]
cell_size_param = unquote(cell_size) # Decode url param
if cell_size_param: # Check if cell_size_param is not an empty string
try:
# Try to convert the value to a float and the map
cell_size_float = float(cell_size_param)
cut_map(self, cursor, lake_name, cell_size_float)
except ValueError:
print("Error: cell_size_param is not a valid float")
else:
cut_map(self, cursor, lake_name)
else:
self.send_response(400)
......
No preview for this file type
No preview for this file type
No preview for this file type
import os
import json
from math import cos, sqrt, fabs
import random
import geopandas as gpd
from matplotlib import pyplot as plt
......@@ -8,11 +9,33 @@ from shapely.geometry import Polygon, LineString, MultiLineString
from server.consts import LAKE_RELATIONS_PATH
'''
# 0
starting coordinate (x,y)
# 1
1 deg lat = 111.32km
1 deg lng = 40075km * cos( lat ) / 360
# 2 Formulas for calculating a distance in kilometers to a distance in latitude and longitude
lat = distance_in_km/111.32km
lng = (distance_in_km × 360)/(40075km × cos(lat))
'''
# Read a json file with relation data and send to response object
def cut_map(self, cursor, lake_name: str):
def cut_map(self, cursor, lake_name: str, cell_size_in_km: float = 0.5):
"""
Cuts a map into a grid based on a selected cell size
Parameters:
self (BaseHTTPRequestHandler): A instance of a BaseHTTPRequestHandler
cursor (cursor): An Sqlite3 cursor object that points to the database
lake_name (str): The name of the lake to be cut
cell_size_in_km (float): The selected cell size in kilometers
"""
try:
# Read relation from GeoJson file and extract all polygons
# Read relation from GeoJson file and extract all geometry of type Polygon
geo_data = gpd.read_file(LAKE_RELATIONS_PATH + lake_name + ".geojson")
polygon_data = geo_data[geo_data['geometry'].geom_type == 'Polygon']
polygons = [Polygon(polygon.exterior) for polygon in polygon_data['geometry']]
......@@ -20,25 +43,35 @@ def cut_map(self, cursor, lake_name: str):
if len(polygons) <= 1:
raise Exception("Failed to convert JSON object to Shapely Polygons")
# List to store all the map tiles
divided_map = []
for polygon in polygons:
cell_width = 0.04
cell_height = 0.02 # NB could be calculated based on cell_width and distance from equator
# Convert the cell size to degrees
cell_size = cell_size_in_km * 10
cell_width = cell_size / 111.32
# A slightly more complicated formula is required to calculate the height. This ensures that
# the height in km is equal to the width in km regardless of the latitude.
cell_height = (cell_size * 360) / (40075 * cos(cell_width))
# Process all polygons
for polygon in polygons:
# Generate a grid based on the calculated cell size
lines = create_grid(polygon, cell_width, cell_height)
lines.append(polygon.boundary)
# Merge the grid lines into a single grid object
lines = unary_union(lines)
lines = linemerge(lines)
lines = list(polygonize(lines))
# Divide the polygon into tiles based on the generated grid
divided_map.extend(combine_grid_with_poly(polygon, lines))
# List to store new GeoJSON feature objects
features = []
# Create subdivisions for each map tile
sub_div_id = 0
for tile in divided_map:
# Calculate tile center based on bounds, and round down to two decimals
min_x, min_y, max_x, max_y = tile.bounds
center = round(max_y - (max_y - min_y), 6), round(max_x - (max_x - min_x), 6)
......@@ -50,6 +83,7 @@ def cut_map(self, cursor, lake_name: str):
rounded_coordinates.append(rounded_coords)
rounded_tile = Polygon(rounded_coordinates)
# Create new feature object
tile_feature = {
'type': 'Feature',
'properties': {
......@@ -58,13 +92,18 @@ def cut_map(self, cursor, lake_name: str):
},
'geometry': rounded_tile.__geo_interface__
}
# Append new feature oject to list, and increment sub_div_id for next iteration
features.append(tile_feature)
sub_div_id += 1
# Create new GeoJSON object containing all the new feature objects
feature_collection = {
'type': 'FeatureCollection',
'features': features,
'tile_count': sub_div_id, # Add the last subdivision ID as number of tiles
'cell_width': cell_width,
'cell_height': cell_height,
'cell_size_in_km': cell_size_in_km
}
# Add lake name to database
......@@ -79,8 +118,11 @@ def cut_map(self, cursor, lake_name: str):
INSERT INTO BodyOfWater(Name) VALUES (?);
''', (lake_name,))
# Plot the newly created map and save it to a new file
plot_map(divided_map)
write_json_to_file(lake_name, feature_collection)
# Return the map to the response object
self.send_response(200)
self.send_header("Content-type", "application/json")
self.end_headers()
......@@ -95,21 +137,33 @@ def cut_map(self, cursor, lake_name: str):
self.end_headers()
def create_grid(poly: Polygon, cell_width, cell_height):
def create_grid(poly: Polygon, cell_width: float, cell_height: float):
"""
Returns a list of vertical and horizontal LineStrings that create a grid.
Parameters:
poly (Polygon): A Shapely Polygon representing a map or part of a map
cell_width (float): The width of the grid cells in degrees
cell_height (float): The height of the grid cells in degrees
Returns:
grid_lines (list): List of LineString objects defining the grid
"""
# Retrieve bounds of the entire polygon
bounds = poly.bounds
min_x, min_y, max_x, max_y = bounds
# List to store all created lines
grid_lines = []
# Horizontal lines
# Create new horizontal lines while within bounds
y = min_y
while y <= max_y:
line = LineString([(min_x, y), (max_x, y)])
grid_lines.append(line)
y += cell_height
# Vertical lines
# Create new vertical lines while within bounds
x = min_x
while x <= max_x:
line = LineString([(x, min_y), (x, max_y)])
......@@ -120,6 +174,17 @@ def create_grid(poly: Polygon, cell_width, cell_height):
def combine_grid_with_poly(polygon, grid):
"""
Returns a list of polygons that together make up map tiles.
Parameters:
polygon (Polygon): A polygon representing a map or part of a map
grid (list): List of LineString objects defining the grid
Returns:
intersecting_tiles (list): List of Polygons
"""
# List to contain all the tiles
intersecting_tiles = []
for line in grid:
......@@ -135,38 +200,59 @@ def combine_grid_with_poly(polygon, grid):
return intersecting_tiles
def write_json_to_file(lake_name: str, json_data: dict):
def write_json_to_file(lake_name: str, map_data: dict):
"""
Writes a divided map to a JSON file and updates all_lake_names.json
Parameters:
lake_name (str): Name of the lake and file to write to
map_data (dict): List of map polygons converted to a JSON dictionary
"""
# Create and write divided map to new file
print("Writing to file...")
if not os.path.exists(LAKE_RELATIONS_PATH):
raise Exception("Directory from path does not exist")
with open(LAKE_RELATIONS_PATH + '/' + lake_name + '_div.json', 'w') as f:
json.dump(json_data, f)
json.dump(map_data, f)
# Update all_system_lakes
# Read all_system_lakes.json
with open(LAKE_RELATIONS_PATH + 'all_lake_names.json', 'r') as file:
data = json.load(file)
# Check if the lake name exists in the list
if lake_name not in data:
data.append(lake_name)
data.append(lake_name) # Only append to list if it does not already exist
# Update all_lake_names.json with new lake name
with open(LAKE_RELATIONS_PATH + 'all_lake_names.json', 'w') as file:
# json.dump(data, file, indent=2)
json.dump(data, file, ensure_ascii=False, indent=2)
# Plotting the map can take a considerable amount of time, especially when creating maps with many
# subdivisions. Removing calls to plot_map will speed up the process, but it is recommended to plot the map
# after each division to ensure that the map was divided as intended.
def plot_map(divided_map):
tiles = [gpd.GeoDataFrame(geometry=[tile]) for tile in divided_map]
"""
Plots a divided map using matplotlib.
Parameters:
divided_map (list): List of Shapely Polygons
"""
print("Plotting... This may take some time...")
# Convert Polygon objects to GeoDataFrames
tiles = [gpd.GeoDataFrame(geometry=[tile]) for tile in divided_map]
# Configure plot settings
fig, ax = plt.subplots()
ax.set_aspect(1.5)
# Plot each tile
for tile in tiles:
# Give each tile a random color to clearly visualize the grid
random_color = "#{:06x}".format(random.randint(0, 0xFFFFFF))
gpd.GeoSeries(tile.geometry).plot(ax=ax, facecolor=random_color, edgecolor='none')
# Display plot
plt.show()
......@@ -2,7 +2,15 @@ from server.consts import LAKE_RELATIONS_PATH
# Writes contents of a lake json file to the response
def get_map_data(self, file_name, measurement: bool):
def get_map_data(self, file_name: str, measurement: bool):
"""
Reads a map file and writes its contents to the response object.
Parameters:
self (BaseHTTPRequestHandler): A instance of a BaseHTTPRequestHandler
file_name (str): The name of the requested file/lake
measurement (bool): Whether the file is of type _measurements.json or _div.json
"""
try:
if measurement:
file_type = "_measurements.json"
......@@ -13,6 +21,7 @@ def get_map_data(self, file_name, measurement: bool):
with open(LAKE_RELATIONS_PATH + file_name + file_type, "r") as file:
data = file.read()
# Set HTTP headers
self.send_response(200)
self.send_header("Content-type", "application/json")
self.end_headers()
......@@ -23,7 +32,7 @@ def get_map_data(self, file_name, measurement: bool):
self.send_response(404)
self.send_header("Content-type", "application/json")
self.end_headers()
except Exception as e:
except Exception:
self.send_response(500)
self.send_header("Content-type", "application/json")
self.end_headers()
......
......@@ -6,7 +6,16 @@ from server.ModelFromNVE.icemodellingscripts.getIceThicknessLakes import get_raw
def get_measurements(self, cursor, lake_name):
"""
Retrieves the measurement data from the database for a given lake, and adds weather data to each subdivision.
Parameters:
self (BaseHTTPRequestHandler): A instance of a BaseHTTPRequestHandler
cursor (cursor): An Sqlite3 cursor object that points to the database
lake_name (str): The name of the requested file/lake
"""
try:
# SQL query to fetch all measurements and subdivisions for the requested lake
sql_query = '''
SELECT m.MeasurementID, m.SensorID, m.TimeMeasured, m.CenterLat, m.CenterLon,
s.SensorType, s.Active,
......@@ -21,8 +30,8 @@ def get_measurements(self, cursor, lake_name):
WHERE b.Name = ?
'''
# Execute the query with the lake name as parameter
cursor.execute(sql_query, (lake_name,))
rows = cursor.fetchall()
# List of all fetched measurement objects
......@@ -36,7 +45,7 @@ def get_measurements(self, cursor, lake_name):
center_lat = row[12]
center_lng = row[13]
# Create subdivision new object
# Create new subdivision object
sub_division = {
'SubdivID': sub_div_id,
'GroupID': row[9],
......@@ -46,6 +55,7 @@ def get_measurements(self, cursor, lake_name):
'CenLongitude': center_lng,
'Accuracy': row[14],
'Color': calculateColor(row[11]), # NB color calculated based on average thickness, should be minimum
# Fetch weather data from the NVE model
'IceStats': get_raw_dates(ice_prognosis_raw_data(sub_div_id=sub_div_id, x=center_lat, y=center_lng))
}
sub_div_ids.append(sub_div_id)
......@@ -71,7 +81,7 @@ def get_measurements(self, cursor, lake_name):
'Subdivisions': [sub_division], # Array of sub_division objects
}
# Populate remaining subdivisions and create "invalid" measurement to store them
# Populate remaining subdivisions and create "invalid" or "proxy" measurement to store them
remaining_sub_divs = fill_remaining_subdivisions(lake_name, sub_div_ids)
measurement_data[-1] = {
'MeasurementID': -1,
......@@ -113,23 +123,36 @@ def get_measurements(self, cursor, lake_name):
self.wfile.write(marker_data.encode('utf-8'))
# Get data for subdivisions that have not been measured by sensors, and thus are not in the database
def fill_remaining_subdivisions(lake_name: str, sub_div_ids: list):
"""
Returns a list of subdivision dictionaries for subdivisions without measurements.
Parameters:
lake_name (str): The name of the requested file/lake
sub_div_ids (list): A list of ids (int) of all subdivisions that have already been processed
Returns:
sub_divisions (list): A list of subdivision dictionaries
"""
try:
# Read the lake relation for the requested lake
with open(LAKE_RELATIONS_PATH + lake_name + '_div.json', 'r') as file:
data = json.load(file)
relation = json.load(file)
relation = data
sub_divisions = []
# Loop through each feature and extract all subdivisions
for sub_div in relation['features']:
sub_div_id = int(sub_div['properties']['sub_div_id'])
# Only get subdivisions that are not in the list
if sub_div_id not in sub_div_ids:
center_lat = sub_div['properties']['sub_div_center'][0]
center_lng = sub_div['properties']['sub_div_center'][1]
# Fetch weather data for each subdivision from the NVE model
ice_stats = get_raw_dates(ice_prognosis_raw_data(sub_div_id=sub_div_id, x=center_lat, y=center_lng))
# Create new subdivision object
sub_division = {
'SubdivID': sub_div_id,
'GroupID': None,
......@@ -142,7 +165,6 @@ def fill_remaining_subdivisions(lake_name: str, sub_div_ids: list):
'Color': calculateColor(ice_stats[0]['Total ice (m)']),
'IceStats': ice_stats,
}
sub_divisions.append(sub_division)
return sub_divisions
......
[
"Mj\u00c3\u00b8sa",
"Skumsj\u00c3\u00b8en"
"Mjøsa",
"Skumsjøen"
]
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment