diff --git a/alembic.ini b/alembic.ini index 3a7be3e..0cd61ef 100644 --- a/alembic.ini +++ b/alembic.ini @@ -97,4 +97,4 @@ formatter = generic [formatter_generic] format = %(levelname)-5.5s [%(name)s] %(message)s -datefmt = %H:%M:%S +datefmt = %H:%M:%S \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py index 7d60ba6..c456dc1 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -5,6 +5,8 @@ from alembic import context +from geoalchemy2 import alembic_helpers + # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config @@ -21,7 +23,7 @@ from app.models.site import * from app.models.taxon import * from app.models.collection import * -from app.models.addon import * +from app.models.gazetter import * from app.database import Base target_metadata = Base.metadata @@ -50,6 +52,9 @@ def run_migrations_offline(): target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, + include_object=alembic_helpers.include_object, + process_revision_directives=alembic_helpers.writer, + render_item=alembic_helpers.render_item, ) with context.begin_transaction(): @@ -71,7 +76,10 @@ def run_migrations_online(): with connectable.connect() as connection: context.configure( - connection=connection, target_metadata=target_metadata + connection=connection, target_metadata=target_metadata, + include_object=alembic_helpers.include_object, + process_revision_directives=alembic_helpers.writer, + render_item=alembic_helpers.render_item, ) with context.begin_transaction(): diff --git a/alembic/script.py.mako b/alembic/script.py.mako index 2c01563..442e676 100644 --- a/alembic/script.py.mako +++ b/alembic/script.py.mako @@ -8,6 +8,7 @@ Create Date: ${create_date} from alembic import op import sqlalchemy as sa ${imports if imports else ""} +import geoalchemy2 # revision identifiers, used by Alembic. revision = ${repr(up_revision)} diff --git a/alembic/versions/5b41d404cb3b_add_country.py b/alembic/versions/5b41d404cb3b_add_country.py new file mode 100644 index 0000000..83262c7 --- /dev/null +++ b/alembic/versions/5b41d404cb3b_add_country.py @@ -0,0 +1,39 @@ +"""add-country + +Revision ID: 5b41d404cb3b +Revises: 675f3d3e99af +Create Date: 2024-02-23 19:31:48.163498 + +""" +from alembic import op +import sqlalchemy as sa +from geoalchemy2 import Geometry +import geoalchemy2 + +# revision identifiers, used by Alembic. +revision = '5b41d404cb3b' +down_revision = '675f3d3e99af' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('country', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name_en', sa.String(length=500), nullable=True), + sa.Column('name_zh', sa.String(length=500), nullable=True), + sa.Column('continent', sa.String(length=500), nullable=True), + sa.Column('iso3166_1', sa.String(length=2), nullable=True), + sa.Column('iso3', sa.String(length=3), nullable=True), + sa.Column('status', sa.String(length=500), nullable=True), + sa.Column('sort', sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('country') + # ### end Alembic commands ### diff --git a/alembic/versions/675f3d3e99af_add_geom.py b/alembic/versions/675f3d3e99af_add_geom.py new file mode 100644 index 0000000..2e91578 --- /dev/null +++ b/alembic/versions/675f3d3e99af_add_geom.py @@ -0,0 +1,31 @@ +"""add-geom + +Revision ID: 675f3d3e99af +Revises: fb2dd663057f +Create Date: 2024-02-23 18:36:09.844916 + +""" +from alembic import op +import sqlalchemy as sa +from geoalchemy2 import Geometry +import geoalchemy2 + +# revision identifiers, used by Alembic. +revision = '675f3d3e99af' +down_revision = 'fb2dd663057f' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_geospatial_column('named_area', sa.Column('geom_mpoly', Geometry(geometry_type='MULTIPOLYGON', srid=4326, spatial_index=False, from_text='ST_GeomFromEWKT', name='geometry'), nullable=True)) + op.create_geospatial_index('idx_named_area_geom_mpoly', 'named_area', ['geom_mpoly'], unique=False, postgresql_using='gist', postgresql_ops={}) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_geospatial_index('idx_named_area_geom_mpoly', table_name='named_area', postgresql_using='gist', column_name='geom_mpoly') + op.drop_geospatial_column('named_area', 'geom_mpoly') + # ### end Alembic commands ### diff --git a/app/blueprints/admin.py b/app/blueprints/admin.py index 2d4f7cf..a96fa1c 100644 --- a/app/blueprints/admin.py +++ b/app/blueprints/admin.py @@ -40,8 +40,6 @@ Record, RecordAssertion, AssertionType, - AreaClass, - NamedArea, Project, Unit, UnitAssertion, @@ -53,6 +51,10 @@ Taxon, MultimediaObject, ) +from app.models.gazetter import ( + AreaClass, + NamedArea, +) from app.models.site import ( User, UserList, @@ -98,7 +100,7 @@ def save_record(record, data, is_create=False): # print(c['name'], c['type'], flush=True) #print(columns_table, '-----------',flush=True) - #print(data, flush=True) + print(data, flush=True) if is_create is True: session.add(record) @@ -142,7 +144,10 @@ def save_record(record, data, is_create=False): name_key = name.replace('__hidden_value', '_id') setattr(record, name_key, value) - elif match := re.search(r'^(named_areas|record_assertions)__(.+)__hidden_value', name): + elif name == 'named_area_ids' and value: + m2m['named_areas'] = value.split(',') + + elif match := re.search(r'^(record_assertions)__(.+)__hidden_value', name): # print(match, match.group(1), match.group(2), value,flush=True) if value: name_class = match.group(1) @@ -176,13 +181,14 @@ def save_record(record, data, is_create=False): o2m[name_class][num]['annotation'][annotation_part] = {} o2m[name_class][num]['annotation'][annotation_part] = value - named_areas = [] # print(m2m, flush=True) + named_areas = [] for i in m2m['named_areas']: # only need id - if obj := session.get(NamedArea, int(i[1])): + if obj := session.get(NamedArea, int(i)): named_areas.append(obj) + assertions = [] for i in m2m['record_assertions']: type_id = int(i[0]) diff --git a/app/blueprints/admin_register.py b/app/blueprints/admin_register.py index 69c9318..3e53be3 100644 --- a/app/blueprints/admin_register.py +++ b/app/blueprints/admin_register.py @@ -9,8 +9,6 @@ ) from app.models.collection import ( AssertionType, - AreaClass, - NamedArea, Collection, Person, Transaction, @@ -18,6 +16,10 @@ Annotation, MultimediaObjectAnnotation, ) +from app.models.gazetter import ( + NamedArea, + AreaClass, +) from app.models.taxon import ( Taxon, ) diff --git a/app/blueprints/api.py b/app/blueprints/api.py index 022ca16..a0c85aa 100644 --- a/app/blueprints/api.py +++ b/app/blueprints/api.py @@ -27,14 +27,17 @@ join, ) from sqlalchemy.dialects.postgresql import ARRAY +from geoalchemy2.functions import ( + ST_Point, + ST_SetSRID, + ST_Within, +) from app.database import session from app.models.collection import ( Record, Person, - NamedArea, - AreaClass, Unit, Identification, Person, @@ -44,6 +47,10 @@ #LogEntry, #get_structed_list, ) +from app.models.gazetter import ( + NamedArea, + AreaClass, +) from app.models.taxon import ( Taxon, TaxonRelation, @@ -360,7 +367,7 @@ def get_named_area_detail(id): #@api.route('/named_areas', methods=['GET']) def get_named_area_list(): - query = NamedArea.query + query = NamedArea.query.join(AreaClass) if filter_str := request.args.get('filter', ''): filter_dict = json.loads(filter_str) if keyword := filter_dict.get('q', ''): @@ -372,6 +379,25 @@ def get_named_area_list(): query = query.filter(NamedArea.area_class_id==area_class_id) if parent_id := filter_dict.get('parent_id'): query = query.filter(NamedArea.parent_id==parent_id) + if within := filter_dict.get('within'): + set_srid = 4326 + if srid := within.get('srid'): + set_srid = srid + if point := within.get('point'): + if len(point) == 2: + query = query.filter(func.ST_Within( + func.ST_SetSRID(func.ST_Point(point[0], point[1]), set_srid), + NamedArea.geom_mpoly + )) + query = query.order_by(AreaClass.sort, NamedArea.name_en) + else: + query = query.filter(NamedArea.id==0) + + args_range = [0, 100] + if x := request.args.get('range'): + args_range = json.loads(x) + + query = query.offset(args_range[0]).limit(args_range[1]) return jsonify(make_query_response(query)) @@ -437,10 +463,12 @@ def get_taxon_list(): query = query.slice(range_dict[0], range_dict[1]) #print(query, flush=True) + query = query.limit(100) # TODO return jsonify(make_query_response(query)) def get_area_class_list(): query = AreaClass.query.order_by('sort') + query = query.filter(AreaClass.id > 4) # HACK if filter_str := request.args.get('filter', ''): filter_dict = json.loads(filter_str) if keyword := filter_dict.get('q', ''): diff --git a/app/blueprints/base.py b/app/blueprints/base.py index 43fb834..5a44bd7 100644 --- a/app/blueprints/base.py +++ b/app/blueprints/base.py @@ -50,13 +50,15 @@ RecordAssertion, AssertionType, AreaClass, - NamedArea, Unit, UnitAssertion, Identification, Person, Taxon, ) +from app.models.gazetter import ( + NamedArea, +) from app.models.taxon import ( Taxon, ) diff --git a/app/models/addon.py b/app/models/addon.py deleted file mode 100644 index 5c868d0..0000000 --- a/app/models/addon.py +++ /dev/null @@ -1,36 +0,0 @@ -from sqlalchemy import ( - Column, - Integer, - SmallInteger, - String, - Text, - DateTime, - Boolean, - ForeignKey, - Table, - desc, -) - -from app.database import Base - -class GeoName(Base): - __tablename__ = 'addon_geoname' - geonameid = Column(Integer, primary_key=True, autoincrement=False) - name = Column(String(500)) - asciiname = Column(String(500)) - alternatenames = Column(Text) - latitude = Column(String(500)) - longitude = Column(String(500)) - feature_class = Column(String(500)) - feature_code = Column(String(500)) - country_code = Column(String(2)) - cc2 = Column(String(500)) - admin1_code = Column(String(500)) - admin2_code = Column(String(500)) - admin3_code = Column(String(500)) - admin4_code = Column(String(500)) - population = Column(Integer) - elevation = Column(Integer) - dem = Column(String(500)) - timezone = Column(String(500)) - modification_date = Column(String(500)) diff --git a/app/models/collection.py b/app/models/collection.py index 683fe87..a1b9cdd 100644 --- a/app/models/collection.py +++ b/app/models/collection.py @@ -41,7 +41,10 @@ Taxon, TaxonRelation, ) - +from app.models.gazetter import ( + AreaClass, + NamedArea, +) from app.utils import ( dd2dms, ) @@ -1198,119 +1201,6 @@ class UnitAssertion(Base, AssertionMixin): id = Column(Integer, primary_key=True) unit_id = Column(ForeignKey('unit.id', ondelete='SET NULL')) - -# Location Assertion -class AreaClass(Base, TimestampMixin): - -#HAST: country (249), province (142), hsienCity (97), hsienTown (371), additionalDesc(specimen.locality_text): ref: hast_id: 144954 - - __tablename__ = 'area_class' - # DEFAULT_OPTIONS = [ - # {'id': 1, 'name': 'country', 'label': '國家'}, - # {'id': 2, 'name': 'stateProvince', 'label': '省/州', 'parent': 'country', 'root': 'country'}, - # {'id': 3, 'name': 'county', 'label': '縣/市', 'parent': 'stateProvince', 'root': 'country'}, - # {'id': 4, 'name': 'municipality', 'label': '鄉/鎮', 'parent': 'county', 'root': 'country'}, - # {'id': 5, 'name': 'national_park', 'label': '國家公園'}, - # {'id': 6, 'name': 'locality', 'label': '地名'}, - # ] - - id = Column(Integer, primary_key=True) - name = Column(String(500)) - label = Column(String(500)) - sort = Column(Integer) - parent_id = Column(Integer, ForeignKey('area_class.id', ondelete='SET NULL'), nullable=True) - collection_id = Column(Integer, ForeignKey('collection.id', ondelete='SET NULL'), nullable=True) - # organization_id = Column(Integer, ForeignKey('organization.id', ondelete='SET NULL'), nullable=True) - #org = models.ForeignKey(on_delete=models.SET_NULL, null=True, blank=True) - # parent = relationship('AreaClass', foreign_keys=[parent_id], uselist=False) - admin_config = Column(JSONB) - - parent = relationship('AreaClass', remote_side=id) - collection = relationship('Collection', back_populates='area_classes') - - def to_dict(self): - return { - 'id': self.id, - 'name': self.name, - 'label': self.label, - 'parent_id': self.parent_id or None, - 'admin_config': self.admin_config or None, - } -#class AreaClassSystem(models.Model): -# ancestor = models.ForeignKey(AreaClass, on_delete=models.SET_NULL, null=True, blank=True, related_name='descendant_nodes') -# descendant = models.ForeignKey(AreaClass, on_delete=models.SET_NULL, null=True, blank=True, related_name='ancestor_nodes') -# depth = models.PositiveSmallIntegerField(default=0) - - -class NamedArea(Base, TimestampMixin): - __tablename__ = 'named_area' - - id = Column(Integer, primary_key=True) - name = Column(String(500)) - name_en = Column(String(500)) - code = Column(String(500)) - #code_standard = models.CharField(max_length=1000, null=True) - area_class_id = Column(Integer, ForeignKey('area_class.id', ondelete='SET NULL'), nullable=True) - area_class = relationship('AreaClass', backref=backref('named_area')) - source_data = Column(JSONB) - parent_id = Column(Integer, ForeignKey('named_area.id', ondelete='SET NULL'), nullable=True) - children = relationship('NamedArea', back_populates='parent') - parent = relationship('NamedArea', back_populates='children', remote_side=[id]) - #parent = relationship('NamedArea', foreign_keys=[parent_id]) - pids = relationship('PersistentIdentifierNamedArea') - - @property - def display_name(self): - return '{}{}'.format( - self.name_en if self.name_en else '', - f' ({self.name})' if self.name.strip() else '' - ) - - def get_parents(self, parents=[]): - # by organization? - - def recur_parents(obj, parents=[]): - data = parents[:] - if obj.parent_id: - data.append(obj.parent) - return recur_parents(obj.parent, data) - else: - return list(reversed(data)) - - return recur_parents(self) - - @property - def name_best(self): - if name := self.name: - return name - elif name := self.name_en: - return name - return '' - - def to_dict(self, with_meta=False): - data = { - 'id': self.id, - 'parent_id': self.parent_id, - 'name': self.name, - 'name_en': self.name_en, - 'area_class_id': self.area_class_id, - 'area_class': self.area_class.to_dict(), - #'name_mix': '/'.join([self.name, self.name_en]), - 'display_name': self.display_name or '', - #'name_best': self.name_best, - # 'higher_area_classes': self.get_higher_area_classes(), - } - if with_meta is True: - #set_locale() - data['meta'] = { - 'term': 'named_area', - 'label': gettext('地點'), - 'display': data['display_name'], - } - - return data - - class FieldNumber(Base, TimestampMixin): __tablename__ = 'other_field_number' diff --git a/app/models/gazetter.py b/app/models/gazetter.py new file mode 100644 index 0000000..9912a59 --- /dev/null +++ b/app/models/gazetter.py @@ -0,0 +1,173 @@ +from sqlalchemy import ( + Column, + Integer, + Numeric, + String, + Text, + DateTime, + Date, + Boolean, + ForeignKey, + Table, + desc, + select, +) +from sqlalchemy.orm import ( + relationship, + backref, + validates, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.ext.declarative import declared_attr +from geoalchemy2.types import Geometry +from flask_babel import ( + gettext, +) + +from app.database import ( + Base, + session, + TimestampMixin, +) + +class Country(Base): + __tablename__ = 'country' + + id = Column(Integer, primary_key=True) + name_en = Column(String(500)) + name_zh = Column(String(500)) + continent = Column(String(500)) + iso3166_1 = Column(String(2)) + iso3 = Column(String(3)) + status = Column(String(500)) + sort = Column(Integer) + +class AreaClass(Base, TimestampMixin): + +#HAST: country (249), province (142), hsienCity (97), hsienTown (371), additionalDesc(specimen.locality_text): ref: hast_id: 144954 + + __tablename__ = 'area_class' + # DEFAULT_OPTIONS = [ + # {'id': 1, 'name': 'country', 'label': '國家'}, + # {'id': 2, 'name': 'stateProvince', 'label': '省/州', 'parent': 'country', 'root': 'country'}, + # {'id': 3, 'name': 'county', 'label': '縣/市', 'parent': 'stateProvince', 'root': 'country'}, + # {'id': 4, 'name': 'municipality', 'label': '鄉/鎮', 'parent': 'county', 'root': 'country'}, + # {'id': 5, 'name': 'national_park', 'label': '國家公園'}, + # {'id': 6, 'name': 'locality', 'label': '地名'}, + # ] + + id = Column(Integer, primary_key=True) + name = Column(String(500)) + label = Column(String(500)) + sort = Column(Integer) + parent_id = Column(Integer, ForeignKey('area_class.id', ondelete='SET NULL'), nullable=True) + collection_id = Column(Integer, ForeignKey('collection.id', ondelete='SET NULL'), nullable=True) + # organization_id = Column(Integer, ForeignKey('organization.id', ondelete='SET NULL'), nullable=True) + #org = models.ForeignKey(on_delete=models.SET_NULL, null=True, blank=True) + # parent = relationship('AreaClass', foreign_keys=[parent_id], uselist=False) + admin_config = Column(JSONB) + + parent = relationship('AreaClass', remote_side=id) + collection = relationship('Collection', back_populates='area_classes') + + def to_dict(self): + return { + 'id': self.id, + 'name': self.name, + 'label': self.label, + 'parent_id': self.parent_id or None, + 'admin_config': self.admin_config or None, + } + + +class NamedArea(Base, TimestampMixin): + __tablename__ = 'named_area' + + id = Column(Integer, primary_key=True) + name = Column(String(500)) + name_en = Column(String(500)) + code = Column(String(500)) + #code_standard = models.CharField(max_length=1000, null=True) + area_class_id = Column(Integer, ForeignKey('area_class.id', ondelete='SET NULL'), nullable=True) + area_class = relationship('AreaClass', backref=backref('named_area')) + source_data = Column(JSONB) + parent_id = Column(Integer, ForeignKey('named_area.id', ondelete='SET NULL'), nullable=True) # DEPRECATED + geom_mpoly = Column(Geometry(geometry_type='MULTIPOLYGON', srid=4326, spatial_index=True)) + children = relationship('NamedArea', back_populates='parent') + parent = relationship('NamedArea', back_populates='children', remote_side=[id]) + #parent = relationship('NamedArea', foreign_keys=[parent_id]) + pids = relationship('PersistentIdentifierNamedArea') + + @property + def display_name(self): + return '{}{}'.format( + self.name_en if self.name_en else '', + f' ({self.name})' if self.name and self.name.strip() else '' + ) + + def get_parents(self, parents=[]): + # by organization? + + def recur_parents(obj, parents=[]): + data = parents[:] + if obj.parent_id: + data.append(obj.parent) + return recur_parents(obj.parent, data) + else: + return list(reversed(data)) + + return recur_parents(self) + + @property + def name_best(self): + if name := self.name: + return name + elif name := self.name_en: + return name + return '' + + def to_dict(self, with_meta=False): + data = { + 'id': self.id, + 'parent_id': self.parent_id, + 'name': self.name, + 'name_en': self.name_en, + 'area_class_id': self.area_class_id, + 'area_class': self.area_class.to_dict(), + #'name_mix': '/'.join([self.name, self.name_en]), + 'display_name': self.display_name or '', + #'name_best': self.name_best, + # 'higher_area_classes': self.get_higher_area_classes(), + } + if with_meta is True: + #set_locale() + data['meta'] = { + 'term': 'named_area', + 'label': gettext('地點'), + 'display': data['display_name'], + } + + return data + + +# class GeoName(Base): +# __tablename__ = 'addon_geoname' +# geonameid = Column(Integer, primary_key=True, autoincrement=False) +# name = Column(String(500)) +# asciiname = Column(String(500)) +# alternatenames = Column(Text) +# latitude = Column(String(500)) +# longitude = Column(String(500)) +# feature_class = Column(String(500)) +# feature_code = Column(String(500)) +# country_code = Column(String(2)) +# cc2 = Column(String(500)) +# admin1_code = Column(String(500)) +# admin2_code = Column(String(500)) +# admin3_code = Column(String(500)) +# admin4_code = Column(String(500)) +# population = Column(Integer) +# elevation = Column(Integer) +# dem = Column(String(500)) +# timezone = Column(String(500)) +# modification_date = Column(String(500)) diff --git a/app/templates/admin/record-form-view.html b/app/templates/admin/record-form-view.html index fbc1be0..afd9b91 100644 --- a/app/templates/admin/record-form-view.html +++ b/app/templates/admin/record-form-view.html @@ -3,7 +3,8 @@ {% block script %} - + + {% endblock %} {% import 'admin/record_macro.html' as record_macro %} diff --git a/frontend/src/components/GazetterSelector.js b/frontend/src/components/GazetterSelector.js index aaca0ee..06d6f6c 100644 --- a/frontend/src/components/GazetterSelector.js +++ b/frontend/src/components/GazetterSelector.js @@ -10,16 +10,37 @@ function reducer(state, action) { areaClasses: action.areaClasses, }; } - if ('value' in action) { + if ('value' in action && action.value) { newState = { ...newState, values: { ...newState.values, - [action.value.area_class.name]: action.value.display_name, + [action.value.area_class.name]: action.value, } }; } return newState; + case 'SET_BY_LONLAT': + let newStateValues = {}; + if ('values' in action && action.values.length > 0) { + for (const k in action.values) { + newStateValues[action.values[k].area_class.name] = action.values[k]; + } + } + return { + ...state, + values: newStateValues, + isFromLonLat: true + }; + case 'SET_BY_SELECT': + return { + ...state, + values: {}, + isFromLonLat: false + }; + case 'DELETE_VALUE': + delete state.values[action.key]; + return state; default: console.log('default'); } @@ -29,9 +50,15 @@ export default function GazetterSelector() { const initState = { areaClasses: [], values: {}, + isFromLonLat: false, }; const [state, dispatch] = useReducer(reducer, initState); + let namedAreaIds = []; + let displayText = { + AList: [], + LList: [], + }; useEffect( () => { async function init() { @@ -76,6 +103,9 @@ export default function GazetterSelector() { const handleAreaClassChange = (e, index) => { if (index < state.areaClasses.length) { let selectedId = e.target.value; + if (!selectedId) { + dispatch({type: 'DELETE_VALUE', key: state.areaClasses[index].name}) + } const selectedItem = state.areaClasses[index].items.find( x => (parseInt(x.id) === parseInt(selectedId))); if (index + 1 === state.areaClasses.length) { // last one only update value dispatch({type: 'SET_DATA', value: selectedItem}); @@ -97,27 +127,70 @@ export default function GazetterSelector() { } } }; + const handleFromSelect = (e) => { + e.preventDefault(); + dispatch({type: 'SET_BY_SELECT'}); + }; + const handleFromLonLat = (e) => { + e.preventDefault(); + const lat = document.getElementById('latitude-decimal'); + const lon = document.getElementById('longitude-decimal'); + if (lon && lon.value && lat && lat.value) { + const ft = JSON.stringify({ + within: { + srid: 4326, + point: [lon.value, lat.value], + } + }); + fetch(`/api/v1/named-areas?filter=${ft}`) + .then( resp => resp.json()) + .then( results => { + if (results.data.length) { + dispatch({type: 'SET_BY_LONLAT', values: results.data}); + } + }); + } + }; - // A: admininistrative, L: areas, parks - let displayText = { - AList: [], - LList: [], + const renderSelectors = (areaClasses) => { + return areaClasses.map( (areaClass, index) => { + return ( +