diff --git a/.env b/.env index aa949fe6e65b88181d00d68f5aa44e9eec20d4d3..018a40dea282a30930ab94b6d5d12f9f89e661bc 100644 --- a/.env +++ b/.env @@ -18,7 +18,7 @@ FROST_POSTGRES_PASSWORD=admin INITIAL_MEMORY=2G MAXIMUM_MEMORY=4G -GEOSERVER_URL=http://geoserver:8080/geoserver +GEOSERVER_URL=http://geoserver:8080/geoserver/ GEOSERVER_USERNAME=admin GEOSERVER_PASSWORD=geoserver diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 879f650c0bfd56fe66103803cda0efc5040b7d9b..6b6f3ff588370596ec7ce4112c1a9c505b69cbc2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,6 +74,9 @@ #### VueJS 3 - https://v3.vuejs.org/guide/introduction.html +#### Pinia-Store +- https://pinia.vuejs.org/getting-started.html + #### Typescript - https://www.typescriptlang.org/ - https://v3.vuejs.org/guide/typescript-support.html @@ -107,6 +110,19 @@ ### GeoServer +#### Setup +- https://docs.geoserver.org/main/en/user/production/index.html +- https://geoserver.geosolutionsgroup.com/edu/en/adv_gsconfig/gsproduction.html + +#### WFS +- https://docs.geoserver.org/main/en/user/services/wfs/basics.html +- https://docs.geoserver.org/main/en/user/services/wfs/reference.html +- https://docs.geoserver.org/2.22.x/en/user/extensions/querylayer/index.html +- https://docs.ogc.org/is/09-025r2/09-025r2.html#50 +- https://docs.geoserver.org/main/en/user/tutorials/cql/cql_tutorial.html#cql-tutorial +- https://gis.stackexchange.com/questions/132229/cql-filter-that-joins-two-feature-types-in-wfs +- https://docs.geoserver.org/latest/en/user/filter/function_reference.html#filter-function-reference + - Python library for using the api: https://pypi.org/project/geoserver-restconfig/ ### Django / Backend diff --git a/backend/data_import/forms/csv_import_job.py b/backend/data_import/forms/csv_import_job.py index 355858645c2257b76773c291b864762299c24ad9..a3dd46ba12cb839ec585865744084f8665efbb71 100644 --- a/backend/data_import/forms/csv_import_job.py +++ b/backend/data_import/forms/csv_import_job.py @@ -9,6 +9,7 @@ class CsvImportJobAdmin(admin.ModelAdmin): list_display = ( 's3_file', 'bucket', + 'target', 'is_processed', 'is_running', 'is_success', @@ -19,7 +20,7 @@ class CsvImportJobAdmin(admin.ModelAdmin): fieldsets = [ (None, { - 'fields': (('bucket'), ('s3_file', 'file_size', 'num_rows'), ('time_created', 'is_running', 'is_processed'), ('started_at', 'finished_at', 'execution_time' )), + 'fields': (('bucket'), ('s3_file', 'file_size', 'num_rows'), 'target', ('time_created', 'is_running', 'is_processed'), ('started_at', 'finished_at', 'execution_time' )), }), ('Results', { 'fields': ('is_success', ('data_points_created', 'data_points_failed'), 'validation_error', ), diff --git a/backend/data_import/forms/csv_parser.py b/backend/data_import/forms/csv_parser.py index f76e2e888cd4c413f2c75e96852eee285d58398f..5343256b80e535ca2de648c8517d214a8c8f1957 100644 --- a/backend/data_import/forms/csv_parser.py +++ b/backend/data_import/forms/csv_parser.py @@ -29,13 +29,18 @@ class CsvParserAdmin(DjangoTabbedChangeformAdmin, admin.ModelAdmin): 'classes': ('tab-basic',), }), (None, { - 'fields': ('group', ), + 'fields': ('location_id_col_num', 'property_id_col_num', 'country_col_num', 'community_col_num' ), + 'classes': ('tab-optional',), + }), + (None, { + 'fields': ('target', 'table_prefix', 'group', ), 'classes': ('tab-group',), }), ] tabs = [ ("Basic Information", ["tab-basic"]), + ("Optional Information", ["tab-optional"]), ("Extra-Columns", ["tab-extra-columns-inline"]), ("Include-Criteria", ["tab-include-criteria-inline"]), ("Group", ["tab-group"]), diff --git a/backend/data_import/lib/PostgisImporter.py b/backend/data_import/lib/PostgisImporter.py new file mode 100644 index 0000000000000000000000000000000000000000..3aea6723a6c8157bc0d62b879367f1ff1e1401a4 --- /dev/null +++ b/backend/data_import/lib/PostgisImporter.py @@ -0,0 +1,577 @@ +import csv + +from django.db import connections # noqa + +from data_import.models import CsvParser, CsvImportJob, CsvParserExtraColumn, CsvIncludeCriteria # noqa +from data_import.models import WfsImportRecord, WFS, STA, STA_THING, STA_PROPERTY, STA_OBSERVATION # noqa +from data_import.lib.utils import is_row_included # noqa + +class PostgisImporter: + + def __init__(self, job: CsvImportJob): + + self.job = job + self.parser = job.bucket.csv_parser + + table_prefix = self.parser.table_prefix + + self.feature_table = "{}_feature".format(table_prefix) + self.property_table = "{}_property".format(table_prefix) + self.timeseries_table = "{}_timeseries".format(table_prefix) + self.timerecord_table = "{}_timerecord".format(table_prefix) + + self.error = False + self.logs = [] + + + def import_csv_to_wfs(self): + + self.create_tables_if_not_exist() + + file_name = self.job.s3_file.split('/')[-1] + + with open(file_name, newline='') as csvfile: + reader = csv.reader(csvfile, delimiter=',') + next(reader) # skip header + + batch_size = 1000 + batch = [] + count = 0 + + p = self.parser + extra_columns = CsvParserExtraColumn.objects.filter(parser=p) + include_criteria = CsvIncludeCriteria.objects.filter(parser=p) + + for row in reader: + + if len(row) == 0: + continue + if is_row_included(row, include_criteria): + + location_props = {} + property_props = {} + record_props = {} + + for ec in extra_columns: + if ec.related_entity == STA_THING: + location_props[ec.col_name] = row[ec.col_num] + if ec.related_entity == STA_PROPERTY: + property_props[ec.col_name] = row[ec.col_num] + if ec.related_entity == STA_OBSERVATION: + record_props[ec.col_name] = row[ec.col_num] + + if row[p.time_col_num] == 'NA': + continue + + # if is point data + # TODO: adapt also for polygon data + + batch.append( + WfsImportRecord( + location_name=row[p.station_col_num], + lat=row[p.lat_col_num], + lon=row[p.lon_col_num], + value=row[p.value_col_num], + date=row[p.time_col_num], + unit=row[p.unit_col_num], + property_name=row[p.property_col_num], + bucket_name=self.job.bucket.name, + import_job=self.job.id, + location_id=row[p.location_id_col_num] if p.location_id_col_num else None, + property_id=row[p.property_id_col_num] if p.property_id_col_num else None, + country=row[p.country_col_num] if p.country_col_num else None, + community=row[p.community_col_num] if p.community_col_num else None, + location_properties=location_props, + property_properties=property_props, + record_properties=record_props + ) + ) + + count += 1 + if count % batch_size == 0: + WfsImportRecord.objects.using('geoserver').bulk_create(batch) + batch = [] + print('{} records created.'.format(count)) + + + def write_data(self): + + c = connections["geoserver"].cursor() + + self.write_features(c) + self.write_properties(c) + self.write_timeseries(c) + self.write_timerecords(c) + + self.update_properties_bbox(c) + self.update_timeseries_values(c) + self.update_features(c) + + try: + c.execute("""DELETE FROM geoserver.data_import_wfsimportrecord""") + print('Import done.') + except Exception as e: + self.logs.append(str(e)) + self.error = True + finally: + c.close() + + + def write_features(self, c): + + try: + c.execute(""" + insert into geoserver.{table} (name, lat, lon, geom, gtype, srid, country, import_id, created_at{custom_columns}) + select + d.location_name, + cast(d.lat as double precision), + cast(d.lon as double precision), + st_point(cast(d.lon as double precision), cast(d.lat as double precision), 4326), + 'Point', + '4326', + max(d.country), + max(d.import_job), + now() + {custom_column_selects} + from geoserver.data_import_wfsimportrecord d + group by (d.location_name, d.lat, d.lon, d.location_properties) + on conflict do nothing; + """.format( + table=self.feature_table, + custom_columns=self.get_custom_columns(STA_THING), + custom_column_selects=self.get_custom_column_selects(STA_THING, 'd.location_properties') + )) + + except Exception as e: + self.logs.append(str(e)) + self.error = True + print('Features inserted.') + + + def write_properties(self, c): + + try: + c.execute(""" + insert into geoserver.{table} (name, technical_id, min_lat, min_lon, max_lat, max_lon{custom_columns}) + select + d.property_name, + d.property_id, + MIN(cast(d.lat as double precision)), + MIN(cast(d.lon as double precision)), + MAX(cast(d.lat as double precision)), + MAX(cast(d.lon as double precision)) + {custom_column_selects} + from geoserver.data_import_wfsimportrecord d + group by (d.property_name, d.property_id, d.property_properties) + on conflict do nothing; + """.format( + table=self.property_table, + custom_columns=self.get_custom_columns(STA_PROPERTY), + custom_column_selects=self.get_custom_column_selects(STA_PROPERTY, 'd.property_properties') + ) + ) + + print('Properties inserted.') + + except Exception as e: + self.logs.append(str(e)) + self.error = True + + def write_timeseries(self, c): + + # if point => take over Point-Geometry + # if polygon => create BBox + try: + c.execute(""" + insert into geoserver.{timeseries_table} (feature_id, property_id, unit, min_value, max_value, min_date, max_date, lat, lon, geom, gtype, srid) + select + (select f.id from {feature_table} f where f.name = d.location_name and f.lat = cast(d.lat as double precision) and f.lon = cast(d.lon as double precision)), + (select p.id from {property_table} p where p.name = d.property_name), + d.unit, + MIN(cast(d.value as double precision)), + MAX(cast(d.value as double precision)), + MIN(to_timestamp(d.date, 'YYYY-MM-DDThh24:mi:ss')::timestamp without time zone at time zone 'Etc/UTC'), + MAX(to_timestamp(d.date, 'YYYY-MM-DDThh24:mi:ss')::timestamp without time zone at time zone 'Etc/UTC'), + cast(d.lat as double precision), + cast(d.lon as double precision), + st_point(cast(d.lon as double precision), cast(d.lat as double precision), 4326), + 'Point', + '4326' + from geoserver.data_import_wfsimportrecord d + group by ( + d.location_name, + d.lat, + d.lon, + d.property_name, + d.unit + ) + on conflict do nothing; + """.format(timeseries_table=self.timeseries_table, feature_table=self.feature_table, property_table=self.property_table)) + + except Exception as e: + self.logs.append(str(e)) + self.error = True + + print('TimeSeries inserted.') + + + def write_timerecords(self, c): + + try: + c.execute(""" + insert into geoserver.{timerecord_table} (timeseries_id, date, value_text, value_number, lat, lon, geom, gtype, srid, import_id{custom_columns}) + select + (select id from {timeseries_table} t where t.feature_id = cf.id and t.property_id = cp.id), + to_timestamp(d.date, 'YYYY-MM-DDThh24:mi:ss')::timestamp without time zone at time zone 'Etc/UTC', + d.value, + cast(d.value as double precision), + cast(d.lat as double precision), + cast(d.lon as double precision), + st_point(cast(d.lon as double precision), cast(d.lat as double precision), 4326), + 'Point', + '4326', + d.import_job + {custom_column_selects} + from geoserver.data_import_wfsimportrecord d + inner join geoserver.{feature_table} cf on d.location_name = cf.name and cast(d.lat as double precision) = cf.lat and cast(d.lon as double precision) = cf.lon + inner join geoserver.{property_table} cp on d.property_name = cp.name; + """.format( + timerecord_table=self.timerecord_table, + feature_table=self.feature_table, + property_table=self.property_table, + timeseries_table=self.timeseries_table, + custom_columns=self.get_custom_columns(STA_OBSERVATION), + custom_column_selects=self.get_custom_column_selects(STA_OBSERVATION, 'd.record_properties') + )) + + except Exception as e: + self.logs.append(str(e)) + self.error = True + + print('TimeRecords inserted.') + + + def update_features(self, c): + + stmt_params = [ + ('min_timerecord', 'min_date', 'MIN'), + ('max_timerecord', 'max_date', 'MAX'), + ] + + try: + for params in stmt_params: + c.execute(""" + update geoserver.{feature_table} cf + set {col1} = x.aggr_date + from ( + select + {aggr_func}(ts.{col2}) as aggr_date, + ts.feature_id + from {timeseries_table} ts + group by ts.feature_id + ) as x + where cf.id = x.feature_id + """.format( + feature_table=self.feature_table, + timeseries_table=self.timeseries_table, + col1=params[0], + col2=params[1], + aggr_func=params[2]) + ) + + except Exception as e: + self.logs.append(str(e)) + self.error = True + + print('Min- and Max-Timerecords of Features updated.') + + def update_properties_bbox(self, c): + + stmt_params = [ + ('min_lat', 'lat', 'MIN', '<'), + ('min_lon', 'lon', 'MIN', '<'), + ('max_lat', 'lat', 'MAX', '>'), + ('max_lon', 'lon', 'MAX', '>'), + ] + + try: + for params in stmt_params: + c.execute(""" + update geoserver.{table_name} cp + set {col1} = x.aggr_value + from ( + select {aggr_func}(cast(d.{col2} as double precision)) as aggr_value, + property_name + from geoserver.data_import_wfsimportrecord d + group by (d.property_name) + ) as x + where cp.name = x.property_name + and x.aggr_value {operator} cp.{col1} + """.format( + table_name=self.property_table, + col1=params[0], + col2=params[1], + aggr_func=params[2], + operator=params[3]) + ) + + c.execute(""" + update geoserver.{} cp + set geom = ST_MakeEnvelope(cp.min_lon, cp.min_lat, cp.max_lon, cp.max_lat), + gtype = 'Point', + srid = '4326' + """.format(self.property_table)) + + except Exception as e: + self.logs.append(str(e)) + self.error = True + + print('Bounding-Box of Properties updated.') + + def update_timeseries_values(self, c): + + stmt_params = [ + ('min_value', 'cast(d.value as double precision)', 'MIN', '<'), + ('max_value', 'cast(d.value as double precision)', 'MAX', '>'), + ('min_date', "to_timestamp(d.date, 'YYYY-MM-DDThh24:mi:ss')::timestamp without time zone at time zone 'Etc/UTC'", 'MIN', '<'), + ('max_date', "to_timestamp(d.date, 'YYYY-MM-DDThh24:mi:ss')::timestamp without time zone at time zone 'Etc/UTC'", 'MAX', '>'), + ] + + try: + for params in stmt_params: + c.execute(""" + update geoserver.{timeseries_table} ts + set {col1} = x.updated_value + from ( + select {aggr_func}({col2}) as updated_value, + d.property_name, + d.location_name, + cast(d.lat as double precision), + cast(d.lon as double precision), + d.unit + from geoserver.data_import_wfsimportrecord d + group by ( + d.property_name, + d.location_name, + d.lat, + d.lon, + d.unit + ) + ) as x + where ts.property_id = (select p.id from geoserver.{property_table} p where x.property_name = p.name) + and ts.feature_id = (select f.id from geoserver.{feature_table} f where x.location_name = f.name and x.lat = f.lat and x.lon = f.lon) + and ts.unit = x.unit + and x.updated_value {operator} ts.{col1}; + """.format( + timeseries_table=self.timeseries_table, + feature_table=self.feature_table, + property_table=self.property_table, + col1=params[0], + col2=params[1], + aggr_func=params[2], + operator=params[3]) + ) + + except Exception as e: + self.logs.append(str(e)) + self.error = True + + print('TimeSeries updated.') + + + def create_tables_if_not_exist(self): + c = connections["geoserver"].cursor() + + stmts = [] + stmts.append(""" + create table if not exists geoserver.data_import_wfsimportrecord + ( + id bigint generated by default as identity primary key, + location_id varchar(1000), + location_name varchar(1000), + type_name varchar(1000), + period_start varchar(1000), + period_end varchar(1000), + lat varchar(1000), + lon varchar(1000), + srid varchar(1000), + geom varchar(4000), + gtype varchar(1000), + country varchar(1000), + community varchar(1000), + corine varchar(1000), + value varchar(1000), + date varchar(1000), + unit varchar(1000), + property_name varchar(1000), + property_id varchar(1000), + location_properties jsonb, + property_properties jsonb, + record_properties jsonb, + import_job integer not null + constraint data_import_wfsimportrecord_import_job_check + check (import_job >= 0), + created_at timestamp with time zone not null, + bucket_name varchar not null + );""") + + stmts.append(""" + create table if not exists geoserver.{feature_table} + ( + id integer generated always as identity + constraint {feature_table}_pk + primary key, + name varchar not null, + lat double precision, + lon double precision, + geom geometry, + gtype varchar, + srid varchar, + country varchar, + community varchar, + import_id integer not null, + min_timerecord timestamp, + max_timerecord timestamp, + created_at timestamp + {properties} + );""".format(feature_table=self.feature_table, properties=self.get_custom_column_defs(STA_THING))) + + stmts.append("create unique index if not exists unique_{table} on geoserver.{table} (name, lat, lon);".format(table=self.feature_table)) + + stmts.append( + """ + create table if not exists geoserver.{property_table} + ( + id integer generated always as identity + constraint {property_table}_pk + primary key, + name varchar not null, + technical_id varchar, + min_lat numeric, + min_lon numeric, + max_lat numeric, + max_lon numeric, + geom geometry, + gtype varchar, + srid varchar + {properties} + );""".format(property_table=self.property_table, properties=self.get_custom_column_defs(STA_PROPERTY))) + + stmts.append("create unique index if not exists unique_{table} on geoserver.{table} (name);".format(table=self.property_table)) + + stmts.append(""" + create table if not exists geoserver.{timeseries_table} + ( + id integer generated always as identity + constraint {timeseries_table}_pk + primary key, + feature_id integer not null + constraint {timeseries_table}_{feature_table}_id_fk + references geoserver.{feature_table}, + property_id integer not null + constraint {timeseries_table}_{property_table}_id_fk + references geoserver.{property_table}, + unit varchar, + lat numeric, + lon numeric, + geom geometry, + gtype varchar, + srid varchar, + min_value double precision, + max_value double precision, + min_date timestamp, + max_date timestamp + );""".format(timeseries_table=self.timeseries_table, feature_table=self.feature_table, property_table=self.property_table)) + + stmts.append("create unique index if not exists unique_{table} on geoserver.{table} (feature_id, property_id, unit);".format(table=self.timeseries_table)) + + stmts.append("create index if not exists {table}_timeseries_id_index on geoserver.{table} (feature_id);".format(table=self.timeseries_table)) + + stmts.append(""" + create table if not exists geoserver.{timerecord_table} + ( + id integer generated always as identity + constraint {timerecord_table}_pk + primary key, + timeseries_id integer not null + constraint "{timerecord_table}_{timeseries_table}_id_fk" + references geoserver.{timeseries_table}, + date timestamp not null, + value_text varchar, + lat numeric, + lon numeric, + geom geometry, + gtype varchar, + import_id integer not null, + value_number double precision, + srid varchar + {properties} + ); + """.format( + timerecord_table=self.timerecord_table, + timeseries_table=self.timeseries_table, + properties=self.get_custom_column_defs(STA_OBSERVATION) + ) + ) + + stmts.append("create index if not exists {table}_timeseries_id_index on geoserver.{table} (timeseries_id);".format(table=self.timerecord_table)) + + tables = [ + "data_import_wfsimportrecord", + self.feature_table, + self.property_table, + self.timeseries_table, + self.timerecord_table + ] + + try: + for stmt in stmts: + c.execute(stmt) + + for table in tables: + c.execute("alter table geoserver.{table} owner to admin;".format(table=table)) + + except Exception as e: + self.logs.append(str(e)) + self.error = True + finally: + c.close() + + def get_custom_columns(self, entity_type: str): + result = "" + for extra_column in CsvParserExtraColumn.objects.filter(parser=self.parser).order_by("id"): + if extra_column.related_entity == entity_type: + result += ", " + extra_column.col_name + return result + + def get_custom_column_selects(self, entity_type: str, field: str): + columns = [] + for extra_column in CsvParserExtraColumn.objects.filter(parser=self.parser): + + if extra_column.related_entity == entity_type: + value = field + "->>'" + extra_column.col_name + "'" + + if extra_column.type_in_db == 'timestamp': + columns.append("to_timestamp({}, 'YYYY-MM-DDThh24:mi:ss')::timestamp without time zone at time zone 'Etc/UTC'".format(value)) + elif extra_column.type_in_db == 'double precision': + columns.append("(SELECT CASE WHEN {value}~E'[0-9]+\.?[0-9]*' THEN CAST({value} as double precision) ELSE NULL END)".format(value=value)) + elif extra_column.type_in_db == 'integer': + columns.append("(SELECT CASE WHEN {value}~E'[0-9]+' THEN CAST({value} as integer) ELSE NULL END)".format(value=value)) + else: + columns.append(value) + + result = "" + if len(columns) > 0: + result = ",\n" + ",\n".join(columns) + return result + + def get_custom_column_defs(self, entity_type: str): + columns = [] + for extra_column in CsvParserExtraColumn.objects.filter(parser=self.parser): + if extra_column.related_entity == entity_type: + columns.append(extra_column.col_name + " " + extra_column.type_in_db) + + result = "" + if len(columns) > 0: + result = ",\n" + ",\n".join(columns) + return result \ No newline at end of file diff --git a/backend/data_import/api/StaApi.py b/backend/data_import/lib/StaImporter.py similarity index 71% rename from backend/data_import/api/StaApi.py rename to backend/data_import/lib/StaImporter.py index f3c7766bac24ea244c336e8b487837c115e2ff0f..f1ed4d8c8f7dd404f8e94d27995b26bcfcd669eb 100644 --- a/backend/data_import/api/StaApi.py +++ b/backend/data_import/lib/StaImporter.py @@ -1,9 +1,14 @@ import requests +import csv from data_import.models import CsvImportJob, ExtendedPointData, PointData # noqa +from data_import.models import CsvParser, CsvImportJob, CsvParserExtraColumn, CsvIncludeCriteria, PointData, ExtendedPointData # noqa +from main.models import StaThingProperty # noqa +from data_import.models import STA_THING # noqa +from data_import.lib.utils import is_row_included # noqa -class StaApi: +class StaImporter: def __init__(self, job: CsvImportJob): self.error = False @@ -14,6 +19,42 @@ class StaApi: self.sta_url = self.sta_endpoint.base_url.rstrip('/') + '/v1.1/' self.auth = (self.sta_endpoint.username, self.sta_endpoint.password) + + def import_csv_in_sta(self): + file_name = self.import_job.s3_file.split('/')[-1] + + with open(file_name, newline='') as csvfile: + + reader = csv.reader(csvfile, delimiter=',') + next(reader) # skip header + thing_props = StaThingProperty.objects.filter(endpoint=self.import_job.bucket.sta_endpoint) + extra_columns = CsvParserExtraColumn.objects.filter(parser=self.import_job.bucket.csv_parser) + + include_criteria = CsvIncludeCriteria.objects.filter(parser=self.import_job.bucket.csv_parser) + + rows_succeeded = 0 + rows_failed = 0 + for row in reader: + + if is_row_included(row, include_criteria): + + point_data = create_point_data(self.import_job, row) + extended_data = create_extended_data(point_data, extra_columns, thing_props, row) + + self.import_point_data(point_data, extended_data) + + if self.error is True: + point_data.validation_error = "\n".join(self.logs) + point_data.save() + for data in extended_data: + data.save() + rows_failed += 1 + else: + rows_succeeded += 1 + self.error = False + self.logs = [] + + def import_point_data(self, point_data: PointData, extended_point_data: list[ExtendedPointData]): location = self.get_location_json(point_data) @@ -125,7 +166,6 @@ class StaApi: print('error {}: {}'.format(route, content)) return False - def get_thing_json(self, point_data: PointData, extended_data: list[ExtendedPointData]): thing = { "name": point_data.thing_name, @@ -213,3 +253,46 @@ def sanitize_str(text: str): result = text.replace("'", "''") result = result.replace("+", "%2b") return result.replace("/", "%2F") + + +def create_point_data(job: CsvImportJob, row): + p: CsvParser = job.bucket.csv_parser + + point_data = PointData( + import_job = job, + thing_name = row[p.station_col_num], + location_name = row[p.station_col_num], + coord_lat = row[p.lat_col_num], + coord_lon = row[p.lon_col_num], + # geometry = '', + property = row[p.property_col_num], + # sensor = '', + result_value = row[p.value_col_num], + result_unit = row[p.unit_col_num], + result_time = row[p.time_col_num] + ) + return point_data + + +def create_extended_data(point_data: PointData, extra_columns: list[CsvParserExtraColumn], thing_props: list[StaThingProperty], row): + result = [] + + for prop in thing_props: + extended_data = ExtendedPointData( + point_data = point_data, + related_entity = STA_THING, + name = prop.property_key, + value = prop.property_value + ) + result.append(extended_data) + + for column in extra_columns: + extended_data = ExtendedPointData( + point_data = point_data, + related_entity = column.related_entity, + name = column.col_name, + value = row[column.col_num] + ) + result.append(extended_data) + + return result diff --git a/backend/data_import/lib/utils.py b/backend/data_import/lib/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..8b4e87df28591ac80dec43da7788ec1e98dc554c --- /dev/null +++ b/backend/data_import/lib/utils.py @@ -0,0 +1,12 @@ + +from data_import.models import CsvIncludeCriteria # noqa + + +def is_row_included(row, include_criteria: list[CsvIncludeCriteria]) -> bool: + if len(include_criteria) > 0: + for criteria in include_criteria: + if row[criteria.col_num] == criteria.text_value: + return True + return False + else: + return True diff --git a/backend/data_import/migrations/0004_wfsimportrecord_csvimportjob_target_and_more.py b/backend/data_import/migrations/0004_wfsimportrecord_csvimportjob_target_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..acc57479a857ee447fd3691cd481f4264fcf70ad --- /dev/null +++ b/backend/data_import/migrations/0004_wfsimportrecord_csvimportjob_target_and_more.py @@ -0,0 +1,74 @@ +# Generated by Django 4.2.16 on 2024-11-15 10:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('data_import', '0003_csvimportjob_finished_at_csvimportjob_started_at_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='WfsImportRecord', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('location_id', models.CharField(blank=True, max_length=1000, null=True)), + ('location_name', models.CharField(blank=True, max_length=1000, null=True)), + ('type_id', models.CharField(blank=True, max_length=1000, null=True)), + ('type_name', models.CharField(blank=True, max_length=1000, null=True)), + ('period_start', models.CharField(blank=True, max_length=1000, null=True)), + ('period_end', models.CharField(blank=True, max_length=1000, null=True)), + ('lat', models.CharField(blank=True, max_length=1000, null=True)), + ('lon', models.CharField(blank=True, max_length=1000, null=True)), + ('srid', models.CharField(blank=True, max_length=1000, null=True)), + ('geom', models.CharField(blank=True, max_length=4000, null=True)), + ('gtype', models.CharField(blank=True, max_length=1000, null=True)), + ('country', models.CharField(blank=True, max_length=1000, null=True)), + ('community', models.CharField(blank=True, max_length=1000, null=True)), + ('corine', models.CharField(blank=True, max_length=1000, null=True)), + ('value', models.CharField(blank=True, max_length=1000, null=True)), + ('date', models.CharField(blank=True, max_length=1000, null=True)), + ('unit', models.CharField(blank=True, max_length=1000, null=True)), + ('property_name', models.CharField(blank=True, max_length=1000, null=True)), + ('property_id', models.CharField(blank=True, max_length=1000, null=True)), + ('location_properties', models.JSONField(blank=True, null=True)), + ('property_properties', models.JSONField(blank=True, null=True)), + ('record_properties', models.JSONField(blank=True, null=True)), + ('bucket_name', models.CharField(max_length=1000)), + ('import_job', models.PositiveIntegerField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.AddField( + model_name='csvimportjob', + name='target', + field=models.CharField(blank=True, max_length=1000, null=True), + ), + migrations.AddField( + model_name='csvparser', + name='community_col_num', + field=models.IntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='csvparser', + name='country_col_num', + field=models.IntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='csvparser', + name='location_id_col_num', + field=models.IntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='csvparser', + name='property_id_col_num', + field=models.IntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='csvparser', + name='target', + field=models.CharField(choices=[('WFS', 'Web Feature Service'), ('STA', 'Sensorthings API')], default='WFS', max_length=20), + ), + ] diff --git a/backend/data_import/migrations/0005_csvparserextracolumn_type_in_db.py b/backend/data_import/migrations/0005_csvparserextracolumn_type_in_db.py new file mode 100644 index 0000000000000000000000000000000000000000..da7b2140be019a7906e8846bec2e54538155cf51 --- /dev/null +++ b/backend/data_import/migrations/0005_csvparserextracolumn_type_in_db.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-12-13 13:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('data_import', '0004_wfsimportrecord_csvimportjob_target_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='csvparserextracolumn', + name='type_in_db', + field=models.CharField(choices=[('varchar', 'string'), ('integer', 'integer'), ('double precision', 'float'), ('timestamp', 'date')], default='varchar', max_length=20), + ), + ] diff --git a/backend/data_import/migrations/0006_csvparser_table_prefix.py b/backend/data_import/migrations/0006_csvparser_table_prefix.py new file mode 100644 index 0000000000000000000000000000000000000000..01ba855ff23c320e9b83ce5f8dfba3b7f11ed2a9 --- /dev/null +++ b/backend/data_import/migrations/0006_csvparser_table_prefix.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.16 on 2024-12-13 16:21 + +import data_import.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('data_import', '0005_csvparserextracolumn_type_in_db'), + ] + + operations = [ + migrations.AddField( + model_name='csvparser', + name='table_prefix', + field=models.CharField(blank=True, max_length=20, null=True, unique=True, validators=[data_import.models.validate_prefix]), + ), + ] diff --git a/backend/data_import/migrations/0007_remove_wfsimportrecord_type_id.py b/backend/data_import/migrations/0007_remove_wfsimportrecord_type_id.py new file mode 100644 index 0000000000000000000000000000000000000000..5595a530daaa0db5559a002c753e89cc553b4be1 --- /dev/null +++ b/backend/data_import/migrations/0007_remove_wfsimportrecord_type_id.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.16 on 2024-12-13 16:30 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('data_import', '0006_csvparser_table_prefix'), + ] + + operations = [ + migrations.RemoveField( + model_name='wfsimportrecord', + name='type_id', + ), + ] diff --git a/backend/data_import/models.py b/backend/data_import/models.py index 3f91c1950ae548c2bf789ebc8cce7c74055c1b31..fcc75fcea8b80d1831bace57d9f4dcc217dadd10 100644 --- a/backend/data_import/models.py +++ b/backend/data_import/models.py @@ -1,15 +1,41 @@ from django.db import models # noqa from django.contrib.auth.models import Group # noqa +import re +from django.core.exceptions import ValidationError # noqa STA_THING = 'thing' +STA_PROPERTY = 'property' +STA_OBSERVATION = 'observation' -STA_COLUMN_TYPES = ( +CSV_ENTITIES = ( (STA_THING, 'thing'), - ('property', 'property'), - ('observation', 'observation'), + (STA_PROPERTY, 'property'), + (STA_OBSERVATION, 'observation'), +) + +DB_COLUMN_TYPES = ( + ('varchar', 'string'), + ('integer', 'integer'), + ('double precision', 'float'), + ('timestamp', 'date'), +) + +WFS = 'WFS' +STA = 'STA' + +TARGETS = ( + (WFS, 'Web Feature Service'), + (STA, 'Sensorthings API'), ) +def validate_prefix(value): + if len(value) < 3 or len(value) > 20: + raise ValidationError("Length must be between 3 and 20 characters.") + if not re.match(r'^[a-z]*$', value): + raise ValidationError('Only lowercase-letters are allowed.') + + class CsvParser(models.Model): lat_col_num = models.IntegerField() lon_col_num = models.IntegerField() @@ -18,6 +44,12 @@ class CsvParser(models.Model): value_col_num = models.IntegerField() unit_col_num = models.IntegerField() time_col_num = models.IntegerField() + location_id_col_num = models.IntegerField(blank=True, null=True) + property_id_col_num = models.IntegerField(blank=True, null=True) + country_col_num = models.IntegerField(blank=True, null=True) + community_col_num = models.IntegerField(blank=True, null=True) + table_prefix = models.CharField(max_length=20, blank=True, null=True, unique=True, validators=[validate_prefix]) + target = models.CharField(max_length=20, choices=TARGETS, default=WFS) group = models.ForeignKey(Group, on_delete=models.CASCADE, null=True) class Meta: @@ -25,13 +57,14 @@ class CsvParser(models.Model): verbose_name_plural = "CSV-Parser" def __str__(self): - return 'Parser ' + str(self.id) + return '{} Parser ({}) {}'.format(self.target, str(self.group), str(self.id)) class CsvParserExtraColumn(models.Model): parser = models.ForeignKey(CsvParser, on_delete=models.CASCADE) - related_entity = models.CharField(max_length=20, choices=STA_COLUMN_TYPES, default=STA_THING) + related_entity = models.CharField(max_length=20, choices=CSV_ENTITIES, default=STA_THING) col_num = models.IntegerField() col_name = models.CharField(max_length=100) + type_in_db = models.CharField(max_length=20, choices=DB_COLUMN_TYPES, default='varchar') class CsvIncludeCriteria(models.Model): parser = models.ForeignKey(CsvParser, on_delete=models.CASCADE) @@ -54,6 +87,7 @@ class CsvImportJob(models.Model): created_at = models.DateTimeField(auto_now_add=True) started_at = models.DateTimeField(blank=True, null=True) finished_at = models.DateTimeField(blank=True, null=True) + target = models.CharField(max_length=1000, blank=True, null=True) class Meta: verbose_name = "CSV-Import-Job" @@ -81,10 +115,37 @@ class PointData(models.Model): class ExtendedPointData(models.Model): point_data = models.ForeignKey(PointData, on_delete=models.CASCADE) - related_entity = models.CharField(max_length=20, choices=STA_COLUMN_TYPES, default='thing') + related_entity = models.CharField(max_length=20, choices=CSV_ENTITIES, default='thing') name = models.CharField(max_length=100) value = models.CharField(max_length=1000) class Meta: verbose_name = "Additional Value" verbose_name_plural = "Additional Values" + + +class WfsImportRecord(models.Model): + location_id = models.CharField(max_length=1000, blank=True, null=True) + location_name = models.CharField(max_length=1000, blank=True, null=True) + type_name = models.CharField(max_length=1000, blank=True, null=True) + period_start = models.CharField(max_length=1000, blank=True, null=True) + period_end = models.CharField(max_length=1000, blank=True, null=True) + lat = models.CharField(max_length=1000, blank=True, null=True) + lon = models.CharField(max_length=1000, blank=True, null=True) + srid = models.CharField(max_length=1000, blank=True, null=True) + geom = models.CharField(max_length=4000, blank=True, null=True) + gtype = models.CharField(max_length=1000, blank=True, null=True) + country = models.CharField(max_length=1000, blank=True, null=True) + community = models.CharField(max_length=1000, blank=True, null=True) + corine = models.CharField(max_length=1000, blank=True, null=True) + value = models.CharField(max_length=1000, blank=True, null=True) + date = models.CharField(max_length=1000, blank=True, null=True) + unit = models.CharField(max_length=1000, blank=True, null=True) + property_name = models.CharField(max_length=1000, blank=True, null=True) + property_id = models.CharField(max_length=1000, blank=True, null=True) + location_properties = models.JSONField(blank=True, null=True) + property_properties = models.JSONField(blank=True, null=True) + record_properties = models.JSONField(blank=True, null=True) + bucket_name = models.CharField(max_length=1000) + import_job = models.PositiveIntegerField() + created_at = models.DateTimeField(auto_now_add=True) \ No newline at end of file diff --git a/backend/gdi-admin/settings.py b/backend/gdi-admin/settings.py index ebce23e05d9a88f0dde08b34fd2a993f7a58f5cd..a6c7f39d8aeb1ef79a75641ee8297e00868c3cf4 100644 --- a/backend/gdi-admin/settings.py +++ b/backend/gdi-admin/settings.py @@ -100,7 +100,19 @@ DATABASES = { 'HOST': os.environ.get('POSTGRES_HOST'), 'PORT': 5432, 'OPTIONS': {'sslmode': os.environ.get('POSTGRES_SSLMODE')}, - } + }, + "geoserver": { + 'ENGINE': 'django.db.backends.postgresql', + "NAME": os.environ.get('POSTGRES_DB'), + "USER": "admin", + "PASSWORD": "admin", + 'HOST': os.environ.get('POSTGRES_HOST'), + 'PORT': 5432, + 'OPTIONS': { + 'sslmode': os.environ.get('POSTGRES_SSLMODE'), + 'options': '-c search_path=geoserver,postgis' + }, + }, } diff --git a/backend/gdi-admin/urls.py b/backend/gdi-admin/urls.py index 897b54eb6066b4309f2b0d8df53b4882165af295..c2018726deac69c9b70c871348456d7049c5fbdc 100644 --- a/backend/gdi-admin/urls.py +++ b/backend/gdi-admin/urls.py @@ -16,7 +16,7 @@ Including another URLconf from django.contrib import admin from django.urls import include, path, re_path -from main.views import views, aggregation_requests, download_requests, timeseries_request, sta_requests +from main.views import views, aggregation_requests, download_requests, timeseries_request, sta_requests, wfs_requests # noqa from main.about import basic_site from health_check.views import get_health @@ -28,13 +28,13 @@ urlpatterns = [ path('gdi-backend/download/zip/<record_id>', download_requests.get_zip_download, name='zipDownload'), path('gdi-backend/countries', aggregation_requests.get_countries, name='countries'), re_path(r'^gdi-backend/proxy', views.proxy_geoserver_request, name='proxy'), - path('gdi-backend/sta-thing-count/<int:sta_layer_id>', sta_requests.proxy_sta_thing_count, name='sta-thing-count'), path('gdi-backend/sta-locations/<int:sta_layer_id>', sta_requests.proxy_sta_locations, name='sta-locations'), path('gdi-backend/sta-obs-properties/<int:sta_layer_id>', sta_requests.proxy_sta_obs_properties, name='sta-obs-properties'), path('gdi-backend/sta-thing/<int:sta_endpoint_id>/<int:thing_id>', sta_requests.proxy_sta_thing, name='sta-thing'), path('gdi-backend/sta-obs-properties-by-thing/<int:sta_layer_id>/<int:thing_id>', sta_requests.proxy_sta_obs_properties_by_thing, name='sta-obs-properties-by-thing'), path('gdi-backend/sta-datastream-properties/<int:sta_endpoint_id>/<int:thing_id>/<int:obs_property_id>', sta_requests.proxy_sta_datastream_properties, name='sta-datastream-properties'), path('gdi-backend/sta-all-datastreams/<int:sta_endpoint_id>/<int:thing_id>/<int:obs_property_id>', sta_requests.proxy_sta_all_datastreams, name='sta-all-datastreams'), + path('gdi-backend/wfs-features/<str:workspace>/<str:prefix>', wfs_requests.wfs_features, name='wfs-features'), re_path(r'^gdi-backend/timeseries/(?P<locale>[a-z]{2})', timeseries_request.request_all_time_series, name='request_time_series'), re_path(r'^gdi-backend/process', aggregation_requests.call_process, name='aggregation'), re_path(r'^gdi-backend/districts/(?P<country_id>[0-9][0-9])$', aggregation_requests.get_districts_by_country, name='districts'), diff --git a/backend/main/fixtures/test_db_content.json b/backend/main/fixtures/test_db_content.json index bb2e3e65c9cf57ab9e3fd3e4b16786caf7ee052b..1080e81cc8ffbb590ca9f7451e645f52630c19d7 100644 --- a/backend/main/fixtures/test_db_content.json +++ b/backend/main/fixtures/test_db_content.json @@ -19,6 +19,7 @@ "connect_to_geoserver": true, "connect_to_thredds": true, "connect_to_sta": true, + "connect_to_wfs": false, "sta_endpoint": 1, "csv_parser": 1, "public_folder": false, @@ -38,12 +39,14 @@ "connect_to_geoserver": false, "connect_to_thredds": false, "connect_to_sta": false, + "connect_to_wfs": true, "sta_endpoint": null, - "csv_parser": null, + "csv_parser": 2, "public_folder": false, "quota": 100, "size_unit": "gi", - "wms_file_suffix": null + "wms_file_suffix": null, + "wfs_file_suffix": null } }, { @@ -101,7 +104,7 @@ "imprint_text_en": "", "faq_text_de": "", "faq_text_en": "", - "theme": "blue", + "theme": "default", "default_lon": "10.4500000000", "default_lat": "52.0000000000", "default_zoom": "6.00000000", @@ -115,7 +118,7 @@ "viewer": null, "print_annotation_de": null, "print_annotation_en": null, - "base_map": "baseMap" + "base_layer": "baseMap" } }, { @@ -136,8 +139,8 @@ "model": "main.area", "pk": 2, "fields": { - "name_de": "Zeitreihendaten", - "name_en": "Timeseries data", + "name_de": "Punktdaten", + "name_en": "Point Data", "project": 1, "position": 2, "is_active": true, @@ -738,17 +741,17 @@ "model": "main.wfslayer", "pk": 1, "fields": { - "name_de": "Köln", - "name_en": "Cologne", + "name_de": "Chemikalien", + "name_en": "Chemicals", "info_de": "", "info_en": "", "time_format": null, "y_axis_min": null, "y_axis_max": null, - "bucket": 1, - "wfs_url": "http://localhost:5001/gdi-backend/proxy/test-bucket/wfs", - "layer_name": "test-bucket:cologne_polygon", - "file_path": "shp/cologne_polygon_shp.zip" + "bucket": 2, + "workspace": "test-bucket", + "prefix": "test_bucket", + "file_path": "chemicals/*.csv" } }, { @@ -763,7 +766,24 @@ "y_axis_min": null, "y_axis_max": null, "bucket": 1, - "file_path": "public/bw.json" + "file_path": "public/bw.json", + "url": "http://localhost:9000/test-bucket/public/bw.json" + } + }, + { + "model": "main.geojsonlayer", + "pk": 2, + "fields": { + "name_de": "Köln", + "name_en": "Cologne", + "info_de": "", + "info_en": "", + "time_format": null, + "y_axis_min": null, + "y_axis_max": null, + "bucket": 1, + "file_path": "shp/cologne_polygon_shp.zip", + "url": "http://localhost:5001/gdi-backend/proxy/test-bucket/ows?service=WFS&version=2.0.0&request=GetFeature&typename=test-bucket:cologne_polygon&outputFormat=application/json&srsname=EPSG:3857" } }, { @@ -836,6 +856,20 @@ "wfs_layer": 1 } }, + { + "model": "main.arealayer", + "pk": 6, + "fields": { + "area": 2, + "position": 1, + "is_active": true, + "is_default_on_start": false, + "wms_layer": null, + "sta_layer": null, + "geojson_layer": 2, + "wfs_layer": null + } + }, { "model": "main.wmslayerlegend", "pk": 1, @@ -1229,15 +1263,15 @@ "record_id": "zyxwvmkcO9Ss26kiROhGIXQzvMr7L7gF", "publisher": 1, "version": "1.0", - "language": "en", + "language": null, "format": 3, "license": 1, "title": "Drought Monitor", "created_at": "2024-09-27T17:46:36.247Z", - "updated_at": "2024-09-30T09:41:35.295Z", + "updated_at": "2024-11-29T11:04:29.173Z", "published_at": "2024-09-30", - "abstract": "Test", - "keywords": "[\"Drought\", \"Environment\", \"Soil\"]" + "abstract": "test", + "keywords": "[\"Drought\", \"Germany\"]" } }, { @@ -1295,6 +1329,31 @@ "value_col_num": 3, "unit_col_num": 5, "time_col_num": 4, + "location_id_col_num": null, + "property_id_col_num": null, + "country_col_num": null, + "community_col_num": null, + "target": "STA", + "group": 1 + } + }, + { + "model": "data_import.csvparser", + "pk": 2, + "fields": { + "lat_col_num": 25, + "lon_col_num": 26, + "station_col_num": 27, + "property_col_num": 2, + "value_col_num": 17, + "unit_col_num": 18, + "time_col_num": 21, + "location_id_col_num": null, + "property_id_col_num": 1, + "country_col_num": 28, + "community_col_num": null, + "target": "WFS", + "table_prefix": "chemicals", "group": 1 } }, @@ -1308,6 +1367,95 @@ "col_name": "station-id" } }, + { + "model": "data_import.csvparserextracolumn", + "pk": 2, + "fields": { + "parser": 2, + "related_entity": "thing", + "col_num": 4, + "col_name": "river_km" + } + }, + { + "model": "data_import.csvparserextracolumn", + "pk": 3, + "fields": { + "parser": 2, + "related_entity": "observation", + "col_num": 15, + "col_name": "concentration_data" + } + }, + { + "model": "data_import.csvparserextracolumn", + "pk": 4, + "fields": { + "parser": 2, + "related_entity": "observation", + "col_num": 16, + "col_name": "concentration_individual" + } + }, + { + "model": "data_import.csvparserextracolumn", + "pk": 5, + "fields": { + "parser": 2, + "related_entity": "observation", + "col_num": 19, + "col_name": "sampling_depth_type" + } + }, + { + "model": "data_import.csvparserextracolumn", + "pk": 6, + "fields": { + "parser": 2, + "related_entity": "observation", + "col_num": 20, + "col_name": "sampling_depth" + } + }, + { + "model": "data_import.csvparserextracolumn", + "pk": 7, + "fields": { + "parser": 2, + "related_entity": "observation", + "col_num": 22, + "col_name": "IDX_l" + } + }, + { + "model": "data_import.csvparserextracolumn", + "pk": 8, + "fields": { + "parser": 2, + "related_entity": "thing", + "col_num": 29, + "col_name": "water_body" + } + }, + { + "model": "data_import.csvparserextracolumn", + "pk": 9, + "fields": { + "parser": 2, + "related_entity": "thing", + "col_num": 30, + "col_name": "river_basin" + } + }, + { + "model": "data_import.csvincludecriteria", + "pk": 1, + "fields": { + "parser": 2, + "col_num": 31, + "text_value": "TRUE" + } + }, { "model": "admin_interface.theme", "pk": 1, diff --git a/backend/main/forms/bucket.py b/backend/main/forms/bucket.py index d322260b20a1b5bac68e4abe9298d7a258db283b..00580497782995208a9196955d2efe01eeda6d76 100644 --- a/backend/main/forms/bucket.py +++ b/backend/main/forms/bucket.py @@ -99,7 +99,7 @@ class BucketAdmin(admin.ModelAdmin): model = Bucket form = BucketForm inlines = [BucketUserInline] - list_display = ('name', 'group', 'connect_to_geoserver', 'connect_to_thredds', 'connect_to_sta', 'link_to_event_logs') + list_display = ('name', 'group', 'connect_to_geoserver', 'connect_to_thredds', 'connect_to_sta', 'connect_to_wfs', 'link_to_event_logs') ordering = ('name',) fieldsets = [ @@ -107,7 +107,7 @@ class BucketAdmin(admin.ModelAdmin): 'fields': ('name', 'group', 'public_folder', ('quota', 'size_unit'), ), }), (None, { - 'fields': (('connect_to_geoserver', 'wms_file_suffix', 'wfs_file_suffix'), 'connect_to_thredds', ('connect_to_sta', 'sta_endpoint', 'csv_parser'), ), + 'fields': (('connect_to_geoserver', 'wms_file_suffix', 'wfs_file_suffix'), 'connect_to_thredds', ('connect_to_sta', 'sta_endpoint'), 'connect_to_wfs', 'csv_parser', ), }) ] diff --git a/backend/main/forms/layer/geojson_layer.py b/backend/main/forms/layer/geojson_layer.py index 2c966440bc593fd5d5e340311430bf2e8ae1c855..b6c7e0c518559bdd8ea114c6a09ed201c7f82fce 100644 --- a/backend/main/forms/layer/geojson_layer.py +++ b/backend/main/forms/layer/geojson_layer.py @@ -17,7 +17,7 @@ class GeojsonLayerAdmin(DjangoTabbedChangeformAdmin, admin.ModelAdmin): fieldsets = [ (None, { - 'fields': (('name_de', 'name_en'), 'bucket', 'file_path' ), + 'fields': (('name_de', 'name_en'), 'bucket', 'url', 'file_path' ), 'classes': ('tab-basic',), }), (None, { diff --git a/backend/main/forms/layer/wfs_layer.py b/backend/main/forms/layer/wfs_layer.py index 3e56ec51e1e3d10c9c215931254086b647cc5694..5005d436416665bf2e2092255f29db3e5d940240 100644 --- a/backend/main/forms/layer/wfs_layer.py +++ b/backend/main/forms/layer/wfs_layer.py @@ -11,11 +11,11 @@ from django.forms import Textarea # noqa class WfsLayerAdmin(DjangoTabbedChangeformAdmin, admin.ModelAdmin): model = WfsLayer - list_display = ('__str__', 'layer_name') + list_display = ('__str__', 'workspace', 'prefix') save_as = True inlines = [FkAreaLayerInline ] - search_fields = ["layer_name", "name_de", "name_en", "info_de", "info_en", "wfs_url"] + search_fields = ["name_de", "name_en", "info_de", "info_en", "workspace", "prefix"] formfield_overrides = { models.TextField: {'widget': Textarea( @@ -29,7 +29,7 @@ class WfsLayerAdmin(DjangoTabbedChangeformAdmin, admin.ModelAdmin): 'classes': ('tab-basic',), }), (None, { - 'fields': ('layer_name', 'wfs_url'), + 'fields': ('workspace', 'prefix'), 'classes': ('tab-wfs',), }), (None, { @@ -46,7 +46,7 @@ class WfsLayerAdmin(DjangoTabbedChangeformAdmin, admin.ModelAdmin): ] def get_readonly_fields(self, request, obj): - fields = ['bucket', 'wfs_url', 'layer_name', 'file_path'] + fields = ['bucket', 'file_path'] if request.user.is_superuser: return [] return fields diff --git a/backend/main/forms/project.py b/backend/main/forms/project.py index c7f8a2de5c393876d8f882b26c5c715aef2ae2bf..49dfe08873541dc976388de06f142e037518c50b 100644 --- a/backend/main/forms/project.py +++ b/backend/main/forms/project.py @@ -28,7 +28,7 @@ class ProjectAdmin(DjangoTabbedChangeformAdmin, admin.ModelAdmin): 'classes': ('tab-basic',), }), (None, { - 'fields': (('show_in_gallery', 'gallery_image', 'position_in_gallery'), 'base_map',), + 'fields': (('show_in_gallery', 'gallery_image', 'position_in_gallery'), ('base_layer', 'is_base_layer_gray'),), 'classes': ('tab-basic',), }), ('Imprint-Page', { diff --git a/backend/main/lib/api/GeoserverApi.py b/backend/main/lib/api/GeoserverApi.py index 932494e91b5f80be7f45f7ef734f4ab1fdeffe2c..ddb4dbd160bc0ea2d6c05b0150e9d8b0f07819f7 100644 --- a/backend/main/lib/api/GeoserverApi.py +++ b/backend/main/lib/api/GeoserverApi.py @@ -1,12 +1,12 @@ import os import requests from requests.adapters import HTTPAdapter -from requests.packages.urllib3.util.retry import Retry +from requests.packages.urllib3.util.retry import Retry # noqa +from xml.etree import ElementTree import json -from geoserver.catalog import Catalog -from owslib.wms import WebMapService +from geoserver.catalog import Catalog # noqa GEOSERVER_MOSAIC_DIR = "/opt/geoserver/data_dir/mosaic" @@ -25,9 +25,10 @@ class GeoServerApi: self.logs = [] self.has_error = False + self.has_timeout = False retry_strategy = Retry( - total=10, + total=3, backoff_factor=2, allowed_methods=['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT', 'POST'] ) @@ -43,6 +44,7 @@ class GeoServerApi: def reset(self): self.logs = [] self.has_error = False + self.has_timeout = False def create_workspace(self, workspace_name): # check for existing workspace @@ -58,7 +60,7 @@ class GeoServerApi: else: self.log("Workspace already exists. Existing Workspace will be used: {}".format(workspace_name)) - def create_datastore(self, workspace, data_store, object_url, type, store_type='coveragestores'): + def create_datastore(self, workspace, data_store, object_url, data_type, store_type='coveragestores'): self.create_workspace(workspace) @@ -67,25 +69,28 @@ class GeoServerApi: else: headers = {"Content-type": "text/plain"} url = '{0}workspaces/{1}/{2}/{3}/{4}'.format( - self.url, workspace, store_type, data_store, type) + self.url, workspace, store_type, data_store, data_type) try: - response = self.session.put(url, data=object_url, auth=(self.username, self.password), headers=headers, timeout=10) + response = self.session.put(url, data=object_url, auth=(self.username, self.password), headers=headers, timeout=5) if response.status_code != 201: self.log("Create status not ok:") self.log(response.content) self.has_error = True else: self.log("Datastore {} created successfully".format(data_store)) + except requests.exceptions.RetryError: + self.log("Timeout, datastore could not be created: {}".format(data_store)) + self.has_timeout = True except Exception as e: - print("Datastore could not be created: {}".format(e)) + self.log("Error, datastore could not be created: {}".format(e)) self.has_error = True - def _create_new_style(self, style_name, style_xml): + def _create_new_style(self, style_xml): try: headers = {"Content-type": "application/vnd.ogc.sld+xml"} url = '{0}styles?raw=true'.format(self.url) - response = self.session.post(url=url, auth=(self.username, self.password), headers=headers, data=style_xml, timeout=10) + response = self.session.post(url=url, auth=(self.username, self.password), headers=headers, data=style_xml, timeout=5) if response.status_code != 201: print('Style could not be created: {}'.format(response.content)) self.has_error = True @@ -99,7 +104,7 @@ class GeoServerApi: try: headers = {"Content-type": "application/vnd.ogc.sld+xml"} url = '{0}styles/{1}?raw=true'.format(self.url, style_name) - response = self.session.put(url=url, auth=(self.username, self.password), headers=headers, data=style_xml, timeout=10) + response = self.session.put(url=url, auth=(self.username, self.password), headers=headers, data=style_xml, timeout=5) if response.status_code != 200: print('Style could not be updated: {}'.format(response.content)) self.has_error = True @@ -112,13 +117,13 @@ class GeoServerApi: def create_style(self, style_name, style_xml): try: url = '{0}styles/{1}.sld'.format(self.url, style_name) - response = self.session.get(url=url, auth=(self.username, self.password), timeout=10) + response = self.session.get(url=url, auth=(self.username, self.password), timeout=5) style_xml = style_xml.encode('utf-8') if response.status_code != 200: print('Style does not exist yet. Style will be added as new.') - self._create_new_style(style_name, style_xml) + self._create_new_style(style_xml) else: print('Update existing style') self._update_style(style_name, style_xml) @@ -139,7 +144,7 @@ class GeoServerApi: } try: - response = self.session.post(url, data=json.dumps(body), auth=(self.username, self.password), headers=headers, timeout=10) + response = self.session.post(url, data=json.dumps(body), auth=(self.username, self.password), headers=headers, timeout=5) if response.status_code != 201: print("Create status not ok: {}".format(response.content)) @@ -177,41 +182,46 @@ class GeoServerApi: "</coverage>" ) try: - response = self.session.put(url, data=time_data, auth=(self.username, self.password), headers=headers, timeout=10) + response = self.session.put(url, data=time_data, auth=(self.username, self.password), headers=headers, timeout=5) if response.status_code not in [200, 201]: self.log("Time dimension status for coverage {} not ok: {}".format(native_name, response.content)) self.has_error = True else: self.log("Time dimension for coverage {} enabled successfully".format(native_name)) + except requests.exceptions.RetryError: + self.log("Timeout, coverage_store could not be configured: {}".format(coverage_store)) + self.has_timeout = True except Exception as e: self.log("Time dimension for coverage {} could not be enabled: {}".format(native_name, e)) self.has_error = True - def update_timesteps_and_save(self, layer, wms_name): - wms_url = os.environ.get("GEOSERVER_URL") + '/ows?service=wms&version=1.3.0&request=GetCapabilities' + def get_wms_timesteps(self, workspace, layer_name): + wms_url = '{}/ows?service=wms&version=1.3.0&request=GetCapabilities&namespace={}'.format(os.environ.get("GEOSERVER_URL"), workspace) try: - wms = WebMapService(wms_url, version='1.3.0') - - # iterate all available wms 'LAYERS' which exist in Geoserver - for wms_variable in list(wms.contents): - if wms_name == wms_variable: - time_steps = wms.contents[wms_name].timepositions - if time_steps is None: - layer.time_steps = [] - else: - layer.time_steps = time_steps - break - layer.save() + response = self.session.get(wms_url) + xml = ElementTree.fromstring(response.text) + + for item in xml.findall(".//{http://www.opengis.net/wms}Layer"): + if item.get("queryable") is not None: + + name = item.find('{http://www.opengis.net/wms}Name').text + if name == layer_name: + for dimension in item.findall("{http://www.opengis.net/wms}Dimension"): + if dimension.get('name') == 'time': + time_steps = dimension.text.strip() + self.log("Layer-timesteps fetched") + return time_steps.split(',') + return [] except Exception as e: - print("Could not update layer {}: {}".format(layer.id, str(e))) + self.log("Could not fetch timesteps {}: {}".format(layer_name, str(e))) self.has_error = True def delete_datastore(self, workspace, data_store, store_type='coveragestores'): url = "{0}workspaces/{1}/{2}/{3}?recurse=true".format(self.url, workspace, store_type, data_store) try: - response = self.session.delete(url, auth=(self.username, self.password), timeout=10) + response = self.session.delete(url, auth=(self.username, self.password), timeout=5) if response.status_code not in [200, 201]: self.log("Error in deleting datastore {}: {}".format(data_store, response.content)) self.has_error = True @@ -220,3 +230,34 @@ class GeoServerApi: except Exception as e: self.log("Error in deleting datastore {}: {}".format(data_store, e)) self.has_error = True + + def add_allowed_url(self, title, allowed_url): + + url = "{}urlchecks".format(self.url) + headers = {"content-type": "application/xml"} + + # ^https://minio.ufz.de.*$ + body = ( + "<regexUrlCheck>" + "<name>" + title + "</name>" + "<description></description>" + "<enabled>true</enabled>" + "<regex>^" + allowed_url + ".*$</regex>" + "</regexUrlCheck>" + ) + + try: + response = self.session.post( + url, + data=body, + auth=(self.username, self.password), + headers=headers, + timeout=5 + ) + if response.status_code not in [200, 201]: + print("Error in setting url-check: {}".format(response.content)) + else: + print("Adding URL-Check successful: {}".format(allowed_url)) + + except Exception as e: + print("Error in setting url-check: {}".format(e)) diff --git a/backend/main/lib/utils/file.py b/backend/main/lib/utils/file.py index 3997a490568dd2fe79a2cc9117c3f6f6b13f912a..eba4cbfd7eef39fa92ef368a43d0838fb465b58b 100644 --- a/backend/main/lib/utils/file.py +++ b/backend/main/lib/utils/file.py @@ -40,7 +40,7 @@ def is_wms_file(bucket: Bucket, file: File): return True -def is_wfs_file(bucket: Bucket, file: File): +def is_zip_file_for_import(bucket: Bucket, file: File): suffix = bucket.wfs_file_suffix if suffix: for file_type in ["zip"]: diff --git a/backend/main/management/commands/loadtestdata.py b/backend/main/management/commands/loadtestdata.py index a496c4602fcc4b3246f2e113621ec93d1fbd339f..27f4200f69bc998e3f4ba91c5db8a9281974fa38 100644 --- a/backend/main/management/commands/loadtestdata.py +++ b/backend/main/management/commands/loadtestdata.py @@ -66,7 +66,14 @@ class Command(BaseCommand): bucket2.geonetwork_group = geonetwork_api.create_group("test-group2") bucket2.save() - print("\nStep 4: Import Netcdf-files") + + print("\nStep 4: Configure GeoServer") + + geoserver_api = GeoServerApi() + geoserver_api.add_allowed_url("Minio", "http://minio:9000") + + print("\nStep 5: Import Netcdf-files") + import_file_to_minio(BUCKET_NAME, "https://minio.ufz.de/rdm/sdi-test-data/drought.nc", "drought.nc", "drought") import_file_to_minio(BUCKET_NAME, "https://minio.ufz.de/rdm/sdi-test-data/precipitation.nc", "precipitation.nc", "pre") @@ -74,9 +81,7 @@ class Command(BaseCommand): import_file_to_minio(BUCKET_NAME, "https://minio.ufz.de/rdm/sdi-test-data/pre_1950_2021_monsum.nc", "pre_1950_2021_monsum.nc", "pre_1950_2021_monsum") - print("\nStep 5: Configure GeoServer") - - geoserver_api = GeoServerApi() + print("\nStep 6: Configure Layer in GeoServer") for legend in WmsLegend.objects.all(): sld = create_sld_file(legend, "de") @@ -100,16 +105,16 @@ class Command(BaseCommand): geoserver_api.add_style(style_name_en, layer.layer_name) - print("\nStep 6: Upload GeoJson-File") + print("\nStep 7: Upload GeoJson-File") upload_file(BUCKET_NAME, "public/bw.json", "main/geojson/districts/08_admin.geojson") - print("\nStep 7: Upload Metadata-Json-File") + print("\nStep 8: Upload Metadata-Json-File") upload_file(BUCKET_NAME, "drought/metadata.jsonld", "main/fixtures/metadata.jsonld") - print("\nStep 8: Import STA-Data") + print("\nStep 9: Import STA-Data") import_file_to_minio(BUCKET_NAME, "https://minio.ufz.de/rdm/sdi-test-data/wis-d_input_last_14_D.csv", "wis-d_input_last_14_D.csv", "sta") - print("\nStep 9: Import Shapefile Data") + print("\nStep 10: Import Shapefile Data") import_file_to_minio(BUCKET_NAME, "https://minio.ufz.de/rdm/sdi-test-data/shp/cologne_polygon_shp.zip", "cologne_polygon_shp.zip", "shp") diff --git a/backend/main/management/commands/parsecsv.py b/backend/main/management/commands/parsecsv.py index cd9cefb45d09ee76874974478acb89f0ea8fe7f0..31461a0cce258719c3ad7489b34979e1611184fc 100644 --- a/backend/main/management/commands/parsecsv.py +++ b/backend/main/management/commands/parsecsv.py @@ -1,16 +1,17 @@ import os -import csv import time -import datetime +from django.utils import timezone # noqa from django.core.management.base import BaseCommand # noqa +from django.db.models import Q # noqa -from data_import.models import CsvParser, CsvImportJob, CsvParserExtraColumn, CsvIncludeCriteria, PointData, ExtendedPointData, STA_THING # noqa -from main.models import StaThingProperty # noqa +from data_import.models import CsvImportJob, PointData, CsvIncludeCriteria # noqa +from data_import.models import WFS, STA # noqa from main.lib.utils.s3_utils import download_minio_file # noqa -from data_import.api.StaApi import StaApi # noqa +from data_import.lib.StaImporter import StaImporter # noqa +from data_import.lib.PostgisImporter import PostgisImporter # noqa SIZE_LIMIT = 30000000 # 30 MB @@ -20,11 +21,12 @@ class Command(BaseCommand): def handle(self, *args, **options): - job = CsvImportJob.objects.filter(is_processed=False, is_running=False, bucket__connect_to_sta=True).select_related('bucket__sta_endpoint', 'bucket__csv_parser').order_by('created_at').first() + job = CsvImportJob.objects.filter(Q(is_processed=False) & Q(is_running=False) & (Q(bucket__connect_to_sta=True) | Q(bucket__connect_to_wfs=True))).select_related('bucket__sta_endpoint', 'bucket__csv_parser').order_by('created_at').first() + if job: start_time = time.time() - job.started_at = datetime.datetime.now() + job.started_at = timezone.now() job.is_running = True job.save() @@ -38,51 +40,31 @@ class Command(BaseCommand): job.save() return - with open(file_name, newline = '') as csvfile: - - reader = csv.reader(csvfile, delimiter=',') - next(reader) # skip header - - api = StaApi(job) - thing_props = StaThingProperty.objects.filter(endpoint=job.bucket.sta_endpoint) - extra_columns = CsvParserExtraColumn.objects.filter(parser=job.bucket.csv_parser) - - include_criteria = CsvIncludeCriteria.objects.filter(parser=job.bucket.csv_parser) - - rows_succeeded = 0 - rows_failed = 0 - for row in reader: - - if is_row_included(row, include_criteria): - - point_data = create_point_data(job, row) - extended_data = create_extended_data(point_data, extra_columns, thing_props, row) - - api.import_point_data(point_data, extended_data) - - if api.error is True: - point_data.validation_error = "\n".join(api.logs) - point_data.save() - for data in extended_data: - data.save() - rows_failed += 1 - else: - rows_succeeded += 1 - api.error = False - api.logs = [] + if job.target == STA: + i = StaImporter(job) + i.import_csv_in_sta() if not PointData.objects.filter(import_job=job).exists(): job.is_success = True - job.is_processed = True - job.is_running = False - job.execution_time = str(round((time.time() - start_time), 2)) + "s" - job.finished_at = datetime.datetime.now() - job.data_points_created = rows_succeeded - job.data_points_failed = rows_failed - job.save() + if job.target == WFS: + i = PostgisImporter(job) + i.import_csv_to_wfs() + i.write_data() + job.is_success = not i.error + if i.error is True: + job.validation_error = "\n".join(i.logs) + + job.is_processed = True + job.is_running = False + job.execution_time = str(round((time.time() - start_time), 2)) + "s" + job.finished_at = timezone.now() + #job.data_points_created = rows_succeeded + #job.data_points_failed = rows_failed + job.save() + + os.remove(file_name) - os.remove(file_name) def set_file_stats_and_validate(job: CsvImportJob, file_name: str): job.file_size = os.path.getsize(file_name) @@ -94,55 +76,3 @@ def set_file_stats_and_validate(job: CsvImportJob, file_name: str): if job.num_rows > ROW_LIMIT: job.validation_error = 'file exceeds maximum number of rows: {}'.format(ROW_LIMIT) - - -def is_row_included(row, include_criteria: list[CsvIncludeCriteria]) -> bool: - if len(include_criteria) > 0: - for criteria in include_criteria: - if row[criteria.col_num] == criteria.text_value: - return True - return False - else: - return True - - -def create_point_data(job: CsvImportJob, row): - p: CsvParser = job.bucket.csv_parser - - point_data = PointData( - import_job = job, - thing_name = row[p.station_col_num], - location_name = row[p.station_col_num], - coord_lat = row[p.lat_col_num], - coord_lon = row[p.lon_col_num], - # geometry = '', - property = row[p.property_col_num], - # sensor = '', - result_value = row[p.value_col_num], - result_unit = row[p.unit_col_num], - result_time = row[p.time_col_num] - ) - return point_data - -def create_extended_data(point_data: PointData, extra_columns: list[CsvParserExtraColumn], thing_props: list[StaThingProperty], row): - result = [] - - for prop in thing_props: - extended_data = ExtendedPointData( - point_data = point_data, - related_entity = STA_THING, - name = prop.property_key, - value = prop.property_value - ) - result.append(extended_data) - - for column in extra_columns: - extended_data = ExtendedPointData( - point_data = point_data, - related_entity = column.related_entity, - name = column.col_name, - value = row[column.col_num] - ) - result.append(extended_data) - - return result diff --git a/backend/main/management/commands/work.py b/backend/main/management/commands/work.py index 78c44656fa63e72b324fcef28b82c2c5660adbfc..0d599f8d1c36b0956f0d99e33eb7540c3caf0c4a 100644 --- a/backend/main/management/commands/work.py +++ b/backend/main/management/commands/work.py @@ -8,13 +8,13 @@ from minio import Minio # noqa from metadata.lib import MetadataService, JsonLDParser # noqa from main.lib.utils import NetcdfParser # noqa -from main.lib.utils.file import File, is_wms_file, is_wfs_file, create_file_identifier, get_or_create_metadata_record, get_metadata_record, search_metadata_record # noqa +from main.lib.utils.file import File, is_wms_file, is_zip_file_for_import, create_file_identifier, get_or_create_metadata_record, get_metadata_record, search_metadata_record # noqa from main.lib.utils.backend import NETCDF_HELPER_VARIABLES, TIME_BNDS # noqa -from main.models import Bucket, BucketEvent, WmsLayer, WfsLayer, DataVariable # noqa +from main.models import Bucket, BucketEvent, WmsLayer, GeojsonLayer, WfsLayer, DataVariable # noqa from metadata.models import MetadataRecord # noqa -from data_import.models import CsvImportJob, PointData # noqa +from data_import.models import CsvImportJob, WFS, STA # noqa from main.lib.utils.s3_utils import download_minio_file, get_s3_filesystem, get_minio_client # noqa from main.lib.api.GeoserverApi import GeoServerApi # noqa @@ -62,17 +62,19 @@ class Command(BaseCommand): self.create_netcdf_datastore(bucket, file) if file.is_geotiff(): self.create_geotiff_datastore(bucket, file) - if is_wfs_file(bucket, file): + if is_zip_file_for_import(bucket, file): if file.is_shapefile(): self.create_shapefile_datastore(bucket, file) if bucket.connect_to_thredds: if file.is_netcdf(): self.download_to_thredds(file) - if bucket.connect_to_sta: - if file.is_csv(): + if file.is_csv(): + if bucket.connect_to_sta: previous_jobs = get_finished_import_jobs(bucket, file) delete_sta_data(previous_jobs) - create_import_job(bucket, file) + create_import_job(bucket, file, STA) + if bucket.connect_to_wfs: + create_import_job(bucket, file, WFS) if event.is_delete_event(): @@ -84,8 +86,10 @@ class Command(BaseCommand): if bucket.connect_to_geoserver: if is_wms_file(bucket, file): self.delete_wms_datastore(file) - if is_wfs_file(bucket, file): - self.delete_wfs_datastore(file) + if is_zip_file_for_import(bucket, file): + # TODO + # self.delete_shp_wfs_datastore(file) + print('deleting of shp wfs not implemented yet') if bucket.connect_to_thredds: if file.is_netcdf(): self.remove_from_thredds(file.absolute_path) @@ -160,9 +164,10 @@ class Command(BaseCommand): for variable in get_netcdf_variables(file, bucket): - wms_name = workspace + ':' + coverage_store + '_' + variable.name + datastore_name = coverage_store + '_' + variable.name + layer_name = workspace + ':' + datastore_name - layers = list(WmsLayer.objects.filter(layer_name=wms_name, variable=variable)) + layers = list(WmsLayer.objects.filter(layer_name=layer_name, variable=variable)) if len(layers) == 0: backend_proxy_url = os.environ.get("BACKEND_URL") + '/proxy/' + workspace + '/wms' @@ -174,7 +179,7 @@ class Command(BaseCommand): axis_label_x='Long', axis_label_y='Lat', scale_factor=1, - layer_name=wms_name, + layer_name=layer_name, variable=variable, no_data_value=-9999, file_path=file.absolute_path, @@ -187,7 +192,8 @@ class Command(BaseCommand): self.geoserver_api.configure_layer(workspace, coverage_store, native_name) for layer in layers: - self.geoserver_api.update_timesteps_and_save(layer, wms_name) + layer.time_steps = self.geoserver_api.get_wms_timesteps(workspace, layer_name) + layer.save() self.logs.extend(self.geoserver_api.logs) self.has_error = self.geoserver_api.has_error @@ -236,27 +242,29 @@ class Command(BaseCommand): object_url = minio_client.presigned_get_object(bucket.name, file.relative_path) workspace = bucket.name - data_store = create_wfs_name(file) + data_store = create_shp_wfs_name(file) if bucket.wfs_file_suffix: data_store = data_store.removesuffix(bucket.wfs_file_suffix) data_store = data_store.rstrip('_.- ') self.geoserver_api.create_datastore(workspace, data_store, object_url, 'url.shp', 'datastores') - wfs_name = workspace + ':' + data_store + layer_name = workspace + ':' + data_store + + base_url = os.environ.get("BACKEND_URL") + '/proxy/' + workspace + '/ows' + + url = '{}?service=WFS&version=2.0.0&request=GetFeature&typename={}&outputFormat=application/json&srsname=EPSG:3857'.format(base_url, layer_name) try: - wfs_layer = WfsLayer.objects.get(layer_name=wfs_name) - except WfsLayer.DoesNotExist: - backend_proxy_url = os.environ.get("BACKEND_URL") + '/proxy/' + workspace + '/wfs' - wfs_layer = WfsLayer( + geojson_layer = GeojsonLayer.objects.get(url=url) + except GeojsonLayer.DoesNotExist: + geojson_layer = GeojsonLayer( bucket=bucket, - wfs_url=backend_proxy_url, - layer_name=wfs_name, + url=url, file_path=file.relative_path, ) - wfs_layer.save() + geojson_layer.save() self.logs.extend(self.geoserver_api.logs) self.has_error = self.geoserver_api.has_error @@ -285,10 +293,10 @@ class Command(BaseCommand): layer.save() - def delete_wfs_datastore(self, file: File): + def delete_shp_wfs_datastore(self, file: File): try: - layer = WfsLayer.objects.get(file_path=file.absolute_path) + layer = GeojsonLayer.objects.get(file_path=file.absolute_path) pieces = layer.layer_name.split(':') workspace = pieces[0] @@ -335,7 +343,9 @@ class Command(BaseCommand): event.logs = final_logs event.is_success = not self.has_error - event.is_new = False + + if not self.geoserver_api.has_timeout: + event.is_new = False event.save() @@ -356,11 +366,13 @@ def create_data_store_name(file: File) -> str: return result.replace(".", "_") -def create_wfs_name(file: File) -> str: +def create_shp_wfs_name(file: File) -> str: name_parts = file.relative_path.split('/') file_name = name_parts[-1] name = file_name.split('.')[0] - return name.removesuffix('_zip') + del name_parts[-1] + name_parts.append(name.removesuffix('_zip')) + return '_'.join(name_parts) def get_crs(file: File): @@ -402,10 +414,11 @@ def get_or_create_variable(variable_name: str, bucket: Bucket): return data_variable -def create_import_job(bucket: Bucket, file: File): +def create_import_job(bucket: Bucket, file: File, target: str): job = CsvImportJob( bucket=bucket, s3_file=file.relative_path, + target=target ) job.save() diff --git a/backend/main/migrations/0038_bucket_connect_to_wfs.py b/backend/main/migrations/0038_bucket_connect_to_wfs.py new file mode 100644 index 0000000000000000000000000000000000000000..7136d6b9b430d1e8eab88d00335469a9b258dc76 --- /dev/null +++ b/backend/main/migrations/0038_bucket_connect_to_wfs.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-11-15 10:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0037_bucket_wfs_file_suffix_wfslayer_arealayer_wfs_layer'), + ] + + operations = [ + migrations.AddField( + model_name='bucket', + name='connect_to_wfs', + field=models.BooleanField(default=False), + ), + ] diff --git a/backend/main/migrations/0039_wfslayer_cql_filter_for_locations_and_more.py b/backend/main/migrations/0039_wfslayer_cql_filter_for_locations_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..d115ea4a5e6459712ed98e8309eb0aa2a9d06fe2 --- /dev/null +++ b/backend/main/migrations/0039_wfslayer_cql_filter_for_locations_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.16 on 2024-11-26 06:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0038_bucket_connect_to_wfs'), + ] + + operations = [ + migrations.AddField( + model_name='wfslayer', + name='cql_filter_for_locations', + field=models.CharField(blank=True, max_length=4000, null=True), + ), + migrations.AddField( + model_name='wfslayer', + name='cql_filter_for_properties', + field=models.CharField(blank=True, max_length=4000, null=True), + ), + ] diff --git a/backend/main/migrations/0040_remove_wfslayer_cql_filter_for_locations_and_more.py b/backend/main/migrations/0040_remove_wfslayer_cql_filter_for_locations_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..80aeb86c5e5a7886fef3d3628a37c49eeb0fa988 --- /dev/null +++ b/backend/main/migrations/0040_remove_wfslayer_cql_filter_for_locations_and_more.py @@ -0,0 +1,52 @@ +# Generated by Django 4.2.16 on 2024-11-29 07:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0039_wfslayer_cql_filter_for_locations_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='wfslayer', + name='cql_filter_for_locations', + ), + migrations.RemoveField( + model_name='wfslayer', + name='cql_filter_for_properties', + ), + migrations.RemoveField( + model_name='wfslayer', + name='layer_name', + ), + migrations.RemoveField( + model_name='wfslayer', + name='wfs_url', + ), + migrations.AddField( + model_name='geojsonlayer', + name='url', + field=models.CharField(default='', max_length=4000), + preserve_default=False, + ), + migrations.AddField( + model_name='wfslayer', + name='prefix', + field=models.CharField(default='', max_length=1000), + preserve_default=False, + ), + migrations.AddField( + model_name='wfslayer', + name='workspace', + field=models.CharField(default='', max_length=1000), + preserve_default=False, + ), + migrations.AlterField( + model_name='wfslayer', + name='file_path', + field=models.CharField(blank=True, max_length=4000, null=True), + ), + ] diff --git a/backend/main/migrations/0041_rename_base_map_project_base_layer_and_more.py b/backend/main/migrations/0041_rename_base_map_project_base_layer_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..eaaf1df66492d8c8fde151d5918dde90abafb924 --- /dev/null +++ b/backend/main/migrations/0041_rename_base_map_project_base_layer_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.16 on 2025-01-07 13:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0040_remove_wfslayer_cql_filter_for_locations_and_more'), + ] + + operations = [ + migrations.RenameField( + model_name='project', + old_name='base_map', + new_name='base_layer', + ), + migrations.AddField( + model_name='project', + name='is_base_layer_gray', + field=models.BooleanField(default=False), + ), + ] diff --git a/backend/main/models.py b/backend/main/models.py index 3c048d8ff9f2b3e343d24a212e1894c1c181fc6b..20e703e4b66ba5f5f5f81f311f7926cceaea21c8 100644 --- a/backend/main/models.py +++ b/backend/main/models.py @@ -3,7 +3,6 @@ from django.contrib.postgres.fields import ArrayField # noqa from django.contrib.auth.models import Group, User # noqa from colorfield.fields import ColorField # noqa import re -import datetime from urllib.parse import unquote from data_import.models import CsvParser # noqa @@ -61,8 +60,8 @@ REQUEST_TYPES = ( def validate_bucket_name(value): if len(value) < 3 or len(value) > 63: raise ValidationError("Length must be between 3 and 63 characters.") - if not re.match(r'^[a-z0-9-]*$', value): - raise ValidationError('Only lowercase-letters, numbers and hyphens are allowed.') + if not re.match(r'^[a-z-]*$', value): + raise ValidationError('Only lowercase-letters and hyphens are allowed.') if "--" in value: raise ValidationError("Invalid combination of characters: '--'") if value.startswith('xn--'): @@ -107,6 +106,7 @@ class Bucket(models.Model): connect_to_geoserver = models.BooleanField(default=False) connect_to_thredds = models.BooleanField(default=False) connect_to_sta = models.BooleanField(default=False) + connect_to_wfs = models.BooleanField(default=False) sta_endpoint = models.ForeignKey(StaEndpoint, on_delete=models.CASCADE, blank=True, null=True) csv_parser = models.ForeignKey(CsvParser, on_delete=models.CASCADE, blank=True, null=True) public_folder = models.BooleanField(default=False) @@ -169,7 +169,8 @@ class Project(CommonInfo): viewer = models.CharField(max_length=4000, blank=True, null=True) print_annotation_de = models.CharField(max_length=120, blank=True, null=True) print_annotation_en = models.CharField(max_length=120, blank=True, null=True) - base_map = models.CharField(max_length=20, choices=BASE_MAPS, default='osm') + base_layer = models.CharField(max_length=20, choices=BASE_MAPS, default='osm') + is_base_layer_gray = models.BooleanField(default=False) class Area(CommonInfo): @@ -323,9 +324,9 @@ class WmsLayer(Layer): class WfsLayer(Layer): bucket = models.ForeignKey(Bucket, on_delete=models.CASCADE) # is readonly - wfs_url = models.URLField(max_length=4000, null=True) # is readonly - layer_name = models.CharField(max_length=4000) # is readonly - file_path = models.CharField(max_length=4000) # is readonly + workspace = models.CharField(max_length=1000) + prefix = models.CharField(max_length=1000) + file_path = models.CharField(max_length=4000, blank=True, null=True) def __str__(self): if self.name_en: @@ -341,6 +342,7 @@ class WfsLayer(Layer): class GeojsonLayer(Layer): bucket = models.ForeignKey(Bucket, on_delete=models.CASCADE, null=True) file_path = models.CharField(max_length=4000, null=True) + url = models.CharField(max_length=4000) class Meta: verbose_name = "GeoJSON-Data-Layer" diff --git a/backend/main/views/sta_requests.py b/backend/main/views/sta_requests.py index 29150addd83da3946aa009cd4e44ef7be4e595c8..7a5debe326980edfabe6f8e3a1fd4d6aa97e77d7 100644 --- a/backend/main/views/sta_requests.py +++ b/backend/main/views/sta_requests.py @@ -1,45 +1,9 @@ import requests -from django.http import HttpResponse -from django.shortcuts import get_object_or_404 +from django.http import HttpResponse # noqa +from django.shortcuts import get_object_or_404 # noqa -from main.models import StaEndpoint, StaLayer, StaThingProperty, StaObservedPropertyFilter - - -def proxy_sta_thing_count(request, sta_layer_id: int): - - url = "v1.1/Things?" - - filter_params = _get_datastream_filter_params(request) - - sta_layer = get_object_or_404(StaLayer, id=sta_layer_id) - endpoint = sta_layer.endpoint - for thing_filter in _get_filter_for_things(endpoint, ''): - filter_params.append(thing_filter) - - url = url + "$expand=Locations($select=location)" - - op_filters = _get_filter_for_observed_properties(sta_layer, 'Datastreams/ObservedProperty/') - for op_filter in op_filters: - filter_params.append(op_filter) - - url = url + "&$count=true&$select=id&$top=1" - - # try to make request with selecting locations by Bounding-Box - advanced_filter_params = filter_params + _get_bbox_filter_params(request) - advanced_url = url + "&$filter=" + " and ".join(advanced_filter_params) - - advanced_url = endpoint.base_url + advanced_url - response = requests.get(advanced_url, auth=(endpoint.username, endpoint.password), timeout=(5, 60)) - - if response.status_code == 500: - # if Bounding-Box is not supported by STA-Endpoint use only the other filters - standard_url = url - if len(filter_params) > 0: - standard_url = url + "&$filter=" + " and ".join(filter_params) - return _request_sta(standard_url, endpoint) - - return HttpResponse(response, content_type='application/json') +from main.models import StaEndpoint, StaLayer, StaThingProperty, StaObservedPropertyFilter # noqa def proxy_sta_locations(request, sta_layer_id: int): @@ -55,7 +19,7 @@ def proxy_sta_locations(request, sta_layer_id: int): url = url + "$expand=Locations($select=location)" - obs_property_id = request.GET.get('obsProperty') + obs_property_id = request.GET.get('property') if obs_property_id: # continue "expand" url = url + ",Datastreams($filter=ObservedProperty/id eq {};$top=1;$orderBy=resultTime desc;$expand=Observations($select=result;$top=1;$orderBy=resultTime desc))".format(obs_property_id) @@ -64,7 +28,11 @@ def proxy_sta_locations(request, sta_layer_id: int): for op_filter in op_filters: filter_params.append(op_filter) - url = url + "&$count=true&$select=id&$resultFormat=GeoJSON&$orderBy=Locations/location/coordinates/0,Locations/location/coordinates/1" + max_results = request.GET.get('count') + if max_results: + url = url + "&$count=true&$select=id&$top=" + str(max_results) + else: + url = url + "&$count=true&$select=id&$resultFormat=GeoJSON&$orderBy=Locations/location/coordinates/0,Locations/location/coordinates/1" # try to make request with selecting locations by Bounding-Box advanced_filter_params = filter_params + _get_bbox_filter_params(request) @@ -170,7 +138,7 @@ def _get_datastream_filter_params(request): filter_params = [] - obs_property_id = request.GET.get('obsProperty') + obs_property_id = request.GET.get('property') if obs_property_id: param = "Datastreams/ObservedProperty/id eq {}".format(obs_property_id) filter_params.append(param) @@ -197,22 +165,28 @@ def _get_bbox_filter_params(request): filter_params = [] - bbox_top_lon = request.GET.get('bboxTopLon') - bbox_top_lat = request.GET.get('bboxTopLat') - bbox_bottom_lon = request.GET.get('bboxBottomLon') - bbox_bottom_lat = request.GET.get('bboxBottomLat') + # bottomLeftLon, bottomLeftLat, topRightLon, topRightLat + bbox_params = request.GET.get('bbox') + + if bbox_params: + bbox_params = bbox_params.split(',') + + bbox_top_lon = bbox_params[0] + bbox_top_lat = bbox_params[3] + bbox_bottom_lon = bbox_params[2] + bbox_bottom_lat = bbox_params[1] - if bbox_top_lon and bbox_top_lat and bbox_bottom_lon and bbox_bottom_lat: + if bbox_top_lon and bbox_top_lat and bbox_bottom_lon and bbox_bottom_lat: - # define polygon in WKT-representation - b_r_corner = "{} {}".format(bbox_bottom_lon, bbox_bottom_lat) - t_r_corner = "{} {}".format(bbox_bottom_lon, bbox_top_lat) - t_l_corner = "{} {}".format(bbox_top_lon, bbox_top_lat) - b_l_corner = "{} {}".format(bbox_top_lon, bbox_bottom_lat) + # define polygon in WKT-representation + b_r_corner = "{} {}".format(bbox_bottom_lon, bbox_bottom_lat) + t_r_corner = "{} {}".format(bbox_bottom_lon, bbox_top_lat) + t_l_corner = "{} {}".format(bbox_top_lon, bbox_top_lat) + b_l_corner = "{} {}".format(bbox_top_lon, bbox_bottom_lat) - # define counter-clock-wise with coords as lon-lat - polygon = "{}, {}, {}, {}, {}".format(b_r_corner, t_r_corner, t_l_corner, b_l_corner, b_r_corner) + # define counter-clock-wise with coords as lon-lat + polygon = "{}, {}, {}, {}, {}".format(b_r_corner, t_r_corner, t_l_corner, b_l_corner, b_r_corner) - filter_params.append("st_within(Locations/location, geography'POLYGON (({}))')".format(polygon)) + filter_params.append("st_within(Locations/location, geography'POLYGON (({}))')".format(polygon)) return filter_params \ No newline at end of file diff --git a/backend/main/views/timeseries_request.py b/backend/main/views/timeseries_request.py index 7bd9518a6eba6619cf1714b1300281fdb7096a12..3acf9d3dae4e96097e0fa16944e51afdc999684a 100644 --- a/backend/main/views/timeseries_request.py +++ b/backend/main/views/timeseries_request.py @@ -9,7 +9,7 @@ from django.shortcuts import get_object_or_404 # noqa from pyproj import Transformer # noqa from main.models import AreaLayer, WmsLayer, WmsLayerLegend, WmsColor, ConnectedLayer, REQUEST_TYPE_1, REQUEST_TYPE_2 # noqa -from main.views import views, sta_requests # noqa +from main.views import views, sta_requests, wfs_requests # noqa from multiprocessing import Pool @@ -50,6 +50,16 @@ def request_all_time_series(request, locale: str): chart_name = timeseries['name'] + + # collect WFS-Data + + wfs_workspace = request.GET.get('workspace') + wfs_prefix = request.GET.get('prefix') + wfs_timeseries_id = request.GET.get('wfsTimeSeries') + if wfs_timeseries_id: + timeseries = _request_wfs_timeseries(wfs_workspace, wfs_prefix, wfs_timeseries_id) + timeseries_collection.append(timeseries) + # collect WMS-Data wms_layer_id = request.GET.get('wmsLayer') @@ -333,18 +343,48 @@ def _request_sta_timeseries(request, area_layer, sta_layer, thing_id, obs_proper return time_series +def _request_wfs_timeseries(workspace, prefix, timeseries_id): + + unit = '' + + response = wfs_requests.get_time_records(workspace, prefix, timeseries_id) + + time_records = json.loads(response.content) + + time_series = { + 'x': [], + 'y': [], + 'type': 'scatter', + 'mode': 'lines+markers', + 'name': 'wfs', + 'position': 0, + 'timeFormat': None, + 'unit': unit, + 'yMin': None, + 'yMax': None, + 'visible': True + } + + for time_record in time_records['features']: + time_series['x'].append(time_record['properties']['date']) + time_series['y'].append(time_record['properties']['value_number']) + + return time_series + + def _get_name_for_sta_data(obs_property, locale): name = '' if obs_property['name']: name = obs_property['name'] - if locale == "de": - if 'name_de' in obs_property['properties']: - name = obs_property['properties']['name_de'] - else: - if obs_property['properties']['name_en']: - name = obs_property['properties']['name_en'] + if 'properties' in obs_property: + if locale == "de": + if 'name_de' in obs_property['properties']: + name = obs_property['properties']['name_de'] + else: + if obs_property['properties']['name_en']: + name = obs_property['properties']['name_en'] return name diff --git a/backend/main/views/views.py b/backend/main/views/views.py index c1a8e4e6bd2715fbc3ef9fddb17d29e564ed6fa6..512071b127da0ea5e8a742e0775b7f85c5260438 100644 --- a/backend/main/views/views.py +++ b/backend/main/views/views.py @@ -201,17 +201,17 @@ def get_geojson_layer(area_layer: AreaLayer, locale: str): layer_record = set_text_by_locale(layer_record, locale) layer_record = set_text_by_locale(layer_record, locale, 'info') - base_url = os.environ.get('MINIO_ENDPOINT') - if base_url.endswith('/'): - base_url = base_url.rstrip('/') - if base_url == 'http://minio:9000': - base_url = 'http://localhost:9000' - - file_path = geojson_layer.file_path - if file_path.startswith('/'): - file_path = file_path.lstrip('/') - - layer_record['url'] = '{}/{}/{}'.format(base_url, geojson_layer.bucket.name, file_path) + # base_url = os.environ.get('MINIO_ENDPOINT') + # if base_url.endswith('/'): + # base_url = base_url.rstrip('/') + # if base_url == 'http://minio:9000': + # base_url = 'http://localhost:9000' + # + # file_path = geojson_layer.file_path + # if file_path.startswith('/'): + # file_path = file_path.lstrip('/') + # + # layer_record['url'] = '{}/{}/{}'.format(base_url, geojson_layer.bucket.name, file_path) layer_record['id'] = area_layer.id layer_record['type'] = 'geojson' @@ -227,11 +227,9 @@ def get_wfs_layer(area_layer: AreaLayer, locale: str): layer_record = set_text_by_locale(layer_record, locale) layer_record = set_text_by_locale(layer_record, locale, 'info') - url = '{}?service=WFS&version=1.1.0&request=GetFeature&typename={}&outputFormat=application/json&srsname=EPSG:3857'.format(layer_record['wfs_url'], layer_record['layer_name']) - layer_record['url'] = url - layer_record['id'] = area_layer.id - layer_record['type'] = 'geojson' + layer_record['own_id'] = wfs_layer.id + layer_record['type'] = 'wfs' return layer_record diff --git a/backend/main/views/wfs_requests.py b/backend/main/views/wfs_requests.py new file mode 100644 index 0000000000000000000000000000000000000000..f5ea2cc899f9ceb1c82b241b90b260dc42a5df9f --- /dev/null +++ b/backend/main/views/wfs_requests.py @@ -0,0 +1,178 @@ +import os +import requests +from jinja2 import Template + +from django.http import HttpResponse, JsonResponse # noqa + + +def get_time_records(workspace: str, prefix: str, timeseries_id: int): + + url = """ + http://localhost:8000/gdi-backend/proxy/geoserver/{workspace}/ows?service=WFS + &version=2.0.0 + &request=GetFeature + &typeName={workspace}%3A{prefix}_timerecord + &outputFormat=application%2Fjson + &CQL_FILTER=timeseries_id%20=%20{timeseries_id} + &propertyName=value_number,date + &sortBy=date+A + """.format(workspace=workspace, prefix=prefix, timeseries_id=timeseries_id) + + return requests.get(url, timeout=(5, 60)) + + +def wfs_features(request, workspace, prefix): + + data = _create_request_body(request, workspace, prefix) + + url = '{}/{}/ows?service=WFS&version=2.0.0&outputFormat=application%2Fjson&count=1'.format(os.environ.get("GEOSERVER_URL"), workspace) + + response = requests.post(url, data=data) + return HttpResponse(response, content_type='application/json') + + +def _create_request_body(request, workspace, prefix): + + count = request.GET.get('count') + # bottomLeftLon, bottomLeftLat, topRightLon, topRightLat + bbox = request.GET.get('bbox') + coords = bbox.split(',') + + property_id = request.GET.get('property') + begin_date = request.GET.get('beginDate') + end_date = request.GET.get('endDate') + threshold_value = request.GET.get('threshold') + + if property_id: + + filter_xml = """<?xml version="1.0" encoding="UTF-8"?> + <wfs:GetFeature service="WFS" version="2.0.0" count="{{ count|e }}" + xmlns:wfs="http://www.opengis.net/wfs/2.0" + xmlns:fes="http://www.opengis.net/fes/2.0" + xmlns:gml="http://www.opengis.net/gml/3.2" + xmlns:sf="http://www.openplans.org/spearfish" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://www.opengis.net/wfs/2.0 + http://schemas.opengis.net/wfs/2.0/wfs.xsd + http://www.opengis.net/gml/3.2 + http://schemas.opengis.net/gml/3.2.1/gml.xsd"> + <wfs:Query typeNames="{{ workspace|e }}:{{ prefix|e }}_feature {{ workspace|e }}:{{ prefix|e }}_timeseries" aliases="a b"> + + <fes:Filter> + <fes:And> + + <fes:BBOX> + <fes:ValueReference>a/geom</fes:ValueReference> + <gml:Envelope srsName="http://www.opengis.net/def/crs/epsg/0/4326"> + <gml:lowerCorner>{{ lowerCornerLat|e }} {{ lowerCornerLon|e }}</gml:lowerCorner> + <gml:upperCorner>{{ upperCornerLat|e }} {{ upperCornerLon|e }}</gml:upperCorner> + </gml:Envelope> + </fes:BBOX> + + <fes:PropertyIsEqualTo> + <fes:ValueReference>a/id</fes:ValueReference> + <fes:ValueReference>b/feature_id</fes:ValueReference> + </fes:PropertyIsEqualTo> + + {% if property_id %} + <fes:PropertyIsEqualTo> + <fes:ValueReference>b/property_id</fes:ValueReference> + <fes:Literal>{{ property_id|e }}</fes:Literal> + </fes:PropertyIsEqualTo> + {% endif %} + + {% if begin_date %} + <fes:PropertyIsGreaterThanOrEqualTo> + <fes:ValueReference>b/max_date</fes:ValueReference> + <fes:Function name="dateParse"> + <fes:Literal>yyyy-MM-dd</fes:Literal> + <fes:Literal>{{ begin_date|e }}</fes:Literal> + </fes:Function> + </fes:PropertyIsGreaterThanOrEqualTo> + {% endif %} + + {% if end_date %} + <fes:PropertyIsLessThanOrEqualTo> + <fes:ValueReference>b/min_date</fes:ValueReference> + <fes:Function name="dateParse"> + <fes:Literal>yyyy-MM-dd</fes:Literal> + <fes:Literal>{{ end_date|e }}</fes:Literal> + </fes:Function> + </fes:PropertyIsLessThanOrEqualTo> + {% endif %} + + {% if threshold_value %} + <fes:PropertyIsGreaterThanOrEqualTo> + <fes:ValueReference>b/max_value</fes:ValueReference> + <fes:Literal>{{ threshold_value|e }}</fes:Literal> + </fes:PropertyIsGreaterThanOrEqualTo> + {% endif %} + + </fes:And> + </fes:Filter> + </wfs:Query> + </wfs:GetFeature>""" + + else: + filter_xml = """<?xml version="1.0" encoding="UTF-8"?> + <wfs:GetFeature service="WFS" version="2.0.0" count="{{ count|e }}" + xmlns:wfs="http://www.opengis.net/wfs/2.0" + xmlns:fes="http://www.opengis.net/fes/2.0" + xmlns:gml="http://www.opengis.net/gml/3.2" + xmlns:sf="http://www.openplans.org/spearfish" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://www.opengis.net/wfs/2.0 + http://schemas.opengis.net/wfs/2.0/wfs.xsd + http://www.opengis.net/gml/3.2 + http://schemas.opengis.net/gml/3.2.1/gml.xsd"> + <wfs:Query typeNames="{{ workspace|e }}:{{ prefix|e }}_feature"> + <fes:Filter> + <fes:And> + + <fes:BBOX> + <fes:ValueReference>geom</fes:ValueReference> + <gml:Envelope srsName="http://www.opengis.net/def/crs/epsg/0/4326"> + <gml:lowerCorner>{{ lowerCornerLat|e }} {{ lowerCornerLon|e }}</gml:lowerCorner> + <gml:upperCorner>{{ upperCornerLat|e }} {{ upperCornerLon|e }}</gml:upperCorner> + </gml:Envelope> + </fes:BBOX> + + {% if begin_date %} + <fes:PropertyIsGreaterThanOrEqualTo> + <fes:ValueReference>max_timerecord</fes:ValueReference> + <fes:Function name="dateParse"> + <fes:Literal>yyyy-MM-dd</fes:Literal> + <fes:Literal>{{ begin_date|e }}</fes:Literal> + </fes:Function> + </fes:PropertyIsGreaterThanOrEqualTo> + {% endif %} + + {% if end_date %} + <fes:PropertyIsLessThanOrEqualTo> + <fes:ValueReference>min_timerecord</fes:ValueReference> + <fes:Function name="dateParse"> + <fes:Literal>yyyy-MM-dd</fes:Literal> + <fes:Literal>{{ end_date|e }}</fes:Literal> + </fes:Function> + </fes:PropertyIsLessThanOrEqualTo> + {% endif %} + </fes:And> + </fes:Filter> + </wfs:Query> + </wfs:GetFeature>""" + + template = Template(filter_xml) + + return template.render( + workspace=workspace, + prefix=prefix, + count=count if count else 1000, + lowerCornerLon=float(coords[0]), + lowerCornerLat=float(coords[1]), + upperCornerLon=float(coords[2]), + upperCornerLat=float(coords[3]), + property_id=property_id, + begin_date=begin_date, + end_date=end_date, + threshold_value=threshold_value + ) diff --git a/backend/requirements.txt b/backend/requirements.txt index caaba7116506b13ff555e3f345aae60b369fa73c..56454f318caa1ad4c848220b1a7abd752daca8b6 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -16,7 +16,6 @@ xarray==2024.6.0 rasterio==1.3.10 django-tabbed-changeform-admin==0.1.7 pytz==2024.1 -OWSLib==0.31.0 netCDF4==1.7.1 h5netcdf==1.3.0 pyproj==3.6.1 diff --git a/bin/build_thredds_image.sh b/bin/build_thredds_image.sh old mode 100644 new mode 100755 diff --git a/container/backend-prod/Dockerfile b/container/backend-prod/Dockerfile index 8348a3e576a12e9f123ff6356b78fcf7933e4103..92d82362d987c9c7f796117fa97364f8104d9e6d 100644 --- a/container/backend-prod/Dockerfile +++ b/container/backend-prod/Dockerfile @@ -1,4 +1,4 @@ -FROM debian:bullseye-slim as base +FROM debian:bullseye-slim AS base RUN apt-get -y update \ && apt-get -y dist-upgrade \ diff --git a/container/geoserver/Dockerfile b/container/geoserver/Dockerfile index 3deec8de650031db6e900689f71c12357baa5546..fb91876db58832b8e47d9894eb0613c06635103e 100644 --- a/container/geoserver/Dockerfile +++ b/container/geoserver/Dockerfile @@ -1,8 +1,5 @@ -ARG IMAGE_VERSION=9.0.85-jdk11-temurin-jammy +FROM tomcat:9.0.98-jdk17-temurin-jammy@sha256:1f35364892e7d90cc6ccbfe23b03d84791fbaaf4197171ea3cdac6672983de2f -ARG JAVA_HOME=/usr/local/openjdk-11 - -FROM tomcat:$IMAGE_VERSION RUN set -eux; \ apt-get update; \ @@ -10,8 +7,8 @@ RUN set -eux; \ && rm -rf /var/lib/apt/lists/* -ENV GEOSERVER_VERSION 2.25.2 -ENV GEOSERVER_VERSION_MM 2.25 +ENV GEOSERVER_VERSION 2.26.1 +ENV GEOSERVER_VERSION_MM 2.26 ENV GEOSERVER_HOME $CATALINA_HOME/geoserver ENV GEOSERVER_DATA_DIR /opt/geoserver/data_dir @@ -35,7 +32,8 @@ RUN for plugin in netcdf netcdf-out; do \ # INSTALL Community-Plugins -RUN for plugin in cov-json cog-s3; do \ + +RUN for plugin in cov-json cog-s3 sec-oauth2-github; do \ curl -OL https://build.geoserver.org/geoserver/${GEOSERVER_VERSION_MM}.x/community-latest/geoserver-${GEOSERVER_VERSION_MM}-SNAPSHOT-${plugin}-plugin.zip && \ unzip -o -d ${GEOSERVER_HOME}/webapps/geoserver/WEB-INF/lib/ geoserver-${GEOSERVER_VERSION_MM}-SNAPSHOT-${plugin}-plugin.zip; \ done; @@ -46,6 +44,9 @@ RUN cd ${GEOSERVER_HOME}/data_dir/ && \ COPY usergroup ${GEOSERVER_HOME}/data_dir/security/usergroup/ +COPY web.xml ${GEOSERVER_HOME}/webapps/geoserver/WEB-INF/web.xml +COPY server.xml /usr/local/tomcat/conf/server.xml + COPY entrypoint.sh /usr/local/bin/ ## redirect tomcat-base-path to GeoServer-Landingpage diff --git a/container/geoserver/server.xml b/container/geoserver/server.xml new file mode 100644 index 0000000000000000000000000000000000000000..eb3f6a70d88f27989a0f4025625208a275dacc48 --- /dev/null +++ b/container/geoserver/server.xml @@ -0,0 +1,183 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<!-- Note: A "Server" is not itself a "Container", so you may not + define subcomponents such as "Valves" at this level. + Documentation at /docs/config/server.html + --> +<Server port="8005" shutdown="SHUTDOWN"> + <Listener className="org.apache.catalina.startup.VersionLoggerListener" /> + <!-- Security listener. Documentation at /docs/config/listeners.html + <Listener className="org.apache.catalina.security.SecurityListener" /> + --> + <!-- APR library loader. Documentation at /docs/apr.html --> + <Listener className="org.apache.catalina.core.AprLifecycleListener" SSLEngine="on" /> + <!-- Prevent memory leaks due to use of particular java/javax APIs--> + <Listener className="org.apache.catalina.core.JreMemoryLeakPreventionListener" /> + <Listener className="org.apache.catalina.mbeans.GlobalResourcesLifecycleListener" /> + <Listener className="org.apache.catalina.core.ThreadLocalLeakPreventionListener" /> + + <!-- Global JNDI resources + Documentation at /docs/jndi-resources-howto.html + --> + <GlobalNamingResources> + <!-- Editable user database that can also be used by + UserDatabaseRealm to authenticate users + --> + <Resource name="UserDatabase" auth="Container" + type="org.apache.catalina.UserDatabase" + description="User database that can be updated and saved" + factory="org.apache.catalina.users.MemoryUserDatabaseFactory" + pathname="conf/tomcat-users.xml" /> + </GlobalNamingResources> + + <!-- A "Service" is a collection of one or more "Connectors" that share + a single "Container" Note: A "Service" is not itself a "Container", + so you may not define subcomponents such as "Valves" at this level. + Documentation at /docs/config/service.html + --> + <Service name="Catalina"> + + <!--The connectors can use a shared executor, you can define one or more named thread pools--> + <!-- + <Executor name="tomcatThreadPool" namePrefix="catalina-exec-" + maxThreads="150" minSpareThreads="4"/> + --> + + + <!-- A "Connector" represents an endpoint by which requests are received + and responses are returned. Documentation at : + Java HTTP Connector: /docs/config/http.html + Java AJP Connector: /docs/config/ajp.html + APR (HTTP/AJP) Connector: /docs/apr.html + Define a non-SSL/TLS HTTP/1.1 Connector on port 8080 + --> + <Connector server="Apache" secure="true" port="8080" protocol="HTTP/1.1" + connectionTimeout="20000" + redirectPort="8443" + proxyName="web.app.ufz.de" + proxyPort="443" + scheme="https" + /> + + <!-- A "Connector" using the shared thread pool--> + <!-- + <Connector executor="tomcatThreadPool" + port="8080" protocol="HTTP/1.1" + connectionTimeout="20000" + redirectPort="8443" + maxParameterCount="1000" + /> + --> + <!-- Define an SSL/TLS HTTP/1.1 Connector on port 8443 + This connector uses the NIO implementation. The default + SSLImplementation will depend on the presence of the APR/native + library and the useOpenSSL attribute of the AprLifecycleListener. + Either JSSE or OpenSSL style configuration may be used regardless of + the SSLImplementation selected. JSSE style configuration is used below. + --> + <!-- + <Connector port="8443" protocol="org.apache.coyote.http11.Http11NioProtocol" + maxThreads="150" SSLEnabled="true" + maxParameterCount="1000" + > + <SSLHostConfig> + <Certificate certificateKeystoreFile="conf/localhost-rsa.jks" + type="RSA" /> + </SSLHostConfig> + </Connector> + --> + <!-- Define an SSL/TLS HTTP/1.1 Connector on port 8443 with HTTP/2 + This connector uses the APR/native implementation which always uses + OpenSSL for TLS. + Either JSSE or OpenSSL style configuration may be used. OpenSSL style + configuration is used below. + --> + <!-- + <Connector port="8443" protocol="org.apache.coyote.http11.Http11AprProtocol" + maxThreads="150" SSLEnabled="true" + maxParameterCount="1000" + > + <UpgradeProtocol className="org.apache.coyote.http2.Http2Protocol" /> + <SSLHostConfig> + <Certificate certificateKeyFile="conf/localhost-rsa-key.pem" + certificateFile="conf/localhost-rsa-cert.pem" + certificateChainFile="conf/localhost-rsa-chain.pem" + type="RSA" /> + </SSLHostConfig> + </Connector> + --> + + <!-- Define an AJP 1.3 Connector on port 8009 --> + <!-- + <Connector protocol="AJP/1.3" + address="::1" + port="8009" + redirectPort="8443" + maxParameterCount="1000" + /> + --> + + <!-- An Engine represents the entry point (within Catalina) that processes + every request. The Engine implementation for Tomcat stand alone + analyzes the HTTP headers included with the request, and passes them + on to the appropriate Host (virtual host). + Documentation at /docs/config/engine.html --> + + <!-- You should set jvmRoute to support load-balancing via AJP ie : + <Engine name="Catalina" defaultHost="localhost" jvmRoute="jvm1"> + --> + <Engine name="Catalina" defaultHost="localhost"> + + <!--For clustering, please take a look at documentation at: + /docs/cluster-howto.html (simple how to) + /docs/config/cluster.html (reference documentation) --> + <!-- + <Cluster className="org.apache.catalina.ha.tcp.SimpleTcpCluster"/> + --> + + <!-- Use the LockOutRealm to prevent attempts to guess user passwords + via a brute-force attack --> + <Realm className="org.apache.catalina.realm.LockOutRealm"> + <!-- This Realm uses the UserDatabase configured in the global JNDI + resources under the key "UserDatabase". Any edits + that are performed against this UserDatabase are immediately + available for use by the Realm. --> + <Realm className="org.apache.catalina.realm.UserDatabaseRealm" + resourceName="UserDatabase"/> + </Realm> + + <Host name="localhost" appBase="webapps" + unpackWARs="true" autoDeploy="true"> + + <!-- SingleSignOn valve, share authentication between web applications + Documentation at: /docs/config/valve.html --> + <!-- + <Valve className="org.apache.catalina.authenticator.SingleSignOn" /> + --> + + <!-- Access log processes all example. + Documentation at: /docs/config/valve.html + Note: The pattern used is equivalent to using pattern="common" --> + <Valve className="org.apache.catalina.valves.AccessLogValve" directory="logs" + prefix="localhost_access_log" suffix=".txt" + pattern="%h %l %u %t "%r" %s %b" /> + + </Host> + </Engine> + </Service> +</Server> diff --git a/container/geoserver/web.xml b/container/geoserver/web.xml new file mode 100644 index 0000000000000000000000000000000000000000..aa0e4ff6fc78a543bcc9ffa8e56f69735829d8d5 --- /dev/null +++ b/container/geoserver/web.xml @@ -0,0 +1,297 @@ +<web-app> + <display-name>GeoServer</display-name> + + <context-param> + <param-name>serviceStrategy</param-name> + <!-- Meaning of the different values : + + PARTIAL-BUFFER2 + - Partially buffers the first xKb to disk. Once that has buffered, the the + result is streamed to the user. This will allow for most errors to be caught + early. + + BUFFER + - stores the entire response in memory first, before sending it off to + the user (may run out of memory) + + SPEED + - outputs directly to the response (and cannot recover in the case of an + error) + + FILE + - outputs to the local filesystem first, before sending it off to the user + --> + <param-value>PARTIAL-BUFFER2</param-value> + </context-param> + + <context-param> + <!-- see comments on the PARTIAL-BUFFER strategy --> + <!-- this sets the size of the buffer. default is "50" = 50kb --> + + <param-name>PARTIAL_BUFFER_STRATEGY_SIZE</param-name> + <param-value>50</param-value> + </context-param> + + <!--Can be true or false (defaults to: false). --> + <!--When true the JSONP (text/javascript) output format is enabled --> + <!-- + <context-param> + <param-name>ENABLE_JSONP</param-name> + <param-value>true</param-value> + </context-param> + --> + <!-- --> + <context-param> + <param-name>PROXY_BASE_URL</param-name> + <param-value>https://web.app.ufz.de/geoserver</param-value> + </context-param> + + <context-param> + <param-name>GEOSERVER_CSRF_WHITELIST</param-name> + <param-value>web.app.ufz.de</param-value> + </context-param> + + <!-- + <context-param> + <param-name>GEOSERVER_DATA_DIR</param-name> + <param-value>C:\eclipse\workspace\geoserver_trunk\cite\confCiteWFSPostGIS</param-value> + </context-param> + --> + + <!-- pick up all spring application contexts --> + <context-param> + <param-name>contextConfigLocation</param-name> + <param-value>classpath*:/applicationContext.xml classpath*:/applicationSecurityContext.xml</param-value> + </context-param> + + <filter> + <filter-name>FlushSafeFilter</filter-name> + <filter-class>org.geoserver.filters.FlushSafeFilter</filter-class> + </filter> + + <filter> + <filter-name>Set Character Encoding</filter-name> + <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> + <init-param> + <param-name>encoding</param-name> + <param-value>UTF-8</param-value> + </init-param> + </filter> + + <filter> + <filter-name>SessionDebugger</filter-name> + <filter-class>org.geoserver.filters.SessionDebugFilter</filter-class> + </filter> + + <filter> + <filter-name>filterChainProxy</filter-name> + <filter-class> org.springframework.web.filter.DelegatingFilterProxy</filter-class> + </filter> + + <filter> + <filter-name>xFrameOptionsFilter</filter-name> + <filter-class>org.geoserver.filters.XFrameOptionsFilter</filter-class> + </filter> + + <filter> + <filter-name>GZIP Compression Filter</filter-name> + <filter-class>org.geoserver.filters.GZIPFilter</filter-class> + <init-param> + <!-- The compressed-types parameter is a comma-separated list of regular expressions. + If a mime type matches any of the regular expressions then it will be compressed. + --> + <param-name>compressed-types</param-name> + <param-value>text/.*,.*xml.*,application/json,application/x-javascript</param-value> + </init-param> + </filter> + + <filter> + <filter-name>Advanced Dispatch Filter</filter-name> + <filter-class>org.geoserver.platform.AdvancedDispatchFilter</filter-class> + <!-- + This filter allows for a single mapping to the spring dispatcher. However using /* as a mapping + in a servlet mapping causes the servlet path to be "/" of the request. This causes problems with + library like wicket and restlet. So this filter fakes the servlet path by assuming the first + component of the path is the mapped path. + --> + </filter> + + <filter> + <filter-name>Spring Delegating Filter</filter-name> + <filter-class>org.geoserver.filters.SpringDelegatingFilter</filter-class> + <!-- + This filter allows for filters to be loaded via spring rather than + registered here in web.xml. One thing to note is that for such filters + init() is not called. INstead any initialization is performed via spring + ioc. + --> + </filter> + + <filter> + <filter-name>Thread locals cleanup filter</filter-name> + <filter-class>org.geoserver.filters.ThreadLocalsCleanupFilter</filter-class> + <!-- + This filter cleans up thread locals Geotools is setting up for concurrency and performance + reasons + --> + </filter> + + <!-- Uncomment following filter to enable CORS in Jetty. Do not forget the second config block further down. + --> + + <filter> + <filter-name>cross-origin</filter-name> + <filter-class>org.eclipse.jetty.servlets.CrossOriginFilter</filter-class> + <init-param> + <param-name>chainPreflight</param-name> + <param-value>false</param-value> + </init-param> + <init-param> + <param-name>allowedOrigins</param-name> + <param-value>*</param-value> + </init-param> + <init-param> + <param-name>allowedMethods</param-name> + <param-value>GET,POST,PUT,DELETE,HEAD,OPTIONS</param-value> + </init-param> + <init-param> + <param-name>allowedHeaders</param-name> + <param-value>*</param-value> + </init-param> + </filter> + + <!-- Uncomment following filter to enable CORS in Tomcat. Do not forget the second config block further down. + <filter> + <filter-name>cross-origin</filter-name> + <filter-class>org.apache.catalina.filters.CorsFilter</filter-class> + <init-param> + <param-name>cors.allowed.origins</param-name> + <param-value>*</param-value> + </init-param> + <init-param> + <param-name>cors.allowed.methods</param-name> + <param-value>GET,POST,PUT,DELETE,HEAD,OPTIONS</param-value> + </init-param> + <init-param> + <param-name>cors.allowed.headers</param-name> + <param-value>*</param-value> + </init-param> + </filter> + --> + + <!-- + THIS FILTER MAPPING MUST BE THE FIRST ONE, otherwise we end up with ruined chars in the input from the GUI + See the "Note" in the Tomcat character encoding guide: + http://wiki.apache.org/tomcat/FAQ/CharacterEncoding + --> + <filter-mapping> + <filter-name>Set Character Encoding</filter-name> + <url-pattern>/*</url-pattern> + </filter-mapping> + + <!-- Uncomment following filter to enable CORS + --> + <filter-mapping> + <filter-name>cross-origin</filter-name> + <url-pattern>/*</url-pattern> + </filter-mapping> + + <filter-mapping> + <filter-name>FlushSafeFilter</filter-name> + <url-pattern>/*</url-pattern> + </filter-mapping> + + <filter-mapping> + <filter-name>SessionDebugger</filter-name> + <url-pattern>/*</url-pattern> + </filter-mapping> + + <filter-mapping> + <filter-name>GZIP Compression Filter</filter-name> + <url-pattern>/*</url-pattern> + </filter-mapping> + + <filter-mapping> + <filter-name>xFrameOptionsFilter</filter-name> + <url-pattern>/*</url-pattern> + </filter-mapping> + + <!-- + If you want to use your security system comment out this one too + --> + <filter-mapping> + <filter-name>filterChainProxy</filter-name> + <url-pattern>/*</url-pattern> + </filter-mapping> + + <filter-mapping> + <filter-name>Advanced Dispatch Filter</filter-name> + <url-pattern>/*</url-pattern> + </filter-mapping> + + <filter-mapping> + <filter-name>Spring Delegating Filter</filter-name> + <url-pattern>/*</url-pattern> + </filter-mapping> + + <filter-mapping> + <filter-name>Thread locals cleanup filter</filter-name> + <url-pattern>/*</url-pattern> + </filter-mapping> + + <!-- general initializer, should be first thing to execute --> + <listener> + <listener-class>org.geoserver.GeoserverInitStartupListener</listener-class> + </listener> + + <!-- logging initializer, should execute before spring context startup --> + <listener> + <listener-class>org.geoserver.logging.LoggingStartupContextListener</listener-class> + </listener> + + <!-- spring context loader --> + <listener> + <listener-class>org.geoserver.platform.GeoServerContextLoaderListener</listener-class> + </listener> + + <!-- http session listener proxy --> + <listener> + <listener-class>org.geoserver.platform.GeoServerHttpSessionListenerProxy</listener-class> + </listener> + + <!-- request context listener for session-scoped beans --> + <listener> + <listener-class>org.springframework.web.context.request.RequestContextListener</listener-class> + </listener> + + <!-- spring dispatcher servlet, dispatches all incoming requests --> + <servlet> + <servlet-name>dispatcher</servlet-name> + <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> + </servlet> + + <!-- single mapping to spring, this only works properly if the advanced dispatch filter is + active --> + <servlet-mapping> + <servlet-name>dispatcher</servlet-name> + <url-pattern>/*</url-pattern> + </servlet-mapping> + + <mime-mapping> + <extension>xsl</extension> + <mime-type>text/xml</mime-type> + </mime-mapping> + <mime-mapping> + <extension>sld</extension> + <mime-type>text/xml</mime-type> + </mime-mapping> + <mime-mapping> + <extension>json</extension> + <mime-type>application/json</mime-type> + </mime-mapping> + + <welcome-file-list> + <welcome-file>index.html</welcome-file> + </welcome-file-list> + +</web-app> diff --git a/container/postgres/create-databases.sh b/container/postgres/create-databases.sh index ba33d9ce36aad11e55768650b1b0a0a8a14af739..c1bef18a8d472647bde50e0e0780dc6f64e55c90 100644 --- a/container/postgres/create-databases.sh +++ b/container/postgres/create-databases.sh @@ -5,21 +5,36 @@ set -u function create_user_and_database() { local database=$1 + echo " Creating user and database '$database'" + psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL CREATE USER $database; CREATE DATABASE $database; GRANT ALL PRIVILEGES ON DATABASE $database TO $database; EOSQL - psql -d $database -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL - CREATE EXTENSION postgis; -EOSQL } -if [ -n "$POSTGRES_MULTIPLE_DATABASES" ]; then - for db in $(echo $POSTGRES_MULTIPLE_DATABASES | tr ',' ' '); do - create_user_and_database $db - done - echo "Databases created" -fi \ No newline at end of file +declare -a databases=("$DJANGO_DATABASE" "$FROST_DATABASE" "$GEONETWORK_DB") + +for db in "${databases[@]}"; do + create_user_and_database $db +done +echo "Databases created" + + +psql -d $DJANGO_DATABASE -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL + CREATE SCHEMA geoserver; + + CREATE EXTENSION postgis SCHEMA geoserver; + + ALTER DATABASE django SET search_path = public,geoserver,postgis; +EOSQL + + +psql -d $FROST_DATABASE -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL + CREATE EXTENSION postgis; +EOSQL + +echo "PostGIS installed" diff --git a/container/thredds/Dockerfile b/container/thredds/Dockerfile index e2232c996d4da62c9c69adf32e197d3ff11178a7..4d6444c843c3a0b28ba2eff3af238ef39b83ef1c 100644 --- a/container/thredds/Dockerfile +++ b/container/thredds/Dockerfile @@ -1,4 +1,4 @@ -FROM unidata/thredds-docker:5.4 +FROM unidata/thredds-docker:5.5 COPY threddsConfig.xml ${CATALINA_HOME}/content/thredds/threddsConfig.xml COPY tomcat-users.xml ${CATALINA_HOME}/conf/tomcat-users.xml diff --git a/container/thredds/server.xml b/container/thredds/server.xml index 058bd2ec82a251fc318c5c758c4f1315649ca8f0..c514fc3af8f344321dc3aa412c57f12b113f17cb 100644 --- a/container/thredds/server.xml +++ b/container/thredds/server.xml @@ -69,7 +69,7 @@ <Connector server="Apache" secure="true" port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" - proxyName="gdi-fs.ufz.de" + proxyName="web.app.ufz.de" proxyPort="443" scheme="https" /> diff --git a/container/thredds/threddsConfig.xml b/container/thredds/threddsConfig.xml index 88fb118076b3df16586d98f64661717dfff88012..83b47e20c12d4ad2246e53cbd3c375076f7dd413 100644 --- a/container/thredds/threddsConfig.xml +++ b/container/thredds/threddsConfig.xml @@ -12,9 +12,9 @@ <keywords>meteorology, atmosphere, climate, ocean, earth science</keywords> <contact> - <name>IT-Support</name> + <name>SDI-Team</name> <organization>Research Data Management (RDM)</organization> - <email>rdm-contact@ufz.de</email> + <email>sdi@ufz.de</email> <!--phone></phone--> </contact> <hostInstitution> diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml index 7563dce01f193493662d260b95631ae9a329fc50..e83e3bb7520d44a56755781e7b9928f49c081e4e 100644 --- a/docker-compose-prod.yml +++ b/docker-compose-prod.yml @@ -1,5 +1,5 @@ x-image2: &backend-image - image: ${REPOSITORY}backend:3.14.1 + image: ${REPOSITORY}backend:3.14.2 build: context: . dockerfile: ./container/backend-prod/Dockerfile @@ -41,7 +41,7 @@ services: - DJANGO_SECRET_KEY=${DJANGO_SECRET_KEY} pygeoapi: - image: git.ufz.de:4567/rdm/wt-systeme/ufz-gdi/pygeoapi:1.0.0 + image: ${REPOSITORY}pygeoapi:1.0.0 build: context: . dockerfile: ./container/pygeoapi/Dockerfile-for-production diff --git a/docker-compose.yml b/docker-compose.yml index 4a4f37dbcb09c048c1c367759f3e5ede10f855d5..ffcb4bcba86edd96828c61cc91ea6540bbb3de51 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ services: entrypoint: ["sh", "/etc/entrypoint.sh"] geoserver: - image: ${REPOSITORY}geoserver:2.25.2 + image: ${REPOSITORY}tanzu-geoserver:2.26.1b build: context: ./container/geoserver dockerfile: ./Dockerfile @@ -104,7 +104,9 @@ services: environment: - POSTGRES_USER=${POSTGRES_USERNAME} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - - POSTGRES_MULTIPLE_DATABASES=${DJANGO_POSTGRES_DB},${FROST_POSTGRES_DB},${GEONETWORK_POSTGRES_DB} + - DJANGO_DATABASE=${DJANGO_POSTGRES_DB} + - FROST_DATABASE=${FROST_POSTGRES_DB} + - GEONETWORK_DB=${GEONETWORK_POSTGRES_DB} healthcheck: test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USERNAME"] interval: 40s @@ -152,7 +154,7 @@ services: condition: service_healthy thredds: - image: ${REPOSITORY}thredds:5.4.0 + image: ${REPOSITORY}thredds:5.5.0 build: context: ./container/thredds dockerfile: ./Dockerfile diff --git a/frontend/index.html b/frontend/index.html index 8398773fc70d76fffa6e051f3b7e5241f045a255..3e1fdce4a4b9441331051c89e5b0950f334272a4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -6,7 +6,6 @@ <meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="robots" content="noindex" /> <link rel="icon" href="/favicon.ico"> - <link rel="stylesheet" href="/blue_theme.css"> </head> <body> <noscript> diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b8ded0a05ba9e8708bba2d58d049075989da1cdf..93c286cf3e29351f5c11f42c79802b9019914bd1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,6 +21,7 @@ "html2canvas": "^1.4.1", "jspdf": "^2.5.1", "marked": "^13.0.0", + "pinia": "^2.2.6", "plotly.js-basic-dist-min": "^2.33.0", "plotly.js-locales": "^2.33.0", "proj4": "^2.11.0", @@ -48,11 +49,32 @@ "vue-tsc": "^2.1.0" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/parser": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.7.tgz", - "integrity": "sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw==", + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.2.tgz", + "integrity": "sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ==", "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.0" + }, "bin": { "parser": "bin/babel-parser.js" }, @@ -72,6 +94,19 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/types": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.0.tgz", + "integrity": "sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -646,13 +681,13 @@ "peer": true }, "node_modules/@intlify/core-base": { - "version": "9.13.1", - "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.13.1.tgz", - "integrity": "sha512-+bcQRkJO9pcX8d0gel9ZNfrzU22sZFSA0WVhfXrf5jdJOS24a+Bp8pozuS9sBI9Hk/tGz83pgKfmqcn/Ci7/8w==", + "version": "9.14.2", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.14.2.tgz", + "integrity": "sha512-DZyQ4Hk22sC81MP4qiCDuU+LdaYW91A6lCjq8AWPvY3+mGMzhGDfOCzvyR6YBQxtlPjFqMoFk9ylnNYRAQwXtQ==", "license": "MIT", "dependencies": { - "@intlify/message-compiler": "9.13.1", - "@intlify/shared": "9.13.1" + "@intlify/message-compiler": "9.14.2", + "@intlify/shared": "9.14.2" }, "engines": { "node": ">= 16" @@ -662,12 +697,12 @@ } }, "node_modules/@intlify/message-compiler": { - "version": "9.13.1", - "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.13.1.tgz", - "integrity": "sha512-SKsVa4ajYGBVm7sHMXd5qX70O2XXjm55zdZB3VeMFCvQyvLew/dLvq3MqnaIsTMF1VkkOb9Ttr6tHcMlyPDL9w==", + "version": "9.14.2", + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.14.2.tgz", + "integrity": "sha512-YsKKuV4Qv4wrLNsvgWbTf0E40uRv+Qiw1BeLQ0LAxifQuhiMe+hfTIzOMdWj/ZpnTDj4RSZtkXjJM7JDiiB5LQ==", "license": "MIT", "dependencies": { - "@intlify/shared": "9.13.1", + "@intlify/shared": "9.14.2", "source-map-js": "^1.0.2" }, "engines": { @@ -678,9 +713,9 @@ } }, "node_modules/@intlify/shared": { - "version": "9.13.1", - "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.13.1.tgz", - "integrity": "sha512-u3b6BKGhE6j/JeRU6C/RL2FgyJfy6LakbtfeVF8fJXURpZZTzfh3e05J0bu0XPw447Q6/WUp3C4ajv4TMS4YsQ==", + "version": "9.14.2", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.2.tgz", + "integrity": "sha512-uRAHAxYPeF+G5DBIboKpPgC/Waecd4Jz8ihtkpJQD5ycb5PwXp0k/+hBGl5dAjwF7w+l74kz/PKA8r8OK//RUw==", "license": "MIT", "engines": { "node": ">= 16" @@ -737,9 +772,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", "license": "MIT" }, "node_modules/@nodelib/fs.scandir": { @@ -849,9 +884,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.0.tgz", - "integrity": "sha512-Tya6xypR10giZV1XzxmH5wr25VcZSncG0pZIjfePT0OVBvqNEurzValetGNarVrGiq66EBVAFn15iYX4w6FKgQ==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.27.4.tgz", + "integrity": "sha512-2Y3JT6f5MrQkICUyRVCw4oa0sutfAsgaSsb0Lmmy1Wi2y7X5vT9Euqw4gOsCyy0YfKURBg35nhUKZS4mDcfULw==", "cpu": [ "arm" ], @@ -862,9 +897,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.18.0.tgz", - "integrity": "sha512-avCea0RAP03lTsDhEyfy+hpfr85KfyTctMADqHVhLAF3MlIkq83CP8UfAHUssgXTYd+6er6PaAhx/QGv4L1EiA==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.27.4.tgz", + "integrity": "sha512-wzKRQXISyi9UdCVRqEd0H4cMpzvHYt1f/C3CoIjES6cG++RHKhrBj2+29nPF0IB5kpy9MS71vs07fvrNGAl/iA==", "cpu": [ "arm64" ], @@ -875,9 +910,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.18.0.tgz", - "integrity": "sha512-IWfdwU7KDSm07Ty0PuA/W2JYoZ4iTj3TUQjkVsO/6U+4I1jN5lcR71ZEvRh52sDOERdnNhhHU57UITXz5jC1/w==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.27.4.tgz", + "integrity": "sha512-PlNiRQapift4LNS8DPUHuDX/IdXiLjf8mc5vdEmUR0fF/pyy2qWwzdLjB+iZquGr8LuN4LnUoSEvKRwjSVYz3Q==", "cpu": [ "arm64" ], @@ -888,9 +923,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.18.0.tgz", - "integrity": "sha512-n2LMsUz7Ynu7DoQrSQkBf8iNrjOGyPLrdSg802vk6XT3FtsgX6JbE8IHRvposskFm9SNxzkLYGSq9QdpLYpRNA==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.27.4.tgz", + "integrity": "sha512-o9bH2dbdgBDJaXWJCDTNDYa171ACUdzpxSZt+u/AAeQ20Nk5x+IhA+zsGmrQtpkLiumRJEYef68gcpn2ooXhSQ==", "cpu": [ "x64" ], @@ -900,10 +935,36 @@ "darwin" ] }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.27.4.tgz", + "integrity": "sha512-NBI2/i2hT9Q+HySSHTBh52da7isru4aAAo6qC3I7QFVsuhxi2gM8t/EI9EVcILiHLj1vfi+VGGPaLOUENn7pmw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.27.4.tgz", + "integrity": "sha512-wYcC5ycW2zvqtDYrE7deary2P2UFmSh85PUpAx+dwTCO9uw3sgzD6Gv9n5X4vLaQKsrfTSZZ7Z7uynQozPVvWA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.18.0.tgz", - "integrity": "sha512-C/zbRYRXFjWvz9Z4haRxcTdnkPt1BtCkz+7RtBSuNmKzMzp3ZxdM28Mpccn6pt28/UWUCTXa+b0Mx1k3g6NOMA==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.27.4.tgz", + "integrity": "sha512-9OwUnK/xKw6DyRlgx8UizeqRFOfi9mf5TYCw1uolDaJSbUmBxP85DE6T4ouCMoN6pXw8ZoTeZCSEfSaYo+/s1w==", "cpu": [ "arm" ], @@ -914,9 +975,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.18.0.tgz", - "integrity": "sha512-l3m9ewPgjQSXrUMHg93vt0hYCGnrMOcUpTz6FLtbwljo2HluS4zTXFy2571YQbisTnfTKPZ01u/ukJdQTLGh9A==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.27.4.tgz", + "integrity": "sha512-Vgdo4fpuphS9V24WOV+KwkCVJ72u7idTgQaBoLRD0UxBAWTF9GWurJO9YD9yh00BzbkhpeXtm6na+MvJU7Z73A==", "cpu": [ "arm" ], @@ -927,9 +988,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.18.0.tgz", - "integrity": "sha512-rJ5D47d8WD7J+7STKdCUAgmQk49xuFrRi9pZkWoRD1UeSMakbcepWXPF8ycChBoAqs1pb2wzvbY6Q33WmN2ftw==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.27.4.tgz", + "integrity": "sha512-pleyNgyd1kkBkw2kOqlBx+0atfIIkkExOTiifoODo6qKDSpnc6WzUY5RhHdmTdIJXBdSnh6JknnYTtmQyobrVg==", "cpu": [ "arm64" ], @@ -940,9 +1001,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.18.0.tgz", - "integrity": "sha512-be6Yx37b24ZwxQ+wOQXXLZqpq4jTckJhtGlWGZs68TgdKXJgw54lUUoFYrg6Zs/kjzAQwEwYbp8JxZVzZLRepQ==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.27.4.tgz", + "integrity": "sha512-caluiUXvUuVyCHr5DxL8ohaaFFzPGmgmMvwmqAITMpV/Q+tPoaHZ/PWa3t8B2WyoRcIIuu1hkaW5KkeTDNSnMA==", "cpu": [ "arm64" ], @@ -953,9 +1014,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.18.0.tgz", - "integrity": "sha512-hNVMQK+qrA9Todu9+wqrXOHxFiD5YmdEi3paj6vP02Kx1hjd2LLYR2eaN7DsEshg09+9uzWi2W18MJDlG0cxJA==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.27.4.tgz", + "integrity": "sha512-FScrpHrO60hARyHh7s1zHE97u0KlT/RECzCKAdmI+LEoC1eDh/RDji9JgFqyO+wPDb86Oa/sXkily1+oi4FzJQ==", "cpu": [ "ppc64" ], @@ -966,9 +1027,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.18.0.tgz", - "integrity": "sha512-ROCM7i+m1NfdrsmvwSzoxp9HFtmKGHEqu5NNDiZWQtXLA8S5HBCkVvKAxJ8U+CVctHwV2Gb5VUaK7UAkzhDjlg==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.27.4.tgz", + "integrity": "sha512-qyyprhyGb7+RBfMPeww9FlHwKkCXdKHeGgSqmIXw9VSUtvyFZ6WZRtnxgbuz76FK7LyoN8t/eINRbPUcvXB5fw==", "cpu": [ "riscv64" ], @@ -979,9 +1040,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.18.0.tgz", - "integrity": "sha512-0UyyRHyDN42QL+NbqevXIIUnKA47A+45WyasO+y2bGJ1mhQrfrtXUpTxCOrfxCR4esV3/RLYyucGVPiUsO8xjg==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.27.4.tgz", + "integrity": "sha512-PFz+y2kb6tbh7m3A7nA9++eInGcDVZUACulf/KzDtovvdTizHpZaJty7Gp0lFwSQcrnebHOqxF1MaKZd7psVRg==", "cpu": [ "s390x" ], @@ -992,9 +1053,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.18.0.tgz", - "integrity": "sha512-xuglR2rBVHA5UsI8h8UbX4VJ470PtGCf5Vpswh7p2ukaqBGFTnsfzxUBetoWBWymHMxbIG0Cmx7Y9qDZzr648w==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.27.4.tgz", + "integrity": "sha512-Ni8mMtfo+o/G7DVtweXXV/Ol2TFf63KYjTtoZ5f078AUgJTmaIJnj4JFU7TK/9SVWTaSJGxPi5zMDgK4w+Ez7Q==", "cpu": [ "x64" ], @@ -1005,9 +1066,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.18.0.tgz", - "integrity": "sha512-LKaqQL9osY/ir2geuLVvRRs+utWUNilzdE90TpyoX0eNqPzWjRm14oMEE+YLve4k/NAqCdPkGYDaDF5Sw+xBfg==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.27.4.tgz", + "integrity": "sha512-5AeeAF1PB9TUzD+3cROzFTnAJAcVUGLuR8ng0E0WXGkYhp6RD6L+6szYVX+64Rs0r72019KHZS1ka1q+zU/wUw==", "cpu": [ "x64" ], @@ -1018,9 +1079,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.18.0.tgz", - "integrity": "sha512-7J6TkZQFGo9qBKH0pk2cEVSRhJbL6MtfWxth7Y5YmZs57Pi+4x6c2dStAUvaQkHQLnEQv1jzBUW43GvZW8OFqA==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.27.4.tgz", + "integrity": "sha512-yOpVsA4K5qVwu2CaS3hHxluWIK5HQTjNV4tWjQXluMiiiu4pJj4BN98CvxohNCpcjMeTXk/ZMJBRbgRg8HBB6A==", "cpu": [ "arm64" ], @@ -1031,9 +1092,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.18.0.tgz", - "integrity": "sha512-Txjh+IxBPbkUB9+SXZMpv+b/vnTEtFyfWZgJ6iyCmt2tdx0OF5WhFowLmnh8ENGNpfUlUZkdI//4IEmhwPieNg==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.27.4.tgz", + "integrity": "sha512-KtwEJOaHAVJlxV92rNYiG9JQwQAdhBlrjNRp7P9L8Cb4Rer3in+0A+IPhJC9y68WAi9H0sX4AiG2NTsVlmqJeQ==", "cpu": [ "ia32" ], @@ -1044,9 +1105,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.18.0.tgz", - "integrity": "sha512-UOo5FdvOL0+eIVTgS4tIdbW+TtnBLWg1YBCcU2KWM7nuNwRz9bksDX1bekJJCpu25N1DVWaCwnT39dVQxzqS8g==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.27.4.tgz", + "integrity": "sha512-3j4jx1TppORdTAoBJRd+/wJRGCPC0ETWkXOecJ6PPZLj6SptXkrXcNqdj0oclbKML6FkQltdz7bBA3rUSirZug==", "cpu": [ "x64" ], @@ -1057,9 +1118,9 @@ ] }, "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", "license": "MIT" }, "node_modules/@types/geojson": { @@ -1396,53 +1457,53 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.4.29", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.29.tgz", - "integrity": "sha512-TFKiRkKKsRCKvg/jTSSKK7mYLJEQdUiUfykbG49rubC9SfDyvT2JrzTReopWlz2MxqeLyxh9UZhvxEIBgAhtrg==", + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz", + "integrity": "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.24.7", - "@vue/shared": "3.4.29", + "@babel/parser": "^7.25.3", + "@vue/shared": "3.5.13", "entities": "^4.5.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.0" } }, "node_modules/@vue/compiler-dom": { - "version": "3.4.29", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.29.tgz", - "integrity": "sha512-A6+iZ2fKIEGnfPJejdB7b1FlJzgiD+Y/sxxKwJWg1EbJu6ZPgzaPQQ51ESGNv0CP6jm6Z7/pO6Ia8Ze6IKrX7w==", + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz", + "integrity": "sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==", "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.4.29", - "@vue/shared": "3.4.29" + "@vue/compiler-core": "3.5.13", + "@vue/shared": "3.5.13" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.4.29", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.29.tgz", - "integrity": "sha512-zygDcEtn8ZimDlrEQyLUovoWgKQic6aEQqRXce2WXBvSeHbEbcAsXyCk9oG33ZkyWH4sl9D3tkYc1idoOkdqZQ==", + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.13.tgz", + "integrity": "sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.24.7", - "@vue/compiler-core": "3.4.29", - "@vue/compiler-dom": "3.4.29", - "@vue/compiler-ssr": "3.4.29", - "@vue/shared": "3.4.29", + "@babel/parser": "^7.25.3", + "@vue/compiler-core": "3.5.13", + "@vue/compiler-dom": "3.5.13", + "@vue/compiler-ssr": "3.5.13", + "@vue/shared": "3.5.13", "estree-walker": "^2.0.2", - "magic-string": "^0.30.10", - "postcss": "^8.4.38", + "magic-string": "^0.30.11", + "postcss": "^8.4.48", "source-map-js": "^1.2.0" } }, "node_modules/@vue/compiler-ssr": { - "version": "3.4.29", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.29.tgz", - "integrity": "sha512-rFbwCmxJ16tDp3N8XCx5xSQzjhidYjXllvEcqX/lopkoznlNPz3jyy0WGJCyhAaVQK677WWFt3YO/WUEkMMUFQ==", + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.13.tgz", + "integrity": "sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.4.29", - "@vue/shared": "3.4.29" + "@vue/compiler-dom": "3.5.13", + "@vue/shared": "3.5.13" } }, "node_modules/@vue/compiler-vue2": { @@ -1554,53 +1615,53 @@ } }, "node_modules/@vue/reactivity": { - "version": "3.4.29", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.29.tgz", - "integrity": "sha512-w8+KV+mb1a8ornnGQitnMdLfE0kXmteaxLdccm2XwdFxXst4q/Z7SEboCV5SqJNpZbKFeaRBBJBhW24aJyGINg==", + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.13.tgz", + "integrity": "sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==", "license": "MIT", "dependencies": { - "@vue/shared": "3.4.29" + "@vue/shared": "3.5.13" } }, "node_modules/@vue/runtime-core": { - "version": "3.4.29", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.29.tgz", - "integrity": "sha512-s8fmX3YVR/Rk5ig0ic0NuzTNjK2M7iLuVSZyMmCzN/+Mjuqqif1JasCtEtmtoJWF32pAtUjyuT2ljNKNLeOmnQ==", + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.13.tgz", + "integrity": "sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.4.29", - "@vue/shared": "3.4.29" + "@vue/reactivity": "3.5.13", + "@vue/shared": "3.5.13" } }, "node_modules/@vue/runtime-dom": { - "version": "3.4.29", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.29.tgz", - "integrity": "sha512-gI10atCrtOLf/2MPPMM+dpz3NGulo9ZZR9d1dWo4fYvm+xkfvRrw1ZmJ7mkWtiJVXSsdmPbcK1p5dZzOCKDN0g==", + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.13.tgz", + "integrity": "sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.4.29", - "@vue/runtime-core": "3.4.29", - "@vue/shared": "3.4.29", + "@vue/reactivity": "3.5.13", + "@vue/runtime-core": "3.5.13", + "@vue/shared": "3.5.13", "csstype": "^3.1.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.4.29", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.29.tgz", - "integrity": "sha512-HMLCmPI2j/k8PVkSBysrA2RxcxC5DgBiCdj7n7H2QtR8bQQPqKAe8qoaxLcInzouBmzwJ+J0x20ygN/B5mYBng==", + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.13.tgz", + "integrity": "sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==", "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.4.29", - "@vue/shared": "3.4.29" + "@vue/compiler-ssr": "3.5.13", + "@vue/shared": "3.5.13" }, "peerDependencies": { - "vue": "3.4.29" + "vue": "3.5.13" } }, "node_modules/@vue/shared": { - "version": "3.4.29", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.29.tgz", - "integrity": "sha512-hQ2gAQcBO/CDpC82DCrinJNgOHI2v+FA7BDW4lMSPeBpQ7sRe2OLHWe5cph1s7D8DUQAwRt18dBDfJJ220APEA==", + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz", + "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==", "license": "MIT" }, "node_modules/@vue/test-utils": { @@ -1773,9 +1834,9 @@ } }, "node_modules/axios": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", - "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.8.tgz", + "integrity": "sha512-Uu0wb7KNqK2t5K+YQyVCLM76prD5sRFjKHbJYCP1J7JFGEQ6nN7HWn9+04LAeiJ3ji54lgS/gZCH1oxyrf1SPw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -2295,9 +2356,9 @@ "license": "MIT" }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", "dependencies": { @@ -2568,9 +2629,9 @@ } }, "node_modules/elliptic": { - "version": "6.5.5", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.5.tgz", - "integrity": "sha512-7EjbcmUm17NQFu4Pmgmq2olYMj8nwMnpcddByChSUjArp8F5DQWcIcpriwO4ZToLNAJig0yiyjswfyGNje/ixw==", + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", + "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", "license": "MIT", "dependencies": { "bn.js": "^4.11.9", @@ -3974,12 +4035,12 @@ } }, "node_modules/magic-string": { - "version": "0.30.10", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.10.tgz", - "integrity": "sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==", + "version": "0.30.14", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.14.tgz", + "integrity": "sha512-5c99P1WKTed11ZC0HMJOj6CDIue6F8ySu+bJL+85q1zBEIY8IklrJ1eiKC2NDRh3Ct3FcvmJPyQHb9erXMTJNw==", "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" + "@jridgewell/sourcemap-codec": "^1.5.0" } }, "node_modules/marked": { @@ -4022,9 +4083,9 @@ "license": "MIT" }, "node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "license": "MIT", "dependencies": { @@ -4126,9 +4187,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "funding": [ { "type": "github", @@ -4593,9 +4654,9 @@ "optional": true }, "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, "node_modules/picomatch": { @@ -4610,6 +4671,58 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pinia": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.2.6.tgz", + "integrity": "sha512-vIsR8JkDN5Ga2vAxqOE2cJj4VtsHnzpR1Fz30kClxlh0yCHfec6uoMeM3e/ddqmwFUejK3NlrcQa/shnpyT4hA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@vue/composition-api": "^1.4.0", + "typescript": ">=4.4.4", + "vue": "^2.6.14 || ^3.5.11" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/pinia/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/pkg-dir": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-5.0.0.tgz", @@ -4644,9 +4757,9 @@ } }, "node_modules/postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.4.49", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", + "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", "funding": [ { "type": "opencollective", @@ -4664,8 +4777,8 @@ "license": "MIT", "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -5033,12 +5146,12 @@ } }, "node_modules/rollup": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.18.0.tgz", - "integrity": "sha512-QmJz14PX3rzbJCN1SG4Xe/bAAX2a6NpCP8ab2vfu2GiUr8AQcr2nCV/oEO3yneFarB67zk8ShlIyWb2LGTb3Sg==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.27.4.tgz", + "integrity": "sha512-RLKxqHEMjh/RGLsDxAEsaLO3mWgyoU6x9w6n1ikAzet4B3gI2/3yP6PWY2p9QzRTh6MfEIXB3MwsOY0Iv3vNrw==", "license": "MIT", "dependencies": { - "@types/estree": "1.0.5" + "@types/estree": "1.0.6" }, "bin": { "rollup": "dist/bin/rollup" @@ -5048,22 +5161,24 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.18.0", - "@rollup/rollup-android-arm64": "4.18.0", - "@rollup/rollup-darwin-arm64": "4.18.0", - "@rollup/rollup-darwin-x64": "4.18.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.18.0", - "@rollup/rollup-linux-arm-musleabihf": "4.18.0", - "@rollup/rollup-linux-arm64-gnu": "4.18.0", - "@rollup/rollup-linux-arm64-musl": "4.18.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.18.0", - "@rollup/rollup-linux-riscv64-gnu": "4.18.0", - "@rollup/rollup-linux-s390x-gnu": "4.18.0", - "@rollup/rollup-linux-x64-gnu": "4.18.0", - "@rollup/rollup-linux-x64-musl": "4.18.0", - "@rollup/rollup-win32-arm64-msvc": "4.18.0", - "@rollup/rollup-win32-ia32-msvc": "4.18.0", - "@rollup/rollup-win32-x64-msvc": "4.18.0", + "@rollup/rollup-android-arm-eabi": "4.27.4", + "@rollup/rollup-android-arm64": "4.27.4", + "@rollup/rollup-darwin-arm64": "4.27.4", + "@rollup/rollup-darwin-x64": "4.27.4", + "@rollup/rollup-freebsd-arm64": "4.27.4", + "@rollup/rollup-freebsd-x64": "4.27.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.27.4", + "@rollup/rollup-linux-arm-musleabihf": "4.27.4", + "@rollup/rollup-linux-arm64-gnu": "4.27.4", + "@rollup/rollup-linux-arm64-musl": "4.27.4", + "@rollup/rollup-linux-powerpc64le-gnu": "4.27.4", + "@rollup/rollup-linux-riscv64-gnu": "4.27.4", + "@rollup/rollup-linux-s390x-gnu": "4.27.4", + "@rollup/rollup-linux-x64-gnu": "4.27.4", + "@rollup/rollup-linux-x64-musl": "4.27.4", + "@rollup/rollup-win32-arm64-msvc": "4.27.4", + "@rollup/rollup-win32-ia32-msvc": "4.27.4", + "@rollup/rollup-win32-x64-msvc": "4.27.4", "fsevents": "~2.3.2" } }, @@ -5236,9 +5351,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -5654,14 +5769,14 @@ "license": "MIT" }, "node_modules/vite": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.1.tgz", - "integrity": "sha512-XBmSKRLXLxiaPYamLv3/hnP/KXDai1NDexN0FpkTaZXTfycHvkRHoenpgl/fvuK/kPbB6xAgoyiryAhQNxYmAQ==", + "version": "5.4.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", + "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==", "license": "MIT", "dependencies": { "esbuild": "^0.21.3", - "postcss": "^8.4.38", - "rollup": "^4.13.0" + "postcss": "^8.4.43", + "rollup": "^4.20.0" }, "bin": { "vite": "bin/vite.js" @@ -5680,6 +5795,7 @@ "less": "*", "lightningcss": "^1.21.0", "sass": "*", + "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" @@ -5697,6 +5813,9 @@ "sass": { "optional": true }, + "sass-embedded": { + "optional": true + }, "stylus": { "optional": true }, @@ -5738,16 +5857,16 @@ "license": "MIT" }, "node_modules/vue": { - "version": "3.4.29", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.4.29.tgz", - "integrity": "sha512-8QUYfRcYzNlYuzKPfge1UWC6nF9ym0lx7mpGVPJYNhddxEf3DD0+kU07NTL0sXuiT2HuJuKr/iEO8WvXvT0RSQ==", + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz", + "integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.4.29", - "@vue/compiler-sfc": "3.4.29", - "@vue/runtime-dom": "3.4.29", - "@vue/server-renderer": "3.4.29", - "@vue/shared": "3.4.29" + "@vue/compiler-dom": "3.5.13", + "@vue/compiler-sfc": "3.5.13", + "@vue/runtime-dom": "3.5.13", + "@vue/server-renderer": "3.5.13", + "@vue/shared": "3.5.13" }, "peerDependencies": { "typescript": "*" @@ -5827,13 +5946,13 @@ } }, "node_modules/vue-i18n": { - "version": "9.13.1", - "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.13.1.tgz", - "integrity": "sha512-mh0GIxx0wPtPlcB1q4k277y0iKgo25xmDPWioVVYanjPufDBpvu5ySTjP5wOrSvlYQ2m1xI+CFhGdauv/61uQg==", + "version": "9.14.2", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.14.2.tgz", + "integrity": "sha512-JK9Pm80OqssGJU2Y6F7DcM8RFHqVG4WkuCqOZTVsXkEzZME7ABejAUqUdA931zEBedc4thBgSUWxeQh4uocJAQ==", "license": "MIT", "dependencies": { - "@intlify/core-base": "9.13.1", - "@intlify/shared": "9.13.1", + "@intlify/core-base": "9.14.2", + "@intlify/shared": "9.14.2", "@vue/devtools-api": "^6.5.0" }, "engines": { diff --git a/frontend/package.json b/frontend/package.json index fac1193011a54b74cff4affa8e8fbf29238d3357..2c66b946800dcfd415823f0f2b67ccf1734f4b64 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "html2canvas": "^1.4.1", "jspdf": "^2.5.1", "marked": "^13.0.0", + "pinia": "^2.2.6", "plotly.js-basic-dist-min": "^2.33.0", "plotly.js-locales": "^2.33.0", "proj4": "^2.11.0", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c7051b68b4a505d78d1a26aa2051ebbf077dbfcf..dc35720e4ddce82253229829889cd3b4b8f75c25 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -24,11 +24,6 @@ export default defineComponent({ <style> -:root { - --header-height: 60px; - --sidenav-width: 360px; -} - html { scrollbar-width: none; overflow: hidden; diff --git a/frontend/src/assets/theme/blue.css b/frontend/src/assets/theme/blue.css new file mode 100644 index 0000000000000000000000000000000000000000..d520640caa3bce26de6be6da87123bb9ea5aa537 --- /dev/null +++ b/frontend/src/assets/theme/blue.css @@ -0,0 +1,38 @@ +:root { + --display-logo: none; + + --header-height: 40px; + --header-offset: 40px; + --controls-offset: 0px; + + --sidenav-width: 360px; + + --first-background-color: #003e6e; + --second-background-color: rgb(225, 232, 238); + --third-background-color: white; + + --header-text-color: lightblue; + --header-text-highlight-color: white; + + --active-background-color: #003e6e; + + --main-text-color: #003e6e; + --outline-color: darkgray; + + --standard-point-data-color: #003e6e; + + --cookie-modal-background: rgba(51, 204, 255, 0.3); + + --ol-background-color: rgb(225, 232, 238) !important; + /*--ol-accent-background-color*/ + /*--ol-subtle-background-color*/ + --ol-partial-background-color: rgb(225, 232, 238) !important; + --ol-subtle-foreground-color: #003e6e !important; + --ol-foreground-color: #003e6e !important; + /*--ol-brand-color*/ + + --chart-color-1: var(--main-color); + --chart-color-2: #0091ff; + --chart-color-3: #80c8ff; + --chart-color-4: #e6f4ff; +} \ No newline at end of file diff --git a/frontend/src/assets/theme/cyan_theme.css b/frontend/src/assets/theme/cyan_theme.css deleted file mode 100644 index 1bacdef7b7196c6414e18b22dd203ec169c0f5da..0000000000000000000000000000000000000000 --- a/frontend/src/assets/theme/cyan_theme.css +++ /dev/null @@ -1,18 +0,0 @@ -:root { - --main-color: #139bb5; - - --cookie-modal-background: rgba(51, 204, 255, 0.3); - - --ol-background-color: var(--main-color); - /*--ol-accent-background-color*/ - /*--ol-subtle-background-color*/ - --ol-partial-background-color: #139bb5a1; - --ol-subtle-foreground-color: white; - --ol-foreground-color: white; - /*--ol-brand-color*/ - - --chart-color-1: #0a4f5c; - --chart-color-2: #1189a2; - --chart-color-3: #18c4e7; - --chart-color-4: #8ce2f3; -} \ No newline at end of file diff --git a/frontend/src/assets/theme/dark.css b/frontend/src/assets/theme/dark.css new file mode 100644 index 0000000000000000000000000000000000000000..dbf963f2611f2053f21fa26b30e072bfd90bce00 --- /dev/null +++ b/frontend/src/assets/theme/dark.css @@ -0,0 +1,38 @@ +:root { + --display-logo: none; + + --header-height: 40px; + --header-offset: 0px; + --controls-offset: 40px; + + --sidenav-width: 360px; + + --first-background-color: rgba(10, 10, 10, 0.1); + --second-background-color: #606060; + --third-background-color: rgba(255, 255, 255, 0); + + --header-text-color: #3a3a3a; + --header-text-highlight-color: white; + + --active-background-color: #3a3a3a; + + --main-text-color: white; + --outline-color: darkgray; + + --standard-point-data-color: #003e6e; + + --cookie-modal-background: rgba(51, 204, 255, 0.3); + + --ol-background-color: rgba(76, 76, 76, 0.85) !important; + /*--ol-accent-background-color*/ + /*--ol-subtle-background-color*/ + --ol-partial-background-color: rgba(76, 76, 76, 0.85) !important; + --ol-subtle-foreground-color: white !important; + --ol-foreground-color: white !important; + /*--ol-brand-color*/ + + --chart-color-1: red; + --chart-color-2: #0091ff; + --chart-color-3: #80c8ff; + --chart-color-4: #e6f4ff; +} \ No newline at end of file diff --git a/frontend/public/blue_theme.css b/frontend/src/assets/theme/default.css similarity index 52% rename from frontend/public/blue_theme.css rename to frontend/src/assets/theme/default.css index f038bd1efa8f55616cc93a4ca1ac94a169e08dcb..d48820d53cce759efdbcb915f8c27d97dcea6784 100644 --- a/frontend/public/blue_theme.css +++ b/frontend/src/assets/theme/default.css @@ -1,5 +1,25 @@ :root { - --main-color: #00589C; + --display-logo: inline-block; + + --header-height: 60px; + --header-offset: 60px; + --controls-offset: 0px; + + --sidenav-width: 360px; + + --first-background-color: white; + --second-background-color: white; + --third-background-color: white; + + --header-text-color: #3a3a3a; + --header-text-highlight-color: white; + + --active-background-color: #00589C; + + --main-text-color: #3a3a3a; + --outline-color: #dbdbdb; + + --standard-point-data-color: #00589C; --cookie-modal-background: rgba(51, 204, 255, 0.3); diff --git a/frontend/src/assets/theme/green_theme.css b/frontend/src/assets/theme/green_theme.css deleted file mode 100644 index babcf608523526f0e44b930f4a64765a0311da62..0000000000000000000000000000000000000000 --- a/frontend/src/assets/theme/green_theme.css +++ /dev/null @@ -1,18 +0,0 @@ -:root { - --main-color: #1d7431; - - --cookie-modal-background: rgba(51, 204, 255, 0.3); - - --ol-background-color: rgba(73,73,73,.85); - /*--ol-accent-background-color*/ - /*--ol-subtle-background-color*/ - --ol-partial-background-color: rgba(0, 0, 0, 0.5); - --ol-subtle-foreground-color: white; - --ol-foreground-color: white; - /*--ol-brand-color*/ - - --chart-color-1: var(--main-color); - --chart-color-2: #484848; - --chart-color-3: #8d8d8d; - --chart-color-4: #d7d7d7; -} \ No newline at end of file diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 5e1749ce3534650b6d46bea8ac4b04a453832678..b4c1f6ca142819a65aade2512201345ee031e101 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -170,7 +170,7 @@ export default defineComponent({ z-index: 20; top: 0; left: 0; - background: white; + background: var(--first-background-color); width: 100%; border-bottom: 1px solid lightgray; } @@ -186,6 +186,7 @@ export default defineComponent({ } #titleText { + color: var(--header-text-color); position: absolute; top: 50%; transform: translate(0%, -50%); @@ -206,6 +207,7 @@ export default defineComponent({ .fullscreen-control-bar { display: inline-block; position: inherit; + line-height: calc(var(--header-height) - 8px); } </style> diff --git a/frontend/src/components/MapView.vue b/frontend/src/components/MapView.vue index 0b7a07386912dd938f66608f9208136f73e602fd..d8522e467d9ddd60382c3de783e6f28893c1bd43 100644 --- a/frontend/src/components/MapView.vue +++ b/frontend/src/components/MapView.vue @@ -17,7 +17,8 @@ :area-names="apiAreaNames" :backendUrl="backendUrl" :menuSidenavToggle="menuSidenavToggle" - :base-map="projectInfo.base_map" + :base-layer="projectInfo.base_layer" + :is-base-layer-gray="projectInfo.is_base_layer_gray" :enable-print-function="projectInfo.enable_print_function" :project-print-annotation="projectInfo.print_annotation" v-if="apiAreaNames.length > 0" @@ -28,12 +29,12 @@ <script lang="ts"> import AppHeader from "./AppHeader.vue"; import MapComponent from "./map/MapComponent.vue"; -import { Coordinate } from "openlayers"; import {defineComponent, ref } from "vue"; import {fetchAreaNames, fetchProject} from "@/composables/fetchDataFromBackend.ts"; import {useRoute} from "vue-router"; import {ProjectInfo} from "@/types"; import proj4 from "proj4"; +import {Coordinate} from "@/types"; export default defineComponent({ components: { @@ -46,14 +47,20 @@ export default defineComponent({ } }, watch: { - 'projectInfo': function(projectInfo: ProjectInfo) { - this.teaser = projectInfo.teaser - this.zoom = Number(projectInfo.default_zoom) + 'projectInfo': function(val: ProjectInfo) { + this.teaser = val.teaser + this.zoom = Number(val.default_zoom) - const mapCenter = [Number(projectInfo.default_lon), Number(projectInfo.default_lat)] + const mapCenter = [Number(val.default_lon), Number(val.default_lat)] this.center = proj4('WGS84', 'EPSG:3857', mapCenter) - // import(/* @vite-ignore */"@/assets/theme/" + projectInfo.theme + "_theme.css") + if (val.theme === 'blue') { + import('@/assets/theme/blue.css'); + } else if (val.theme === 'dark') { + import('@/assets/theme/dark.css'); + } else { + import('@/assets/theme/default.css'); + } } }, setup() { diff --git a/frontend/src/components/header/AppLink.vue b/frontend/src/components/header/AppLink.vue index 52f3f1c685141a892e993594912ca67da7f79243..9941e7929a84a230ae601b6d69865b6099ddbc51 100644 --- a/frontend/src/components/header/AppLink.vue +++ b/frontend/src/components/header/AppLink.vue @@ -77,14 +77,23 @@ export default defineComponent({ <style scoped> .logo { - display: inline-block; + display: var(--display-logo); margin: 5px 10px; } .page-link { vertical-align: middle !important; - margin-top: -2px !important; width: 180px; + background: var(--second-background-color); + color: var(--main-text-color); +} + +.simple-link { + color: var(--header-text-color); +} + +.simple-link:hover { + color: var(--header-text-highlight-color); } .ufz_logo { @@ -101,4 +110,8 @@ export default defineComponent({ max-height:100%; } +.float-left { + float: left; +} + </style> \ No newline at end of file diff --git a/frontend/src/components/header/RightControls.vue b/frontend/src/components/header/RightControls.vue index 93bc37cf3f5c08039eb4e0fed03a277ddee8d9b3..b458befa71551fb60e7a419b6103c493d4880243 100644 --- a/frontend/src/components/header/RightControls.vue +++ b/frontend/src/components/header/RightControls.vue @@ -3,17 +3,17 @@ <div class="is-inline-block lineHeightHeader" style="float: right"> <AppLink - styles="is-size-7 is-small mr-4 is-inline-block" + styles="is-size-7 is-small mr-4 is-inline-block simple-link float-left" target="imprint" label="imprint" ></AppLink> <!-- language switcher--> - <div class="dropdown is-right mr-3" style="margin-top: 2px" v-if="hasEnglishVersion"> + <div class="dropdown is-right mr-3" v-if="hasEnglishVersion"> <div class="dropdown-trigger is-size-4" @click="toggleLanguages"> - <font-awesome-icon :icon="['fas', 'language']" :style="[showMenuLanguages ? 'color: var(--main-color);' : 'color: #4a4a4a;']" /> + <font-awesome-icon :icon="['fas', 'language']" class="langIcon" :style="[showMenuLanguages ? 'color: var(--header-text-highlight-color);' : 'color: var(--header-text-color);']" /> </div> <div @@ -78,4 +78,8 @@ export default defineComponent({ right: 0; } +.langIcon:hover { + color: var(--header-text-highlight-color) !important; +} + </style> \ No newline at end of file diff --git a/frontend/src/components/map/LayerMenu.vue b/frontend/src/components/map/LayerMenu.vue index 3501d56ae2d703148b287e403178a0eb830cec15..3c04e184258c2f0165a99400232bdd2273f9e037 100644 --- a/frontend/src/components/map/LayerMenu.vue +++ b/frontend/src/components/map/LayerMenu.vue @@ -1,13 +1,13 @@ <template> - <div class="ml-3 mr-3" :class="[isNonWms(type) && showSwiper ? 'menu-disabled' : '']"> + <div class="ml-3 mr-3" :class="[!isWmsLayerMenu && showSwiper ? 'menu-disabled' : '']"> <div :class="[showSwiper ? 'menuSmall shrinking-object' : 'menuLarge growing-object']" class="menu innerLayerMenu" > <ul class="menu-list"> <template v-for="(layer, index) in selectableLayers" :key="'active' + index" > - <li v-if="layer.type == type"> + <li v-if="layerTypes?.indexOf(layer.type) !== -1"> <a @click="setLayer(layer)" class="button is-small is-clipped menuItem" @@ -31,49 +31,46 @@ import { Layer } from "@/types"; export default defineComponent({ props: { selectableLayers: Array as PropType<Layer[]>, - type: String, + layerTypes: Array as PropType<String[]>, label: String, layerToLoad: Number, showSwiper: Boolean }, data() { return { - selectedLayerId: this.$props.layerToLoad as number, + selectedLayerId: this.$props.layerToLoad as number }; }, watch: { 'showSwiper': function (val) { - if(this.isNonWms(this.$props.type) && val == true) { + if(!this.isWmsLayerMenu && val == true) { this.selectedLayerId = 0 } }, 'layerToLoad': function(id: number|undefined) { - if (this.$props.type === 'sta' && id === undefined) { - return - } this.selectedLayerId = id } }, computed: { - 'areOtherLayersAvailable'() { + 'areLayersInMenuUnsettable'() { for (const layer of this.selectableLayers) { - if (layer.type !== this.$props.type) { + if (layer.type !== 'wms') { return true } } return false + }, + 'isWmsLayerMenu'() { + return this.$props.types?.indexOf('wms') !== -1; } }, methods: { - isNonWms(type: string | undefined) { - return (type == 'sta' || type == 'geojson') - }, setLayer(layer: Layer): void { if (this.selectedLayerId !== layer.id) { this.selectedLayerId = layer.id this.$emit("layerChanged", layer) } else { - if (this.areOtherLayersAvailable) { + if (this.areLayersInMenuUnsettable) { this.selectedLayerId = 0 this.$emit("layerUnset") } @@ -137,6 +134,8 @@ export default defineComponent({ } .menuItem { + background: var(--second-background-color); + color: var(--main-text-color); word-break: break-word; height: auto !important; white-space: inherit !important; diff --git a/frontend/src/components/map/MapComponent.ts b/frontend/src/components/map/MapComponent.ts index ce202d94447ac8293f9f146427c625f1f93494e6..8f3dfc844f9a13b7e33fb764384ddad27461bc53 100644 --- a/frontend/src/components/map/MapComponent.ts +++ b/frontend/src/components/map/MapComponent.ts @@ -1,3 +1,5 @@ +// noinspection TypeScriptValidateTypes + import {defineComponent, onMounted, PropType, ref} from "vue" import axios from 'axios' import proj4 from 'proj4' @@ -11,7 +13,6 @@ import { getLastTimeStepIfExists, setPageHeight } from "@/composables/utils" import { countryCenterCoords } from "@/composables/countries" import shp, { FeatureCollectionWithFilename, ShpJSBuffer } from 'shpjs' import OpacityControl from "./wms/OpacityControl.vue"; -import StaLayer from "./sta/StaLayer.vue"; import AggregationWidget from "./wms/AggregationWidget.vue"; import LayerMenu from "./LayerMenu.vue"; import TimeSeriesChart from "./TimeSeriesChart.vue"; @@ -19,17 +20,14 @@ import MapPopup from "./MapPopup.vue"; import GeojsonLayer from "./geojson/GeojsonLayer.vue"; import {convertTime} from "@/composables/time_utils"; import ProjectTeaser from "./ProjectTeaser.vue"; -import StaControl from "./sta/StaControl.vue"; import StickyPopup from "./StickyPopup.vue"; import { AggregationEvent, Area, AreaCache, AreaName, - BoundingBox, Coordinate, CovJsonSingleResult, - DataCollection, GEOJSONLayer, Layer, LayerFilter, @@ -38,18 +36,24 @@ import { Vue3TileLayer, WMSLayer } from "@/types" -import {ObservedProperty, STAFilterParams, STALayer, STAObservedProperty, StaPopupInfo, StaThing} from "@/types/sta"; +import {STALayer, WFSLayer} from "@/types/vector"; import UncertaintyLayer from "@/components/map/geojson/UncertaintyLayer.vue"; import CountryLayer from "@/components/map/geojson/CountryLayer.vue"; import StickyPopupControl from "@/components/map/wms/StickyPopupControl.vue"; import MapPrintControl from "@/components/map/MapPrintControl.vue"; import FilterMenu from "@/components/map/FilterMenu.vue"; +import {TimeseriesStore, useTimeseriesStore} from '@/stores/timeseries.ts' +import {FeatureStore, useFeatureStore} from "@/stores/feature.ts"; +import VectorLayer from "@/components/map/vector/VectorLayer.vue"; +import VectorControl from "@/components/map/vector/VectorControl.vue"; + proj4.defs("EPSG:32632", "+proj=utm +zone=32 +datum=WGS84 +units=m +no_defs") proj4.defs("EPSG:3035","+proj=laea +lat_0=52 +lon_0=10 +x_0=4321000 +y_0=3210000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs"); export default defineComponent({ components: { + VectorLayer, MapPrintControl, CountryLayer, UncertaintyLayer, @@ -57,9 +61,8 @@ export default defineComponent({ MapPopup, LayerMenu, AggregationWidget, - StaLayer, FilterMenu, - StaControl, + VectorControl, MapLegend, OpacityControl, GeojsonLayer, @@ -84,7 +87,8 @@ export default defineComponent({ menuSidenavToggle: Boolean, enablePrintFunction: Boolean, projectPrintAnnotation: String, - baseMap: String + baseLayer: String, + isBaseLayerGray: Boolean }, data() { return { @@ -101,7 +105,6 @@ export default defineComponent({ isWms1Loading: false as boolean, isWms2Loading: false as boolean, isVectorDataLoading: false as boolean, - isChartLoading: false as boolean, isMapVisible: true as boolean, areCountriesVisible: false as boolean, @@ -119,7 +122,8 @@ export default defineComponent({ significanceUncertainty: undefined as string | undefined, showStabilityUncertainty: true as boolean, showSignificanceUncertainty: true as boolean, - showStaLayer: false as boolean, + showVectorLayer: false as boolean, + showVectorControl: true as boolean, showSideNav: false as boolean, showAreaMenu: false as boolean, @@ -130,7 +134,6 @@ export default defineComponent({ showLayerInfo: false as boolean, showShapefileInfo: false as boolean, showMainLayerTimeRange: true as boolean, - showStaControl: true as boolean, layerInfoTitle: '' as string, layerInfoText: '' as string, mainFilters: {} as LayerFilter, @@ -142,15 +145,8 @@ export default defineComponent({ comparisonLayerOpacity: 0.5 as number, isOpaque: false as boolean, - isStaRequest: false as boolean, staLayer: {} as STALayer|null, - popupInfos: [] as StaPopupInfo[], - thingProperties: [] as ObservedProperty[], - staObsProperty: {} as ObservedProperty|undefined, - staInput: {} as STAFilterParams, - showStaFilter: false as boolean, - staThing: undefined as StaThing | undefined, - currentBoundingBox: [[12.5,51.4], [13.3, 51.35]] as BoundingBox, + wfsLayer: null as WFSLayer|null, showPrintDialog: false as boolean, showPointRequestMessage: false as boolean, @@ -158,8 +154,10 @@ export default defineComponent({ hasAreaWmsLayers: false as boolean, hasAreaStaLayers: false as boolean, hasAreaGeojsonLayers: false as boolean, + hasAreaWfsLayers: false as boolean, - dataCollection: {} as DataCollection, + featureStore: {} as FeatureStore, + timeseriesStore: {} as TimeseriesStore, clickedLayer: {} as WMSLayer|null, clickedLayerTime: '' as string, clickedCoord: [0, 0] as Coordinate, @@ -215,24 +213,16 @@ export default defineComponent({ attribution() { return '© Bundesamt für Kartographie und Geodäsie ' + new Date().getFullYear() }, - showStationInformation() { - return (this.staLayer != null) - }, showTimeSeriesButton() { - if (this.isChartLoading || this.showChart) { + if (this.timeseriesStore.isChartLoading) { return false } - if (this.dataCollection.timeSeriesCollection) { - if (this.dataCollection.timeSeriesCollection.length == 0) { - return false - } - if (!this.isWideScreen || this.staLayer) { - return true - } - if (!this.showChart) { + if (this.timeseriesStore.dataCollection.timeSeriesCollection.length > 0) { + if (!this.isWideScreen) { return true } + return !this.showChart } return false }, @@ -241,6 +231,15 @@ export default defineComponent({ }, isClearDisabled() { return (this.showMainFilter && this.isMainFilterCleared) || (this.showComparisonFilter && this.isComparisonFilterCleared) + }, + vectorLayerId() { + if (this.staLayer) { + return this.staLayer.id + } + if (this.wfsLayer) { + return this.wfsLayer.id + } + return undefined } }, methods: { @@ -287,6 +286,13 @@ export default defineComponent({ this.isSidenavLoading = true + this.unsetMainWmsLayer() + this.unsetVectorLayer() + this.unsetGeojsonLayer() + this.timeseriesStore.clickedLayer = null + + this.showPrintDialog = false + this.requestArea(areaId) .then((area: Area) => { this.activeArea = area @@ -295,12 +301,6 @@ export default defineComponent({ this.requestFilters(area) } - this.unsetMainWmsLayer() - this.unsetStaLayer() - this.unsetGeojsonLayer() - - this.showPrintDialog = false - if (layerId === 0) { this.setLayer(this.getDefaultLayer(area)) } else { @@ -321,10 +321,9 @@ export default defineComponent({ if (layer.type === 'geojson') { this.hasAreaGeojsonLayers = true } - } - - if (!this.hasAreaStaLayers && !this.hasAreaGeojsonLayers) { - this.setComparisonLayer(this.activeArea.layers[0]) + if (layer.type === 'wfs') { + this.hasAreaWfsLayers = true + } } this.clearMainFilter() @@ -340,9 +339,6 @@ export default defineComponent({ }) .then(() => { this.isSidenavLoading = false - if (this.mainLayer) { - this.updateURL(this.mainLayer) - } }) }, toggleSwiper() { @@ -352,10 +348,21 @@ export default defineComponent({ this.areDistrictsVisible = false this.isShapefileVisible = false this.closeAggregationWidget() - this.showSwiper = !this.showSwiper - this.showStaLayer = !this.showSwiper this.showComparisonFilter = false + + if (!this.showSwiper) { + this.layerList.push(this.$refs.wmsLayer1.olImageLayer.imageLayer) + this.layerList.push(this.$refs.wmsLayer2.olImageLayer.imageLayer) + + this.$nextTick(() => { + this.showSwiper = true + this.showVectorLayer = false + }); + } else { + this.showSwiper = false + this.showVectorLayer = true + } }, toggleOpacity(deactivateOpacity = false) { if (this.mainLayerOpacity === 1 || deactivateOpacity) { @@ -401,14 +408,17 @@ export default defineComponent({ }, setLayer(layer: Layer) { if (layer.type == 'wms') { - this.setMainLayer(layer as WMSLayer, false) + this.setMainLayer(layer as WMSLayer) + this.setComparisonLayer(layer as WMSLayer) } else if (layer.type == 'sta') { this.setStaLayer(layer as STALayer) } else if (layer.type == 'geojson') { this.setGeojsonLayer(layer as GEOJSONLayer) + } else if (layer.type == 'wfs') { + this.setWfsLayer(layer as WFSLayer) } }, - setMainLayer(mainLayer: WMSLayer, updateUrl = true) { + setMainLayer(mainLayer: WMSLayer) { this.showPrintDialog = false this.showPopup = false this.showChart = false @@ -421,44 +431,70 @@ export default defineComponent({ this.aggregationEvent = null this.setMainLayerUncertainties() - this.setPreviousResults() - - if (updateUrl) { - this.updateURL(mainLayer) + this.updateURL(mainLayer) + }, + setVectorLayer(layer: Layer) { + if (layer.type === 'sta') { + this.setStaLayer(layer) } + if (layer.type === 'wfs') { + this.setWfsLayer(layer) + } + this.updateURL(layer) }, setStaLayer(layer: STALayer) { - this.unsetStaLayer() - this.showStaLayer = true - this.showStaControl = true + this.unsetVectorLayer() + this.showVectorLayer = true + this.showVectorControl = true this.showMainLayerTimeRange = false + + this.featureStore.staLayer = layer + this.timeseriesStore.staLayer = layer + this.updateURL(layer) setTimeout(() => this.staLayer = layer, 100) }, setGeojsonLayer(layer: GEOJSONLayer) { this.unsetGeojsonLayer() + this.updateURL(layer) setTimeout(() => this.geojsonLayer = layer, 100) }, + setWfsLayer(layer: WFSLayer) { + this.unsetVectorLayer() + this.showVectorLayer = true + this.showVectorControl = true + this.showMainLayerTimeRange = false + + this.featureStore.wfsLayer = layer + this.timeseriesStore.wfsLayer = layer + this.updateURL(layer) + setTimeout(() => { this.wfsLayer = layer }, 100) + }, unsetMainWmsLayer() { this.showSwiper = false this.mainLayer = null this.clickedLayer = null this.closeChartAndPopup() this.showMainLayerTimeRange = false - this.showStaControl = true + this.showVectorControl = true }, - unsetStaLayer() { - this.staObsProperty = undefined + unsetVectorLayer() { this.staLayer = null - this.showStaLayer = false - this.staThing = undefined - this.dataCollection = { - chartName: '', - mapPopupResult: {}, - timeSeriesCollection: [], - singleResultCollection: [] - } - this.showStaControl = false + this.wfsLayer = null + this.showVectorLayer = false + + this.featureStore.staLayer = null + this.featureStore.wfsLayer = null + this.featureStore.setSelectedProperty(undefined) + + this.timeseriesStore.staLayer = null + this.timeseriesStore.staThing = undefined + this.timeseriesStore.wfsLayer = null + this.timeseriesStore.setSelectedProperty(undefined) + + this.timeseriesStore.unsetTimeseriesData() + + this.showVectorControl = false this.showMainLayerTimeRange = true this.closeChartAndPopup() }, @@ -491,26 +527,20 @@ export default defineComponent({ this.setMainLayerUncertainties() }, - setStaObsProperty(property: ObservedProperty|undefined) { + requestVectorData() { this.showChart = false - this.staObsProperty = property - if (this.staThing) { - this.isStaRequest = true - this.requestPointData() + this.showPopup = true + if (this.timeseriesStore.isClickOnFeature) { + this.showChart = true } }, - setStaObsPropertyAndReload(property: ObservedProperty|undefined) { - this.staObsProperty = property + reloadStaFeatures() { this.closeChartAndPopup() - if (property != undefined) { - this.isMainFilterCleared = false - } + this.featureStore.requestStaFeatures() }, - setStaInput(params: STAFilterParams) { - this.staInput = params - if (params.beginDate !== "" || params.endDate !== "" || params.threshold !== "") { - this.isMainFilterCleared = false - } + reloadWfsFeatures() { + this.closeChartAndPopup() + this.featureStore.requestWfsFeatures() }, removeShapefile() { this.isShapefileVisible = false @@ -528,7 +558,7 @@ export default defineComponent({ }, toggleTimeRange() { this.showMainLayerTimeRange = !this.showMainLayerTimeRange - this.showStaControl = !this.showMainLayerTimeRange; + this.showVectorControl = !this.showMainLayerTimeRange; }, setComparisonLayerTime(value: string) { this.comparisonLayerTime = value @@ -583,6 +613,9 @@ export default defineComponent({ this.$refs.view.animate({zoom: 6.5, center: ger_center}) }, + setCenterToCluster(coord: Coordinate) { + this.$refs.view.animate({zoom: this.$refs.view.getZoom() + 2, center: coord}) + }, requestFilters(area: Area) { const url = this.backendUrl + 'filter/' + this.$i18n.locale + '/' + area.id @@ -691,9 +724,9 @@ export default defineComponent({ setPreviousResults() { this.previousClickedCoord = this.clickedCoord this.previousClickedCoordWgs84 = this.clickedCoordWgs84 - if (this.dataCollection.mapPopupResult) { - if (this.dataCollection.mapPopupResult.value) { - this.previousSingleResult = this.dataCollection.mapPopupResult + if (this.timeseriesStore.dataCollection.mapPopupResult) { + if (this.timeseriesStore.dataCollection.mapPopupResult.value) { + this.previousSingleResult = this.timeseriesStore.dataCollection.mapPopupResult } } }, @@ -730,58 +763,26 @@ export default defineComponent({ this.clickedLayerTime = this.comparisonLayerTime } } - if (!this.isStaRequest) { - this.requestPointData() - } - } - }, - requestPointData() { - const params: string[] = [] + this.timeseriesStore.clickedLayer = this.clickedLayer + this.timeseriesStore.clickedLayerTime = this.clickedLayerTime + this.timeseriesStore.clickedCoord = this.clickedCoord - if (this.clickedLayer) { - if (this.clickedLayer.is_point_request_enabled === false) { - this.showPointRequestMessage = true - setTimeout(() => this.showPointRequestMessage = false, 4000) - return - } - params.push('wmsLayer=' + this.clickedLayer.own_id) - params.push('time=' + this.clickedLayerTime) - params.push('x=' + this.clickedCoord[0]) - params.push('y=' + this.clickedCoord[1]) - } - - if (this.isStaRequest && this.staLayer && this.staThing && this.staObsProperty) { - params.push('staLayer=' + this.staLayer.own_id) - params.push('staThing=' + this.staThing['@iot.id']) - params.push('staObsProperty=' + this.staObsProperty.id) - } + if (this.mainLayer) { + this.timeseriesStore.requestPointData() - if (params.length > 0) { - this.isChartLoading = true - - const url = import.meta.env.VITE_APP_BACKEND_URL + 'timeseries/' + this.$i18n.locale + '?' + params.join('&') + if (!this.clickedLayer.is_point_request_enabled) { + this.showPointRequestMessage = true + setTimeout(() => this.showPointRequestMessage = false, 4000) + } + } - axios.get(url) - .then(response => { - this.dataCollection = response.data - if (this.dataCollection.mapPopupResult) { - this.setTimeFormatInPopup(this.dataCollection.mapPopupResult) - } - }) - .catch((error) => { - console.log(error) - }) - .finally(() => { - this.showPopup = true - if (!this.staLayer || this.staLayer && this.staObsProperty) { - if (this.isWideScreen && this.dataCollection.timeSeriesCollection.length > 0) { - this.showChart = true - } - } - this.isChartLoading = false - this.isStaRequest = false - }) + if (this.mainLayer || this.timeseriesStore.isClickOnFeature) { + this.showPopup = true + if (this.isWideScreen) { + this.showChart = true + } + } } }, triggerAggregationWidget(layer: WMSLayer, type: string) { @@ -903,76 +904,6 @@ export default defineComponent({ }.bind(this)) }, - setTimeFormatInPopup(result: CovJsonSingleResult) { - this.dataCollection.mapPopupResult.time = convertTime( - result.time, - this.$i18n.locale, - result.timeFormat - ) - }, - setStaThing(thing: StaThing) { - this.staThing = thing - this.isStaRequest = true - this.requestThingProperties(thing) - this.requestPointData() - }, - requestThingProperties(thing: StaThing) { - - let url = import.meta.env.VITE_APP_BACKEND_URL + 'sta-obs-properties-by-thing/' + this.staLayer.own_id + '/' + thing['@iot.id'] - - axios.get(url) - .then(response => { - this.setPopupInfos(thing) - this.setThingProperties(response.data.value) - this.showPopup = true - }) - .catch((error: any) => { - console.log(error) - }) - .finally(() => { - if (this.thingProperties.length == 1) { - this.isStaRequest = true - this.staObsProperty = this.thingProperties[0] - this.requestPointData() - } - }) - }, - setPopupInfos(thing: StaThing) { - this.popupInfos = [] - for (const key of Object.keys(thing.properties)) { - const config = this.staLayer.popupInfoConfig[key] - if (config !== undefined) { - - let infoText = thing.properties[key] - if (infoText instanceof Array) { - infoText = infoText.join(', ') - } - this.popupInfos.push({ - label: (this.$i18n.locale === 'en') ? config.name_in_frontend_en : config.name_in_frontend_de, - text: infoText - }) - } - } - }, - setThingProperties(obsProperties: STAObservedProperty[]) { - this.thingProperties = [] - - const name_prop = (this.$i18n.locale == 'de') ? 'name_de' : 'name_en' // TODO: switch to name - - for (const obsProperty of obsProperties) { - - let fullName = obsProperty.properties[name_prop] - if (!fullName) { - fullName = obsProperty.name - } - - this.thingProperties.push({ - id: obsProperty['@iot.id'], - fullName: fullName - }) - } - this.thingProperties.sort((a: ObservedProperty, b: ObservedProperty) => a.fullName.localeCompare(b.fullName)) - }, getCurrentExtent() { if (this.$refs.map) { return this.$refs.view.calculateExtent(this.$refs.map.map.getSize()) @@ -982,10 +913,17 @@ export default defineComponent({ setBoundingBox() { const extent = this.getCurrentExtent() if (extent) { - this.currentBoundingBox = [ - proj4('EPSG:3857', 'WGS84', [extent[0], extent[3]]), - proj4('EPSG:3857', 'WGS84', [extent[2], extent[1]]), + this.featureStore.currentBoundingBox = [ + proj4('EPSG:3857', 'WGS84', [extent[0], extent[1]]), + proj4('EPSG:3857', 'WGS84', [extent[2], extent[3]]), ] + if (this.staLayer) { + this.featureStore.requestStaFeatures() + } + + if (this.wfsLayer) { + this.featureStore.requestWfsFeatures() + } } }, }, @@ -1001,6 +939,18 @@ export default defineComponent({ setPageHeight() this.$refs.map.updateSize() + if (this.isBaseLayerGray) { + if (this.baseLayer=='baseMap') { + this.$refs.baseLayer.imageLayer.on('postrender', function(event) { + greyscale(event.context); + }) + } else { + this.$refs.osmLayer.tileLayer.on('postrender', function(event) { + greyscale(event.context); + }) + } + } + window.addEventListener('resize', () => { setPageHeight() this.$refs.map.updateSize() @@ -1010,6 +960,9 @@ export default defineComponent({ if (this.isWideScreen) { this.showSideNav = true } + + this.featureStore = useFeatureStore() + this.timeseriesStore = useTimeseriesStore() }, watch: { menuSidenavToggle() { @@ -1044,35 +997,6 @@ export default defineComponent({ comparisonLayerStyle.value = style } - //initiate Swiper (with layerList) - // TODO: make this somehow better... - onMounted(() => { - axios.get(import.meta.env.VITE_APP_BACKEND_URL + 'area/de/' + props.areaNames[0].id) - .then(response => { - for (const layer of response.data.result.area.layers) { - if (layer.type == 'wms') { - const wmsLayer = layer as WMSLayer - - mainLayer = ref<WMSLayer>(wmsLayer) - mainLayerTime.value = getLastTimeStepIfExists(wmsLayer) - mainLayerStyle.value = wmsLayer.styles[0] - comparisonLayer = ref<WMSLayer>(wmsLayer) - comparisonLayerTime.value = getLastTimeStepIfExists(wmsLayer) - comparisonLayerStyle.value = wmsLayer.styles[0] as string - return true - } - } - }) - .then(() => { - setTimeout(() => { - if (wmsLayer1.value && wmsLayer2.value) { - layerList.value.push(wmsLayer1.value.olImageLayer.imageLayer) - layerList.value.push(wmsLayer2.value.olImageLayer.imageLayer) - } - }, 200) - }) - }) - const map = ref<string>() return { @@ -1094,3 +1018,13 @@ export default defineComponent({ } } }) + +function greyscale(context) { + const imageData = context.getImageData(0, 0, context.canvas.width, context.canvas.height); + const data = imageData.data; + + for (let i=0; i < data.length; i += 4) { + data[i] = data[i + 1] = data[i + 2] = (3 * data[i] + 4 * data[i + 1] + data[i + 2]) / 8 + } + context.putImageData(imageData, 0, 0) +} \ No newline at end of file diff --git a/frontend/src/components/map/MapComponent.vue b/frontend/src/components/map/MapComponent.vue index c77c3c145730870f1900c271ebd1bf19ec848695..7751de42b7ba2cedb03f2492950fa6a9f36bd056 100644 --- a/frontend/src/components/map/MapComponent.vue +++ b/frontend/src/components/map/MapComponent.vue @@ -13,7 +13,7 @@ <div class="block m-3" > <div v-if="areaNames.length > 1"> <div> - <button class="button is-small width260" @click="toggleAreaMenu"> + <button class="button is-small width260 standard-button" @click="toggleAreaMenu"> {{ $t("message.selectablesAreas") }}: {{ activeArea.name }} <font-awesome-icon :icon="['fas', 'angle-up']" v-show="showAreaMenu"/> <font-awesome-icon :icon="['fas', 'angle-down']" v-show="!showAreaMenu"/> @@ -22,13 +22,13 @@ <div v-if="showAreaMenu" class="appearing-object"> <div v-for="areaName in areaNames" :key="areaName.id"> - <button class="button is-small width260" :class="[areaName.id === activeArea.id ? 'is-active' : '']" @click="switchArea(areaName.id)">{{ areaName.name }}</button> + <button class="button is-small width260" :class="[areaName.id === activeArea.id ? 'is-active' : 'second-color-scheme']" @click="switchArea(areaName.id)">{{ areaName.name }}</button> </div> </div> </div> <div class="mt-1 mb-6"> - <button :disabled="showPrintDialog" class="button is-small is-rounded" @click="toggleAreaInfoBox"> + <button :disabled="showPrintDialog" class="button is-small is-rounded standard-button" @click="toggleAreaInfoBox"> <font-awesome-icon :icon="['fas', 'circle-info']" size="lg" style="color: #4a4a4a; margin-top: -2px;" /> {{ $t("message.showDescription") }} </button> </div> @@ -49,17 +49,18 @@ <div class="mt-3"> <LayerMenu :selectable-layers="selectableMainLayers" - :type="'wms'" + :layer-types="['wms']" :label="'Layer 1:'" :show-swiper="showSwiper" :layer-to-load="mainLayer?.id" @layerChanged="setMainLayer" @layerUnset="unsetMainWmsLayer" + v-if="hasAreaWmsLayers" ></LayerMenu> <LayerMenu :selectable-layers="selectableMainLayers" - :type="'geojson'" + :layer-types="['geojson']" :label="''" :show-swiper="showSwiper" :layer-to-load="geojsonLayer?.id" @@ -70,13 +71,13 @@ <LayerMenu :selectable-layers="selectableMainLayers" - :type="'sta'" + :layer-types="['sta', 'wfs']" :label="''" :show-swiper="showSwiper" - :layer-to-load="staLayer?.id" - @layerChanged="setStaLayer" - @layerUnset="unsetStaLayer" - v-if="hasAreaStaLayers" + :layer-to-load="vectorLayerId" + @layerChanged="setVectorLayer" + @layerUnset="unsetVectorLayer" + v-if="hasAreaStaLayers || hasAreaWfsLayers" ></LayerMenu> <div v-show="selectableMainLayers?.length==0" id="emptyLayerListText">{{ $t("message.noLayerSelectable") }}</div> @@ -88,7 +89,7 @@ <div class="m-1" v-if="activeArea.layers.length > 1 && !hasAreaStaLayers"> <button :disabled="showPrintDialog" - class="button is-small" + class="button is-small standard-button" :class="[showSwiper ? 'is-success' : '']" @click="toggleSwiper" > @@ -115,7 +116,7 @@ <div class="m-3"> <LayerMenu :selectable-layers="selectableComparisonLayers" - :type="'wms'" + :layer-types="['wms']" :label="'Layer 2:'" :show-swiper="showSwiper" :layer-to-load="comparisonLayer?.id" @@ -130,7 +131,7 @@ v-if="(mainLayer?.type == 'wms' && mainLayer.available_methods.length > 0) && !showSwiper" > <div> - <button class="button is-small width260" @click="toggleRegionalSelectables"> + <button class="button is-small width260 standard-button" @click="toggleRegionalSelectables"> {{ $t("message.selectablesRegional") }} <font-awesome-icon :icon="['fas', 'angle-up']" v-show="showRegionalSelectables"/> <font-awesome-icon :icon="['fas', 'angle-down']" v-show="!showRegionalSelectables"/> @@ -142,7 +143,7 @@ <div class="m-2" v-if="mainLayer?.type == 'wms' && mainLayer.available_methods.length > 0"> <button :disabled="showPrintDialog" - class="button is-small appearing-object" + class="button is-small appearing-object standard-button" :class="[areCountriesVisible || areDistrictsVisible ? 'is-success' : '']" @click="toggleCountries" > @@ -156,7 +157,7 @@ <div class="file is-small is-inline-block is-white appearing-object"> <label class="file-label mt-1"> <input :disabled="showPrintDialog" class="file-input" type="file" ref="fileInput" name="resume" @change="uploadShapefile"> - <span class="file-cta upload-button"> + <span class="file-cta upload-button standard-button"> <span class="file-label"> <font-awesome-icon :icon="['fas', 'upload']" style="margin-top: 2px;"/> {{ $t("message.uploadShapefile") }} @@ -166,7 +167,7 @@ </div> <button - class="button is-small m-1 infoButton appearing-object" + class="button is-small m-1 infoButton appearing-object standard-button" @click="toggleShapefileInfo" > <font-awesome-icon :icon="['fas', 'circle-info']" size="lg" style="color: #4a4a4a;" /> @@ -195,7 +196,7 @@ @singleclick="handleMapClick" @moveend="setBoundingBox" > - <div id="loadingInfo" class="loading-ring" v-show="isSidenavLoading || isWms1Loading || isWms2Loading || isVectorDataLoading || isChartLoading"></div> + <div id="loadingInfo" class="loading-ring" v-show="isSidenavLoading || isWms1Loading || isWms2Loading || isVectorDataLoading || timeseriesStore.isChartLoading || featureStore.isLoading"></div> <ol-view ref="view" @@ -211,7 +212,7 @@ /> <div v-if="isMapVisible"> - <ol-image-layer :zIndex="-1" v-if="baseMap=='baseMap'"> + <ol-image-layer :zIndex="-1" v-if="baseLayer=='baseMap'" ref="baseLayer"> <ol-source-image-wms v-bind="!showPrintDialog && {attributions: attribution}" :ratio="1" @@ -284,16 +285,25 @@ v-if="mainLayer && mainLayer.styles.length > 0" ></MapLegend> - <StaControl + + <VectorControl :layer="staLayer" - :obs-property="staObsProperty" :is-wms-time-control-active="mainLayer!=undefined" - v-if="staLayer && showStaLayer && showStaControl" - @sendObsProperty="setStaObsPropertyAndReload" - @sendStaFilterParams="setStaInput" + v-if="staLayer && showVectorLayer && showVectorControl" + @sendVectorFilter="reloadStaFeatures" + @sendLayerInfo="toggleLayerInfo" + @sendIsVisible="toggleTimeRange" + ></VectorControl> + + + <VectorControl + :layer="wfsLayer" + :is-wms-time-control-active="mainLayer!=undefined" + v-if="wfsLayer && showVectorLayer && showVectorControl" + @sendVectorFilter="reloadWfsFeatures" @sendLayerInfo="toggleLayerInfo" @sendIsVisible="toggleTimeRange" - ></StaControl> + ></VectorControl> <WmsTimeControl v-if="mainLayer && !showPrintDialog" label="Layer 1" @@ -342,15 +352,21 @@ ></WmsTimeControl> </div> - <div v-if="staLayer && showStaLayer"> - <StaLayer ref="staLayer" + <div v-if="staLayer && showVectorLayer"> + <VectorLayer ref="staLayer" :layer="staLayer" - :sta-obs-property="staObsProperty" - :sta-filter-params="staInput" - :bounding-box="currentBoundingBox" - @sendLoadingStatus="setVectorDataLoading" - @sendFeatureInformation="setStaThing" - ></StaLayer> + @sendFeatureInformation="requestVectorData" + @zoomCluster="setCenterToCluster" + ></VectorLayer> + </div> + + + <div v-if="wfsLayer && showVectorLayer"> + <VectorLayer + :layer="wfsLayer" + @sendFeatureInformation="requestVectorData" + @zoomCluster="setCenterToCluster" + ></VectorLayer> </div> <GeojsonLayer @@ -415,18 +431,13 @@ <MapPopup :clicked-coord="clickedCoord" :clicked-coord-wgs84="clickedCoordWgs84" - :single-result="dataCollection.mapPopupResult" - :sta-thing="staThing" - :popup-infos="popupInfos" - :selectable-sta-properties="thingProperties" - :selected-sta-property="staObsProperty" :show-time-series-button="showTimeSeriesButton" - :show-station-information="showStationInformation" + :single-result="timeseriesStore.dataCollection.mapPopupResult" v-show="isTablet || (!isTablet && !showSideNav)" v-if="showPopup" @popupClosed="closeChartAndPopup" @timeSeriesButtonClicked="showChart=true" - @sendObsProperty="setStaObsProperty" + @sendProperty="requestVectorData" ></MapPopup> <StickyPopup @@ -464,9 +475,9 @@ ></AggregationWidget> <TimeSeriesChart - :data-collection="dataCollection" + :data-collection="timeseriesStore.dataCollection" :clicked-coord-wgs84="clickedCoordWgs84" - :show-chart="showChart" + :show-chart="showChart && timeseriesStore.dataCollection.timeSeriesCollection.length > 0" :print-annotation="projectPrintAnnotation" @chartClosed="showChart = false" v-show="showChart && !showMainFilter && !showComparisonFilter" @@ -484,7 +495,7 @@ <div class="modal-content"> <article class="message m-2"> - <div class="message-body is-size-7 is-main-color"> + <div class="message-body is-size-7 text-window"> <div v-if="showAreaInfoBox" class="mb-3"> <span class="has-text-weight-bold is-size-6">{{ $t("message.area") }}: {{ activeArea?.name }}</span> @@ -614,19 +625,14 @@ <style> #map { - height: 94vh; - margin-top: var(--header-height); - /*background-color: #837d7d; - background-image: linear-gradient(#ababab 1px, transparent 1px), linear-gradient(to right, #ababab 1px, #837d7d 1px); - background-size: 54px 54px;*/ - /* limit max-size to prevent too much load for geoserver */ - /*max-width: 1600px;*/ - /*max-height: 900px;*/ - /*margin: auto;*/ + height: calc(100vh - var(--header-offset)) ; + margin-top: var(--header-offset); } #sidenav, #sideNavModal { - height: 94vh; + height: calc(100vh - var(--header-height)); + margin-top: var(--controls-offset); + position: absolute; display: none; @@ -664,7 +670,7 @@ .tool-block { margin: auto; margin-top: -4px; - background: var(--ol-subtle-foreground-color); + background: var(--third-background-color); width: 258px; } @@ -730,8 +736,8 @@ border: 4px solid rgba(10, 10, 10, 0.1); border-radius: 50%; - border-top: 4px solid var(--main-color); - border-bottom: 4px solid var(--main-color); + border-top: 4px solid var(--active-background-color); + border-bottom: 4px solid var(--active-background-color); animation: spin 2s linear infinite; } @@ -754,8 +760,8 @@ } button.is-active, a.is-active, #nav-midsize-button.is-active, .active-caret { - background: var(--main-color) !important; - color: white !important; + background: var(--active-background-color) !important; + color: var(--header-text-highlight-color) !important; } .modal-content { @@ -768,6 +774,15 @@ button.is-active, a.is-active, #nav-midsize-button.is-active, .active-caret { opacity: 0.5; } + +.ol-zoom { + top: calc(.5em + var(--controls-offset)) !important; +} + +.ol-zoom-extent { + top: calc(4.643em + var(--controls-offset)) !important; +} + @media only screen and (min-width: 1408px) { #burgerButton { display: none; @@ -805,9 +820,10 @@ button.is-active, a.is-active, #nav-midsize-button.is-active, .active-caret { cursor: default; } -.is-main-color { - background: white; - border-color: var(--main-color) !important; +.text-window { + color: var(--main-text-color) !important; + background: var(--second-background-color); + border-color: var(--first-background-color) !important; margin-left: -1px; } @@ -905,8 +921,13 @@ button.is-active, a.is-active, #nav-midsize-button.is-active, .active-caret { line-height: 50px; vertical-align: middle !important; font-weight: bold; - color: white; + color: var(--header-text-highlight-color); font-size: 12px; } +.standard-button { + background: var(--second-background-color) !important; + color: var(--main-text-color) !important; +} + </style> diff --git a/frontend/src/components/map/MapLegend.vue b/frontend/src/components/map/MapLegend.vue index 542aa8cbccf1dd5a453468fddffc9886037c7a03..1603d73f62d5e73829e9fe3e08e7b61739c4f157 100644 --- a/frontend/src/components/map/MapLegend.vue +++ b/frontend/src/components/map/MapLegend.vue @@ -1,5 +1,5 @@ <template> - <div :id="legendId" :class="[showPrintLegend ? 'print-legend' : 'legend' ]" style="background: white"> + <div :id="legendId" :class="[showPrintLegend ? 'print-legend' : 'legend' ]"> <div class="legendContent"> <font-awesome-icon v-if="!showPrintLegend" class="closeButton" :icon="['fas', 'rectangle-xmark']" size="lg" @click="toggleLegend" v-show="showLegend" /> @@ -51,7 +51,7 @@ <button v-if="!showPrintLegend" - class="button is-small mt-1 infoButton" + class="button is-small mt-1 infoButton standard-button" @click="showLayerInfo" v-show="layerInfo" > @@ -61,7 +61,7 @@ <div v-show="!showLegend" - class="button is-size-7 unfoldLegend" + class="button is-size-7 unfoldLegend standard-button" @click="toggleLegend" > {{ $t("message.legend") }} @@ -191,6 +191,9 @@ export default defineComponent({ text-align: left; line-height: 0.5; border: 1px solid lightgray; + + background: var(--second-background-color); + color: var(--main-text-color); } .legendColorList { @@ -246,7 +249,7 @@ export default defineComponent({ margin-right: 5px; border: 1px solid #666666; border-radius: 2px; - background: white; + background: var(--second-background-color); } </style> \ No newline at end of file diff --git a/frontend/src/components/map/MapPopup.vue b/frontend/src/components/map/MapPopup.vue index 11c6e04999462a4f9f182b068393d80d94937a51..d94b46155166a6d4def1c84266d1dfb86b1604ee 100644 --- a/frontend/src/components/map/MapPopup.vue +++ b/frontend/src/components/map/MapPopup.vue @@ -12,21 +12,35 @@ <p v-if="clickedCoordWgs84">Lat: {{ round4(clickedCoordWgs84[1]) }} / Lon: {{ round4(clickedCoordWgs84[0]) }}</p> - <div v-if="staThing && showStationInformation"> - <p><b>{{ staThing.name}}</b></p> - <p - v-for="(popupInfo, index) in popupInfos" - :key="'thingProp' + index" - > - <i>{{ popupInfo.label }}: {{ popupInfo.text }}</i> - </p> - - <ul id="thingPropList" class="mt-3 standardScrollbar"> + <div v-if="timeseriesStore.staLayer || timeseriesStore.wfsLayer"> + + <div v-if="timeseriesStore.staLayer && timeseriesStore.staThing"> + <p><b>{{ timeseriesStore.staThing.properties.name}}</b></p> + <p + v-for="(popupInfo, index) in timeseriesStore.popupInfos" + :key="'thingProp' + index" + > + <i>{{ popupInfo.label }}: {{ popupInfo.text }}</i> + </p> + </div> + + <div v-if="timeseriesStore.wfsLayer"> + + <h2 class="m-2"><b>{{ locationName }}</b></h2> + <div v-if="timeseriesStore.feature"> + <p v-for="info of wfsLocationInfos"> + {{ info }} + </p> + </div> + </div> + + + <ul id="locationPropertyList" class="mt-3 standardScrollbar"> <li - class="staProperty" - :class="[selectedStaProperty?.id == property.id ? 'activeStaProperty' : '']" - v-for="property of selectableStaProperties" - :key="'obs_prop' + property.id" + class="locationProperty" + :class="[timeseriesStore.selectedProperty?.id == property.id ? 'activeProperty' : '']" + v-for="property of timeseriesStore.locationProperties" + :key="'loc_prop' + property.id" @click="switchProperty(property)" > {{ property.fullName }} @@ -34,7 +48,7 @@ </ul> </div> - <div v-if="singleResult && !showStationInformation"> + <div v-if="singleResult && !(featureStore.staLayer || featureStore.wfsLayer)"> <p class="has-text-weight-bold mt-1">{{ singleResult.label }}</p> <p class="has-text-weight-bold mt-1" v-show="singleResult.time !== ''">{{ singleResult.time }}</p> @@ -51,14 +65,12 @@ <span v-else>{{ singleResult.textValue }}</span> </p> - </div> </div> <button v-show="showTimeSeriesButton" class="button mt-1 is-small" @click="showTimeSeries"> {{ $t("message.timeSeries") }} </button> - </div> </div> </ol-overlay> @@ -69,20 +81,55 @@ import { defineComponent, PropType } from "vue"; import { round4 } from "@/composables/utils"; -import { ObservedProperty, StaPopupInfo, StaThing } from "@/types/sta"; -import { Coordinate, CovJsonSingleResult } from "@/types"; +import {Coordinate, CovJsonSingleResult} from "@/types"; +import {TimeseriesStore, useTimeseriesStore} from "@/stores/timeseries.ts"; +import {FeatureStore, useFeatureStore} from "@/stores/feature.ts"; +import {VectorProperty} from "@/types/vector"; export default defineComponent({ props: { clickedCoord: Object as PropType<Coordinate>, clickedCoordWgs84: Object as PropType<Coordinate>, singleResult: Object as PropType<CovJsonSingleResult>, - staThing: Object as PropType<StaThing|undefined>, - popupInfos: Array as Object as PropType<StaPopupInfo[]>, - selectableStaProperties: Array as Object as PropType<ObservedProperty[]>, - selectedStaProperty: Object as PropType<ObservedProperty|undefined>, - showTimeSeriesButton: Boolean, - showStationInformation: Boolean + showTimeSeriesButton: Boolean + }, + data() { + return { + timeseriesStore: {} as TimeseriesStore, + featureStore: {} as FeatureStore + } + }, + computed: { + locationName() { + if (this.timeseriesStore.feature) { + if (this.timeseriesStore.feature.properties) { + return this.timeseriesStore.feature.properties['name'] + } + } + return '' + }, + wfsLocationInfos() { + const result = [] + if (this.timeseriesStore.feature) { + if (this.timeseriesStore.feature.properties) { + for (const key of Object.keys(this.timeseriesStore.feature.properties)) { + if (['name', 'created_at', 'import_id', 'type', 'srid', 'lat', 'lon', 'gtype'].indexOf(key) !== -1) + continue + + if (key === 'properties') { + const props = JSON.parse(this.timeseriesStore.feature.properties[key]) + for (const subKey of Object.keys(props)) { + result.push(subKey + ': ' + props[subKey]) + } + } else { + result.push(key + ': ' + this.timeseriesStore.feature.properties[key]) + } + } + } + } + + return result + } }, methods: { close() { @@ -94,20 +141,30 @@ export default defineComponent({ showTimeSeries() { this.$emit('timeSeriesButtonClicked') }, - switchProperty(property: ObservedProperty|undefined) { - this.$emit('sendObsProperty', property) + switchProperty(property: VectorProperty) { + this.timeseriesStore.isClickOnFeature = true + this.timeseriesStore.setSelectedProperty(property) + this.featureStore.setSelectedProperty(property) + + this.timeseriesStore.requestPointData() + + this.$emit('sendProperty') } }, emits: { popupClosed() { - return true; + return true }, - sendObsProperty(){ - return true; + sendProperty(){ + return true }, timeSeriesButtonClicked() { - return true; + return true } + }, + created() { + this.timeseriesStore = useTimeseriesStore() + this.featureStore = useFeatureStore() } }) </script> @@ -116,7 +173,8 @@ export default defineComponent({ <style> #popup { - background: white; + background: var(--second-background-color); + color: var(--main-text-color); border: 1px solid #cccccc; border-radius: 4px; margin-left: -24px; @@ -143,28 +201,28 @@ export default defineComponent({ } #popupArrow::after { - border-color: #fff transparent; + border-color: var(--second-background-color) transparent; top: -3px; left: -1px; } -#thingPropList { +#locationPropertyList { max-height: 240px; } -.activeStaProperty { +.activeProperty { color: red; font-weight: bold; } -.staProperty { +.locationProperty { white-space: nowrap; overflow-x: hidden; text-overflow: ellipsis; } -.staProperty:hover { - color: var(--main-color); +.locationProperty:hover { + color: var(--first-background-color); font-weight: bolder; cursor: pointer; } diff --git a/frontend/src/components/map/MapPrint.vue b/frontend/src/components/map/MapPrint.vue index 1d42d9fa270efe8059a3ed607700977d80cf65f3..74158e639698be6890d1958e7983a72de4f0e686 100644 --- a/frontend/src/components/map/MapPrint.vue +++ b/frontend/src/components/map/MapPrint.vue @@ -36,8 +36,8 @@ <input type="radio" id="radioOptionCurrent" value="map" v-model="extent"/> <label class="radio is-size-7 m-1" for="radioOptionCurrent">{{ $t("message.extentCurrent") }}</label> <br> - <button class="button is-small" @click="print">{{ $t("message.save") }}</button> - <button class="button is-small" @click="printLegend">{{ $t("message.printLegend") }}</button> + <button class="button is-small standard-button" @click="print">{{ $t("message.save") }}</button> + <button class="button is-small standard-button" @click="printLegend">{{ $t("message.printLegend") }}</button> <div class="mt-2"> <div id="print-loading-ring" v-show="isDownloading"></div> @@ -321,8 +321,8 @@ export default defineComponent({ border: 4px solid rgba(10, 10, 10, 0.1); border-radius: 50%; - border-top: 4px solid var(--main-color); - border-bottom: 4px solid var(--main-color); + border-top: 4px solid var(--active-background-color); + border-bottom: 4px solid var(--active-background-color); animation: spin 2s linear infinite; } diff --git a/frontend/src/components/map/MapPrintControl.vue b/frontend/src/components/map/MapPrintControl.vue index 742000839159535843b187a0099de75122b8b68f..71fcb9275a8d8ffd555a8c29a29f165445e755bb 100644 --- a/frontend/src/components/map/MapPrintControl.vue +++ b/frontend/src/components/map/MapPrintControl.vue @@ -39,7 +39,7 @@ export default defineComponent({ #mapPrintControl { right: .5em; - top: 4.5em; + top: calc(4.5em + var(--controls-offset)); pointer-events: auto; } diff --git a/frontend/src/components/map/ProjectTeaser.vue b/frontend/src/components/map/ProjectTeaser.vue index 846bc2b9477bb37f51ca90db03f27d8c3c4d37e7..9a561f56aef140551093df5afb9a1936d4dee202 100644 --- a/frontend/src/components/map/ProjectTeaser.vue +++ b/frontend/src/components/map/ProjectTeaser.vue @@ -37,9 +37,9 @@ export default defineComponent({ #teaser { width: 720px; - background-color: var(--main-color); + background-color: var(--first-background-color); opacity: 0.7; - color: var(--ol-subtle-foreground-color); + color: var(--header-text-color); border-radius: 4px; top: calc(var(--header-height) + 10px); diff --git a/frontend/src/components/map/TimeSeriesChart.vue b/frontend/src/components/map/TimeSeriesChart.vue index 6cfcfbbb2c3563f88d2f276d541abf9af62ed6ae..4ff08132100fa1dc5f5664d39af83c6241b94474 100644 --- a/frontend/src/components/map/TimeSeriesChart.vue +++ b/frontend/src/components/map/TimeSeriesChart.vue @@ -28,7 +28,7 @@ </p> </div> - <div v-show="showTable"> + <div id="resultTable" v-show="showTable"> <div class="has-text-right"> <font-awesome-icon class="tableButton tableButtonCSV" :icon="['fas', 'file-csv']" @click="exportCSV" :title="$t('message.exportCSV')" /> <font-awesome-icon class="tableButton tableButtonToggle" :icon="['fas', 'chart-line']" @click="toggleTable" :title="$t('message.showDiagram')" /> @@ -144,7 +144,7 @@ export default defineComponent({ traces: [ { line: { - color: "#00589C", + color: getComputedStyle(document.documentElement).getPropertyValue('--main-text-color'), width: 4, shape: "line" } @@ -159,7 +159,7 @@ export default defineComponent({ y: chartMarginBottom }, yaxis: { - linecolor: '#cccccc', + linecolor: getComputedStyle(document.documentElement).getPropertyValue('--outline-color'), mirror: true, fixedrange: false, automargin: true, @@ -167,12 +167,11 @@ export default defineComponent({ showticksuffix: 'none', hoverformat: '.2f' } as any, - //paper_bgcolor: 'lightblue', title: { text: '', font: { size: 12, - color: '#4a4a4a' + color: getComputedStyle(document.documentElement).getPropertyValue('--main-text-color') } }, annotations: [], @@ -327,7 +326,7 @@ export default defineComponent({ text: this.lat + ' - ' + this.lon, font: { size: 12, - color: '#4a4a4a' + color: getComputedStyle(document.documentElement).getPropertyValue('--main-text-color') } } if (!this.isWideScreen) { @@ -422,8 +421,12 @@ export default defineComponent({ // use timeout because plotly cannot react to changed width of bulma modal-content setTimeout(function () { const element = document.getElementById(plotlyId) - if (element) { - Plotly.relayout(plotlyId, {width: element.offsetWidth}); + if (element && this.timeSeriesCollection) { + Plotly.relayout(plotlyId, { + paper_bgcolor: getComputedStyle(document.documentElement).getPropertyValue('--second-background-color'), + plot_bgcolor: getComputedStyle(document.documentElement).getPropertyValue('--second-background-color'), + width: element.offsetWidth + }); } }, 10) }, @@ -492,7 +495,8 @@ export default defineComponent({ overflow-y: scroll; scrollbar-width: none; padding: 12px; - border: 1px solid #cccccc; + border: 1px solid var(--outline-color); + background: var(--second-background-color); } #covJsonWidgetContent::-webkit-scrollbar { @@ -551,8 +555,8 @@ export default defineComponent({ } .tabs li.is-active a { - border-bottom-color: var(--main-color); - color: var(--main-color); + border-bottom-color: var(--main-text-color); + color: var(--main-text-color); } .timeseriesTable { @@ -564,4 +568,8 @@ export default defineComponent({ overflow-y: hidden; } +#resultTable { + color: var(--main-text-color); +} + </style> \ No newline at end of file diff --git a/frontend/src/components/map/sta/StaLayer.vue b/frontend/src/components/map/sta/StaLayer.vue index 8d935f825f3014e06803278d9d10893c6c4fece3..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644 --- a/frontend/src/components/map/sta/StaLayer.vue +++ b/frontend/src/components/map/sta/StaLayer.vue @@ -1,457 +0,0 @@ -<template> - - <ol-interaction-select - @select="featureSelected" - :condition="selectConditionClick" - :filter="selectInteractionFilterClick" - v-if="!showCluster" - > - <ol-style :overrideStyleFunction="getSelectStyle"> - </ol-style> - </ol-interaction-select> - - <ol-interaction-select - @select="featureSelected" - :condition="selectConditionHover" - :filter="selectInteractionFilterHover" - v-if="!showCluster" - > - - <ol-style :overrideStyleFunction="getHoverStyle"> - </ol-style> - </ol-interaction-select> - - <ol-vector-image-layer :z-index="30"> - <ol-source-vector - @featuresloadstart="$emit('sendLoadingStatus', true)" - @featuresloaderror="$emit('sendLoadingStatus', false)" - @featuresloadend="$emit('sendLoadingStatus', false)" - > - <ol-feature - v-for="(point, i) in locations" - :key="'point' + i" - :properties="point.properties" - v-if="!showCluster" - > - <ol-geom-point :coordinates="point.coordinates"></ol-geom-point> - </ol-feature> - - <ol-feature - v-for="(cluster, i) in clusters" - :key="'cluster' + i" - v-if="showCluster" - > - <ol-geom-point :coordinates="cluster.coords" v-if="cluster.requestId === currentRequestId"></ol-geom-point> - <ol-style> - <ol-style-circle :radius="cluster.count / 50 + 30"> - <ol-style-stroke color="#00589C" :width="5"></ol-style-stroke> - <ol-style-fill color="#00589C1A"></ol-style-fill> - - </ol-style-circle> - <ol-style-text :text="'' + cluster.count" scale="2"> - <ol-style-stroke color="white" :width="6"></ol-style-stroke> - <ol-style-fill color="#333"></ol-style-fill> - </ol-style-text> - </ol-style> - </ol-feature> - - <ol-style :overrideStyleFunction="getNormalStyle"> - </ol-style> - </ol-source-vector> - - </ol-vector-image-layer> - -</template> - -<script lang="ts"> -import {defineComponent, inject, PropType} from "vue"; -import axios from "axios"; -import Circle from "ol/style/Circle"; -import Fill from "ol/style/Fill"; -import Stroke from "ol/style/Stroke"; -import Style from "ol/style/Style"; -import proj4 from "proj4"; -import {Cluster, MatrixCell, ObservedProperty, STAFilterParams, STALayer, StaThing} from "@/types/sta"; -import {BoundingBox} from "@/types"; - -const MAX_LOCATIONS_ON_DESKTOP = 300 -const MAX_LOCATIONS_ON_MOBILE = 200 - -const MATRIX_WIDTH = 5 -const MATRIX_HEIGHT = 5 -const MATRIX: MatrixCell[] = [ - [[0,0],[1,1], 1], - [[0,1],[1,2], 2], - [[0,2],[1,3], 3], - [[0,3],[1,4], 4], - [[0,4],[1,5], 5], - - [[1,0],[2,1], 6], - [[1,1],[2,2], 7], - [[1,2],[2,3], 8], - [[1,3],[2,4], 9], - [[1,4],[2,5], 10], - - [[2,0],[3,1], 11], - [[2,1],[3,2], 12], - [[2,2],[3,3], 13], - [[2,3],[3,4], 14], - [[2,4],[3,5], 15], - - [[3,0],[4,1], 16], - [[3,1],[4,2], 17], - [[3,2],[4,3], 18], - [[3,3],[4,4], 19], - [[3,4],[4,5], 20], - - [[4,0],[5,1], 21], - [[4,1],[5,2], 22], - [[4,2],[5,3], 23], - [[4,3],[5,4], 24], - [[4,4],[5,5], 25], -] - - -export default defineComponent({ - props: { - layer: Object as PropType<STALayer>, - staObsProperty: Object as PropType<ObservedProperty|undefined>, - staFilterParams: Object as PropType<STAFilterParams>, - boundingBox: Object as PropType<BoundingBox> - }, - data() { - return { - dataLayer: this.$props.layer as STALayer, - locations: [] as any[], - thresholdValue: '' as string, - beginDate: '' as string, - endDate: '' as string, - currentRequestId: 0 as number, - locationsCount: 0 as number, - absoluteLocationsCount: 0 as number, - clusters: [] as Cluster[] - } - }, - computed: { - thingCountUrl() { - return import.meta.env.VITE_APP_BACKEND_URL + 'sta-thing-count/' + this.dataLayer.own_id - }, - locationsUrl() { - return import.meta.env.VITE_APP_BACKEND_URL + 'sta-locations/' + this.dataLayer.own_id - }, - endpoint() { - return this.$props.layer.endpoint - }, - obsPropertyId() { - if (this.$props.staObsProperty) { - return this.$props.staObsProperty.id - } - return undefined - }, - bboxTopLon() { - return this.$props.boundingBox[0][0] - }, - bboxTopLat() { - return this.$props.boundingBox[0][1] - }, - bboxBottomLon() { - return this.$props.boundingBox[1][0] - }, - bboxBottomLat() { - return this.$props.boundingBox[1][1] - }, - showCluster() { - return this.clusters.length > 0 - }, - cellWidth() { - return Math.abs((this.bboxTopLon - this.bboxBottomLon) / MATRIX_WIDTH) - }, - cellHeight() { - return Math.abs((this.bboxTopLat - this.bboxBottomLat) / MATRIX_HEIGHT) - }, - maxLocations() { - return (window.innerWidth > 1408) ? MAX_LOCATIONS_ON_DESKTOP : MAX_LOCATIONS_ON_MOBILE - } - }, - watch: { - staObsProperty() { - this.absoluteLocationsCount = 0 - this.requestLocations() - }, - staFilterParams(val: STAFilterParams) { - this.absoluteLocationsCount = 0 - this.thresholdValue = val.threshold - this.beginDate = val.beginDate - this.endDate = val.endDate - this.requestLocations() - }, - boundingBox() { - this.requestLocations() - } - }, - methods: { - getFilterParams(cell: MatrixCell|undefined = undefined) { - const params = [] - - if (this.obsPropertyId) { - params.push('obsProperty=' + this.obsPropertyId) - } - if (this.thresholdValue !== '') { - params.push('threshold=' + this.thresholdValue) - } - if (this.beginDate !== '') { - params.push('beginDate=' + this.beginDate) - } - if (this.endDate !== '') { - params.push('endDate=' + this.endDate) - } - - if (cell) { - const cellTopLon = this.bboxTopLon + (this.cellWidth * cell[0][0]) - const cellTopLat = this.bboxTopLat - (this.cellHeight * cell[0][1]) - - const cellBottomLon = this.bboxTopLon + (this.cellWidth * cell[1][0]) - const cellBottomLat = this.bboxTopLat - (this.cellHeight * cell[1][1]) - - params.push('bboxTopLon=' + cellTopLon) - params.push('bboxTopLat=' + cellTopLat) - params.push('bboxBottomLon=' + cellBottomLon) - params.push('bboxBottomLat=' + cellBottomLat) - - } else { - - params.push('bboxTopLon=' + this.bboxTopLon) - params.push('bboxTopLat=' + this.bboxTopLat) - params.push('bboxBottomLon=' + this.bboxBottomLon) - params.push('bboxBottomLat=' + this.bboxBottomLat) - } - - if (params.length > 0) { - return '?' + params.join('&') - } - return '' - }, - requestLocations() { - - axios.get(this.thingCountUrl + this.getFilterParams()) - .then(response => { - - this.locationsCount = response.data["@iot.count"] - - if (this.locationsCount < this.absoluteLocationsCount || this.absoluteLocationsCount === 0) { - - this.clusters = [] - this.locations = [] - this.$emit('sendLoadingStatus', true) - - if (this.locationsCount > this.maxLocations || this.locationsCount === 0) { - this.requestCluster() - } else { - this.requestFullLocations() - } - } - }) - .catch((error: any) => { - console.log(error) - }) - .finally(() => { - if (this.absoluteLocationsCount < this.locationsCount) { - this.absoluteLocationsCount = this.locationsCount - } - }) - }, - requestCluster() { - - const requestId = Math.floor(Math.random() * 1000000) - this.currentRequestId = requestId - - let countFinishedRequests = 0 - - const minDistX = this.cellWidth / 4 - const minDistY = this.cellHeight / 4 - - for (const cell of MATRIX) { - - axios.get(this.locationsUrl + this.getFilterParams(cell)) - .then(response => { - - const countFeatures = response.data["@iot.count"] - if (countFeatures > 0 && requestId === this.currentRequestId) { - - const idx = Math.floor(countFeatures / 2) - - if (response.data.features[idx]) { - - const newCluster = { - coords: proj4( 'WGS84', 'EPSG:3857', response.data.features[idx].geometry.coordinates), - coordsWgs84: response.data.features[idx].geometry.coordinates, - count: countFeatures, - requestId: requestId, - cellNumber: cell[2] - } - - let isClusterTooNearToOtherOne = false - - for (const cluster of this.clusters) { - const distX = Math.abs(Math.abs(cluster.coordsWgs84[0]) - Math.abs(newCluster.coordsWgs84[0])) - const distY = Math.abs(Math.abs(cluster.coordsWgs84[1]) - Math.abs(newCluster.coordsWgs84[1])) - - if (distX < minDistX && distY < minDistY) { - isClusterTooNearToOtherOne = true - - const midpoint = [(cluster.coordsWgs84[0] + newCluster.coordsWgs84[0]) / 2, (cluster.coordsWgs84[1] + newCluster.coordsWgs84[1]) / 2]; - - cluster.coords = proj4( 'WGS84', 'EPSG:3857', midpoint) - cluster.coordsWgs84 = midpoint - cluster.count += newCluster.count - } - } - if (!isClusterTooNearToOtherOne && requestId === this.currentRequestId) { - this.clusters.push(newCluster) - } - } - } - }) - .catch((error: any) => { - console.log(error) - }) - .finally(() => { - countFinishedRequests++ - if (countFinishedRequests === MATRIX.length) { - this.$emit('sendLoadingStatus', false) - } - }) - } - }, - requestFullLocations() { - axios.get(this.locationsUrl + this.getFilterParams()) - .then(response => { - if (response.data['features']) { - for (const point of response.data['features']) { - this.locations.push({ - coordinates: proj4( 'WGS84', 'EPSG:3857',point.geometry.coordinates), - properties: point.properties - }) - } - } - }) - .catch((error: any) => { - console.log(error) - }) - .finally(() => { - this.$emit('sendLoadingStatus', false) - }) - }, - selectInteractionFilterClick(feature: any) { - - let thing = feature.values_ - - if (thing.id !== undefined) { - - const url = import.meta.env.VITE_APP_BACKEND_URL + 'sta-thing/' + this.dataLayer.endpoint + '/' + thing.id - - axios.get(url) - .then(response => { - thing = response.data.value[0] - }) - .catch((error: any) => { - console.log(error) - }) - .finally(() => { - this.$emit('sendFeatureInformation', thing) - }) - } - - return true - }, - getStyleCircle(feature: any, borderWidth: number, borderColor: string) { - - const lastObservationResult = feature.values_['Datastreams/0/Observations/0/result'] - - let radius = 10 - for (const staColor of this.dataLayer.legend) { - if (lastObservationResult < staColor.upper_limit) { - radius = staColor.radius - - if (staColor.color != '#FFFFFF') { - borderColor = staColor.color - } - } - } - - feature.setStyle(new Style({ - image: new Circle({ - radius: radius, - fill: new Fill({ - color: (borderColor == 'red') ? 'white' : '#ffffff6e' - }), - stroke: new Stroke({ - color: borderColor, - width: borderWidth - }) - }) - })) - }, - getNormalStyle(feature: any) { - this.getStyleCircle(feature, 4, '#00589C') - }, - getHoverStyle(feature: any) { - this.getStyleCircle(feature, 8, 'red') - }, - getSelectStyle(feature: any) { - this.getStyleCircle(feature, 8, 'red') - } - }, - emits: { - sendLoadingStatus(isLoading: boolean) { - if (isLoading || !isLoading) { - return true - } else { - console.warn('Invalid submit event payload!') - return false - } - }, - sendFeatureInformation(id: StaThing) { - if (id) { - return true - } else { - console.warn('Invalid submit event payload!') - return false - } - } - }, - created() { - this.dataLayer = this.$props.layer - - this.requestLocations() - }, - setup() { - const strategy: any = inject("ol-loadingstrategy"); - const bbox = strategy.bbox; - - const format: any = inject("ol-format"); - const GeoJSON = new format.GeoJSON(); - - const selectConditions: any = inject("ol-selectconditions") - const selectConditionClick = selectConditions.click - - const selectConditionHover = selectConditions.pointerMove - - const featureSelected = () => {} - - const selectInteractionFilterHover = () => { - return true - } - - return { - bbox, - GeoJSON, - selectConditionClick, - selectConditionHover, - featureSelected, - selectInteractionFilterHover - }; - }, -}); - -</script> diff --git a/frontend/src/components/map/sta/StaControl.vue b/frontend/src/components/map/vector/VectorControl.vue similarity index 50% rename from frontend/src/components/map/sta/StaControl.vue rename to frontend/src/components/map/vector/VectorControl.vue index 0cea1b58a3978b06b44b8c36088e9037a8cf58e1..3aea3ac05a0f906a039edbbb0ad0ded33f8298bf 100644 --- a/frontend/src/components/map/sta/StaControl.vue +++ b/frontend/src/components/map/vector/VectorControl.vue @@ -3,7 +3,7 @@ <div class="bottomControlWrapper"> - <div id="staControlBar" class="bottomControl"> + <div id="vectorControlBar" class="bottomControl"> <div class="is-size-7 columns"> <div class="column" v-show="isWmsTimeControlActive"> @@ -18,30 +18,30 @@ </div> <div class="column"> - <p class="has-text-weight-bold">{{ $t("message.observations") }}</p> + <p class="has-text-weight-bold">{{ $t("message.timeSeries") }}</p> <div class="dropdown is-up is-inline-block"> <div class="dropdown-trigger" @click="togglePropertyMenu"> - <input class="input is-small" type="text" v-model="staAutoComplete" :placeholder="propertyInputPlaceholder"> + <input class="input is-small" type="text" v-model="autoComplete" :placeholder="timeseriesStore.inputPlaceholder"> </div> - <div class="dropdown-menu" :class="[showPropertyMenu ? 'activePropertyMenu' : '']" id="dropdown-menu3" role="menu"> + <div class="dropdown-menu" :class="[showPropertyMenu ? 'activePropertyMenu' : '']" role="menu"> <div class="dropdown-content"> <a href="#" @click="switchProperty(undefined)" class="dropdown-item is-small" - :class="[selectedProperty === undefined ? 'is-active' : '']" + :class="[timeseriesStore.selectedProperty === undefined ? 'is-active' : '']" > - </a> <a href="#" - v-for="property in selectableProperties" :key="property.id" + v-for="property in timeseriesStore.layerProperties" :key="property.id" @click="switchProperty(property)" class="dropdown-item is-small" - :class="[property.id === selectedProperty?.id && selectedProperty?.id ? 'is-active' : '']" + :class="[property.id == timeseriesStore.selectedProperty?.id ? 'is-active' : '']" v-show="searchByAutoCompleteInput(property)" > {{ property.fullName }} @@ -55,18 +55,20 @@ <p class="has-text-weight-bold">{{ $t("message.measurementsFrom") }}:</p> <p class="has-text-danger" v-show="showBeginDateWarning">{{ $t("message.invalidDate") }}</p> - <input class="input is-small" type="text" v-model="staBeginDate" placeholder="YYYY-MM-DD" @keyup.enter="setFilter"> + <input class="input is-small" type="text" v-model="beginDate" placeholder="YYYY-MM-DD" @keyup.enter="setFilter"> </div> <div class="column" :class="[isWmsTimeControlActive ? 'is-one-fifth' : 'is-one-quarter']"> <p class="has-text-weight-bold">{{ $t("message.measurementsUntil") }}:</p> <p class="has-text-danger" v-show="showEndDateWarning">{{ $t("message.invalidDate") }}</p> - <input class="input is-small" type="text" v-model="staEndDate" placeholder="YYYY-MM-DD" @keyup.enter="setFilter"> + <input class="input is-small" type="text" v-model="endDate" placeholder="YYYY-MM-DD" @keyup.enter="setFilter"> </div> <div class="column"> <p class="has-text-weight-bold">{{ $t("message.threshold") }}:</p> - <input class="input is-small" type="text" v-model="staInput" placeholder="e.g 0.5" @keyup.enter="setFilter"> + <input class="input is-small" type="text" v-model="thresholdValue" placeholder="e.g 0.5" @keyup.enter="setFilter"> + <p class="has-text-danger" v-show="showPropertyWarning">{{ $t("message.invalidProperty") }}</p> + <p class="has-text-danger" v-show="showThresholdWarning">{{ $t("message.invalidThreshold") }}</p> </div> <div class="column"> @@ -82,7 +84,6 @@ </button> </div> - </div> </div> </div> @@ -92,114 +93,109 @@ <script lang="ts"> import {defineComponent, PropType} from "vue"; -import axios from "axios"; import {isValidDateFormat} from "@/composables/time_utils"; -import {ObservedProperty, STAFilterParams, STALayer} from "@/types/sta"; +import {FeatureStore, useFeatureStore} from "@/stores/feature.ts"; +import {TimeseriesStore, useTimeseriesStore} from "@/stores/timeseries.ts"; +import {Layer} from "@/types"; +import {VectorProperty} from "@/types/vector"; export default defineComponent({ props: { - layer: Object as PropType<STALayer>, - obsProperty: Object as PropType<ObservedProperty>, + layer: Object as PropType<Layer>, isWmsTimeControlActive: Boolean }, data() { return { - selectableProperties: [] as ObservedProperty[], showPropertyMenu: false as boolean, - selectedProperty: {} as ObservedProperty|undefined, - staAutoComplete: '' as string, - staBeginDate: '' as string, - staEndDate: '' as string, - staInput: '' as string, + autoComplete: '' as string, + beginDate: '' as string, + endDate: '' as string, + thresholdValue: '' as string, showBeginDateWarning: false as boolean, - showEndDateWarning: false as boolean - } - }, - computed: { - endpoint() { - return this.$props.layer.endpoint - }, - propertyInputPlaceholder() { - return this.selectedProperty ? this.selectedProperty.fullName : '-' + showEndDateWarning: false as boolean, + showPropertyWarning: false as boolean, + showThresholdWarning: false as boolean, + featureStore: {} as FeatureStore, + timeseriesStore: {} as TimeseriesStore } }, methods: { togglePropertyMenu(){ this.showPropertyMenu = !this.showPropertyMenu }, - getSelectableProperties() { - this.selectableProperties = [] - - let url = import.meta.env.VITE_APP_BACKEND_URL + 'sta-obs-properties/' + this.layer.own_id - - axios.get(url) - .then(response => { - - const name_prop = (this.$i18n.locale == 'de') ? 'name_de' : 'name_en' - for (const property of response.data.value) { - - let fullName = property.name - if (property.properties) { - if (property.properties[name_prop]) { - fullName = property.properties[name_prop] - } - } - this.selectableProperties.push({ - id: property['@iot.id'], - fullName: fullName - }) - } - this.selectableProperties.sort((a: ObservedProperty, b: ObservedProperty) => a.fullName.localeCompare(b.fullName)) - }) - .catch((error: any) => { - console.log(error) - }) - }, - switchProperty(property: ObservedProperty|undefined) { - this.selectedProperty = property + switchProperty(property: VectorProperty|undefined) { this.showPropertyMenu = false - this.$emit('sendObsProperty', property) - this.staAutoComplete = '' + this.autoComplete = '' + + if (property) { + this.featureStore.setSelectedProperty(property) + this.timeseriesStore.setSelectedProperty(property) + this.$emit('sendVectorFilter') + + this.timeseriesStore.requestPointData() + } else { + this.clear() + } }, setFilter() { - if (this.staBeginDate !== '' || this.staEndDate !== '' || this.staInput !== '') { this.showBeginDateWarning = false this.showEndDateWarning = false - if (!isValidDateFormat(this.staBeginDate)) { + this.showPropertyWarning = false + this.showThresholdWarning = false + + if (!isValidDateFormat(this.beginDate)) { this.showBeginDateWarning = true return } - if (!isValidDateFormat(this.staEndDate)) { + + if (!isValidDateFormat(this.endDate)) { this.showEndDateWarning = true return } - this.$emit('sendStaFilterParams', { - threshold: this.staInput, - beginDate: this.staBeginDate, - endDate: this.staEndDate - }) - } + if (this.thresholdValue !== '') { + if (!this.featureStore.selectedPropertyId) { + this.showPropertyWarning = true + return + } + + const floatVal = parseFloat(this.thresholdValue) + if (isNaN(floatVal)) { + this.showThresholdWarning = true + return + } else { + this.thresholdValue = floatVal + } + } + + this.featureStore.beginDate = this.beginDate + this.featureStore.endDate = this.endDate + this.featureStore.thresholdValue = this.thresholdValue + + this.$emit('sendVectorFilter') }, - searchByAutoCompleteInput(property: ObservedProperty) { - const searchTerm = this.staAutoComplete.toLowerCase() + searchByAutoCompleteInput(property: VectorProperty) { + const searchTerm = this.autoComplete.toLowerCase() const keyword = property.fullName.toLowerCase() return (keyword.includes(searchTerm)) }, clear() { - this.selectedProperty = undefined - this.staBeginDate = '' - this.staEndDate = '' - this.staInput = '' - this.$emit('sendObsProperty', undefined) - this.$emit('sendStaFilterParams', { - threshold: '', - beginDate: '', - endDate: '' - }) + this.featureStore.setSelectedProperty(undefined) + this.timeseriesStore.setSelectedProperty(undefined) + this.timeseriesStore.unsetTimeseriesData() + this.showBeginDateWarning = false this.showEndDateWarning = false + + this.beginDate = '' + this.endDate = '' + this.thresholdValue = '' + this.featureStore.beginDate = this.beginDate + this.featureStore.endDate = this.endDate + this.featureStore.thresholdValue = this.thresholdValue + + this.$emit('sendVectorFilter') }, showLayerInfo() { this.$emit("sendLayerInfo", this.$props.layer) @@ -208,36 +204,11 @@ export default defineComponent({ this.$emit("sendIsVisible") } }, - watch: { - obsProperty(val) { - if (val !== this.selectedProperty) { - this.selectedProperty = val - } - }, - isCleared(val: boolean) { - if (val) { - this.clear() - } - } - }, emits: { - sendObsProperty(property: ObservedProperty|undefined){ - if (property || property === undefined) { - return true; - } else { - console.warn("Invalid StaFilterParams!"); - return false; - } - }, - sendStaFilterParams(params: STAFilterParams){ - if (params) { - return true; - } else { - console.warn("Invalid StaFilterParams!"); - return false; - } + sendVectorFilter(){ + return true }, - sendLayerInfo(layer: STALayer) { + sendLayerInfo(layer: Layer) { if (layer) { return true } else { @@ -249,8 +220,16 @@ export default defineComponent({ return true } }, - mounted() { - this.getSelectableProperties() + created() { + this.featureStore = useFeatureStore() + this.timeseriesStore = useTimeseriesStore() + + if (this.$props.layer.type === 'sta') { + this.timeseriesStore.requestStaProperties(this.$i18n.locale) + } + if (this.$props.layer.type === 'wfs') { + this.timeseriesStore.requestWfsProperties() + } } }) @@ -258,7 +237,7 @@ export default defineComponent({ <style scoped> -#staControlBar { +#vectorControlBar { position: absolute; z-index: 10; background: white; @@ -273,6 +252,10 @@ export default defineComponent({ display: block !important; } +.dropdown-item { + max-width: 65vw; +} + .dropdown-content { max-height: 500px; overflow-y: scroll; diff --git a/frontend/src/components/map/vector/VectorLayer.vue b/frontend/src/components/map/vector/VectorLayer.vue new file mode 100644 index 0000000000000000000000000000000000000000..94bc2ad6757206b085d5caf7e46b6ea4228d60bc --- /dev/null +++ b/frontend/src/components/map/vector/VectorLayer.vue @@ -0,0 +1,225 @@ +<template> + + <ol-interaction-select + @select="featureSelected" + :condition="selectConditionClick" + :filter="selectInteractionFilterClick" + > + <ol-style :overrideStyleFunction="getSelectStyle"> + </ol-style> + </ol-interaction-select> + + <ol-interaction-select + @select="featureSelected" + :condition="selectConditionHover" + :filter="selectInteractionFilterHover" + v-if="!showCluster" + > + + <ol-style :overrideStyleFunction="getHoverStyle"> + </ol-style> + </ol-interaction-select> + + <ol-vector-layer + :updateWhileAnimating="true" + :updateWhileInteracting="true" + :z-index="30" + > + <ol-source-vector> + + <ol-animation-fade :duration="1000"> + + <ol-feature + v-for="(point, i) in featureStore.features" + :key="'point' + i" + :properties="point.properties" + v-if="!showCluster" + > + <ol-geom-point :coordinates="point.coordinates"></ol-geom-point> + + <ol-style> + <ol-style-circle :radius="10"> + <ol-style-stroke color="#00589C" :width="4"></ol-style-stroke> + <ol-style-fill color="#FFFFFF"></ol-style-fill> + </ol-style-circle> + </ol-style> + </ol-feature> + + <ol-feature + v-for="(cluster, i) in featureStore.clusters" + :key="'cluster' + i" + v-if="showCluster" + > + <ol-geom-point :coordinates="cluster.coords" v-if="cluster.requestId === featureStore.currentRequestId"></ol-geom-point> + + <ol-style> + <ol-style-circle :radius="cluster.count / 50 + 30"> + <ol-style-stroke color="#00589C" :width="5"></ol-style-stroke> + <ol-style-fill color="#00589C1A"></ol-style-fill> + + </ol-style-circle> + <ol-style-text :text="'' + cluster.count" scale="2"> + <ol-style-stroke color="white" :width="6"></ol-style-stroke> + <ol-style-fill color="#333"></ol-style-fill> + </ol-style-text> + </ol-style> + + </ol-feature> + + </ol-animation-fade> + </ol-source-vector> + </ol-vector-layer> + +</template> + +<script lang="ts"> +import {defineComponent, inject, PropType} from "vue"; +import Circle from "ol/style/Circle"; +import Fill from "ol/style/Fill"; +import Stroke from "ol/style/Stroke"; +import Style from "ol/style/Style"; +import {TimeseriesStore, useTimeseriesStore} from "@/stores/timeseries.ts"; +import {FeatureStore, useFeatureStore} from "@/stores/feature.ts"; +import {Coordinate, Layer} from "@/types"; + + +export default defineComponent({ + props: { + layer: Object as PropType<Layer> + }, + data() { + return { + featureIdField: '', + hoveredThingId: 0, + featureStore: {} as FeatureStore, + timeseriesStore: {} as TimeseriesStore + } + }, + computed: { + showCluster() { + return this.featureStore.clusters.length > 0 + }, + }, + methods: { + selectInteractionFilterHover(feature: any) { + if (feature.values_[this.featureIdField]) { + this.hoveredThingId = feature.values_[this.featureIdField] + } + return true + }, + selectInteractionFilterClick(feature: any) { + + if (this.showCluster) { + + this.$emit('zoomCluster', [ + feature.values_.geometry.flatCoordinates[0], + feature.values_.geometry.flatCoordinates[1] + ]) + + } else { + + if (feature.values_[this.featureIdField]) { + if (feature.values_[this.featureIdField] === this.hoveredThingId) { + + if (this.$props.layer?.type === 'sta') { + this.timeseriesStore.setStaFeature(feature) + } + if (this.$props.layer?.type === 'wfs') { + this.timeseriesStore.setFeature(feature) + } + + this.$emit('sendFeatureInformation') + } + } + } + }, + getStyleCircle(feature: any, borderWidth: number, borderColor: string) { + + let radius = 10 + + if (this.$props.layer.type === 'sta') { + + const lastObservationResult = feature.values_['Datastreams/0/Observations/0/result'] + + for (const staColor of this.$props.layer.legend) { + if (lastObservationResult < staColor.upper_limit) { + radius = staColor.radius + + if (staColor.color != '#FFFFFF') { + borderColor = staColor.color + } + } + } + } + + feature.setStyle(new Style({ + image: new Circle({ + radius: radius, + fill: new Fill({ + color: (borderColor == 'red') ? 'white' : '#ffffff6e' + }), + stroke: new Stroke({ + color: borderColor, + width: borderWidth + }) + }) + })) + }, + getHoverStyle(feature: any) { + this.getStyleCircle(feature, 8, 'red') + }, + getSelectStyle(feature: any) { + this.getStyleCircle(feature, 8, 'red') + } + }, + emits: { + sendFeatureInformation() { + return true + }, + zoomCluster(coord: Coordinate) { + if (coord) { + return true + } else { + console.warn('Invalid submit event payload!') + return false + } + } + }, + created() { + this.featureStore = useFeatureStore() + this.timeseriesStore = useTimeseriesStore() + + if (this.$props.layer.type === 'sta') { + this.featureIdField = 'id' + this.featureStore.requestStaFeatures() + } + if (this.$props.layer.type === 'wfs') { + this.featureIdField = 'fid' + this.featureStore.requestWfsFeatures() + } + }, + setup() { + const strategy: any = inject("ol-loadingstrategy"); + const bbox = strategy.bbox; + + const format: any = inject("ol-format"); + const GeoJSON = new format.GeoJSON(); + + const selectConditions: any = inject("ol-selectconditions") + const selectConditionClick = selectConditions.click + + const selectConditionHover = selectConditions.pointerMove + + const featureSelected = () => {} + + return { + bbox, + GeoJSON, + selectConditionClick, + selectConditionHover, + featureSelected + }; + }, +}); + +</script> diff --git a/frontend/src/components/map/wms/AggregationWidget.vue b/frontend/src/components/map/wms/AggregationWidget.vue index dd9ae7de6193c4a9b456207304691ad7473ab130..4cb73a837d22803131589ecb6a18e4f1570674df 100644 --- a/frontend/src/components/map/wms/AggregationWidget.vue +++ b/frontend/src/components/map/wms/AggregationWidget.vue @@ -15,7 +15,7 @@ <div> <p class="has-text-weight-bold">{{ title }}</p> <div v-if="areCountriesVisible"> - <button class="button is-small mt-2" @click="showDistricts"><b>{{ $t("message.activateDistricts") }}</b></button> + <button class="button is-small mt-2 standard-button" @click="showDistricts"><b>{{ $t("message.activateDistricts") }}</b></button> </div> </div> @@ -195,3 +195,12 @@ export default defineComponent({ }); </script> + +<style scoped> + +#aggregationWidgetContent { + background: var(--second-background-color); + color: var(--main-text-color); +} + +</style> diff --git a/frontend/src/components/map/wms/OpacityControl.vue b/frontend/src/components/map/wms/OpacityControl.vue index 49e5c232f846db241838d1b61eb8cf35525885f3..19c938ee346ef52792f866b970c2cbcd4c8b81cf 100644 --- a/frontend/src/components/map/wms/OpacityControl.vue +++ b/frontend/src/components/map/wms/OpacityControl.vue @@ -37,7 +37,7 @@ export default defineComponent({ #opacityControl { right: .5em; - top: .5em; + top: calc(.5em + var(--controls-offset)); pointer-events: auto; } diff --git a/frontend/src/components/map/wms/StickyPopupControl.vue b/frontend/src/components/map/wms/StickyPopupControl.vue index 8351fea74a9361edda82848f821150aca1f8d4c8..6cdda1158dd5d268ff9f3de94f168938d1039489 100644 --- a/frontend/src/components/map/wms/StickyPopupControl.vue +++ b/frontend/src/components/map/wms/StickyPopupControl.vue @@ -1,8 +1,8 @@ <template> <div id="stickyPopupControl" class="ol-unselectable ol-control"> <button class="ol-info" type="button" :title="$t('message.activateStickyPopup')" @click="changeStickyPopup"> - <font-awesome-icon v-show="isActive" :icon="['fas', 'thumbtack']" size="xs" /> - <svg v-show="!isActive" xmlns="http://www.w3.org/2000/svg" height="12" width="15" viewBox="0 0 640 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path fill="#ffffff" d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L481.4 352c9.8-.4 18.9-5.3 24.6-13.3c6-8.3 7.7-19.1 4.4-28.8l-1-3c-13.8-41.5-42.8-74.8-79.5-94.7L418.5 64 448 64c17.7 0 32-14.3 32-32s-14.3-32-32-32L192 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l29.5 0-6.1 79.5L38.8 5.1zM324.9 352L177.1 235.6c-20.9 18.9-37.2 43.3-46.5 71.3l-1 3c-3.3 9.8-1.6 20.5 4.4 28.8s15.7 13.3 26 13.3l164.9 0zM288 384l0 96c0 17.7 14.3 32 32 32s32-14.3 32-32l0-96-64 0z"/></svg> + <font-awesome-icon v-show="isActive" :icon="['fas', 'thumbtack']" size="xs" style="color: var(--ol-subtle-foreground-color) !important;" /> + <svg v-show="!isActive" xmlns="http://www.w3.org/2000/svg" height="12" width="15" viewBox="0 0 640 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path fill="var(--ol-subtle-foreground-color)" d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L481.4 352c9.8-.4 18.9-5.3 24.6-13.3c6-8.3 7.7-19.1 4.4-28.8l-1-3c-13.8-41.5-42.8-74.8-79.5-94.7L418.5 64 448 64c17.7 0 32-14.3 32-32s-14.3-32-32-32L192 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l29.5 0-6.1 79.5L38.8 5.1zM324.9 352L177.1 235.6c-20.9 18.9-37.2 43.3-46.5 71.3l-1 3c-3.3 9.8-1.6 20.5 4.4 28.8s15.7 13.3 26 13.3l164.9 0zM288 384l0 96c0 17.7 14.3 32 32 32s32-14.3 32-32l0-96-64 0z"/></svg> </button> </div> </template> @@ -37,7 +37,7 @@ export default defineComponent({ #stickyPopupControl { right: .5em; - top: 2.5em; + top: calc(2.5em + var(--controls-offset)); pointer-events: auto; } diff --git a/frontend/src/components/map/wms/WmsTimeControl.vue b/frontend/src/components/map/wms/WmsTimeControl.vue index d6a87b0c51282928fe69d9697e240027eaeb5c36..7b835d6255c7cb42b08e77c8c19fa7f4598b7d0a 100644 --- a/frontend/src/components/map/wms/WmsTimeControl.vue +++ b/frontend/src/components/map/wms/WmsTimeControl.vue @@ -11,7 +11,7 @@ <span class="tag switchLabel" v-show="isStaControlActive">{{ $t("message.rasterData") }}</span> <div v-if="timeSteps.length == 1" class="pb-2" :class="compactControlBarStyle"> - <span class="tag is-main-color sliderLabel"> + <span class="tag sliderLabel"> {{ convertTime(timeSteps[0]) }} </span> </div> @@ -30,7 +30,7 @@ </div> </div> - <span class="tag sliderLabel is-main-color">{{ convertTime(selectedTime) }}</span> + <span class="tag sliderLabel">{{ convertTime(selectedTime) }}</span> </div> <div v-if="timeSteps.length >= sliderLimit || !isTabletSize" style="display: block" class="select is-small pl-1 pr-1 mt-1" :class="compactControlBarStyle"> @@ -182,7 +182,7 @@ export default defineComponent({ .timeControl { overflow: hidden; - background: white; + background: var(--second-background-color); position: absolute; transform: translate(calc(50vw - 50%)); border-radius: 4px; @@ -236,6 +236,9 @@ export default defineComponent({ margin-left: 6px; margin-right: 7px; width: 100px; + + background: var(--active-background-color); + color: var(--header-text-highlight-color); } .controlBarMarginLeft { @@ -252,12 +255,12 @@ export default defineComponent({ .sliderScaleLine > div { width: inherit; - border-left: 1px solid lightgray; + border-left: 1px solid var(--outline-color); height: 6px; } .sliderScaleLine > div:last-child { - border-right: 1px solid lightgray; + border-right: 1px solid var(--outline-color); } .switch-button { @@ -272,9 +275,14 @@ export default defineComponent({ float: left; } -.is-main-color { - color: white; - background: var(--main-color); +input[type="range"].slider::-moz-range-thumb { + background: var(--active-background-color); + border-radius: 12px; + border: none; +} + +input[type="range"].slider::-moz-range-track { + background: var(--outline-color); } </style> diff --git a/frontend/src/components/map/wms/processes/PreAnomaly.vue b/frontend/src/components/map/wms/processes/PreAnomaly.vue index 5a5c027e66025887c3f5cf3744533f0723e4e33e..4fc109b33b7303008248382593bb95ae623c205b 100644 --- a/frontend/src/components/map/wms/processes/PreAnomaly.vue +++ b/frontend/src/components/map/wms/processes/PreAnomaly.vue @@ -22,7 +22,7 @@ </div> <button - class="button is-small mt-2 mb-2" + class="button is-small mt-2 mb-2 standard-button" @click.prevent="requestAnomaly" v-show="!isLoading" > diff --git a/frontend/src/components/map/wms/processes/ResultMessage.vue b/frontend/src/components/map/wms/processes/ResultMessage.vue index 1af694dd9b5493d484b78faf0d2915cc7bb49677..1efa7ad123762baaa29904d5ff0ab8e483b5dd3e 100644 --- a/frontend/src/components/map/wms/processes/ResultMessage.vue +++ b/frontend/src/components/map/wms/processes/ResultMessage.vue @@ -33,8 +33,8 @@ export default defineComponent({ border: 4px solid rgba(10, 10, 10, 0.1); border-radius: 50%; - border-top: 4px solid var(--main-color); - border-bottom: 4px solid var(--main-color); + border-top: 4px solid var(--active-background-color); + border-bottom: 4px solid var(--active-background-color); animation: spin 2s linear infinite; } diff --git a/frontend/src/components/map/wms/processes/StandardAggregation.vue b/frontend/src/components/map/wms/processes/StandardAggregation.vue index ed47259bbd71daf698a1e78610b76b297806f6f1..0e3e4565cb43a91c6405cbe13beaee1a4b13d245 100644 --- a/frontend/src/components/map/wms/processes/StandardAggregation.vue +++ b/frontend/src/components/map/wms/processes/StandardAggregation.vue @@ -22,7 +22,7 @@ <div> <button :disabled="checkedAggregationTypes.length === 0" - class="button is-small mt-2 mb-2" + class="button is-small mt-2 mb-2 standard-button" @click.prevent="requestAggregationValues" v-show="!isLoading" > diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 8460affbe5a38529d8ef6e45369c5449c1756bd8..9bd0cd1368e7caf057cc2e70265dfc1e4d63563b 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -4,7 +4,7 @@ import { createApp } from "vue"; import App from "./App.vue"; import router from "./router"; import { createI18n } from 'vue-i18n' - +import { createPinia } from 'pinia' import 'bulma/css/bulma.css' /* import the fontawesome core */ @@ -63,10 +63,13 @@ const i18n = createI18n({ }) const app = createApp(App); +const pinia = createPinia() + app.use(router); app.use(OpenLayersMap); app.use(drag); app.use(i18n); +app.use(pinia) app.component('font-awesome-icon', FontAwesomeIcon) diff --git a/frontend/src/stores/feature.ts b/frontend/src/stores/feature.ts new file mode 100644 index 0000000000000000000000000000000000000000..badada669cdc60a84fc700b9a2062d58e3061b88 --- /dev/null +++ b/frontend/src/stores/feature.ts @@ -0,0 +1,251 @@ +import {defineStore} from 'pinia' +import axios from "axios"; +import proj4 from "proj4"; +import {Cluster, MatrixCell, STALayer, VectorProperty, WFSLayer} from "@/types/vector"; +import {BoundingBox} from "@/types"; + + +const MAX_LOCATIONS_ON_DESKTOP = 300 +const MAX_LOCATIONS_ON_MOBILE = 200 + +const MATRIX_WIDTH = 5 +const MATRIX_HEIGHT = 3 +const MATRIX: MatrixCell[] = [ + [[0, 2], [1, 3], 1], + [[0, 1], [1, 2], 2], + [[0, 0], [1, 1], 3], + + [[1, 2], [2, 3], 4], + [[1, 1], [2, 2], 5], + [[1, 0], [2, 1], 6], + + [[2, 2], [3, 3], 7], + [[2, 1], [3, 2], 8], + [[2, 0], [3, 1], 9], + + [[3, 2], [4, 3], 10], + [[3, 1], [4, 2], 11], + [[3, 0], [4, 1], 12], + + [[4, 2], [5, 3], 13], + [[4, 1], [5, 2], 14], + [[4, 0], [5, 1], 15], +] + +export interface FeatureStore { + wfsLayer: WFSLayer|null, + staLayer: STALayer|null, + clusters: Cluster[], + features: any[], + featuresCount: number, + selectedPropertyId: number|undefined, + beginDate: string, + endDate: string, + thresholdValue: string, + currentBoundingBox: BoundingBox, + currentRequestId: number, + isLoading: boolean +} + +export const useFeatureStore = defineStore('feature', { + + state: (): FeatureStore => ({ + wfsLayer: null, + staLayer: null, + clusters: [], + features: [], + featuresCount: 0, + selectedPropertyId: undefined, + beginDate: '', + endDate: '', + thresholdValue: '', + currentBoundingBox: [[0, 0], [90, 90]] as BoundingBox, + currentRequestId: 0 as number, + isLoading: false + }), + getters: { + baseUrl() { + return import.meta.env.VITE_APP_BACKEND_URL + 'wfs-features/'+ this.wfsLayer.workspace + '/'+ this.wfsLayer.prefix + '?' + }, + bboxBottomLeftLon() { + return this.currentBoundingBox[0][0] + }, + bboxBottomLeftLat() { + return this.currentBoundingBox[0][1] + }, + bboxTopRightLon() { + return this.currentBoundingBox[1][0] + }, + bboxTopRightLat() { + return this.currentBoundingBox[1][1] + }, + cellWidth() { + return Math.abs((this.bboxBottomLeftLon - this.bboxTopRightLon) / MATRIX_WIDTH) + }, + cellHeight() { + return Math.abs((this.bboxBottomLeftLat - this.bboxTopRightLat) / MATRIX_HEIGHT) + }, + maxLocations() { + return (window.innerWidth > 1408) ? MAX_LOCATIONS_ON_DESKTOP : MAX_LOCATIONS_ON_MOBILE + } + }, + actions: { + setSelectedProperty(property: VectorProperty|undefined) { + this.featuresCount = 0 + + if (property) { + this.selectedPropertyId = property.id + } else { + this.selectedPropertyId = undefined + } + + if (this.wfsLayer) { + this.requestWfsFeatures() + } + if (this.staLayer) { + this.requestStaFeatures() + } + }, + requestWfsFeatures() { + const wfsParams = [ + 'service=WFS', + 'outputFormat=application%2Fjson', + 'srs=EPSG:4326' + ] + const url = this.baseUrl + wfsParams.join('&') + '&' + + this.requestFeatures(url, 'totalFeatures') + }, + requestStaFeatures() { + const url = import.meta.env.VITE_APP_BACKEND_URL + 'sta-locations/' + this.staLayer.own_id + '?' + + this.requestFeatures(url, '@iot.count') + }, + requestFeatures(url: string, countField: string) { + + const countUrl = url + this.getFilterParams() + '&count=1' + + axios.get(countUrl).then(response => { + + this.featuresCount = response.data[countField] + + this.isLoading = true + + this.clusters = [] + this.features = [] + + if (this.featuresCount > this.maxLocations || this.featuresCount === 0) { + this.requestCluster(url, countField) + } else { + this.requestFullLocations(url) + } + }) + .catch((error: any) => { + console.log(error) + }) + }, + getFilterParams(cell: MatrixCell | undefined = undefined) { + + const params = [] + + if (this.thresholdValue !== '') { + params.push('threshold=' + this.thresholdValue) + } + if (this.beginDate !== '') { + params.push('beginDate=' + this.beginDate) + } + if (this.endDate !== '') { + params.push('endDate=' + this.endDate) + } + + if (cell) { + const cellBottomLon = this.bboxBottomLeftLon + (this.cellWidth * cell[0][0]) + const cellBottomLat = this.bboxBottomLeftLat + (this.cellHeight * cell[0][1]) + + const cellTopLon = this.bboxBottomLeftLon + (this.cellWidth * cell[1][0]) + const cellTopLat = this.bboxBottomLeftLat + (this.cellHeight * cell[1][1]) + + params.push('bbox=' + cellBottomLon + ',' + cellBottomLat + ',' + cellTopLon + ',' + cellTopLat + ',EPSG:4326') + } else { + params.push('bbox=' + this.bboxBottomLeftLon + ',' + this.bboxBottomLeftLat + ',' + this.bboxTopRightLon + ',' + this.bboxTopRightLat + ',EPSG:4326') + } + + if (this.selectedPropertyId) { + params.push('property=' + this.selectedPropertyId) + } + + return params.join('&') + }, + requestCluster(url: string, countField: string) { + + const requestId = Math.floor(Math.random() * 1000000) + this.currentRequestId = requestId + + let countFinishedRequests = 0 + + for (const cell of MATRIX) { + + let clusterUrl = url + this.getFilterParams(cell) + '&count=1' + + axios.get(clusterUrl).then(response => { + + const countFeatures = response.data[countField] + + if (countFeatures > 0 && requestId === this.currentRequestId) { + + if (response.data.features[0]) { + + const newCluster = { + coords: proj4('WGS84', 'EPSG:3857', response.data.features[0].geometry.coordinates), + coordsWgs84: response.data.features[0].geometry.coordinates, + count: countFeatures, + requestId: requestId, + cellNumber: cell[2] + } + + if (requestId === this.currentRequestId) { + this.clusters.push(newCluster) + } + } + } + }) + .catch((error: any) => { + console.log(error) + this.isLoading = false + }) + .finally(() => { + countFinishedRequests++ + if (countFinishedRequests === MATRIX.length) { + this.isLoading = false + } + }) + } + }, + requestFullLocations(url: string) { + + const locationUrl = url + this.getFilterParams() + + axios.get(locationUrl) + .then(response => { + if (response.data['features']) { + for (const point of response.data['features']) { + if (this.wfsLayer) { + point.properties['fid'] = point['id'].replace(this.wfsLayer.prefix + '_feature.', '') + } + this.features.push({ + coordinates: proj4('WGS84', 'EPSG:3857', point.geometry.coordinates), + properties: point.properties + }) + } + } + }) + .catch((error: any) => { + console.log(error) + this.isLoading = false + }) + .finally(() => { + this.isLoading = false + }) + } + } +}) diff --git a/frontend/src/stores/timeseries.ts b/frontend/src/stores/timeseries.ts new file mode 100644 index 0000000000000000000000000000000000000000..eadf3770c2893a87b3860dedf1aa40eba4123b90 --- /dev/null +++ b/frontend/src/stores/timeseries.ts @@ -0,0 +1,344 @@ +import {defineStore} from 'pinia' +import axios from "axios"; +import {Coordinate, CovJsonSingleResult, DataCollection, WMSLayer} from "@/types"; +import {convertTime} from "@/composables/time_utils.ts"; +import {STALayer, StaPopupInfo, StaThing, VectorProperty, WFSLayer, WfsTimeseriesFeature} from "@/types/vector"; + +export interface TimeseriesStore { + wfsLayer: WFSLayer|null, + staLayer: STALayer|null, + feature: {}, + featureId: number|undefined, + selectedTimeseriesId: number|undefined, + locationProperties: VectorProperty[], + layerProperties: VectorProperty[], + selectedProperty: VectorProperty|undefined, + dataCollection: DataCollection, + inputPlaceholder: string, + isChartLoading: boolean, + clickedLayer: WMSLayer|undefined, + clickedLayerTime: string|undefined, + clickedCoord: Coordinate|undefined, + staThing: StaThing|undefined, + isClickOnFeature: boolean, + popupInfos: StaPopupInfo[] +} + +export const useTimeseriesStore = defineStore('timeseries', { + + state: (): TimeseriesStore => ({ + wfsLayer: null, + staLayer: null, + feature: {}, + featureId: undefined, + selectedTimeseriesId: undefined, + locationProperties: [] as VectorProperty[], + layerProperties: [] as VectorProperty[], + selectedProperty: {} as VectorProperty, + dataCollection: {} as DataCollection, + inputPlaceholder: '-', + isChartLoading: false, + + clickedLayer: undefined, + clickedLayerTime: undefined, + clickedCoord: undefined, + + staThing: undefined, + isClickOnFeature: false, + popupInfos: [] as StaPopupInfo[] + }), + getters: { + baseUrl() { + return import.meta.env.VITE_APP_BACKEND_URL + 'proxy/geoserver/' + this.wfsLayer.workspace + '/ows?' + }, + typeName() { + return this.wfsLayer.workspace + '%3A' + this.wfsLayer.prefix + }, + }, + actions: { + setFeature(feature: any) { + this.featureId = feature.values_.fid + + if (this.featureId !== undefined) { + + this.isClickOnFeature = true + + const params = [ + 'service=WFS', + 'version=2.0.0', + 'request=GetFeature', + 'typeName=' + this.typeName + '_feature', + 'outputFormat=application%2Fjson', + 'featureID=' + this.featureId + ] + const url = this.baseUrl + params.join('&') + + axios.get(url) + .then(response => { + this.feature = response.data.features[0] + + this.locationProperties = [] + }) + .catch((error: any) => { + console.log(error) + }) + .finally(() => { + this.requestTimeSeriesFeatures() + }) + } + }, + setStaFeature(feature: any) { + let thing = feature.values_ + + if (thing.id !== undefined) { + + const url = import.meta.env.VITE_APP_BACKEND_URL + 'sta-thing/' + this.staLayer.endpoint + '/' + thing.id + + axios.get(url) + .then(response => { + thing = response.data.value[0] + }) + .catch((error: any) => { + console.log(error) + }) + .finally(() => { + this.setStaThing(thing) + }) + } + }, + requestTimeSeriesFeatures() { + + const params = [ + 'service=WFS', + 'version=2.0.0', + 'request=GetFeature', + 'typeName=' + this.typeName + '_timeseries', + 'outputFormat=application%2Fjson', + 'CQL_FILTER=feature_id = ' + this.featureId + ] + const url = this.baseUrl + params.join('&') + + axios.get(url) + .then(response => { + + if (this.locationProperties.length === 0) { // following loop gets somehow executed twice + for (const timeseriesFeature of response.data['features']) { + this.locationProperties.push({ + id: timeseriesFeature.properties.property_id, + wfsTimeseriesId: timeseriesFeature.id.replace(this.wfsLayer.prefix + '_timeseries.', ''), + fullName: this.getNameOfProperty(timeseriesFeature) + ' (' + timeseriesFeature.properties.unit + ')' + }) + } + } + this.locationProperties.sort((a: VectorProperty, b: VectorProperty) => a.fullName.localeCompare(b.fullName)) + }) + .catch((error: any) => { + console.log(error) + }) + .finally(() => { + if (this.selectedProperty) { + for (const property of this.locationProperties) { + if (this.selectedProperty.id == property.id) { + this.selectedTimeseriesId = property.wfsTimeseriesId + } + } + } + }) + }, + getNameOfProperty(timeseriesFeature: WfsTimeseriesFeature) { + for (const prop of this.layerProperties) { + if (prop.id == timeseriesFeature.properties.property_id) { + return prop.fullName + } + } + return timeseriesFeature.id + }, + setSelectedProperty(property: VectorProperty|undefined) { + this.selectedProperty = property + + if (property) { + this.inputPlaceholder = property.fullName + this.selectedTimeseriesId = property.wfsTimeseriesId + + } else { + this.inputPlaceholder = '-' + this.selectedTimeseriesId = undefined + } + + if (this.staThing) { + this.isClickOnFeature = true + } + }, + requestWfsProperties() { + + this.layerProperties = [] + + const params = [ + 'service=WFS', + 'version=2.0.0', + 'request=GetFeature', + 'typeName=' + this.typeName + '_property', + 'outputFormat=application%2Fjson' + ] + const url = this.baseUrl + params.join('&') + + axios.get(url) + .then(response => { + for (const property of response.data['features']) { + this.layerProperties.push({ + id: property['id'].replace(this.wfsLayer.prefix + '_property.', ''), + fullName: property['properties']['name'] + }) + } + this.layerProperties.sort((a: VectorProperty, b: VectorProperty) => a.fullName.localeCompare(b.fullName)) + }) + .catch((error: any) => { + console.log(error) + }) + }, + requestStaProperties(locale: string) { + this.layerProperties = [] + + let url = import.meta.env.VITE_APP_BACKEND_URL + 'sta-obs-properties/' + this.staLayer.own_id + + axios.get(url) + .then(response => { + + const name_prop = (locale == 'de') ? 'name_de' : 'name_en' + for (const property of response.data.value) { + + let fullName = property.name + if (property.properties) { + if (property.properties[name_prop]) { + fullName = property.properties[name_prop] + } + } + this.layerProperties.push({ + id: property['@iot.id'], + fullName: fullName + }) + } + this.layerProperties.sort((a: VectorProperty, b: VectorProperty) => a.fullName.localeCompare(b.fullName)) + }) + .catch((error: any) => { + console.log(error) + }) + }, + setStaThing(thing: StaThing) { + this.staThing = thing + this.isClickOnFeature = true + this.requestThingProperties(thing) + this.requestPointData() + }, + requestThingProperties(thing: StaThing) { + + let url = import.meta.env.VITE_APP_BACKEND_URL + 'sta-obs-properties-by-thing/' + this.staLayer.own_id + '/' + thing['@iot.id'] + + this.locationProperties = [] + + axios.get(url) + .then(response => { + this.setPopupInfos(thing) + + for (const obsProperty of response.data.value) { + this.locationProperties.push({ + id: obsProperty['@iot.id'], + fullName: obsProperty['name'] + }) + } + this.locationProperties.sort((a: VectorProperty, b: VectorProperty) => a.fullName.localeCompare(b.fullName)) + }) + .catch((error: any) => { + console.log(error) + }) + .finally(() => { + if (this.locationProperties.length == 1) { + this.isClickOnFeature = true + this.selectedProperty = this.locationProperties[0] + this.requestPointData() + } + }) + }, + setPopupInfos(thing: StaThing) { + this.popupInfos = [] + for (const key of Object.keys(thing.properties)) { + + let infoText = thing.properties[key] + if (infoText instanceof Array) { + infoText = infoText.join(', ') + } + this.popupInfos.push({ + label: key, + text: infoText + }) + } + }, + requestPointData() { + + const params: string[] = [] + + if (this.clickedLayer && this.clickedLayerTime && this.clickedCoord) { + params.push('wmsLayer=' + this.clickedLayer.own_id) + params.push('time=' + this.clickedLayerTime) + params.push('x=' + this.clickedCoord[0]) + params.push('y=' + this.clickedCoord[1]) + } + + if (this.isClickOnFeature && this.staLayer && this.staThing && this.selectedProperty) { + params.push('staLayer=' + this.staLayer.own_id) + params.push('staThing=' + this.staThing['@iot.id']) + params.push('staObsProperty=' + this.selectedProperty.id) + } + + if (this.isClickOnFeature && this.wfsLayer && this.selectedProperty && this.selectedTimeseriesId) { + params.push('workspace=' + this.wfsLayer.workspace) + params.push('prefix=' + this.wfsLayer.prefix) + params.push('wfsTimeSeries=' + this.selectedTimeseriesId) + } + + if (params.length > 0) { + + this.isChartLoading = true + + // const url = import.meta.env.VITE_APP_BACKEND_URL + 'timeseries/' + this.$i18n.locale + '?' + params.join('&') + const url = import.meta.env.VITE_APP_BACKEND_URL + 'timeseries/de?' + params.join('&') + + axios.get(url) + .then(response => { + this.dataCollection = response.data + if (this.dataCollection.mapPopupResult) { + this.setTimeFormatInPopup(this.dataCollection.mapPopupResult) + } + if (this.dataCollection.timeSeriesCollection[0]) { + if (this.dataCollection.timeSeriesCollection[0].name === 'wfs') { + this.dataCollection.timeSeriesCollection[0].name = this.selectedProperty.fullName + // this.dataCollection.timeSeriesCollection[0].unit = this.selectedTimeseries.unit + } + } + }) + .catch((error) => { + console.log(error) + }) + .finally(() => { + this.isChartLoading = false + this.isClickOnFeature = false + }) + } + }, + setTimeFormatInPopup(result: CovJsonSingleResult) { + this.dataCollection.mapPopupResult.time = convertTime( + result.time, + 'de', + result.timeFormat + ) + }, + unsetTimeseriesData() { + this.dataCollection = { + chartName: '', + mapPopupResult: {}, + timeSeriesCollection: [], + singleResultCollection: [] + } + } + } +}) \ No newline at end of file diff --git a/frontend/src/translation/messages.ts b/frontend/src/translation/messages.ts index 3754c2edd01c05f7842454351969463290bb441b..b07496a362e9b04cca4b5a0a69ed58e15a8098ac 100644 --- a/frontend/src/translation/messages.ts +++ b/frontend/src/translation/messages.ts @@ -31,6 +31,8 @@ export const messages = { filterOptions: 'Filteroptionen', imprint: 'Impressum', invalidDate: 'Ungültiges Datumsformat!', + invalidProperty: 'Keine Zeitreihe ausgewählt!', + invalidThreshold: 'Keine Zahl eingegeben!', legend: 'Legende', map: 'Karte', measurementsFrom: 'Messwerte ab Datum', @@ -40,7 +42,6 @@ export const messages = { noMethodAvailable: 'Keine Methode verfügbar', noLayerSelectable: 'Kein Layer auswählbar', noPointRequest: 'Klimasimulationen sind am Punkt nicht aussagekräftig. Bitte nutzen Sie die Möglichkeit der Auswertung für Bundesländer und Landkreise oder laden Sie ein eigenes Shapefile hoch.', - observations: 'Observationen', pointData: 'Punktdaten', precipitationAnomaly: 'Niederschlagsanomalien', printChart: 'Diagramm als Bild herunterladen', @@ -120,6 +121,8 @@ export const messages = { filterOptions: 'Filter Options', imprint: 'Imprint', invalidDate: 'Invalid date!', + invalidProperty: 'No Timeseries selected!', + invalidThreshold: 'Not a number!', legend: 'Legend', map: 'Map', measurementsFrom: 'Measurements from date', @@ -129,7 +132,6 @@ export const messages = { noMethodAvailable: 'No method available', noLayerSelectable: 'No layer selectable', noPointRequest: 'Climate simulations cannot be interpreted at the grid cell level. Please use the regional analysis to define your region of interest.', - observations: 'Observations', pointData: 'Point Data', precipitationAnomaly: 'Precipitation Anomaly', printChart: 'Download diagram as image', diff --git a/frontend/src/types/index.d.ts b/frontend/src/types/index.d.ts index f7fccf372d2cfe5a4587e83fcb47df92ea224d78..ff2a2cda25b0db8bafb53ec55d629e445c416613 100644 --- a/frontend/src/types/index.d.ts +++ b/frontend/src/types/index.d.ts @@ -16,8 +16,6 @@ export interface AreaName { export interface Area { id: number name: string - activeBaseMap: string - baseMaps: [] layers: Layer[] info: string is_default_on_start: boolean @@ -46,7 +44,8 @@ export interface ProjectInfo { default_lon: number, default_lat: number, default_zoom: number, - baseMap: string, + base_layer: string, + is_base_layer_gray: string, gallery_image: string } @@ -58,7 +57,7 @@ export interface Layer { time_format: DateFormat y_axis_min: number y_axis_max: number - type: 'wms' | 'sta' | 'geojson' + type: 'wms' | 'sta' | 'wfs' | 'geojson' is_default_on_start: boolean } @@ -92,7 +91,6 @@ export interface GEOJSONLayer extends Layer { url: string } - export interface MapClickEvent { coordinate: Coordinate pixel: [number, number] diff --git a/frontend/src/types/sta.d.ts b/frontend/src/types/vector.d.ts similarity index 58% rename from frontend/src/types/sta.d.ts rename to frontend/src/types/vector.d.ts index 5f32312f9bbc630e88ed0008be989a8154fcb4a5..79069c527e8636c78627bf4aa0f45729e3a91396 100644 --- a/frontend/src/types/sta.d.ts +++ b/frontend/src/types/vector.d.ts @@ -16,6 +16,13 @@ export interface STALayer extends Layer { } +export interface WFSLayer extends Layer { + own_id: number + workspace: string + prefix: string +} + + export interface STAObservedProperty { "@iot.id": number, "definition": string, @@ -26,23 +33,47 @@ export interface STAObservedProperty { } } -export interface ObservedProperty { - id: number, - fullName: string + +export interface WfsTimeseriesFeature { + "type": "Feature", + "id": string, + "geometry": { + "type": "Point", + "coordinates": { + 0: number, + 1: number + } + }, + "geometry_name": "geom", + "properties": { + feature_id: number, + property_id: number, + unit: string, + lat: number, + lon: number, + gtype: "Point", + srid: number, + min_value: number, + max_value: number, + min_date: string, + max_date: string + } } -export interface STAFilterParams { - threshold: string, - beginDate: string - endDate: string +export interface VectorProperty { + id: number, + fullName: string, + wfsTimeseriesId: number, + type: 'sta'|'wfs' } export interface StaThing { - "description": string + "@iot.selfLink": string "@iot.id": number - "name": string + name: string + description: string "properties": { [key: string]: string|number|string[] } diff --git a/pygeoapi/pygeoapi_conn/pygeoapi-config-prod.yaml b/pygeoapi/pygeoapi_conn/pygeoapi-config-prod.yaml index b8ab250c67d265680a437b722be33f2eda14757a..0bafa3edeefc472c47b819334df4829a4db92b1f 100755 --- a/pygeoapi/pygeoapi_conn/pygeoapi-config-prod.yaml +++ b/pygeoapi/pygeoapi_conn/pygeoapi-config-prod.yaml @@ -31,7 +31,7 @@ server: bind: host: 0.0.0.0 port: 5000 - url: https://gdi-fs.ufz.de/ + url: https://web.app.ufz.de/ mimetype: application/json; charset=UTF-8 encoding: utf-8 gzip: false