diff --git a/omf/models/derConsumer.html b/omf/models/derConsumer.html index 569e796d9..e83f2ea02 100644 --- a/omf/models/derConsumer.html +++ b/omf/models/derConsumer.html @@ -20,7 +20,7 @@
- +
@@ -41,7 +41,7 @@
-

Specific REopt Model Inputs

+

General Model Inputs


@@ -80,28 +80,24 @@
- -
-
-
-

Model DER technologies

+
- +
- -
- + - - + +
@@ -124,7 +120,7 @@
-

Specific vbatDispatch Model Inputs

+

Thermal Model Inputs


@@ -164,6 +160,7 @@ } } + @@ -222,6 +219,32 @@
+
+

Distributed Energy Resource (DER) Program Design inputs

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
{{ omfModelButtons }}
@@ -313,6 +336,8 @@ + + {{ rawOutputFiles }} {% endif %} \ No newline at end of file diff --git a/omf/models/derConsumer.py b/omf/models/derConsumer.py index 8ee455b6c..fb92cb7d6 100644 --- a/omf/models/derConsumer.py +++ b/omf/models/derConsumer.py @@ -12,7 +12,7 @@ import pandas as pd import plotly.graph_objs as go import plotly.utils -from numpy_financial import npv +import requests # OMF imports from omf.models import __neoMetaModel__ @@ -22,48 +22,51 @@ # Model metadata: tooltip = ('The derConsumer model evaluates the financial costs of controlling behind-the-meter \ - distributed energy resources (DERs) at the residential level using the NREL renewable \ - energy optimization tool (REopt) and the OMF virtual battery dispatch module (vbatDispatch).') + distributed energy resources (DERs) at the residential level using the National Renewable Energy \ + Laboratory (NREL) Renewable Energy Optimization Tool (REopt) and the OMF virtual battery dispatch \ + module (vbatDispatch).') modelName, template = __neoMetaModel__.metadata(__file__) -hidden = True +hidden = True ## Keep the model hidden=True during active development def create_timestamps(start_time='2017-01-01',end_time='2017-12-31 23:00:00',arr_size=8760): - ''' Creates an array of timestamps given a start time, stop time, and array size. - Inputs: - start_time (str) Beginning of the timestamp array in 'year-month-day hh:mm:ss format' - end_time (str) End of the timestamp array in 'year-month-day hh:mm:ss format' - arr_size (int) Size of the timestamp array (default=8760 for one year in hourly increments) - Outputs: - timestamps (arr) Of size arr_size from start_time to end_time - + ''' + Creates an array of timestamps (using pandas package) given a start time, stop time, and array size. + ** Inputs + start_time: (str) Beginning of the timestamp array in 'year-month-day hh:mm:ss format' + end_time: (str) End of the timestamp array in 'year-month-day hh:mm:ss format' + arr_size: (int) Size of the timestamp array (default=8760 for one year in hourly increments) + ** Outputs + timestamps: (arr) Of size arr_size from start_time to end_time + ** Required packages + import pandas as pd ''' timestamps = pd.date_range(start=start_time, end=end_time, periods=arr_size) return timestamps def create_REopt_jl_jsonFile(modelDir, inputDict): + ''' + Function that assembles a dictionary (and saves as a JSON file) of all user inputs for the purposes \ + of preparing to run reopt_jl. + ** Inputs + modelDir: (str) path of where to save the ouput JSON file (needed for reopt_jl) + inputDict: (dict) input dictionary containing relevant user inputs to be modeled in REopt + (e.g. latitude, longitude, urdb label, etc.) + ** Outputs + scenario: (dict) output dictionary containing the correct format. + ''' ## Site parameters latitude = float(inputDict['latitude']) longitude = float(inputDict['longitude']) urdbLabel = str(inputDict['urdbLabel']) year = int(inputDict['year']) - outage = inputDict['outage'] demand = np.asarray([float(value) for value in inputDict['demandCurve'].split('\n') if value.strip()]) - - demand = demand.tolist() if isinstance(demand, np.ndarray) else demand - - ## Energy technologies - #solar = inputDict['solar'] - #generator = inputDict['generator'] - #battery = inputDict['battery'] - - ## Load demand file and make it JSON ready - #with open(pJoin(modelDir, "demand.csv")) as loadFile: - # load = pd.read_csv(loadFile, header=None) - # load = load[0].values.tolist() - + demand = demand.tolist() if isinstance(demand, np.ndarray) else demand ## make demand into a list """ + ## NOTE: The following lines of code are optional parameters that may or may not be used in the future. + ## Copied from omf.models.microgridDesign + ## Financial and Load parameters energyCost = float(inputDict['energyCost']) demandCost = float(inputDict['demandCost']) @@ -164,63 +167,109 @@ def create_REopt_jl_jsonFile(modelDir, inputDict): } """ + ## Begin the REopt input dictionary called 'scenario' scenario = { - "Site": { - "latitude": latitude, - "longitude": longitude + 'Site': { + 'latitude': latitude, + 'longitude': longitude }, - "ElectricTariff": { - "urdb_label": urdbLabel - }, - "ElectricLoad": { - "loads_kw": demand, - "year": year - }, - "PV": { - }, - "ElectricStorage": { - + 'ElectricTariff': { + 'urdb_label': urdbLabel }, + 'ElectricLoad': { + 'loads_kw': demand, + 'year': year + } } - ## Outages - if (inputDict['outage']): + ## Add a PV section if enabled + if inputDict['PV'] == 'Yes': + scenario['PV'] = { + ##TODO: Add options here, if needed + } + + ## Add a Battery Energy Storage System (BESS) section if enabled + if inputDict['BESS'] == 'Yes': + scenario['ElectricStorage'] = { + ##TODO: Add options here, if needed + } + + ## Add a Diesel Generator section if enabled + if inputDict['generator'] == 'Yes': + scenario['generator'] = { + ##TODO: Add options here, if needed + } + + ## Add an Outage section if enabled + if inputDict['outage'] == 'Yes': scenario['ElectricUtility'] = { 'outage_start_time_step': int(inputDict['outage_start_hour']), 'outage_end_time_step': int(inputDict['outage_start_hour'])+int(inputDict['outage_duration']) - } + } - ## Save scenario file - with open(pJoin(modelDir, "reopt_input_scenario.json"), "w") as jsonFile: + ## Save the scenario file + ## NOTE: reopt_jl currently requires a path for the input file, so the file must be saved to a location + ## preferrably in the modelDir + with open(pJoin(modelDir, 'reopt_input_scenario.json'), 'w') as jsonFile: json.dump(scenario, jsonFile) return scenario + +def get_tou_rates(modelDir, lat, lon): ## TODO: change lat,lon to inputDict once model is working + api_url = 'https://api.openei.org/utility_rates?parameters' + api_key = '5dFShfSVRt2XJpPYCbzBeLM6nHrOXc0VFPTWxfJJ' ## API key generated by following this website: 'https://openei.org/services/' + + params = { + 'version': '3', + 'format': 'json', + 'lat': lat, + 'lon': lon, + 'api_key': api_key + } + try: + response = requests.get(api_url, params=params) + response.raise_for_status() ## Raise an exception for HTTP errors + + data = response.json() + + # Save the retrieved data as a JSON file + with open(pJoin(modelDir, 'OEDIrateData.json'), 'w') as json_file: + json.dump(data, json_file) + + return data + + except requests.exceptions.RequestException as e: + print('Error:', e) + return None + + def work(modelDir, inputDict): ''' Run the model in its directory. ''' # Delete output file every run if it exists outData = {} - ## NOTE: This code will be used once reopt_jl is working ## Create REopt input file create_REopt_jl_jsonFile(modelDir, inputDict) - ## NOTE: This code is used temporarily until reopt_jl is working + ## NOTE: The single commented code below is used temporarily if reopt_jl is not working or for other debugging purposes. + ## Also NOTE: If this is used, you typically have to add a ['outputs'] key before the variable of interest. + ## For example, instead of reoptResults['ElectricStorage']['storage_to_load_series_kw'], it would have to be + ## reoptResults['outputs']['ElectricStorage']['storage_to_load_series_kw'] when using the static reopt file below. ## Read in a static REopt test file - #with open(pJoin(__neoMetaModel__._omfDir,"static","testFiles","residential_REopt_results.json")) as f: + #with open(pJoin(__neoMetaModel__._omfDir,"static","testFiles","residential_reopt_results.json")) as f: # reoptResults = pd.json_normalize(json.load(f)) # print('Successfully loaded REopt test file. \n') - # Model operations goes here. - - ## NOTE: This code will be used once reopt_jl is working ## Run REopt.jl - outage_flag = inputDict['outage'] - - reopt_jl.run_reopt_jl(modelDir, "reopt_input_scenario.json", outages=outage_flag) + reopt_jl.run_reopt_jl(modelDir, 'reopt_input_scenario.json', outages=inputDict['outage']) with open(pJoin(modelDir, 'results.json')) as jsonFile: reoptResults = json.load(jsonFile) - outData.update(reoptResults) ## Update output file with reopt results + outData.update(reoptResults) ## Update output file outData with REopt results data + + ## Convert user provided demand and temp data from str to float + temperatures = [float(value) for value in inputDict['tempCurve'].split('\n') if value.strip()] + demand = np.asarray([float(value) for value in inputDict['demandCurve'].split('\n') if value.strip()]) ## Create timestamp array from REopt input information try: @@ -228,14 +277,10 @@ def work(modelDir, inputDict): except KeyError: year = inputDict['year'] # Use the user provided year if none found in reoptResults - arr_size = np.size(reoptResults['ElectricUtility']['electric_to_load_series_kw']) + arr_size = np.size(demand) # desired array size of the timestamp array timestamps = create_timestamps(start_time=f'{year}-01-01', end_time=f'{year}-12-31 23:00:00', arr_size=arr_size) - ## Convert temperature data from str to float - temperatures = [float(value) for value in inputDict['tempCurve'].split('\n') if value.strip()] - demand = np.asarray([float(value) for value in inputDict['demandCurve'].split('\n') if value.strip()]) - - ## If outage is specified, load the resilience results + ## If outage is specified in the inputs, load the resilience results if (inputDict['outage']): try: with open(pJoin(modelDir, 'resultsResilience.json')) as jsonFile: @@ -246,37 +291,50 @@ def work(modelDir, inputDict): print(f"File '{results_file}' not found. REopt may not have simulated the outage.") raise - ## Run vbatDispatch - vbatResults = vb.work(modelDir,inputDict) - with open(pJoin(modelDir, 'vbatResults.json'), 'w') as jsonFile: - json.dump(vbatResults, jsonFile) - outData.update(vbatResults) ## Update output file with vbat results + ## Run vbatDispatch, unless it is disabled + if inputDict['load_type'] != '0': ## Load type 0 corresponds to the "None" option, which disables this vbatDispatch function + vbatResults = vb.work(modelDir,inputDict) + with open(pJoin(modelDir, 'vbatResults.json'), 'w') as jsonFile: + json.dump(vbatResults, jsonFile) + outData.update(vbatResults) ## Update output file with vbat results + + ## vbatDispatch variables + vbpower_series = pd.Series(vbatResults['VBpower'][0]) + vbat_charge = vbpower_series.where(vbpower_series > 0, 0) ##positive values = charging + vbat_discharge = vbpower_series.where(vbpower_series < 0, 0) #negative values = discharging + vbat_discharge_flipsign = vbat_discharge.mul(-1) ## flip sign of vbat discharge for plotting purposes ## DER Overview plot - showlegend = False #temporarily disable the legend toggle - - PV = reoptResults['PV']['electric_to_load_series_kw'] - BESS = reoptResults['ElectricStorage']['storage_to_load_series_kw'] - vbpower_series = pd.Series(vbatResults['VBpower'][0]) - vbat_discharge = vbpower_series.where(vbpower_series < 0, 0) #negative values - vbat_charge = vbpower_series.where(vbpower_series > 0, 0) ## positive values; part of the New Load? - vbat_discharge_flipsign = vbat_discharge.mul(-1) ## flip sign of vbat discharge for plotting purposes + showlegend = True # either enable or disable the legend toggle in the plot grid_to_load = reoptResults['ElectricUtility']['electric_to_load_series_kw'] - grid_charging_BESS = reoptResults['ElectricUtility']['electric_to_storage_series_kw'] + if inputDict['PV'] == 'Yes': ## PV + PV = reoptResults['PV']['electric_to_load_series_kw'] + else: + PV = np.zeros_like(demand) + + if inputDict['BESS'] == 'Yes': ## BESS + #BESS = reoptResults['ElectricStorage']['storage_to_load_series_kw'] + BESS = np.ones_like(demand) ## NOTE: Ad-hoc line used because BESS is not being built in REopt for some reason. Currently debugging 5/2024 + grid_charging_BESS = reoptResults['ElectricUtility']['electric_to_storage_series_kw'] + + ## NOTE: The following 3 lines of code are temporary; it reads in the SOC info from a static reopt test file + ## For some reason REopt is not producing BESS results so this is a workaround + with open(pJoin(__neoMetaModel__._omfDir,'static','testFiles','residential_reopt_results.json')) as f: + static_reopt_results = json.load(f) + outData['chargeLevelBattery'] = static_reopt_results['outputs']['ElectricStorage']['soc_series_fraction'] + else: + BESS = np.zeros_like(demand) + grid_charging_BESS = np.zeros_like(demand) + + ## Create DER overview plot object fig = go.Figure() - fig.add_trace(go.Scatter(x=timestamps, - y=np.asarray(BESS) + np.asarray(demand) + np.asarray(vbat_discharge_flipsign), - yaxis='y1', - mode='none', - fill='tozeroy', - name='BESS Serving Load (kW)', - fillcolor='rgba(0,137,83,1)', - showlegend=showlegend)) - fig.update_traces(fillpattern_shape='/', selector=dict(name='BESS Serving Load (kW)')) - fig.add_trace(go.Scatter(x=timestamps, + if inputDict['load_type'] != '0': ## Load type 0 corresponds to the "None" option, which disables this vbatDispatch function + vbat_discharge_component = np.asarray(vbat_discharge_flipsign) + vbat_charge_component = np.asarray(vbat_charge) + fig.add_trace(go.Scatter(x=timestamps, y=np.asarray(vbat_discharge_flipsign)+np.asarray(demand), yaxis='y1', mode='none', @@ -284,28 +342,49 @@ def work(modelDir, inputDict): fillcolor='rgba(127,0,255,1)', name='vbat Serving Load (kW)', showlegend=showlegend)) - fig.update_traces(fillpattern_shape='/', selector=dict(name='vbat Serving Load (kW)')) + fig.update_traces(fillpattern_shape='/', selector=dict(name='vbat Serving Load (kW)')) + else: + vbat_discharge_component = np.zeros_like(demand) + vbat_charge_component = np.zeros_like(demand) + + ## BESS serving load piece + if (inputDict['BESS'] == 'Yes'): + fig.add_trace(go.Scatter(x=timestamps, + y=np.asarray(BESS) + np.asarray(demand) + vbat_discharge_component, + yaxis='y1', + mode='none', + fill='tozeroy', + name='BESS Serving Load (kW)', + fillcolor='rgba(0,137,83,1)', + showlegend=showlegend)) + fig.update_traces(fillpattern_shape='/', selector=dict(name='BESS Serving Load (kW)')) + ## Original load piece (minus any vbat or BESS charging aka 'new/additional loads') fig.add_trace(go.Scatter(x=timestamps, - y=np.asarray(demand)-np.asarray(BESS)-np.asarray(vbat_discharge_flipsign), - yaxis='y1', - mode='none', - name='Original Load (kW)', - fill='tozeroy', - fillcolor='rgba(100,200,210,1)', - showlegend=showlegend)) + y=np.asarray(demand)-np.asarray(BESS)-vbat_discharge_component, + yaxis='y1', + mode='none', + name='Original Load (kW)', + fill='tozeroy', + fillcolor='rgba(100,200,210,1)', + showlegend=showlegend)) + ## Additional load (Charging BESS and vbat) + ## NOTE: demand is added here for plotting purposes, so that the additional load shows up above the demand curve. + ## How or if this should be done is still being discussed fig.add_trace(go.Scatter(x=timestamps, - y=np.asarray(demand) + np.asarray(reoptResults['ElectricUtility']['electric_to_storage_series_kw'][0]) + np.asarray(vbat_charge), + y=np.asarray(demand) + np.asarray(grid_charging_BESS) + vbat_charge_component, yaxis='y1', mode='none', - name='Additional Load (Charging BESS and vbat)', + name='Additional Load (Charging BESS and vbat)', ## TODO: Edit this title accordingly if BESS and/or vbat are disabled fill='tonexty', fillcolor='rgba(175,0,42,0)', showlegend=showlegend)) fig.update_traces(fillpattern_shape='.', selector=dict(name='Additional Load (Charging BESS and vbat)')) - grid_serving_new_load = np.asarray(grid_to_load) + np.asarray(grid_charging_BESS)+ np.asarray(vbat_charge) - np.asarray(vbat_discharge_flipsign) + np.asarray(PV) + ## Grid serving new load + ## TODO: Should PV really be in this? + grid_serving_new_load = np.asarray(grid_to_load) + np.asarray(grid_charging_BESS)+ vbat_charge_component - vbat_discharge_component + np.asarray(PV) fig.add_trace(go.Scatter(x=timestamps, y=grid_serving_new_load, yaxis='y1', @@ -315,6 +394,7 @@ def work(modelDir, inputDict): fillcolor='rgba(192,192,192,1)', showlegend=showlegend)) + ## Temperature line on a secondary y-axis (defined in the plot layout) fig.add_trace(go.Scatter(x=timestamps, y=temperatures, yaxis='y2', @@ -324,6 +404,8 @@ def work(modelDir, inputDict): showlegend=showlegend )) + ## NOTE: This code hides the demand curve initially when the plot is made, but it can be + ## toggled back on by the user by clicking it in the plot legend #fig.add_trace(go.Scatter(x=timestamps, # y=demand, # yaxis='y1', @@ -333,34 +415,41 @@ def work(modelDir, inputDict): # showlegend=showlegend)) #fig.update_traces(legendgroup='Demand', visible='legendonly', selector=dict(name='Original Load (kW)')) ## Make demand hidden on plot by default - fig.add_trace(go.Scatter(x=timestamps, - y=PV, - yaxis='y1', - mode='none', - fill='tozeroy', - name='PV Serving Load (kW)', - fillcolor='rgba(255,246,0,1)', - showlegend=showlegend - )) + ## PV plot, if enabled + if (inputDict['PV'] == 'Yes'): + fig.add_trace(go.Scatter(x=timestamps, + y=PV, + yaxis='y1', + mode='none', + fill='tozeroy', + name='PV Serving Load (kW)', + fillcolor='rgba(255,246,0,1)', + showlegend=showlegend + )) + ## Plot layout fig.update_layout( #title='Residential Data', xaxis=dict(title='Timestamp'), - yaxis=dict(title="Power (kW)"), + yaxis=dict(title='Power (kW)'), yaxis2=dict(title='degrees Celsius', overlaying='y', side='right' ), legend=dict( orientation='h', - yanchor="bottom", + yanchor='bottom', y=1.02, - xanchor="right", + xanchor='right', x=1 ) ) - fig.show() ## This opens a window that displays the correct figure with the appropriate patterns + ## NOTE: This opens a window that displays the correct figure with the appropriate patterns. + ## For some reason, the slash-mark patterns are not showing up on the OMF page otherwise. + ## Eventually we will delete this part. + fig.show() + ## Encode plot data as JSON for showing in the HTML side outData['derOverviewData'] = json.dumps(fig.data, cls=plotly.utils.PlotlyJSONEncoder) outData['derOverviewLayout'] = json.dumps(fig.layout, cls=plotly.utils.PlotlyJSONEncoder) @@ -395,7 +484,7 @@ def makeGridLine(x,y,color,name): powerGridToLoad = makeGridLine(x_values,reoptResults['ElectricUtility']['electric_to_load_series_kw'],'blue','Load met by Grid') plotData.append(powerGridToLoad) - if (inputDict['outage']): + if (inputDict['outage']): ## TODO: condense this code if possible outData['resilience'] = reoptResultsResilience['resilience_by_time_step'] outData['minOutage'] = reoptResultsResilience['resilience_hours_min'] outData['maxOutage'] = reoptResultsResilience['resilience_hours_max'] @@ -412,55 +501,68 @@ def makeGridLine(x,y,color,name): plotData.append(resilience) plotlyLayout['yaxis'].update(title='Longest Outage survived (Hours)') plotlyLayout['xaxis'].update(title='Start Hour') - outData["resilienceData"] = json.dumps(plotData, cls=plotly.utils.PlotlyJSONEncoder) - outData["resilienceLayout"] = json.dumps(plotlyLayout, cls=plotly.utils.PlotlyJSONEncoder) + outData['resilienceData'] = json.dumps(plotData, cls=plotly.utils.PlotlyJSONEncoder) + outData['resilienceLayout'] = json.dumps(plotlyLayout, cls=plotly.utils.PlotlyJSONEncoder) plotData = [] survivalProb = go.Scatter( x=outData['survivalProbX'], y=outData['survivalProbY'], line=dict( color=('red') ), - name="Probability of Surviving Outage of a Given Duration") + name='Probability of Surviving Outage of a Given Duration') plotData.append(survivalProb) plotlyLayout['yaxis'].update(title='Probability of meeting critical Load') plotlyLayout['xaxis'].update(title='Outage Length (Hours)') - outData["resilienceProbData" ] = json.dumps(plotData, cls=plotly.utils.PlotlyJSONEncoder) - outData["resilienceProbLayout"] = json.dumps(plotlyLayout, cls=plotly.utils.PlotlyJSONEncoder) - + outData['resilienceProbData' ] = json.dumps(plotData, cls=plotly.utils.PlotlyJSONEncoder) + outData['resilienceProbLayout'] = json.dumps(plotlyLayout, cls=plotly.utils.PlotlyJSONEncoder) - ## Exported Power Plot - PVcurtailed = reoptResults['PV']['electric_curtailed_series_kw'] - electric_to_grid = reoptResults['PV']['electric_to_grid_series_kw'] + ## Create Exported Power plot object fig = go.Figure() - + ## Power used to charge BESS (electric_to_storage_series_kw) - fig.add_trace(go.Scatter(x=timestamps, - y=np.asarray(grid_charging_BESS), - mode='none', - fill='tozeroy', - name='Power Used to Charge BESS', - fillcolor='rgba(75,137,83,1)', - showlegend=True)) + if inputDict['BESS'] == 'Yes': + fig.add_trace(go.Scatter(x=timestamps, + y=np.asarray(grid_charging_BESS), + mode='none', + fill='tozeroy', + name='Power Used to Charge BESS', + fillcolor='rgba(75,137,83,1)', + showlegend=True)) ## Power used to charge vbat (vbat_charging) - fig.add_trace(go.Scatter(x=timestamps, - y=np.asarray(vbat_charge), - mode='none', - fill='tozeroy', - name='Power Used to Charge VBAT', - fillcolor='rgba(155,148,225,1)', - showlegend=True)) - - ## PV curtailed (electric_curtailed_series_kw) - fig.add_trace(go.Scatter(x=timestamps, - y=np.asarray(PVcurtailed), - mode='none', - fill='tozeroy', - name='PV Curtailed', - fillcolor='rgba(0,137,83,1)', - showlegend=True)) + if inputDict['load_type'] != '0': + fig.add_trace(go.Scatter(x=timestamps, + y=np.asarray(vbat_charge), + mode='none', + fill='tozeroy', + name='Power Used to Charge VBAT', + fillcolor='rgba(155,148,225,1)', + showlegend=True)) + + if inputDict['PV'] == 'Yes': + PVcurtailed = reoptResults['PV']['electric_curtailed_series_kw'] + electric_to_grid = reoptResults['PV']['electric_to_grid_series_kw'] + + ## PV curtailed (electric_curtailed_series_kw) + fig.add_trace(go.Scatter(x=timestamps, + y=np.asarray(PVcurtailed), + mode='none', + fill='tozeroy', + name='PV Curtailed', + fillcolor='rgba(0,137,83,1)', + showlegend=True)) + + ## PV exported to grid (electric_to_grid_series_kw) + fig.add_trace(go.Scatter(x=timestamps, + y=np.asarray(electric_to_grid), + mode='none', + fill='tozeroy', + name='Power Exported to Grid', + fillcolor='rgba(33,78,154,1)', + showlegend=True)) + ## Power used to meet load (NOTE: Does this mean grid to load?) fig.add_trace(go.Scatter(x=timestamps, y=np.asarray(grid_to_load), @@ -469,25 +571,15 @@ def makeGridLine(x,y,color,name): name='Grid Serving Load', fillcolor='rgba(100,131,130,1)', showlegend=True)) - - ## Power exported to grid (electric_to_grid_series_kw) - fig.add_trace(go.Scatter(x=timestamps, - y=np.asarray(electric_to_grid), - mode='none', - fill='tozeroy', - name='Power Exported to Grid', - fillcolor='rgba(33,78,154,1)', - showlegend=True)) - fig.update_layout( xaxis=dict(title='Timestamp'), - yaxis=dict(title="Power (kW)"), + yaxis=dict(title='Power (kW)'), legend=dict( orientation='h', - yanchor="bottom", + yanchor='bottom', y=1.02, - xanchor="right", + xanchor='right', x=1 ) ) @@ -496,65 +588,118 @@ def makeGridLine(x,y,color,name): outData['exportedPowerData'] = json.dumps(fig.data, cls=plotly.utils.PlotlyJSONEncoder) outData['exportedPowerLayout'] = json.dumps(fig.layout, cls=plotly.utils.PlotlyJSONEncoder) - # Model operations typically ends here. + ## DER Sharing Program options + if (inputDict['utilityProgram']): + print('Considering utility DER sharing program \n') + + ## Gather TOU rates + #latitude = float(inputDict['latitude']) + #longitude = float(inputDict['longitude']) + ## NOTE: Temporarily use the lat/lon for a utility that has TOU rates specifically + latitude = 39.986771 + longitude = -104.812599 ## Brighton, CO + rate_info = get_tou_rates(modelDir, latitude, longitude) ## NOTE: lan/lon will be replaced by inputDict + + ## Extract "name" keys containing "TOU" or "time of use" + filtered_names = [item['name'] for item in rate_info['items'] if 'TOU' in item['name'] or 'time-of-use' in item['name'] or 'Time of Use' in item['name']] + for name in filtered_names: ## Print the filtered names + print(name) + + TOUname = 'Residential Time of Use' + TOUdata = [] + + for item in rate_info['items']: + if item['name'] == TOUname: + TOUdata.append(item) + + print(TOUdata) + + ''' + rate_info = { + 'rate_name': data['items'][0]['name'], ## Rate name + 'fixed_monthly_charge': data['items'][0]['fixedmonthlycharge'], ## Fixed monthly charge ($) + 'demand_rate_unit': data['items'][0]['demandrateunit'], ## Time of Use Rate Units + 'demand_rate_structure': data['items'][0]['demandratestructure'], ## Time of Use Demand Charge Structure + 'demand_weekday_schedule': data['items'][0]['demandweekdayschedule'], + 'demand_weekend_schedule': data['items'][0]['demandweekendschedule'], + 'flat_demand_structure': data['items'][0]['flatdemandstructure'], + 'flat_demand_months': data['items'][0]['flatdemandmonths'], + 'flat_demand_unit': data['items'][0]['flatdemandunit'] + } + ''' + + ## Generate peak shave schedule + + ## Determine area under the curve + + ## Apply rate compensations to DERs deployed + + ## Plot the peak shave schedule? + # Stdout/stderr. - outData["stdout"] = "Success" - outData["stderr"] = "" + outData['stdout'] = 'Success' + outData['stderr'] = '' return outData def new(modelDir): ''' Create a new instance of this model. Returns true on success, false on failure. ''' - with open(pJoin(__neoMetaModel__._omfDir,"static","testFiles","residential_PV_load.csv")) as f: + with open(pJoin(__neoMetaModel__._omfDir,'static','testFiles','residential_PV_load.csv')) as f: demand_curve = f.read() - with open(pJoin(__neoMetaModel__._omfDir,"static","testFiles","residential_extended_temperature_data.csv")) as f: + with open(pJoin(__neoMetaModel__._omfDir,'static','testFiles','residential_extended_temperature_data.csv')) as f: temp_curve = f.read() defaultInputs = { ## OMF inputs: - "user": "admin", - "modelType": modelName, - "created": str(datetime.datetime.now()), + 'user' : 'admin', + 'modelType': modelName, + 'created': str(datetime.datetime.now()), ## REopt inputs: - "latitude": '39.532165', ## Rivesville, WV - "longitude": '-80.120618', ## TODO: Should these be strings or floats? Update - strings. - "year": '2018', - "analysisYears": '25', - "urdbLabel": '643476222faee2f0f800d8b1', ## Rivesville, WV - Monongahela Power - "fileName": "residential_PV_load.csv", - "tempFileName": "residential_extended_temperature_data.csv", - "demandCurve": demand_curve, - "tempCurve": temp_curve, - "PV": "Yes", - "BESS": "No", - "generator": "No", - "outage": True, - "outage_start_hour": "2100", - "outage_duration": "3", + 'latitude': '39.532165', ## Rivesville, WV + 'longitude': '-80.120618', ## TODO: Should these be strings or floats? Update - strings. + 'year' : '2018', + 'analysis_years' : '25', + 'urdbLabel': '643476222faee2f0f800d8b1', ## Rivesville, WV - Monongahela Power + 'fileName': 'residential_PV_load.csv', + 'tempFileName': 'residential_extended_temperature_data.csv', + 'demandCurve': demand_curve, + 'tempCurve': temp_curve, + 'PV': 'No', + 'BESS': 'Yes', + 'generator': 'No', + 'outage': True, + 'outage_start_hour': '4637', + 'outage_duration': '3', ## vbatDispatch inputs: - "load_type": '2', ## Heat Pump - "number_devices": '1', - "power": '5.6', - "capacitance": '2', - "resistance": '2', - "cop": '2.5', - "setpoint": '19.5', - "deadband": '0.625', - "demandChargeCost": '25', - "electricityCost": '0.16', - "projectionLength": '25', - "discountRate": '2', - "unitDeviceCost": '150', - "unitUpkeepCost": '5', + 'load_type': '2', ## Heat Pump + 'number_devices': '1', + 'power': '5.6', + 'capacitance': '2', + 'resistance': '2', + 'cop': '2.5', + 'setpoint': '19.5', + 'deadband': '0.625', + 'demandChargeCost': '25', + 'electricityCost': '0.16', + 'projectionLength': '25', + 'discountRate': '2', + 'unitDeviceCost': '150', + 'unitUpkeepCost': '5', + + ## DER Program Design inputs: + 'utilityProgram': 'Yes', ## unit: $/kWh + 'rateCompensation': '0', ## unit: $ + 'fixedCompensation': '0', ## unit: $ + 'fixedCompensationFrequency': '0', ## 0=one-time only, 1=yearly } return __neoMetaModel__.new(modelDir, defaultInputs) @neoMetaModel_test_setup def _tests_disabled(): # Location - modelLoc = pJoin(__neoMetaModel__._omfDir,"data","Model","admin","Automated Testing of " + modelName) + modelLoc = pJoin(__neoMetaModel__._omfDir,'data','Model','admin','Automated Testing of ' + modelName) # Blow away old test results if necessary. try: shutil.rmtree(modelLoc) diff --git a/omf/models/derUtilityCost.html b/omf/models/derUtilityCost.html index a5085477a..58231a5cb 100644 --- a/omf/models/derUtilityCost.html +++ b/omf/models/derUtilityCost.html @@ -6,7 +6,8 @@ @@ -17,7 +18,7 @@
- +
@@ -38,9 +39,9 @@
-

System Parameters

+

General Model Inputs

-
+
@@ -80,15 +81,15 @@
- +
- - @@ -100,16 +101,13 @@
-
-

Resilience - To optimize for resilience, specify an Outage Start Hour greater than 0

-
-
+
@@ -121,7 +119,7 @@
-

Specific vbatDispatch Model Inputs

+

Thermal Model Inputs


@@ -161,6 +159,7 @@ } } + @@ -224,13 +223,89 @@
{{ omfRunDebugBlock }} {% if modelStatus == 'finished' %} + -

Plotly Test Plot

-
-
- {{ rawOutputFiles }} + +

DER Serving Load Overview

+
+ +

Battery Charge Percentage

+
+ +

Monthly Cost Comparison

+
+
+ + +
+
+ + + + + + + + + + + + + + + +
JanFebMarAprMayJunJulAugSepOctNovDec
+ + +
+
+ +

Cash Flow Projection

+
+
+ +
+ + + {{ rawOutputFiles }} {% endif %} \ No newline at end of file diff --git a/omf/models/derUtilityCost.py b/omf/models/derUtilityCost.py index 145d25442..febff7192 100644 --- a/omf/models/derUtilityCost.py +++ b/omf/models/derUtilityCost.py @@ -23,10 +23,10 @@ # Model metadata: tooltip = ('The derUtilityCost model evaluates the financial costs of controlling behind-the-meter ' - 'distributed energy resources (DERs) using the NREL renewable energy optimization tool (REopt) and ' + 'distributed energy resources (DERs) using the NREL Renewable Energy Optimization Tool (REopt) and ' 'the OMF virtual battery dispatch module (vbatDispatch).') modelName, template = __neoMetaModel__.metadata(__file__) -hidden = True +hidden = True ## Keep the model hidden=True during active development def work(modelDir, inputDict): @@ -36,22 +36,25 @@ def work(modelDir, inputDict): outData = {} ## Create REopt input file - reopt_input_scenario = derConsumer.create_REopt_jl_jsonFile(modelDir, inputDict) + derConsumer.create_REopt_jl_jsonFile(modelDir, inputDict) + + ## NOTE: The single commented code below is used temporarily if reopt_jl is not working or for other debugging purposes. + ## Also NOTE: If this is used, you typically have to add a ['outputs'] key before the variable of interest. + ## For example, instead of reoptResults['ElectricStorage']['storage_to_load_series_kw'], it would have to be + ## reoptResults['outputs']['ElectricStorage']['storage_to_load_series_kw'] when using the static reopt file below. + #with open(pJoin(__neoMetaModel__._omfDir,"static","testFiles","utility_reopt_results.json")) as f: + # reoptResults = pd.json_normalize(json.load(f)) + # print('Successfully read in REopt test file. \n') - ## NOTE: This code will be used once reopt_jl is working ## Run REopt.jl - outage_flag = inputDict['outage'] - - reopt_jl.run_reopt_jl(modelDir, "reopt_input_scenario.json", outages=outage_flag) + reopt_jl.run_reopt_jl(modelDir, "reopt_input_scenario.json", outages=inputDict['outage']) with open(pJoin(modelDir, 'results.json')) as jsonFile: reoptResults = json.load(jsonFile) outData.update(reoptResults) ## Update output file with reopt results - ## NOTE: This code is temporary - ## Read in a static REopt test file - with open(pJoin(__neoMetaModel__._omfDir,"static","testFiles","utility_reopt_results.json")) as f: - reoptResults = pd.json_normalize(json.load(f)) - print('Successfully read in REopt test file. \n') + ## Convert user provided demand and temp data from str to float + temperatures = [float(value) for value in inputDict['tempCurve'].split('\n') if value.strip()] + demand = np.asarray([float(value) for value in inputDict['demandCurve'].split('\n') if value.strip()]) ## Create timestamp array from REopt input information try: @@ -59,13 +62,9 @@ def work(modelDir, inputDict): except KeyError: year = inputDict['year'] # Use the user provided year if none found in reoptResults - arr_size = np.size(reoptResults['ElectricUtility']['electric_to_load_series_kw']) + arr_size = np.size(demand) # desired array size of the timestamp array timestamps = derConsumer.create_timestamps(start_time=f'{year}-01-01', end_time=f'{year}-12-31 23:00:00', arr_size=arr_size) - ## Convert temperature data from str to float - temperatures = [float(value) for value in inputDict['tempCurve'].split('\n') if value.strip()] - demand = np.asarray([float(value) for value in inputDict['demandCurve'].split('\n') if value.strip()]) - ## If outage is specified, load the resilience results if (inputDict['outage']): try: @@ -77,54 +76,49 @@ def work(modelDir, inputDict): print(f"File '{results_file}' not found. REopt may not have simulated the outage.") raise - ## Run vbatDispatch - vbatResults = vb.work(modelDir,inputDict) - with open(pJoin(modelDir, 'vbatResults.json'), 'w') as jsonFile: - json.dump(vbatResults, jsonFile) - outData.update(vbatResults) ## Update output file with vbat results - - - ## Output data - #outData['solar'] = inputDict['solar'] - #outData['generator'] = inputDict['generator'] ## TODO: make generator switch on only during outage? - #outData['battery'] = inputDict['battery'] - #outData['year'] = inputDict['year'] - #outData['urdbLabel'] = inputDict['urdbLabel'] - #out['demandCost'] = results['ElectricTariff']['lifecycle_demand_cost_after_tax'] - #out['powerPVToGrid'] = results['PV']['electric_to_grid_series_kw']#['year_one_to_grid_series_kw'] - - ## Run REopt and gather outputs for vbatDispath - ## TODO: Create a function that will gather the urdb label from a user provided location (city,state) - #RE.run_reopt_jl(modelDir,inputFile,outages) - - #reopt_jl.run_reopt_jl(path="/Users/astronobri/Documents/CIDER/reopt/inputs/", inputFile="UP_PV_outage_1hr.json", outages=outage) # UP coop PV - #reopt_jl.run_reopt_jl(path="/Users/astronobri/Documents/CIDER/reopt/inputs/", inputFile=pJoin(__neoMetaModel__._omfDir,"static","testFiles","residential_input.json"), outages=True) # residential PV + ## Run vbatDispatch, unless it is disabled + if inputDict['load_type'] != '0': ## Load type 0 corresponds to the "None" option, which disables this vbatDispatch function + vbatResults = vb.work(modelDir,inputDict) + with open(pJoin(modelDir, 'vbatResults.json'), 'w') as jsonFile: + json.dump(vbatResults, jsonFile) + outData.update(vbatResults) ## Update output file with vbat results + + ## vbatDispatch variables + vbpower_series = pd.Series(vbatResults['VBpower']) + vbat_charge = vbpower_series.where(vbpower_series > 0, 0) ##positive values = charging + vbat_discharge = vbpower_series.where(vbpower_series < 0, 0) #negative values = discharging + vbat_discharge_flipsign = vbat_discharge.mul(-1) ## flip sign of vbat discharge for plotting purposes + + ## DER Overview plot + showlegend = True # either enable or disable the legend toggle in the plot + grid_to_load = reoptResults['ElectricUtility']['electric_to_load_series_kw'] + + if inputDict['PV'] == 'Yes': ## PV + PV = reoptResults['PV']['electric_to_load_series_kw'] + else: + PV = np.zeros_like(demand) - ## Test plot - showlegend = False #temporarily disable the legend toggle - - PV = reoptResults['outputs.PV.electric_to_load_series_kw'][0] - BESS = reoptResults['outputs.ElectricStorage.storage_to_load_series_kw'][0] - vbpower_series = pd.Series(vbatResults['VBpower'][0]) - vbat_discharge = vbpower_series.where(vbpower_series < 0, 0) #negative values - vbat_charge = vbpower_series.where(vbpower_series > 0, 0) ## positive values; part of the New Load? - vbat_discharge_flipsign = vbat_discharge.mul(-1) ## flip sign of vbat discharge for plotting purposes - grid_to_load = reoptResults['outputs.ElectricUtility.electric_to_load_series_kw'][0] - grid_charging_BESS = reoptResults['outputs.ElectricUtility.electric_to_storage_series_kw'][0] - - + if inputDict['BESS'] == 'Yes': ## BESS + #BESS = reoptResults['ElectricStorage']['storage_to_load_series_kw'] + BESS = np.ones_like(demand) ## Ad-hoc line used because BESS is not being built in REopt for some reason. Currently debugging 5/2024 + grid_charging_BESS = reoptResults['ElectricUtility']['electric_to_storage_series_kw'] + #outData['chargeLevelBattery'] = reoptResults['ElectricStorage']['soc_series_fraction'] + + ## NOTE: The following 3 lines of code are temporary; it reads in the SOC info from a static reopt test file + ## For some reason REopt is not producing BESS results so this is a workaround + with open(pJoin(__neoMetaModel__._omfDir,'static','testFiles','utility_reopt_results.json')) as f: + static_reopt_results = json.load(f) + outData['chargeLevelBattery'] = static_reopt_results['outputs']['ElectricStorage']['soc_series_fraction'] + else: + BESS = np.zeros_like(demand) + grid_charging_BESS = np.zeros_like(demand) + + ## Create DER overview plot object fig = go.Figure() - fig.add_trace(go.Scatter(x=timestamps, - y=np.asarray(BESS) + np.asarray(demand) + np.asarray(vbat_discharge_flipsign), - yaxis='y1', - mode='none', - fill='tozeroy', - name='BESS Serving Load (kW)', - fillcolor='rgba(0,137,83,1)', - showlegend=showlegend)) - fig.update_traces(fillpattern_shape='/', selector=dict(name='BESS Serving Load (kW)')) - - fig.add_trace(go.Scatter(x=timestamps, + if inputDict['load_type'] != '0': ## Load type 0 corresponds to the "None" option, which disables this vbatDispatch function + vbat_discharge_component = np.asarray(vbat_discharge_flipsign) + vbat_charge_component = np.asarray(vbat_charge) + fig.add_trace(go.Scatter(x=timestamps, y=np.asarray(vbat_discharge_flipsign)+np.asarray(demand), yaxis='y1', mode='none', @@ -132,19 +126,38 @@ def work(modelDir, inputDict): fillcolor='rgba(127,0,255,1)', name='vbat Serving Load (kW)', showlegend=showlegend)) - fig.update_traces(fillpattern_shape='/', selector=dict(name='vbat Serving Load (kW)')) + fig.update_traces(fillpattern_shape='/', selector=dict(name='vbat Serving Load (kW)')) + else: + vbat_discharge_component = np.zeros_like(demand) + vbat_charge_component = np.zeros_like(demand) + + ## BESS serving load piece + if (inputDict['BESS'] == 'Yes'): + fig.add_trace(go.Scatter(x=timestamps, + y=np.asarray(BESS) + np.asarray(demand) + vbat_discharge_component, + yaxis='y1', + mode='none', + fill='tozeroy', + name='BESS Serving Load (kW)', + fillcolor='rgba(0,137,83,1)', + showlegend=showlegend)) + fig.update_traces(fillpattern_shape='/', selector=dict(name='BESS Serving Load (kW)')) + ## Original load piece (minus any vbat or BESS charging aka 'new/additional loads') fig.add_trace(go.Scatter(x=timestamps, - y=np.asarray(demand)-np.asarray(BESS)-np.asarray(vbat_discharge_flipsign), - yaxis='y1', - mode='none', - name='Original Load (kW)', - fill='tozeroy', - fillcolor='rgba(100,200,210,1)', - showlegend=showlegend)) - + y=np.asarray(demand)-np.asarray(BESS)-vbat_discharge_component, + yaxis='y1', + mode='none', + name='Original Load (kW)', + fill='tozeroy', + fillcolor='rgba(100,200,210,1)', + showlegend=showlegend)) + + ## Additional load (Charging BESS and vbat) + ## NOTE: demand is added here for plotting purposes, so that the additional load shows up above the demand curve. + ## How or if this should be done is still being discussed fig.add_trace(go.Scatter(x=timestamps, - y=np.asarray(demand) + np.asarray(reoptResults['outputs.ElectricUtility.electric_to_storage_series_kw'][0]) + np.asarray(vbat_charge), + y=np.asarray(demand) + np.asarray(grid_charging_BESS) + vbat_charge_component, yaxis='y1', mode='none', name='Additional Load (Charging BESS and vbat)', @@ -153,8 +166,9 @@ def work(modelDir, inputDict): showlegend=showlegend)) fig.update_traces(fillpattern_shape='.', selector=dict(name='Additional Load (Charging BESS and vbat)')) - - grid_serving_new_load = np.asarray(grid_to_load) + np.asarray(grid_charging_BESS)+ np.asarray(vbat_charge) - np.asarray(vbat_discharge_flipsign) + np.asarray(PV) + ## Grid serving new load + ## TODO: Should PV really be in this? + grid_serving_new_load = np.asarray(grid_to_load) + np.asarray(grid_charging_BESS)+ vbat_charge_component - vbat_discharge_component + np.asarray(PV) fig.add_trace(go.Scatter(x=timestamps, y=grid_serving_new_load, yaxis='y1', @@ -164,6 +178,7 @@ def work(modelDir, inputDict): fillcolor='rgba(192,192,192,1)', showlegend=showlegend)) + ## Temperature line on a secondary y-axis (defined in the plot layout) fig.add_trace(go.Scatter(x=timestamps, y=temperatures, yaxis='y2', @@ -173,6 +188,8 @@ def work(modelDir, inputDict): showlegend=showlegend )) + ## NOTE: This code hides the demand curve initially when the plot is made, but it can be + ## toggled back on by the user by clicking it in the plot legend #fig.add_trace(go.Scatter(x=timestamps, # y=demand, # yaxis='y1', @@ -182,97 +199,123 @@ def work(modelDir, inputDict): # showlegend=showlegend)) #fig.update_traces(legendgroup='Demand', visible='legendonly', selector=dict(name='Original Load (kW)')) ## Make demand hidden on plot by default - fig.add_trace(go.Scatter(x=timestamps, - y=PV, - yaxis='y1', - mode='none', - fill='tozeroy', - name='PV Serving Load (kW)', - fillcolor='rgba(255,246,0,1)', - showlegend=showlegend - )) + ## PV piece, if enabled + if (inputDict['PV'] == 'Yes'): + fig.add_trace(go.Scatter(x=timestamps, + y=PV, + yaxis='y1', + mode='none', + fill='tozeroy', + name='PV Serving Load (kW)', + fillcolor='rgba(255,246,0,1)', + showlegend=showlegend + )) + ## Plot layout fig.update_layout( title='Utility Data Test', xaxis=dict(title='Timestamp'), - yaxis=dict(title="Energy (kW)"), + yaxis=dict(title="Power (kW)"), yaxis2=dict(title='degrees Celsius', overlaying='y', side='right' ), legend=dict( orientation='h', - yanchor="bottom", + yanchor='bottom', y=1.02, - xanchor="right", + xanchor='right', x=1 ) ) + ## NOTE: This opens a window that displays the correct figure with the appropriate patterns. + ## For some reason, the slash-mark patterns are not showing up on the OMF page otherwise. + ## Eventually we will delete this part. fig.show() ## Encode plot data as JSON for showing in the HTML - outData['plotlyPlot'] = json.dumps(fig.data, cls=plotly.utils.PlotlyJSONEncoder) - outData['plotlyLayout'] = json.dumps(fig.layout, cls=plotly.utils.PlotlyJSONEncoder) + outData['derOverviewData'] = json.dumps(fig.data, cls=plotly.utils.PlotlyJSONEncoder) + outData['derOverviewLayout'] = json.dumps(fig.layout, cls=plotly.utils.PlotlyJSONEncoder) + + ## Battery State of Charge plot + if inputDict['BESS'] == 'Yes': + fig = go.Figure() + + fig.add_trace(go.Scatter(x=timestamps, + y=outData['chargeLevelBattery'], + mode='none', + fill='tozeroy', + fillcolor='red', + name='Battery Charge Level', + showlegend=True)) + fig.update_layout( + xaxis=dict(title='Timestamp'), + yaxis=dict(title='Charge (%)') + ) + + outData['batteryChargeData'] = json.dumps(fig.data, cls=plotly.utils.PlotlyJSONEncoder) + outData['batteryChargeLayout'] = json.dumps(fig.layout, cls=plotly.utils.PlotlyJSONEncoder) # Model operations typically ends here. # Stdout/stderr. - outData["stdout"] = "Success" - outData["stderr"] = "" + outData['stdout'] = 'Success' + outData['stderr'] = '' return outData def new(modelDir): ''' Create a new instance of this model. Returns true on success, false on failure. ''' - with open(pJoin(__neoMetaModel__._omfDir,"static","testFiles","utility_2018_kW_load.csv")) as f: + with open(pJoin(__neoMetaModel__._omfDir,'static','testFiles','utility_2018_kW_load.csv')) as f: demand_curve = f.read() - with open(pJoin(__neoMetaModel__._omfDir,"static","testFiles","utility_CO_2018_temperatures.csv")) as f: + with open(pJoin(__neoMetaModel__._omfDir,'static','testFiles','utility_CO_2018_temperatures.csv')) as f: temp_curve = f.read() defaultInputs = { ## OMF inputs: - "user" : "admin", - "modelType": modelName, - "created":str(datetime.datetime.now()), + 'user' : 'admin', + 'modelType': modelName, + 'created': str(datetime.datetime.now()), ## REopt inputs: - "latitude" : '39.7392358', - "longitude" : '-104.990251', ## Brighton, CO - "year" : '2018', - "analysis_years" : '25', - "urdbLabel" : '612ff9c15457a3ec18a5f7d3', ## Brighton, CO - "fileName": "utility_2018_kW_load.csv", - "tempFileName": "utility_CO_2018_temperatures.csv", - "demandCurve": demand_curve, - "tempCurve": temp_curve, - "PV": "Yes", - "BESS": "No", - "generator": "No", - "outage": True, - "outage_start_hour": '2100', - "outage_duration": '3', + 'latitude' : '39.986771 ', + 'longitude' : '-104.812599', ## Brighton, CO + 'year' : '2018', + 'analysis_years' : '25', + 'urdbLabel' : '612ff9c15457a3ec18a5f7d3', ## Brighton, CO + ## TODO: Create a function that will gather the urdb label from a user provided location (city,state), rather than requiring the URDB label + 'fileName': 'utility_2018_kW_load.csv', + 'tempFileName': 'utility_CO_2018_temperatures.csv', + 'demandCurve': demand_curve, + 'tempCurve': temp_curve, + 'PV': 'Yes', + 'BESS': 'Yes', + 'generator': 'No', + 'outage': True, + 'outage_start_hour': '2100', + 'outage_duration': '3', ## vbatDispatch inputs: - "load_type": "2", ## Heat Pump - "number_devices": "1", - "power": "5.6", - "capacitance": "2", - "resistance": "2", - "cop": "2.5", - "setpoint": "19.5", - "deadband": "0.625", - "demandChargeCost":"25", - "electricityCost":"0.16", - "projectionLength":"25", - "discountRate":"2", - "unitDeviceCost":"150", - "unitUpkeepCost":"5", + 'load_type': '2', ## Heat Pump + 'number_devices': '1', + 'power': '5.6', + 'capacitance': '2', + 'resistance': '2', + 'cop': '2.5', + 'setpoint': '19.5', + 'deadband': '0.625', + 'demandChargeCost': '25', + 'electricityCost': '0.16', + 'projectionLength': '25', + 'discountRate': '2', + 'unitDeviceCost': '150', + 'unitUpkeepCost': '5', } return __neoMetaModel__.new(modelDir, defaultInputs) @neoMetaModel_test_setup def _tests_disabled(): # Location - modelLoc = pJoin(__neoMetaModel__._omfDir,"data","Model","admin","Automated Testing of " + modelName) + modelLoc = pJoin(__neoMetaModel__._omfDir,'data','Model','admin','Automated Testing of ' + modelName) # Blow away old test results if necessary. try: shutil.rmtree(modelLoc) diff --git a/omf/models/hostingCapacity.py b/omf/models/hostingCapacity.py index 700b198da..95f622f34 100644 --- a/omf/models/hostingCapacity.py +++ b/omf/models/hostingCapacity.py @@ -50,7 +50,7 @@ def createColorCSV(modelDir, df): new_df = df[['bus','max_kw']] new_df.to_csv(Path(modelDir, 'color_by.csv'), index=False) -def run_downline_load_algorithm( modelDir, inputDict, outData): +def run_downline_load_algorithm( modelDir, inputDict, outData ): feederName = [x for x in os.listdir(modelDir) if x.endswith('.omd') and x[:-4] == inputDict['feederName1'] ][0] inputDict['feederName1'] = feederName[:-4] path_to_omd = Path(modelDir, feederName) @@ -86,7 +86,7 @@ def run_downline_load_algorithm( modelDir, inputDict, outData): outData['downline_runtime'] = convert_seconds_to_hms_ms( downline_end_time - downline_start_time ) return sorted_downlineDF -def run_ami_algorithm(modelDir, inputDict, outData): +def run_ami_algorithm( modelDir, inputDict, outData ): # mohca data-driven hosting capacity inputPath = Path(modelDir, inputDict['AMIDataFileName']) inputAsString = inputPath.read_text() @@ -130,8 +130,10 @@ def run_ami_algorithm(modelDir, inputDict, outData): outData['AMI_tableValues'] = ( list(sorted_results.itertuples(index=False, name=None)) ) outData['AMI_runtime'] = convert_seconds_to_hms_ms( AMI_end_time - AMI_start_time ) -def run_traditional_algorithm(modelDir, inputDict, outData): +def run_traditional_algorithm( modelDir, inputDict, outData ): # traditional hosting capacity if they uploaded an omd circuit file and chose to use it. + + # Check if the file was upploaded and checks to make sure the name matches feederName = [x for x in os.listdir(modelDir) if x.endswith('.omd') and x[:-4] == inputDict['feederName1'] ][0] inputDict['feederName1'] = feederName[:-4] path_to_omd = Path(modelDir, feederName) @@ -141,7 +143,7 @@ def run_traditional_algorithm(modelDir, inputDict, outData): traditionalHCResults = opendss.hosting_capacity_all( FNAME = Path(modelDir, 'circuit.dss'), max_test_kw=int(inputDict["traditionalHCMaxTestkw"]), multiprocess=False) traditional_end_time = time.time() # - opendss.hosting_capacity_all() changes the cwd, so change it back so other code isn't affected - tradHCDF = pd.DataFrame(traditionalHCResults) + tradHCDF = pd.DataFrame( traditionalHCResults ) sorted_tradHCDF = tradHCDF.sort_values(by='bus') sorted_tradHCDF['plot_color'] = sorted_tradHCDF.apply ( lambda row: bar_chart_coloring(row), axis=1 ) # Plotly has its own colors - need to map the "name" of given colors to theirs diff --git a/omf/models/resilientCommunity.py b/omf/models/resilientCommunity.py index a137cb3d8..7753bb85c 100644 --- a/omf/models/resilientCommunity.py +++ b/omf/models/resilientCommunity.py @@ -651,6 +651,26 @@ def getPercentile(loads, columnName): for i, (k,v) in enumerate(loads.items()): loads[k][new_str] = round(result[i],2) +def coordCheck(long, lat, latlonList): + point = Point(long, lat) + + for k,v in latlonList.items(): + coords, geoType = v[0], v[1] + + ## Need to figure out when we are dealing with a multipolygon or polygon list + + if geoType == 'Polygon': + poly = Polygon(coords) + + if poly.intersects(point): + return k + else: + for i in coords: + if poly.intersects(point): + return k + + return '' + def getDownLineLoadsEquipment(pathToOmd,nriGeoJson, equipmentList): ''' @@ -665,6 +685,7 @@ def getDownLineLoadsEquipment(pathToOmd,nriGeoJson, equipmentList): tracts = {} tractData = [] geos = [] + lon_lat = {} cols = ['TRACT','BUILDVALUE','AGRIVALUE','EAL_VALT','EAL_VALB','EAL_VALP','EAL_VALA','SOVI_SCORE','SOVI_RATNG','RESL_RATNG','RESL_VALUE','AVLN_AFREQ','CFLD_AFREQ','CWAV_AFREQ','DRGT_AFREQ','ERQK_AFREQ','HAIL_AFREQ','HWAV_AFREQ','HRCN_AFREQ','ISTM_AFREQ','LNDS_AFREQ','LTNG_AFREQ','RFLD_AFREQ','SWND_AFREQ','TRND_AFREQ','TSUN_AFREQ','VLCN_AFREQ','WFIR_AFREQ','WNTW_AFREQ'] for ob in omd.get('tree', {}).values(): obType = ob['object'] @@ -685,11 +706,20 @@ def getDownLineLoadsEquipment(pathToOmd,nriGeoJson, equipmentList): loads[key]["base crit score"]= ((math.sqrt((kw * kw) + (kvar * kvar) ))/ (5)) * 4 - - lat = float(ob['latitude']) long = float(ob['longitude']) - - tract = findCensusTract(lat, long) + lat = float(ob['latitude']) + + if lon_lat: + check = coordCheck(long,lat, lon_lat) + if check: + svi_score = round(float(tracts.get(tract)['SOVI_SCORE']),2) + loads[key]["community crit score"] = round((((math.sqrt((kw * kw) + (kvar * kvar) ))/ (5)) * 4) * svi_score,2) + loads[key]['SOVI_SCORE'] = svi_score + continue + else: + tract = findCensusTract(lat,long) + else: + tract = findCensusTract(lat, long) # if api call failed. repeat it while tract == None: @@ -725,13 +755,21 @@ def getDownLineLoadsEquipment(pathToOmd,nriGeoJson, equipmentList): tracts[tractID] = i['properties'] if (i['geometry']['type'] == 'MultiPolygon'): + lon_lat_list = [] for j in i['geometry']['coordinates']: + ## changes values so make copy + lon_lat_list.append(transform(j.copy())) geos.append(j) tractData.append(vals) + lon_lat[tract] = (lon_lat_list,'MultiPolygon') else: + # changes values so make copy + lon_lat[tract] = (transform(i['geometry']['coordinates'][0].copy()), 'Polygon') geos.append(i['geometry']['coordinates'][0]) tractData.append(vals) + + break diff --git a/omf/solvers/opendss/__init__.py b/omf/solvers/opendss/__init__.py index 63e5badbb..c3fc25855 100644 --- a/omf/solvers/opendss/__init__.py +++ b/omf/solvers/opendss/__init__.py @@ -348,7 +348,7 @@ def get_hosting_capacity_of_single_bus_multiprocessing(FILE_PATH:str, BUS_NAME:s lower_kw_bound = 1 upper_kw_bound = 1 while True: - results = check_hosting_capacity_of_single_bus(FILE_PATH, BUS_NAME, upper_kw_bound, lock) + results = check_hosting_capacity_of_single_bus(FILE_PATH, BUS_NAME, upper_kw_bound) thermal_violation = results['thermal_violation'] voltage_violation = results['voltage_violation'] if thermal_violation or voltage_violation or upper_kw_bound == max_test_kw: @@ -484,14 +484,15 @@ def hosting_capacity_single_bus(FILE_PATH:str, kwSTEPS:int, kwValue:float, BUS_N return {'bus':BUS_NAME, 'max_kw':kwValue * step, 'reached_max':False, 'thermal_violation':therm_violation, 'voltage_violation':volt_violation} def multiprocessor_function( FILE_PATH, max_test_kw, lock, BUS_NAME): - print( "inside multiprocessor function" ) - try: - single_output = get_hosting_capacity_of_single_bus_multiprocessing( FILE_PATH, BUS_NAME, max_test_kw, lock) - return single_output - except: - print(f'Could not solve hosting capacity for BUS_NAME={BUS_NAME}') + with lock: + # print( "inside multiprocessor function" ) + try: + single_output = get_hosting_capacity_of_single_bus_multiprocessing( FILE_PATH, BUS_NAME, max_test_kw, lock) + return single_output + except: + print(f'Could not solve hosting capacity for BUS_NAME={BUS_NAME}') - + #Jenny def hosting_capacity_all(FNAME:str, max_test_kw:float=50000, BUS_LIST:list = None, multiprocess=False, cores: int=8): ''' Generate hosting capacity results for all_buses. ''' @@ -504,12 +505,12 @@ def hosting_capacity_all(FNAME:str, max_test_kw:float=50000, BUS_LIST:list = Non all_output = [] # print('GEN_BUSES', gen_buses) if multiprocess == True: - lock = multiprocessing.Lock() - pool = multiprocessing.Pool( processes=cores ) - print(f'Running multiprocessor {len(gen_buses)} times with {cores} cores') - # Executes parallel_hc_func in parallel for each item in gen_buses - all_output.extend(pool.starmap(multiprocessor_function, [(fullpath, max_test_kw, lock, bus) for bus in gen_buses])) - print( "multiprocess all output: ", all_output) + with multiprocessing.Manager() as manager: + lock = manager.Lock() + pool = multiprocessing.Pool( processes=cores ) + print(f'Running multiprocessor {len(gen_buses)} times with {cores} cores') + all_output.extend(pool.starmap(multiprocessor_function, [(fullpath, max_test_kw, lock, bus) for bus in gen_buses])) + print( "multiprocess all output: ", all_output) elif multiprocess == False: for bus in gen_buses: try: @@ -843,7 +844,7 @@ def dss_to_nx_fulldata( dssFilePath, tree=None, fullData = True ): lines = [x for x in tree if x.get('object', 'N/A').startswith('line.')] lines_bus1 = [x.split('.')[0] for x in [x['bus1'] for x in lines if 'bus1' in x]] lines_bus2 = [x.split('.')[0] for x in [x['bus2'] for x in lines if 'bus2' in x]] - lines_name = [x.split('.')[1] for x in [x['object'] for x in lines if 'object' in x]] + # lines_name = [x.split('.')[1] for x in [x['object'] for x in lines if 'object' in x]] edges = [] for bus1, bus2 in zip(lines_bus1, lines_bus2 ): edges.append( (bus1, bus2, {'color': 'blue'}) ) @@ -853,7 +854,6 @@ def dss_to_nx_fulldata( dssFilePath, tree=None, fullData = True ): # some lines have "switch" # How to add data when sometimes there sometimes not - # If there is a transformer tied to a load, we get it from here. loads = [x for x in tree if x.get('object', 'N/A').startswith('load.')] # This is an orderedDict load_names = [x['object'].split('.')[1] for x in loads if 'object' in x and x['object'].startswith('load.')] load_bus = [x.split('.')[0] for x in [x['bus1'] for x in loads if 'bus1' in x]] @@ -891,10 +891,13 @@ def dss_to_nx_fulldata( dssFilePath, tree=None, fullData = True ): transformer_name = [x.split('.')[1] for x in [x['object'] for x in transformers if 'object' in x]] transformer_edges = [] for bus_pair, t_name in zip(transformer_buses_names_split, transformer_name): - if bus_pair[0] and bus_pair[1] in G.nodes: - transformer_edges.append ( (bus_pair[0], bus_pair[1], {'key': t_name}) ) + if bus_pair[0] and bus_pair[1] in G.nodes: # If both buses exist + transformer_edges.append ( (bus_pair[0], bus_pair[1]) ) G.add_edges_from(transformer_edges) + # Check if there exists a line - would a transformer exist on an already existing line? + # Check if both buses exist + # Need to add data for transformers # Some have windings. diff --git a/omf/static/geoJsonMap/v3/clusterControl.js b/omf/static/geoJsonMap/v3/clusterControl.js index a3f9b80a2..a0d4c768a 100644 --- a/omf/static/geoJsonMap/v3/clusterControl.js +++ b/omf/static/geoJsonMap/v3/clusterControl.js @@ -16,6 +16,7 @@ const ClusterControlClass = L.Control.extend({ throw TypeError('The "controller" argument must be instanceof FeatureController.'); } this._controller = controller; + this._on = false; // - Create div this._div = L.DomUtil.create('div', 'leaflet-bar'); this._div.classList.add('leaflet-customControlDiv'); @@ -24,33 +25,31 @@ const ClusterControlClass = L.Control.extend({ // - Add a tooltip this._div.title = '- The node grouping tool allows you to group nodes into a cluster\n- Click this button to enable\n- Click this button to disable' // - Attach listeners - this._on = false; this._div.addEventListener('click', () => { - LeafletLayer.map.removeLayer(LeafletLayer.nodeLayers); - const overlayMap = []; - for (const layer of LeafletLayer.control._layers) { - if (layer.overlay === true) { - overlayMap.push(layer); - } - } - overlayMap.forEach(layer => LeafletLayer.control.removeLayer(layer.layer)); - if (LeafletLayer.nodeLayers instanceof L.MarkerClusterGroup) { + // - Clustering was enabled, so disable it + if (this._on) { this._div.style.removeProperty('border'); this._div.replaceChildren(this._getExpandSvg()); - LeafletLayer.nodeLayers = L.featureGroup(LeafletLayer.nodeLayers.getLayers()); + for (const node of LeafletLayer.nodeClusterLayers.getLayers()) { + LeafletLayer.nodeLayers.addLayer(node); + } + LeafletLayer.nodeClusterLayers.clearLayers(); + this._on = false; + // - Clustering was disabled, so enable it } else { this._div.style.border = '2px solid #7FFF00'; this._div.replaceChildren(this._getCompressSvg()); - const nodeLayers = LeafletLayer.nodeLayers.getLayers(); - LeafletLayer.nodeLayers = L.markerClusterGroup(); - nodeLayers.forEach(l => LeafletLayer.nodeLayers.addLayer(l)); + for (const node of LeafletLayer.nodeLayers.getLayers()) { + LeafletLayer.nodeClusterLayers.addLayer(node); + } + LeafletLayer.nodeLayers.clearLayers(); + this._on = true; } - const nodeLayerIndex = overlayMap.findIndex(layer => layer.name === 'Nodes'); - overlayMap[nodeLayerIndex] = {layer: LeafletLayer.nodeLayers, name: 'Nodes'}; - for (const layer of overlayMap) { - LeafletLayer.control.addOverlay(layer.layer, layer.name); + // - Force redraw + for (const layer of [LeafletLayer.nodeLayers, LeafletLayer.nodeClusterLayers]) { + LeafletLayer.map.removeLayer(layer); + LeafletLayer.map.addLayer(layer); } - LeafletLayer.map.addLayer(LeafletLayer.nodeLayers); }); if (options !== null) { L.setOptions(this, options); diff --git a/omf/static/geoJsonMap/v3/feature.js b/omf/static/geoJsonMap/v3/feature.js index 949938c1b..3d2f54c74 100644 --- a/omf/static/geoJsonMap/v3/feature.js +++ b/omf/static/geoJsonMap/v3/feature.js @@ -5,7 +5,6 @@ class Feature { #feature; #graph; #observers; - #originalFeature; /** * @param {Object} feature - a standard GeoJSON feature @@ -16,7 +15,6 @@ class Feature { this.#feature = feature; this.#graph = null this.#observers = []; - this.#originalFeature = structuredClone(feature); } // ********************************* @@ -34,10 +32,10 @@ class Feature { deleteProperty(propertyKey, namespace='treeProps') { // - The function signature above is part of the ObservableInterface API. The implementation below is not if (typeof propertyKey !== 'string') { - throw TypeError('"propertyKey" argument must be a string.'); + throw TypeError('The "propertyKey" argument must be a string.'); } if (typeof namespace !== 'string') { - throw TypeError('"namespace" argument must be a string.'); + throw TypeError('The "namespace" argument must be a string.'); } if (['treeProps', 'formProps', 'urlProps'].includes(namespace)) { if (this.#feature.properties.hasOwnProperty(namespace)) { @@ -146,7 +144,7 @@ class Feature { getProperties(namespace) { // - The function signature above is part of the ObservableInterface API. The implementation below is not if (typeof namespace !== 'string') { - throw TypeError('"namespace" argument must be a string'); + throw TypeError('The "namespace" argument must be a string'); } if (['treeProps', 'formProps', 'urlProps'].includes(namespace)) { if (this.#feature.properties.hasOwnProperty(namespace)) { @@ -172,10 +170,10 @@ class Feature { getProperty(propertyKey, namespace='treeProps') { // - The function signature above is part of the ObservableInterface API. The implementation below is not if (typeof propertyKey !== 'string') { - throw TypeError('"propertyKey" argument must be a string.'); + throw TypeError('The "propertyKey" argument must be a string.'); } if (typeof namespace !== 'string') { - throw TypeError('"namespace" argument must be a string.'); + throw TypeError('The "namespace" argument must be a string.'); } if (['treeProps', 'formProps', 'urlProps'].includes(namespace)) { if (this.#feature.properties.hasOwnProperty(namespace)) { @@ -234,10 +232,10 @@ class Feature { hasProperty(propertyKey, namespace='treeProps') { // - The function signature above is part of the ObservableInterface API. The implementation below is not if (typeof propertyKey !== 'string') { - throw TypeError('"propertyKey" argument must be a string.'); + throw TypeError('The "propertyKey" argument must be a string.'); } if (typeof namespace !== 'string') { - throw TypeError('"namespace" argument must be a string.'); + throw TypeError('The "namespace" argument must be a string.'); } if (['treeProps', 'formProps', 'urlProps'].includes(namespace)) { if (this.#feature.properties.hasOwnProperty(namespace)) { @@ -299,7 +297,7 @@ class Feature { setCoordinates(coordinates) { // - The function signature above is part of the ObservableInterface API. The implementation below is not if (!(coordinates instanceof Array)) { - throw TypeError('"coordinates" argument must be instanceof Array.'); + throw TypeError('The "coordinates" argument must be instanceof Array.'); } // - Check that all coordinate values are valid numbers const flatCoordinates = coordinates.flat(3); @@ -376,10 +374,10 @@ class Feature { setProperty(propertyKey, propertyValue, namespace='treeProps') { // - The function signature above is part of the ObservableInterface API. The implementation below is not if (typeof propertyKey !== 'string') { - throw TypeError('"propertyKey" argument must be a string.'); + throw TypeError('The "propertyKey" argument must be a string.'); } if (typeof namespace !== 'string') { - throw TypeError('"namespace" argument must be a string.'); + throw TypeError('The "namespace" argument must be a string.'); } if (['treeProps', 'formProps', 'urlProps'].includes(namespace)) { if (this.#feature.properties.hasOwnProperty(namespace)) { @@ -431,10 +429,10 @@ class Feature { updatePropertyOfObservers(propertyKey, oldPropertyValue, namespace='treeProps') { // - The function signature above is part of the ObservableInterface API. The implementation below is not if (typeof propertyKey !== 'string') { - throw TypeError('"propertyKey" argument must be typeof string.'); + throw TypeError('The "propertyKey" argument must be typeof string.'); } if (typeof namespace !== 'string') { - throw TypeError('"namespace" argument must be typeof string.'); + throw TypeError('The "namespace" argument must be typeof string.'); } this.#observers.forEach(ob => ob.handleUpdatedProperty(this, propertyKey, oldPropertyValue, namespace)); if (this.#graph instanceof FeatureGraph) { @@ -473,7 +471,7 @@ class Feature { handleUpdatedCoordinates(observable, oldCoordinates) { // - The function signature above is part of the ObserverInterface API. The implementation below is not if (!(oldCoordinates instanceof Array)) { - throw TypeError('"oldCoordinates" argument must be an array.'); + throw TypeError('The "oldCoordinates" argument must be an array.'); } const observableName = observable.getProperty('name'); const thisName = this.getProperty('name'); @@ -511,10 +509,10 @@ class Feature { handleUpdatedProperty(observable, propertyKey, oldPropertyValue, namespace='treeProps') { // - The function signature above is part of the ObserverInterface API. The implementation below is not if (typeof propertyKey !== 'string') { - throw TypeError('"propertyKey" argument must be a string.'); + throw TypeError('The "propertyKey" argument must be a string.'); } if (typeof namespace !== 'string') { - throw TypeError('"namespace" argument must be a string.'); + throw TypeError('The "namespace" argument must be a string.'); } if (propertyKey === 'name') { ['from', 'to', 'parent'].forEach(k => { @@ -601,31 +599,6 @@ class Feature { isPolygon() { return this.#feature.geometry.type === 'Polygon' && !this.isConfigurationObject(); } - - /** - * - Reset this Feature's coordinates and properties to how they were when the page was loaded - * @returns {undefined} - */ - resetState() { - this.setCoordinates(structuredClone(this.#originalFeature.geometry.coordinates)); - for (const [key, val] of Object.entries(this.#feature.properties)) { - if (!this.#originalFeature.properties.hasOwnProperty(key)) { - this.deleteProperty(key, 'meta'); - } else { - this.setProperty(key, this.#originalFeature.properties[key], 'meta'); - } - if (key === 'treeProps') { - for (const [tKey, tVal] of Object.entries(this.#feature.properties.treeProps)) { - if (!this.#originalFeature.properties.treeProps.hasOwnProperty(tKey)) { - this.deleteProperty(tKey); - } else { - this.setProperty(tKey, this.#originalFeature.properties.treeProps[tKey]); - } - } - } - // - Modal features can't be edited by the user, so I don't need to deal with formProps or urlProps - } - } } class UnsupportedOperationError extends Error { diff --git a/omf/static/geoJsonMap/v3/featureController.js b/omf/static/geoJsonMap/v3/featureController.js index ae37674ce..710544c8a 100644 --- a/omf/static/geoJsonMap/v3/featureController.js +++ b/omf/static/geoJsonMap/v3/featureController.js @@ -13,7 +13,7 @@ class FeatureController { // implements ControllerInterface */ constructor(observableGraph) { if (!(observableGraph instanceof FeatureGraph)) { - throw TypeError('"observableGraph" argument must be instanceof FeatureGraph.') + throw TypeError('The "observableGraph" argument must be instanceof FeatureGraph.') } this.observableGraph = observableGraph; } @@ -56,13 +56,13 @@ class FeatureController { // implements ControllerInterface */ deleteProperty(observables, propertyKey, namespace='treeProps') { if (!(observables instanceof Array)) { - throw TypeError('"observables" argument must be instanceof Array.'); + throw TypeError('The "observables" argument must be instanceof Array.'); } if (typeof propertyKey !== 'string') { - throw TypeError('"propertyKey" argument must be a string.'); + throw TypeError('The "propertyKey" argument must be a string.'); } if (typeof namespace !== 'string') { - throw TypeError('"namespace" argument must be a string.'); + throw TypeError('The "namespace" argument must be a string.'); } observables.forEach(ob => { if (ob.hasProperty(propertyKey, namespace)) { @@ -80,7 +80,7 @@ class FeatureController { // implements ControllerInterface */ deleteObservables(observables) { if (!(observables instanceof Array)) { - throw TypeError('"observables" argument must be instanceof Array.'); + throw TypeError('The "observables" argument must be instanceof Array.'); } const observablesCopy = [...observables]; for (const ob of observablesCopy) { @@ -100,10 +100,10 @@ class FeatureController { // implements ControllerInterface */ setCoordinates(observables, coordinates) { if (!(observables instanceof Array)) { - throw TypeError('"observables" argument must be instanceof Array.'); + throw TypeError('The "observables" argument must be instanceof Array.'); } if (!(coordinates instanceof Array)) { - throw TypeError('"coordinates" argument must be instanceof Array.'); + throw TypeError('The "coordinates" argument must be instanceof Array.'); } observables.forEach(ob => { ob.setCoordinates(coordinates); @@ -125,13 +125,13 @@ class FeatureController { // implements ControllerInterface */ setProperty(observables, propertyKey, propertyValue, namespace='treeProps') { if (!(observables instanceof Array)) { - throw TypeError('"observables" argument must be instanceof Array.'); + throw TypeError('The "observables" argument must be instanceof Array.'); } if (typeof propertyKey !== 'string') { - throw TypeError('"propertyKey" argument must be a string.'); + throw TypeError('The "propertyKey" argument must be a string.'); } if (typeof namespace !== 'string') { - throw TypeError('"namespace" argument must be a string.'); + throw TypeError('The "namespace" argument must be a string.'); } observables.forEach(ob => { ob.setProperty(propertyKey, propertyValue, namespace); @@ -180,13 +180,13 @@ class FeatureController { // implements ControllerInterface throw TypeError('"observable" argument must be instanceof Feature.'); } if (!(modal instanceof Modal)) { - throw TypeError('"modal" argument must be instanceof Modal.'); + throw TypeError('The "modal" argument must be instanceof Modal.'); } if (!(submitButton instanceof HTMLButtonElement) && !(submitButton === null)) { - throw TypeError('"submitButton" argument must be instanceof HTMLButtonElement or null'); + throw TypeError('The "submitButton" argument must be instanceof HTMLButtonElement or null'); } if (typeof reload !== 'boolean') { - throw TypeError('"reload" argument must be a boolean.'); + throw TypeError('The "reload" argument must be a boolean.'); } if (submitButton !== null) { submitButton.disabled = true; diff --git a/omf/static/geoJsonMap/v3/featureEditModal.js b/omf/static/geoJsonMap/v3/featureEditModal.js index 52021c22e..8cfa3afdb 100644 --- a/omf/static/geoJsonMap/v3/featureEditModal.js +++ b/omf/static/geoJsonMap/v3/featureEditModal.js @@ -132,27 +132,32 @@ class FeatureEditModal { // implements ObserverInterface, ModalInterface */ refreshContent() { const tableState = {}; + // - Don't grab a row that was added with the "+" button, if it exists [...this.#modal.divElement.getElementsByTagName('tr')].filter(tr => tr.querySelector('span[data-property-key]') !== null).forEach(tr => { const span = tr.getElementsByTagName('span')[0]; let namespace = span.dataset.propertyNamespace; + // - latitude and longitude don't exist in any property namespace if (namespace === undefined) { namespace = null; } - let valueInput = tr.getElementsByTagName('input'); - if (valueInput.length === 0) { - valueInput = null; - } else { - valueInput = valueInput[0]; - } tableState[span.dataset.propertyKey] = { propertyNamespace: namespace, propertyTableRow: tr, - propertyValueInput: valueInput + // - This is either an input or a span + propertyValueElement: tr.children[2].children[0].children[0] } }); const tableKeys = Object.keys(tableState); const observablesState = this.#getKeyToValuesMapping(); - // - I don't need to iterate over the meta namespace because IDs are never added, removed, or changed + for (const [key, ary] of Object.entries(observablesState.meta)) { + const valueElement = tableState[key].propertyValueElement; + if (valueElement instanceof HTMLSpanElement) { + valueElement.textContent = ary.join(','); + } else { + // - Only the treeKey should be in the meta namespace and the treeKey only ever displays with a span + throw Error() + } + } for (const [key, ary] of Object.entries(observablesState.treeProps)) { // - First, compare the observables' state to the table state. If the observables' state has a property that is not in the table state, // add a row to the table @@ -175,29 +180,32 @@ class FeatureEditModal { // implements ObserverInterface, ModalInterface keySpan.dataset.propertyNamespace = 'treeProps'; this.#modal.insertTBodyRow([this.#getDeletePropertyButton(key), keySpan, this.#getValueTextInput(key, ary)], 'beforeEnd'); } else { - const valueInput = tableState[key].propertyValueInput; - if (valueInput !== null) { - valueInput.replaceWith(this.#getValueTextInput(key, ary)); + // - If the table has the key, update the display of the values for that key + const valueElement = tableState[key].propertyValueElement; + if (valueElement instanceof HTMLInputElement) { + valueElement.replaceWith(this.#getValueTextInput(key, ary)); + } else if (valueElement instanceof HTMLSpanElement) { + valueElement.textContent = ary.join(', '); } } } for (const [key, ary] of Object.entries(observablesState.coordinates)) { // - Don't add latitude or longitude rows to tables that didn't already have those rows, just update the existing inputs if (tableKeys.includes(key)) { - const valueInput = tableState[key].propertyValueInput; - if (valueInput !== null) { - valueInput.replaceWith(this.#getValueTextInput(key, ary)); + const valueElement = tableState[key].propertyValueElement; + if (valueElement instanceof HTMLInputElement) { + valueElement.replaceWith(this.#getValueTextInput(key, ary)); } } } - const observablesKeys = []; + // - Now compare the table's state to the observables' state. If the table's state has a property that is not in the observables' state, + // remove the row from the table + const observablesKeysFromAllNamespaces = []; for (const obj of Object.values(observablesState)) { - observablesKeys.push(...Object.keys(obj)) + observablesKeysFromAllNamespaces.push(...Object.keys(obj)) } for (const [key, obj] of Object.entries(tableState)) { - // - Second, compare the observables' state to the table state. If the observables' state lacks a property that is in the table state, - // remove the corresponding row from the table - if (!observablesKeys.includes(key)) { + if (!observablesKeysFromAllNamespaces.includes(key)) { obj.propertyTableRow.remove(); } } @@ -231,7 +239,6 @@ class FeatureEditModal { // implements ObserverInterface, ModalInterface if (ary.length === 1) { modal.insertTHeadRow([null, keySpan, ary[0].toString()], 'prepend'); } else { - //modal.insertTHeadRow([null, keySpan, ''], 'prepend'); modal.insertTHeadRow([null, keySpan, ary.join(',')], 'prepend'); } } diff --git a/omf/static/geoJsonMap/v3/featureGraph.js b/omf/static/geoJsonMap/v3/featureGraph.js index 5811080c9..1d4c78f01 100644 --- a/omf/static/geoJsonMap/v3/featureGraph.js +++ b/omf/static/geoJsonMap/v3/featureGraph.js @@ -51,7 +51,7 @@ class FeatureGraph { */ getObservable(key) { if (typeof key !== 'string') { - throw TypeError('"key" argument must be a string.'); + throw TypeError('The "key" argument must be a string.'); } if (!this.#keyToFeature.hasOwnProperty(key)) { throw new FeatureNotFoundError(key); @@ -127,7 +127,7 @@ class FeatureGraph { */ notifyObserversOfNewObservable(observable) { if (!(observable instanceof Feature)) { - throw TypeError('"observable" argument must be an instance of my Feature class.'); + throw TypeError('The "observable" argument must be an instance of my Feature class.'); } this.#observers.forEach(ob => ob.handleNewObservable(observable)); } @@ -271,7 +271,7 @@ class FeatureGraph { handleUpdatedCoordinates(observable, oldCoordinates) { // - The function signature above is part of the ObserverInterface API. The implementation below is not if (!(oldCoordinates instanceof Array)) { - throw TypeError('"oldCoordinates" argument must be an array.') + throw TypeError('The "oldCoordinates" argument must be an array.') } const observableKey = observable.getProperty('treeKey', 'meta'); // - Mark this node as visited to prevent infinite recursion @@ -308,10 +308,10 @@ class FeatureGraph { handleUpdatedProperty(observable, propertyKey, oldPropertyValue, namespace='treeProps') { // - The function signature above is part of the ObserverInterface API. The implementation below is not if (typeof propertyKey !== 'string') { - throw TypeError('"propertyKey" argument must be a string.'); + throw TypeError('The "propertyKey" argument must be a string.'); } if (typeof namespace !== 'string') { - throw TypeError('"namespace" argument must be a string.'); + throw TypeError('The "namespace" argument must be a string.'); } const observableKey = observable.getProperty('treeKey', 'meta'); // - I don't think I need to mark nodes as visited since there's no recursion @@ -389,10 +389,10 @@ class FeatureGraph { */ getKey(name, featureKey) { if (typeof name !== 'string') { - throw TypeError('"name" argument must be typeof string.'); + throw TypeError('The "name" argument must be typeof string.'); } if (typeof featureKey !== 'string') { - throw TypeError('"feature" argument must be typeof string.'); + throw TypeError('The "feature" argument must be typeof string.'); } const feature = this.getObservable(featureKey); const keys = this.#nameToKey[name]; @@ -437,7 +437,7 @@ class FeatureGraph { */ getKeyForComponent(name) { if (typeof name !== 'string') { - throw TypeError('"name" argument must be a string.'); + throw TypeError('The "name" argument must be a string.'); } const keys = this.#nameToKey[name]; if (keys === undefined) { @@ -579,7 +579,7 @@ class FeatureGraph { */ insertObservable(observable) { if (!(observable instanceof Feature)) { - throw TypeError('"observable" argument must be an instance of Feature class.'); + throw TypeError('The "observable" argument must be an instance of Feature class.'); } this.#insertObservableIntoKeyToFeature(observable); const observableKey = observable.getProperty('treeKey', 'meta'); @@ -728,10 +728,10 @@ class FeatureGraph { */ #removeObservableFromNameToKey(observable, oldName) { if (!(observable instanceof Feature)) { - throw TypeError('"observable" argument must be an instanceof Feature'); + throw TypeError('The "observable" argument must be an instanceof Feature'); } if (typeof oldName !== 'string') { - throw TypeError('"oldName" argument must be a string.'); + throw TypeError('The "oldName" argument must be a string.'); } const key = observable.getProperty('treeKey', 'meta'); // - The "name" property cannot be deleted from ObservableInterface instances, so I shouldn't have to deal with a situation where an diff --git a/omf/static/geoJsonMap/v3/geojsonModal.js b/omf/static/geoJsonMap/v3/geojsonModal.js index 3e1e39b8d..3111d544f 100644 --- a/omf/static/geoJsonMap/v3/geojsonModal.js +++ b/omf/static/geoJsonMap/v3/geojsonModal.js @@ -19,10 +19,10 @@ class GeojsonModal { // implements ModalInterface, ObserverInterface */ constructor(observables, controller) { if (!(observables instanceof Array)) { - throw TypeError('"observables" argumnet must be an Array.'); + throw TypeError('The "observables" argumnet must be instanceof Array.'); } if (!(controller instanceof FeatureController)) { - throw Error('"controller" argument must be instanceof FeatureController.'); + throw Error('The "controller" argument must be instanceof FeatureController.'); } this.#controller = controller; this.#filenameToLayerGroup = {}; @@ -47,7 +47,7 @@ class GeojsonModal { // implements ModalInterface, ObserverInterface handleDeletedObservable(observable) { // - The function signature above is part of the ObserverInterface API. The implementation below is not if (!(observable instanceof Feature)) { - throw TypeError('"observable" argument must be instanceof Feature.'); + throw TypeError('The "observable" argument must be instanceof Feature.'); } if (!this.#removed) { observable.removeObserver(this); @@ -84,10 +84,10 @@ class GeojsonModal { // implements ModalInterface, ObserverInterface handleUpdatedCoordinates(observable, oldCoordinates) { // - The function signature above is part of the ObserverInterface API. The implementation below is not if (!(observable instanceof Feature)) { - throw TypeError('"observable" argument must be instanceof Feature.'); + throw TypeError('The "observable" argument must be instanceof Feature.'); } if (!(oldCoordinates instanceof Array)) { - throw TypeError('"oldCoordinates" argument must be an array.'); + throw TypeError('The "oldCoordinates" argument must be an array.'); } this.#updateLayerGroups(); this.refreshContent(); @@ -105,13 +105,13 @@ class GeojsonModal { // implements ModalInterface, ObserverInterface handleUpdatedProperty(observable, propertyKey, oldPropertyValue, namespace='treeProps') { // - The function signature above is part of the ObserverInterface API. The implementation below is not if (!(observable instanceof Feature)) { - throw TypeError('"observable" argument must be instanceof Feature.'); + throw TypeError('The "observable" argument must be instanceof Feature.'); } if (typeof propertyKey !== 'string') { - throw TypeError('"propertyKey" argument must be a string.'); + throw TypeError('The "propertyKey" argument must be a string.'); } if (typeof namespace !== 'string') { - throw TypeError('"namespace" argument must be a string.'); + throw TypeError('The "namespace" argument must be a string.'); } this.#updateLayerGroups(); this.refreshContent(); diff --git a/omf/static/geoJsonMap/v3/leafletLayer.js b/omf/static/geoJsonMap/v3/leafletLayer.js index ee6e7f084..89639b7c5 100644 --- a/omf/static/geoJsonMap/v3/leafletLayer.js +++ b/omf/static/geoJsonMap/v3/leafletLayer.js @@ -3,23 +3,26 @@ import { Feature } from './feature.js'; import { FeatureController } from "./featureController.js"; import { FeatureEditModal } from './featureEditModal.js'; import { TestModal } from './extensions/testModal.js'; +import { ClusterControlClass } from './clusterControl.js'; /** * - Each LeafletLayer instance is a view in the MVC pattern. Each observes an ObservableInterface instance, which is part of the model in the MVC * pattern. An ObservableInterface instance does NOT observe a LeafletLayer instance. Instead, a LeafletLayer instance uses the controller to pass * its own changes to the underlying ObservableInterface instance */ -class LeafletLayer { // implements ObserverInterface - #controller; // ControllerInterface instance - #layer; // - Leaflet layer - #observable; // - ObservableInterface instance +class LeafletLayer { // implements ObserverInterface + #controller; // ControllerInterface instance + #layer; // - Leaflet layer + #observable; // - ObservableInterface instance #modal; static trackCursor = null; // - HACK: the currently active trackCusor function. This is needed to get rid of sticky markers when the map gets a mouseup event - static map; - static control; + static map = null; + static control = null; static nodeLayers = L.featureGroup(); static lineLayers = L.featureGroup(); static parentChildLineLayers = L.featureGroup(); + static nodeClusterLayers = L.markerClusterGroup(); + static clusterControl = null; /** * @param {Feature} observable - an ObservableInterface instance @@ -28,10 +31,10 @@ class LeafletLayer { // implements ObserverInterface */ constructor(observable, controller) { if (!(observable instanceof Feature)) { - throw TypeError('"observable" argument must be instance of Feature.'); + throw TypeError('The "observable" argument must be instance of Feature.'); } if (!(controller instanceof FeatureController)) { - throw Error('"controller" argument must be an instance of FeatureController.'); + throw Error('The "controller" argument must be an instance of FeatureController.'); } this.#controller = controller; this.#observable = observable; @@ -83,6 +86,9 @@ class LeafletLayer { // implements ObserverInterface * @returns {undefined} */ handleDeletedObservable(observable) { + if (!(observable instanceof Feature)) { + throw TypeError('The "observable" argument must be instance of Feature.'); + } // - The function signature above is part of the ObserverInterface API. The implementation below is not observable.removeObserver(this); const layer = Object.values(this.#layer._layers)[0]; @@ -91,6 +97,7 @@ class LeafletLayer { // implements ObserverInterface // - Need to explicitly remove the underlying layer from its LayerGroup if (observable.isNode()) { LeafletLayer.nodeLayers.removeLayer(this.#layer); + LeafletLayer.nodeClusterLayers.removeLayer(this.#layer); } else if (observable.isLine()) { if (observable.isParentChildLine()) { LeafletLayer.parentChildLineLayers.removeLayer(this.#layer); @@ -122,8 +129,11 @@ class LeafletLayer { // implements ObserverInterface */ handleUpdatedCoordinates(observable, oldCoordinates) { // - The function signature above is part of the ObserverInterface API. The implementation below is not + if (!(observable instanceof Feature)) { + throw TypeError('The "observable" argument must be instance of Feature.'); + } if (!(oldCoordinates instanceof Array)) { - throw TypeError('"oldCoordinates" argument must be an array.') + throw TypeError('The "oldCoordinates" argument must be an array.') } const coordinates = observable.getCoordinates(); if (observable.isNode()) { @@ -146,12 +156,16 @@ class LeafletLayer { // implements ObserverInterface */ handleUpdatedProperty(observable, propertyKey, oldPropertyValue, namespace='treeProps') { // - The function signature above is part of the ObserverInterface API. The implementation below is not + if (!(observable instanceof Feature)) { + throw TypeError('The "observable" argument must be instance of Feature.'); + } if (typeof propertyKey !== 'string') { throw TypeError('"propertyKey" argument must be a string.'); } if (typeof namespace !== 'string') { throw TypeError('"namespace" argument must be a string.'); } + // - Currently, we don't need to do anything like change the color of a node in response to a property change, but we could } // ******************** @@ -192,7 +206,13 @@ class LeafletLayer { // implements ObserverInterface } const ll = new LeafletLayer(observable, controller); if (observable.isNode()) { - LeafletLayer.nodeLayers.addLayer(ll.getLayer()); + // - If clustering is active and a component is added, then the layer of the component should go into the nodeClusterLayers group (very + // rare case but it's good to cover it) + if (LeafletLayer.clusterControl._on) { + LeafletLayer.nodeClusterLayers.addLayer(ll.getLayer()); + } else { + LeafletLayer.nodeLayers.addLayer(ll.getLayer()); + } } else if (observable.isLine()) { if (observable.isParentChildLine()) { LeafletLayer.parentChildLineLayers.addLayer(ll.getLayer()); @@ -219,6 +239,39 @@ class LeafletLayer { // implements ObserverInterface return this.#layer; } + /** + * - Iterate through all observables and put every visible overvable back into its proper layer group + * - This has to be static because it shouldn't need to be called off of any particular instance + * @returns {undefined} + */ + static resetLayerGroups(controller) { + if (!(controller instanceof FeatureController)) { + throw TypeError('The "controller" argument must be instanceof FeatureController.'); + } + for (const observable of controller.observableGraph.getObservables()) { + if (observable.isNode() && !observable.isConfigurationObject()) { + const ll = observable.getObservers().filter(observer => observer instanceof LeafletLayer)[0]; + if (LeafletLayer.clusterControl._on) { + LeafletLayer.nodeClusterLayers.addLayer(ll.getLayer()); + } else { + LeafletLayer.nodeLayers.addLayer(ll.getLayer()); + } + } else if (observable.isLine()) { + const ll = observable.getObservers().filter(observer => observer instanceof LeafletLayer)[0]; + if (observable.isParentChildLine()) { + LeafletLayer.parentChildLineLayers.addLayer(ll.getLayer()); + } else { + LeafletLayer.lineLayers.addLayer(ll.getLayer()); + } + } + } + // - Force redraw + for (const layer of [LeafletLayer.parentChildLineLayers, LeafletLayer.lineLayers, LeafletLayer.nodeLayers, LeafletLayer.nodeClusterLayers]) { + LeafletLayer.map.removeLayer(layer); + LeafletLayer.map.addLayer(layer); + } + } + // ********************* // ** Private methods ** // ********************* diff --git a/omf/static/geoJsonMap/v3/lib/MarkerCluster.Default.css b/omf/static/geoJsonMap/v3/lib/MarkerCluster.Default.css deleted file mode 100644 index bbc8c9fb0..000000000 --- a/omf/static/geoJsonMap/v3/lib/MarkerCluster.Default.css +++ /dev/null @@ -1,60 +0,0 @@ -.marker-cluster-small { - background-color: rgba(181, 226, 140, 0.6); - } -.marker-cluster-small div { - background-color: rgba(110, 204, 57, 0.6); - } - -.marker-cluster-medium { - background-color: rgba(241, 211, 87, 0.6); - } -.marker-cluster-medium div { - background-color: rgba(240, 194, 12, 0.6); - } - -.marker-cluster-large { - background-color: rgba(253, 156, 115, 0.6); - } -.marker-cluster-large div { - background-color: rgba(241, 128, 23, 0.6); - } - - /* IE 6-8 fallback colors */ -.leaflet-oldie .marker-cluster-small { - background-color: rgb(181, 226, 140); - } -.leaflet-oldie .marker-cluster-small div { - background-color: rgb(110, 204, 57); - } - -.leaflet-oldie .marker-cluster-medium { - background-color: rgb(241, 211, 87); - } -.leaflet-oldie .marker-cluster-medium div { - background-color: rgb(240, 194, 12); - } - -.leaflet-oldie .marker-cluster-large { - background-color: rgb(253, 156, 115); - } -.leaflet-oldie .marker-cluster-large div { - background-color: rgb(241, 128, 23); -} - -.marker-cluster { - background-clip: padding-box; - border-radius: 20px; - } -.marker-cluster div { - width: 30px; - height: 30px; - margin-left: 5px; - margin-top: 5px; - - text-align: center; - border-radius: 15px; - font: 12px "Helvetica Neue", Arial, Helvetica, sans-serif; - } -.marker-cluster span { - line-height: 30px; - } \ No newline at end of file diff --git a/omf/static/geoJsonMap/v3/lib/MarkerCluster.css b/omf/static/geoJsonMap/v3/lib/MarkerCluster.css deleted file mode 100644 index c60d71b7a..000000000 --- a/omf/static/geoJsonMap/v3/lib/MarkerCluster.css +++ /dev/null @@ -1,14 +0,0 @@ -.leaflet-cluster-anim .leaflet-marker-icon, .leaflet-cluster-anim .leaflet-marker-shadow { - -webkit-transition: -webkit-transform 0.3s ease-out, opacity 0.3s ease-in; - -moz-transition: -moz-transform 0.3s ease-out, opacity 0.3s ease-in; - -o-transition: -o-transform 0.3s ease-out, opacity 0.3s ease-in; - transition: transform 0.3s ease-out, opacity 0.3s ease-in; -} - -.leaflet-cluster-spider-leg { - /* stroke-dashoffset (duration and function) should match with leaflet-marker-icon transform in order to track it exactly */ - -webkit-transition: -webkit-stroke-dashoffset 0.3s ease-out, -webkit-stroke-opacity 0.3s ease-in; - -moz-transition: -moz-stroke-dashoffset 0.3s ease-out, -moz-stroke-opacity 0.3s ease-in; - -o-transition: -o-stroke-dashoffset 0.3s ease-out, -o-stroke-opacity 0.3s ease-in; - transition: stroke-dashoffset 0.3s ease-out, stroke-opacity 0.3s ease-in; -} diff --git a/omf/static/geoJsonMap/v3/lib/leaflet.markercluster.js b/omf/static/geoJsonMap/v3/lib/leaflet.markercluster.js deleted file mode 100644 index 67c52dcd6..000000000 --- a/omf/static/geoJsonMap/v3/lib/leaflet.markercluster.js +++ /dev/null @@ -1,3 +0,0 @@ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e.Leaflet=e.Leaflet||{},e.Leaflet.markercluster=e.Leaflet.markercluster||{}))}(this,function(e){"use strict";var t=L.MarkerClusterGroup=L.FeatureGroup.extend({options:{maxClusterRadius:80,iconCreateFunction:null,clusterPane:L.Marker.prototype.options.pane,spiderfyOnMaxZoom:!0,showCoverageOnHover:!0,zoomToBoundsOnClick:!0,singleMarkerMode:!1,disableClusteringAtZoom:null,removeOutsideVisibleBounds:!0,animate:!0,animateAddingMarkers:!1,spiderfyDistanceMultiplier:1,spiderLegPolylineOptions:{weight:1.5,color:"#222",opacity:.5},chunkedLoading:!1,chunkInterval:200,chunkDelay:50,chunkProgress:null,polygonOptions:{}},initialize:function(e){L.Util.setOptions(this,e),this.options.iconCreateFunction||(this.options.iconCreateFunction=this._defaultIconCreateFunction),this._featureGroup=L.featureGroup(),this._featureGroup.addEventParent(this),this._nonPointGroup=L.featureGroup(),this._nonPointGroup.addEventParent(this),this._inZoomAnimation=0,this._needsClustering=[],this._needsRemoving=[],this._currentShownBounds=null,this._queue=[],this._childMarkerEventHandlers={dragstart:this._childMarkerDragStart,move:this._childMarkerMoved,dragend:this._childMarkerDragEnd};var t=L.DomUtil.TRANSITION&&this.options.animate;L.extend(this,t?this._withAnimation:this._noAnimation),this._markerCluster=t?L.MarkerCluster:L.MarkerClusterNonAnimated},addLayer:function(e){if(e instanceof L.LayerGroup)return this.addLayers([e]);if(!e.getLatLng)return this._nonPointGroup.addLayer(e),this.fire("layeradd",{layer:e}),this;if(!this._map)return this._needsClustering.push(e),this.fire("layeradd",{layer:e}),this;if(this.hasLayer(e))return this;this._unspiderfy&&this._unspiderfy(),this._addLayer(e,this._maxZoom),this.fire("layeradd",{layer:e}),this._topClusterLevel._recalculateBounds(),this._refreshClustersIcons();var t=e,i=this._zoom;if(e.__parent)for(;t.__parent._zoom>=i;)t=t.__parent;return this._currentShownBounds.contains(t.getLatLng())&&(this.options.animateAddingMarkers?this._animationAddLayer(e,t):this._animationAddLayerNonAnimated(e,t)),this},removeLayer:function(e){return e instanceof L.LayerGroup?this.removeLayers([e]):e.getLatLng?this._map?e.__parent?(this._unspiderfy&&(this._unspiderfy(),this._unspiderfyLayer(e)),this._removeLayer(e,!0),this.fire("layerremove",{layer:e}),this._topClusterLevel._recalculateBounds(),this._refreshClustersIcons(),e.off(this._childMarkerEventHandlers,this),this._featureGroup.hasLayer(e)&&(this._featureGroup.removeLayer(e),e.clusterShow&&e.clusterShow()),this):this:(!this._arraySplice(this._needsClustering,e)&&this.hasLayer(e)&&this._needsRemoving.push({layer:e,latlng:e._latlng}),this.fire("layerremove",{layer:e}),this):(this._nonPointGroup.removeLayer(e),this.fire("layerremove",{layer:e}),this)},addLayers:function(e,t){if(!L.Util.isArray(e))return this.addLayer(e);var i,n=this._featureGroup,r=this._nonPointGroup,s=this.options.chunkedLoading,o=this.options.chunkInterval,a=this.options.chunkProgress,h=e.length,l=0,u=!0;if(this._map){var _=(new Date).getTime(),d=L.bind(function(){for(var c=(new Date).getTime();h>l;l++){if(s&&0===l%200){var p=(new Date).getTime()-c;if(p>o)break}if(i=e[l],i instanceof L.LayerGroup)u&&(e=e.slice(),u=!1),this._extractNonGroupLayers(i,e),h=e.length;else if(i.getLatLng){if(!this.hasLayer(i)&&(this._addLayer(i,this._maxZoom),t||this.fire("layeradd",{layer:i}),i.__parent&&2===i.__parent.getChildCount())){var f=i.__parent.getAllChildMarkers(),m=f[0]===i?f[1]:f[0];n.removeLayer(m)}}else r.addLayer(i),t||this.fire("layeradd",{layer:i})}a&&a(l,h,(new Date).getTime()-_),l===h?(this._topClusterLevel._recalculateBounds(),this._refreshClustersIcons(),this._topClusterLevel._recursivelyAddChildrenToMap(null,this._zoom,this._currentShownBounds)):setTimeout(d,this.options.chunkDelay)},this);d()}else for(var c=this._needsClustering;h>l;l++)i=e[l],i instanceof L.LayerGroup?(u&&(e=e.slice(),u=!1),this._extractNonGroupLayers(i,e),h=e.length):i.getLatLng?this.hasLayer(i)||c.push(i):r.addLayer(i);return this},removeLayers:function(e){var t,i,n=e.length,r=this._featureGroup,s=this._nonPointGroup,o=!0;if(!this._map){for(t=0;n>t;t++)i=e[t],i instanceof L.LayerGroup?(o&&(e=e.slice(),o=!1),this._extractNonGroupLayers(i,e),n=e.length):(this._arraySplice(this._needsClustering,i),s.removeLayer(i),this.hasLayer(i)&&this._needsRemoving.push({layer:i,latlng:i._latlng}),this.fire("layerremove",{layer:i}));return this}if(this._unspiderfy){this._unspiderfy();var a=e.slice(),h=n;for(t=0;h>t;t++)i=a[t],i instanceof L.LayerGroup?(this._extractNonGroupLayers(i,a),h=a.length):this._unspiderfyLayer(i)}for(t=0;n>t;t++)i=e[t],i instanceof L.LayerGroup?(o&&(e=e.slice(),o=!1),this._extractNonGroupLayers(i,e),n=e.length):i.__parent?(this._removeLayer(i,!0,!0),this.fire("layerremove",{layer:i}),r.hasLayer(i)&&(r.removeLayer(i),i.clusterShow&&i.clusterShow())):(s.removeLayer(i),this.fire("layerremove",{layer:i}));return this._topClusterLevel._recalculateBounds(),this._refreshClustersIcons(),this._topClusterLevel._recursivelyAddChildrenToMap(null,this._zoom,this._currentShownBounds),this},clearLayers:function(){return this._map||(this._needsClustering=[],this._needsRemoving=[],delete this._gridClusters,delete this._gridUnclustered),this._noanimationUnspiderfy&&this._noanimationUnspiderfy(),this._featureGroup.clearLayers(),this._nonPointGroup.clearLayers(),this.eachLayer(function(e){e.off(this._childMarkerEventHandlers,this),delete e.__parent},this),this._map&&this._generateInitialClusters(),this},getBounds:function(){var e=new L.LatLngBounds;this._topClusterLevel&&e.extend(this._topClusterLevel._bounds);for(var t=this._needsClustering.length-1;t>=0;t--)e.extend(this._needsClustering[t].getLatLng());return e.extend(this._nonPointGroup.getBounds()),e},eachLayer:function(e,t){var i,n,r,s=this._needsClustering.slice(),o=this._needsRemoving;for(this._topClusterLevel&&this._topClusterLevel.getAllChildMarkers(s),n=s.length-1;n>=0;n--){for(i=!0,r=o.length-1;r>=0;r--)if(o[r].layer===s[n]){i=!1;break}i&&e.call(t,s[n])}this._nonPointGroup.eachLayer(e,t)},getLayers:function(){var e=[];return this.eachLayer(function(t){e.push(t)}),e},getLayer:function(e){var t=null;return e=parseInt(e,10),this.eachLayer(function(i){L.stamp(i)===e&&(t=i)}),t},hasLayer:function(e){if(!e)return!1;var t,i=this._needsClustering;for(t=i.length-1;t>=0;t--)if(i[t]===e)return!0;for(i=this._needsRemoving,t=i.length-1;t>=0;t--)if(i[t].layer===e)return!1;return!(!e.__parent||e.__parent._group!==this)||this._nonPointGroup.hasLayer(e)},zoomToShowLayer:function(e,t){"function"!=typeof t&&(t=function(){});var i=function(){!e._icon&&!e.__parent._icon||this._inZoomAnimation||(this._map.off("moveend",i,this),this.off("animationend",i,this),e._icon?t():e.__parent._icon&&(this.once("spiderfied",t,this),e.__parent.spiderfy()))};e._icon&&this._map.getBounds().contains(e.getLatLng())?t():e.__parent._zoomt;t++)n=this._needsRemoving[t],n.newlatlng=n.layer._latlng,n.layer._latlng=n.latlng;for(t=0,i=this._needsRemoving.length;i>t;t++)n=this._needsRemoving[t],this._removeLayer(n.layer,!0),n.layer._latlng=n.newlatlng;this._needsRemoving=[],this._zoom=Math.round(this._map._zoom),this._currentShownBounds=this._getExpandedVisibleBounds(),this._map.on("zoomend",this._zoomEnd,this),this._map.on("moveend",this._moveEnd,this),this._spiderfierOnAdd&&this._spiderfierOnAdd(),this._bindEvents(),i=this._needsClustering,this._needsClustering=[],this.addLayers(i,!0)},onRemove:function(e){e.off("zoomend",this._zoomEnd,this),e.off("moveend",this._moveEnd,this),this._unbindEvents(),this._map._mapPane.className=this._map._mapPane.className.replace(" leaflet-cluster-anim",""),this._spiderfierOnRemove&&this._spiderfierOnRemove(),delete this._maxLat,this._hideCoverage(),this._featureGroup.remove(),this._nonPointGroup.remove(),this._featureGroup.clearLayers(),this._map=null},getVisibleParent:function(e){for(var t=e;t&&!t._icon;)t=t.__parent;return t||null},_arraySplice:function(e,t){for(var i=e.length-1;i>=0;i--)if(e[i]===t)return e.splice(i,1),!0},_removeFromGridUnclustered:function(e,t){for(var i=this._map,n=this._gridUnclustered,r=Math.floor(this._map.getMinZoom());t>=r&&n[t].removeObject(e,i.project(e.getLatLng(),t));t--);},_childMarkerDragStart:function(e){e.target.__dragStart=e.target._latlng},_childMarkerMoved:function(e){if(!this._ignoreMove&&!e.target.__dragStart){var t=e.target._popup&&e.target._popup.isOpen();this._moveChild(e.target,e.oldLatLng,e.latlng),t&&e.target.openPopup()}},_moveChild:function(e,t,i){e._latlng=t,this.removeLayer(e),e._latlng=i,this.addLayer(e)},_childMarkerDragEnd:function(e){var t=e.target.__dragStart;delete e.target.__dragStart,t&&this._moveChild(e.target,t,e.target._latlng)},_removeLayer:function(e,t,i){var n=this._gridClusters,r=this._gridUnclustered,s=this._featureGroup,o=this._map,a=Math.floor(this._map.getMinZoom());t&&this._removeFromGridUnclustered(e,this._maxZoom);var h,l=e.__parent,u=l._markers;for(this._arraySplice(u,e);l&&(l._childCount--,l._boundsNeedUpdate=!0,!(l._zoomt?"small":100>t?"medium":"large",new L.DivIcon({html:"
"+t+"
",className:"marker-cluster"+i,iconSize:new L.Point(40,40)})},_bindEvents:function(){var e=this._map,t=this.options.spiderfyOnMaxZoom,i=this.options.showCoverageOnHover,n=this.options.zoomToBoundsOnClick;(t||n)&&this.on("clusterclick",this._zoomOrSpiderfy,this),i&&(this.on("clustermouseover",this._showCoverage,this),this.on("clustermouseout",this._hideCoverage,this),e.on("zoomend",this._hideCoverage,this))},_zoomOrSpiderfy:function(e){for(var t=e.layer,i=t;1===i._childClusters.length;)i=i._childClusters[0];i._zoom===this._maxZoom&&i._childCount===t._childCount&&this.options.spiderfyOnMaxZoom?t.spiderfy():this.options.zoomToBoundsOnClick&&t.zoomToBounds(),e.originalEvent&&13===e.originalEvent.keyCode&&this._map._container.focus()},_showCoverage:function(e){var t=this._map;this._inZoomAnimation||(this._shownPolygon&&t.removeLayer(this._shownPolygon),e.layer.getChildCount()>2&&e.layer!==this._spiderfied&&(this._shownPolygon=new L.Polygon(e.layer.getConvexHull(),this.options.polygonOptions),t.addLayer(this._shownPolygon)))},_hideCoverage:function(){this._shownPolygon&&(this._map.removeLayer(this._shownPolygon),this._shownPolygon=null)},_unbindEvents:function(){var e=this.options.spiderfyOnMaxZoom,t=this.options.showCoverageOnHover,i=this.options.zoomToBoundsOnClick,n=this._map;(e||i)&&this.off("clusterclick",this._zoomOrSpiderfy,this),t&&(this.off("clustermouseover",this._showCoverage,this),this.off("clustermouseout",this._hideCoverage,this),n.off("zoomend",this._hideCoverage,this))},_zoomEnd:function(){this._map&&(this._mergeSplitClusters(),this._zoom=Math.round(this._map._zoom),this._currentShownBounds=this._getExpandedVisibleBounds())},_moveEnd:function(){if(!this._inZoomAnimation){var e=this._getExpandedVisibleBounds();this._topClusterLevel._recursivelyRemoveChildrenFromMap(this._currentShownBounds,Math.floor(this._map.getMinZoom()),this._zoom,e),this._topClusterLevel._recursivelyAddChildrenToMap(null,Math.round(this._map._zoom),e),this._currentShownBounds=e}},_generateInitialClusters:function(){var e=Math.ceil(this._map.getMaxZoom()),t=Math.floor(this._map.getMinZoom()),i=this.options.maxClusterRadius,n=i;"function"!=typeof i&&(n=function(){return i}),null!==this.options.disableClusteringAtZoom&&(e=this.options.disableClusteringAtZoom-1),this._maxZoom=e,this._gridClusters={},this._gridUnclustered={};for(var r=e;r>=t;r--)this._gridClusters[r]=new L.DistanceGrid(n(r)),this._gridUnclustered[r]=new L.DistanceGrid(n(r));this._topClusterLevel=new this._markerCluster(this,t-1)},_addLayer:function(e,t){var i,n,r=this._gridClusters,s=this._gridUnclustered,o=Math.floor(this._map.getMinZoom());for(this.options.singleMarkerMode&&this._overrideMarkerIcon(e),e.on(this._childMarkerEventHandlers,this);t>=o;t--){i=this._map.project(e.getLatLng(),t);var a=r[t].getNearObject(i);if(a)return a._addChild(e),e.__parent=a,void 0;if(a=s[t].getNearObject(i)){var h=a.__parent;h&&this._removeLayer(a,!1);var l=new this._markerCluster(this,t,a,e);r[t].addObject(l,this._map.project(l._cLatLng,t)),a.__parent=l,e.__parent=l;var u=l;for(n=t-1;n>h._zoom;n--)u=new this._markerCluster(this,n,u),r[n].addObject(u,this._map.project(a.getLatLng(),n));return h._addChild(u),this._removeFromGridUnclustered(a,t),void 0}s[t].addObject(e,i)}this._topClusterLevel._addChild(e),e.__parent=this._topClusterLevel},_refreshClustersIcons:function(){this._featureGroup.eachLayer(function(e){e instanceof L.MarkerCluster&&e._iconNeedsUpdate&&e._updateIcon()})},_enqueue:function(e){this._queue.push(e),this._queueTimeout||(this._queueTimeout=setTimeout(L.bind(this._processQueue,this),300))},_processQueue:function(){for(var e=0;ee?(this._animationStart(),this._animationZoomOut(this._zoom,e)):this._moveEnd()},_getExpandedVisibleBounds:function(){return this.options.removeOutsideVisibleBounds?L.Browser.mobile?this._checkBoundsMaxLat(this._map.getBounds()):this._checkBoundsMaxLat(this._map.getBounds().pad(1)):this._mapBoundsInfinite},_checkBoundsMaxLat:function(e){var t=this._maxLat;return void 0!==t&&(e.getNorth()>=t&&(e._northEast.lat=1/0),e.getSouth()<=-t&&(e._southWest.lat=-1/0)),e},_animationAddLayerNonAnimated:function(e,t){if(t===e)this._featureGroup.addLayer(e);else if(2===t._childCount){t._addToMap();var i=t.getAllChildMarkers();this._featureGroup.removeLayer(i[0]),this._featureGroup.removeLayer(i[1])}else t._updateIcon()},_extractNonGroupLayers:function(e,t){var i,n=e.getLayers(),r=0;for(t=t||[];r=0;i--)o=h[i],n.contains(o._latlng)||r.removeLayer(o)}),this._forceLayout(),this._topClusterLevel._recursivelyBecomeVisible(n,t),r.eachLayer(function(e){e instanceof L.MarkerCluster||!e._icon||e.clusterShow()}),this._topClusterLevel._recursively(n,e,t,function(e){e._recursivelyRestoreChildPositions(t)}),this._ignoreMove=!1,this._enqueue(function(){this._topClusterLevel._recursively(n,e,s,function(e){r.removeLayer(e),e.clusterShow()}),this._animationEnd()})},_animationZoomOut:function(e,t){this._animationZoomOutSingle(this._topClusterLevel,e-1,t),this._topClusterLevel._recursivelyAddChildrenToMap(null,t,this._getExpandedVisibleBounds()),this._topClusterLevel._recursivelyRemoveChildrenFromMap(this._currentShownBounds,Math.floor(this._map.getMinZoom()),e,this._getExpandedVisibleBounds())},_animationAddLayer:function(e,t){var i=this,n=this._featureGroup;n.addLayer(e),t!==e&&(t._childCount>2?(t._updateIcon(),this._forceLayout(),this._animationStart(),e._setPos(this._map.latLngToLayerPoint(t.getLatLng())),e.clusterHide(),this._enqueue(function(){n.removeLayer(e),e.clusterShow(),i._animationEnd()})):(this._forceLayout(),i._animationStart(),i._animationZoomOutSingle(t,this._map.getMaxZoom(),this._zoom)))}},_animationZoomOutSingle:function(e,t,i){var n=this._getExpandedVisibleBounds(),r=Math.floor(this._map.getMinZoom());e._recursivelyAnimateChildrenInAndAddSelfToMap(n,r,t+1,i);var s=this;this._forceLayout(),e._recursivelyBecomeVisible(n,i),this._enqueue(function(){if(1===e._childCount){var o=e._markers[0];this._ignoreMove=!0,o.setLatLng(o.getLatLng()),this._ignoreMove=!1,o.clusterShow&&o.clusterShow()}else e._recursively(n,i,r,function(e){e._recursivelyRemoveChildrenFromMap(n,r,t+1)});s._animationEnd()})},_animationEnd:function(){this._map&&(this._map._mapPane.className=this._map._mapPane.className.replace(" leaflet-cluster-anim","")),this._inZoomAnimation--,this.fire("animationend")},_forceLayout:function(){L.Util.falseFn(document.body.offsetWidth)}}),L.markerClusterGroup=function(e){return new L.MarkerClusterGroup(e)};var i=L.MarkerCluster=L.Marker.extend({options:L.Icon.prototype.options,initialize:function(e,t,i,n){L.Marker.prototype.initialize.call(this,i?i._cLatLng||i.getLatLng():new L.LatLng(0,0),{icon:this,pane:e.options.clusterPane}),this._group=e,this._zoom=t,this._markers=[],this._childClusters=[],this._childCount=0,this._iconNeedsUpdate=!0,this._boundsNeedUpdate=!0,this._bounds=new L.LatLngBounds,i&&this._addChild(i),n&&this._addChild(n)},getAllChildMarkers:function(e,t){e=e||[];for(var i=this._childClusters.length-1;i>=0;i--)this._childClusters[i].getAllChildMarkers(e);for(var n=this._markers.length-1;n>=0;n--)t&&this._markers[n].__dragStart||e.push(this._markers[n]);return e},getChildCount:function(){return this._childCount},zoomToBounds:function(e){for(var t,i=this._childClusters.slice(),n=this._group._map,r=n.getBoundsZoom(this._bounds),s=this._zoom+1,o=n.getZoom();i.length>0&&r>s;){s++;var a=[];for(t=0;ts?this._group._map.setView(this._latlng,s):o>=r?this._group._map.setView(this._latlng,o+1):this._group._map.fitBounds(this._bounds,e)},getBounds:function(){var e=new L.LatLngBounds;return e.extend(this._bounds),e},_updateIcon:function(){this._iconNeedsUpdate=!0,this._icon&&this.setIcon(this)},createIcon:function(){return this._iconNeedsUpdate&&(this._iconObj=this._group.options.iconCreateFunction(this),this._iconNeedsUpdate=!1),this._iconObj.createIcon()},createShadow:function(){return this._iconObj.createShadow()},_addChild:function(e,t){this._iconNeedsUpdate=!0,this._boundsNeedUpdate=!0,this._setClusterCenter(e),e instanceof L.MarkerCluster?(t||(this._childClusters.push(e),e.__parent=this),this._childCount+=e._childCount):(t||this._markers.push(e),this._childCount++),this.__parent&&this.__parent._addChild(e,!0)},_setClusterCenter:function(e){this._cLatLng||(this._cLatLng=e._cLatLng||e._latlng)},_resetBounds:function(){var e=this._bounds;e._southWest&&(e._southWest.lat=1/0,e._southWest.lng=1/0),e._northEast&&(e._northEast.lat=-1/0,e._northEast.lng=-1/0)},_recalculateBounds:function(){var e,t,i,n,r=this._markers,s=this._childClusters,o=0,a=0,h=this._childCount;if(0!==h){for(this._resetBounds(),e=0;e=0;i--)n=r[i],n._icon&&(n._setPos(t),n.clusterHide())},function(e){var i,n,r=e._childClusters;for(i=r.length-1;i>=0;i--)n=r[i],n._icon&&(n._setPos(t),n.clusterHide())})},_recursivelyAnimateChildrenInAndAddSelfToMap:function(e,t,i,n){this._recursively(e,n,t,function(r){r._recursivelyAnimateChildrenIn(e,r._group._map.latLngToLayerPoint(r.getLatLng()).round(),i),r._isSingleParent()&&i-1===n?(r.clusterShow(),r._recursivelyRemoveChildrenFromMap(e,t,i)):r.clusterHide(),r._addToMap()})},_recursivelyBecomeVisible:function(e,t){this._recursively(e,this._group._map.getMinZoom(),t,null,function(e){e.clusterShow()})},_recursivelyAddChildrenToMap:function(e,t,i){this._recursively(i,this._group._map.getMinZoom()-1,t,function(n){if(t!==n._zoom)for(var r=n._markers.length-1;r>=0;r--){var s=n._markers[r];i.contains(s._latlng)&&(e&&(s._backupLatlng=s.getLatLng(),s.setLatLng(e),s.clusterHide&&s.clusterHide()),n._group._featureGroup.addLayer(s))}},function(t){t._addToMap(e)})},_recursivelyRestoreChildPositions:function(e){for(var t=this._markers.length-1;t>=0;t--){var i=this._markers[t];i._backupLatlng&&(i.setLatLng(i._backupLatlng),delete i._backupLatlng)}if(e-1===this._zoom)for(var n=this._childClusters.length-1;n>=0;n--)this._childClusters[n]._restorePosition();else for(var r=this._childClusters.length-1;r>=0;r--)this._childClusters[r]._recursivelyRestoreChildPositions(e)},_restorePosition:function(){this._backupLatlng&&(this.setLatLng(this._backupLatlng),delete this._backupLatlng)},_recursivelyRemoveChildrenFromMap:function(e,t,i,n){var r,s;this._recursively(e,t-1,i-1,function(e){for(s=e._markers.length-1;s>=0;s--)r=e._markers[s],n&&n.contains(r._latlng)||(e._group._featureGroup.removeLayer(r),r.clusterShow&&r.clusterShow())},function(e){for(s=e._childClusters.length-1;s>=0;s--)r=e._childClusters[s],n&&n.contains(r._latlng)||(e._group._featureGroup.removeLayer(r),r.clusterShow&&r.clusterShow())})},_recursively:function(e,t,i,n,r){var s,o,a=this._childClusters,h=this._zoom;if(h>=t&&(n&&n(this),r&&h===i&&r(this)),t>h||i>h)for(s=a.length-1;s>=0;s--)o=a[s],o._boundsNeedUpdate&&o._recalculateBounds(),e.intersects(o._bounds)&&o._recursively(e,t,i,n,r)},_isSingleParent:function(){return this._childClusters.length>0&&this._childClusters[0]._childCount===this._childCount}});L.Marker.include({clusterHide:function(){var e=this.options.opacity;return this.setOpacity(0),this.options.opacity=e,this},clusterShow:function(){return this.setOpacity(this.options.opacity)}}),L.DistanceGrid=function(e){this._cellSize=e,this._sqCellSize=e*e,this._grid={},this._objectPoint={}},L.DistanceGrid.prototype={addObject:function(e,t){var i=this._getCoord(t.x),n=this._getCoord(t.y),r=this._grid,s=r[n]=r[n]||{},o=s[i]=s[i]||[],a=L.Util.stamp(e);this._objectPoint[a]=t,o.push(e)},updateObject:function(e,t){this.removeObject(e),this.addObject(e,t)},removeObject:function(e,t){var i,n,r=this._getCoord(t.x),s=this._getCoord(t.y),o=this._grid,a=o[s]=o[s]||{},h=a[r]=a[r]||[];for(delete this._objectPoint[L.Util.stamp(e)],i=0,n=h.length;n>i;i++)if(h[i]===e)return h.splice(i,1),1===n&&delete a[r],!0},eachObject:function(e,t){var i,n,r,s,o,a,h,l=this._grid;for(i in l){o=l[i];for(n in o)for(a=o[n],r=0,s=a.length;s>r;r++)h=e.call(t,a[r]),h&&(r--,s--)}},getNearObject:function(e){var t,i,n,r,s,o,a,h,l=this._getCoord(e.x),u=this._getCoord(e.y),_=this._objectPoint,d=this._sqCellSize,c=null;for(t=u-1;u+1>=t;t++)if(r=this._grid[t])for(i=l-1;l+1>=i;i++)if(s=r[i])for(n=0,o=s.length;o>n;n++)a=s[n],h=this._sqDist(_[L.Util.stamp(a)],e),(d>h||d>=h&&null===c)&&(d=h,c=a);return c},_getCoord:function(e){var t=Math.floor(e/this._cellSize);return isFinite(t)?t:e},_sqDist:function(e,t){var i=t.x-e.x,n=t.y-e.y;return i*i+n*n}},function(){L.QuickHull={getDistant:function(e,t){var i=t[1].lat-t[0].lat,n=t[0].lng-t[1].lng;return n*(e.lat-t[0].lat)+i*(e.lng-t[0].lng)},findMostDistantPointFromBaseLine:function(e,t){var i,n,r,s=0,o=null,a=[];for(i=t.length-1;i>=0;i--)n=t[i],r=this.getDistant(n,e),r>0&&(a.push(n),r>s&&(s=r,o=n));return{maxPoint:o,newPoints:a}},buildConvexHull:function(e,t){var i=[],n=this.findMostDistantPointFromBaseLine(e,t);return n.maxPoint?(i=i.concat(this.buildConvexHull([e[0],n.maxPoint],n.newPoints)),i=i.concat(this.buildConvexHull([n.maxPoint,e[1]],n.newPoints))):[e[0]]},getConvexHull:function(e){var t,i=!1,n=!1,r=!1,s=!1,o=null,a=null,h=null,l=null,u=null,_=null;for(t=e.length-1;t>=0;t--){var d=e[t];(i===!1||d.lat>i)&&(o=d,i=d.lat),(n===!1||d.latr)&&(h=d,r=d.lng),(s===!1||d.lng=0;t--)e=i[t].getLatLng(),n.push(e);return L.QuickHull.getConvexHull(n)}}),L.MarkerCluster.include({_2PI:2*Math.PI,_circleFootSeparation:25,_circleStartAngle:0,_spiralFootSeparation:28,_spiralLengthStart:11,_spiralLengthFactor:5,_circleSpiralSwitchover:9,spiderfy:function(){if(this._group._spiderfied!==this&&!this._group._inZoomAnimation){var e,t=this.getAllChildMarkers(null,!0),i=this._group,n=i._map,r=n.latLngToLayerPoint(this._latlng);this._group._unspiderfy(),this._group._spiderfied=this,t.length>=this._circleSpiralSwitchover?e=this._generatePointsSpiral(t.length,r):(r.y+=10,e=this._generatePointsCircle(t.length,r)),this._animationSpiderfy(t,e)}},unspiderfy:function(e){this._group._inZoomAnimation||(this._animationUnspiderfy(e),this._group._spiderfied=null)},_generatePointsCircle:function(e,t){var i,n,r=this._group.options.spiderfyDistanceMultiplier*this._circleFootSeparation*(2+e),s=r/this._2PI,o=this._2PI/e,a=[];for(s=Math.max(s,35),a.length=e,i=0;e>i;i++)n=this._circleStartAngle+i*o,a[i]=new L.Point(t.x+s*Math.cos(n),t.y+s*Math.sin(n))._round();return a},_generatePointsSpiral:function(e,t){var i,n=this._group.options.spiderfyDistanceMultiplier,r=n*this._spiralLengthStart,s=n*this._spiralFootSeparation,o=n*this._spiralLengthFactor*this._2PI,a=0,h=[];for(h.length=e,i=e;i>=0;i--)e>i&&(h[i]=new L.Point(t.x+r*Math.cos(a),t.y+r*Math.sin(a))._round()),a+=s/r+5e-4*i,r+=o/a;return h},_noanimationUnspiderfy:function(){var e,t,i=this._group,n=i._map,r=i._featureGroup,s=this.getAllChildMarkers(null,!0);for(i._ignoreMove=!0,this.setOpacity(1),t=s.length-1;t>=0;t--)e=s[t],r.removeLayer(e),e._preSpiderfyLatlng&&(e.setLatLng(e._preSpiderfyLatlng),delete e._preSpiderfyLatlng),e.setZIndexOffset&&e.setZIndexOffset(0),e._spiderLeg&&(n.removeLayer(e._spiderLeg),delete e._spiderLeg);i.fire("unspiderfied",{cluster:this,markers:s}),i._ignoreMove=!1,i._spiderfied=null}}),L.MarkerClusterNonAnimated=L.MarkerCluster.extend({_animationSpiderfy:function(e,t){var i,n,r,s,o=this._group,a=o._map,h=o._featureGroup,l=this._group.options.spiderLegPolylineOptions;for(o._ignoreMove=!0,i=0;i=0;i--)a=u.layerPointToLatLng(t[i]),n=e[i],n._preSpiderfyLatlng=n._latlng,n.setLatLng(a),n.clusterShow&&n.clusterShow(),p&&(r=n._spiderLeg,s=r._path,s.style.strokeDashoffset=0,r.setStyle({opacity:m}));this.setOpacity(.3),l._ignoreMove=!1,setTimeout(function(){l._animationEnd(),l.fire("spiderfied",{cluster:h,markers:e})},200)},_animationUnspiderfy:function(e){var t,i,n,r,s,o,a=this,h=this._group,l=h._map,u=h._featureGroup,_=e?l._latLngToNewLayerPoint(this._latlng,e.zoom,e.center):l.latLngToLayerPoint(this._latlng),d=this.getAllChildMarkers(null,!0),c=L.Path.SVG;for(h._ignoreMove=!0,h._animationStart(),this.setOpacity(1),i=d.length-1;i>=0;i--)t=d[i],t._preSpiderfyLatlng&&(t.closePopup(),t.setLatLng(t._preSpiderfyLatlng),delete t._preSpiderfyLatlng,o=!0,t._setPos&&(t._setPos(_),o=!1),t.clusterHide&&(t.clusterHide(),o=!1),o&&u.removeLayer(t),c&&(n=t._spiderLeg,r=n._path,s=r.getTotalLength()+.1,r.style.strokeDashoffset=s,n.setStyle({opacity:0})));h._ignoreMove=!1,setTimeout(function(){var e=0;for(i=d.length-1;i>=0;i--)t=d[i],t._spiderLeg&&e++;for(i=d.length-1;i>=0;i--)t=d[i],t._spiderLeg&&(t.clusterShow&&t.clusterShow(),t.setZIndexOffset&&t.setZIndexOffset(0),e>1&&u.removeLayer(t),l.removeLayer(t._spiderLeg),delete t._spiderLeg);h._animationEnd(),h.fire("unspiderfied",{cluster:a,markers:d})},200)}}),L.MarkerClusterGroup.include({_spiderfied:null,unspiderfy:function(){this._unspiderfy.apply(this,arguments)},_spiderfierOnAdd:function(){this._map.on("click",this._unspiderfyWrapper,this),this._map.options.zoomAnimation&&this._map.on("zoomstart",this._unspiderfyZoomStart,this),this._map.on("zoomend",this._noanimationUnspiderfy,this),L.Browser.touch||this._map.getRenderer(this)},_spiderfierOnRemove:function(){this._map.off("click",this._unspiderfyWrapper,this),this._map.off("zoomstart",this._unspiderfyZoomStart,this),this._map.off("zoomanim",this._unspiderfyZoomAnim,this),this._map.off("zoomend",this._noanimationUnspiderfy,this),this._noanimationUnspiderfy() -},_unspiderfyZoomStart:function(){this._map&&this._map.on("zoomanim",this._unspiderfyZoomAnim,this)},_unspiderfyZoomAnim:function(e){L.DomUtil.hasClass(this._map._mapPane,"leaflet-touching")||(this._map.off("zoomanim",this._unspiderfyZoomAnim,this),this._unspiderfy(e))},_unspiderfyWrapper:function(){this._unspiderfy()},_unspiderfy:function(e){this._spiderfied&&this._spiderfied.unspiderfy(e)},_noanimationUnspiderfy:function(){this._spiderfied&&this._spiderfied._noanimationUnspiderfy()},_unspiderfyLayer:function(e){e._spiderLeg&&(this._featureGroup.removeLayer(e),e.clusterShow&&e.clusterShow(),e.setZIndexOffset&&e.setZIndexOffset(0),this._map.removeLayer(e._spiderLeg),delete e._spiderLeg)}}),L.MarkerClusterGroup.include({refreshClusters:function(e){return e?e instanceof L.MarkerClusterGroup?e=e._topClusterLevel.getAllChildMarkers():e instanceof L.LayerGroup?e=e._layers:e instanceof L.MarkerCluster?e=e.getAllChildMarkers():e instanceof L.Marker&&(e=[e]):e=this._topClusterLevel.getAllChildMarkers(),this._flagParentsIconsNeedUpdate(e),this._refreshClustersIcons(),this.options.singleMarkerMode&&this._refreshSingleMarkerModeMarkers(e),this},_flagParentsIconsNeedUpdate:function(e){var t,i;for(t in e)for(i=e[t].__parent;i;)i._iconNeedsUpdate=!0,i=i.__parent},_refreshSingleMarkerModeMarkers:function(e){var t,i;for(t in e)i=e[t],this.hasLayer(i)&&i.setIcon(this._overrideMarkerIcon(i))}}),L.Marker.include({refreshIconOptions:function(e,t){var i=this.options.icon;return L.setOptions(i,e),this.setIcon(i),t&&this.__parent&&this.__parent._group.refreshClusters(this),this}}),e.MarkerClusterGroup=t,e.MarkerCluster=i}); -//# sourceMappingURL=leaflet.markercluster.js.map \ No newline at end of file diff --git a/omf/static/geoJsonMap/v3/main.js b/omf/static/geoJsonMap/v3/main.js index 051dd5815..6e2350368 100644 --- a/omf/static/geoJsonMap/v3/main.js +++ b/omf/static/geoJsonMap/v3/main.js @@ -30,8 +30,8 @@ function main() { const nav = new Nav(); setupNav(controller, nav); const topTab = new TopTab(); + setupMap(controller, nav); createSearchModal(controller, nav, topTab); - setupMap(controller); setupControls(controller); const modalInsert = document.getElementById('modalInsert'); modalInsert.addEventListener('click', hideModalInsert); @@ -43,10 +43,41 @@ function main() { addMenuEventHandlers(); } -function setupMap(controller) { +/** + * @param {FeatureController} controller - a FeatureController instance + * @param {Nav} nav - a Nav instance + * @returns {undefined} + */ +function setupNav(controller, nav) { + if (!(controller instanceof FeatureController)) { + throw TypeError('The "controller" argument must be instanceof FeatureController.'); + } + if (!(nav instanceof Nav)) { + throw TypeError('The "nav" argument must be instanceof Nav.'); + } + const header = document.getElementsByTagName('header')[0]; + if (gIsOnline) { + nav.topNav.setHomepageName(`"${gThisFeederName}" from ${gThisModelName}`); + } else { + nav.topNav.setHomepageName(''); + } + header.prepend(nav.topNavNavElement); + const main = document.getElementsByTagName('main')[0]; + main.prepend(nav.sideNavArticleElement); + main.prepend(nav.sideNavDivElement); + main.prepend(nav.sideNavNavElement); +} + +function setupMap(controller, nav) { if (!(controller instanceof FeatureController)) { throw Error('The "controller" argument must be instanceof controller.'); } + if (!(nav instanceof Nav)) { + throw TypeError('The "nav" argument must be instanceof Nav.'); + } + const div = document.createElement('div'); + div.id = 'map'; + nav.sideNavArticleElement.prepend(div); const maxZoom = 32; var esri_satellite_layer = L.esri.basemapLayer('Imagery', { maxZoom: maxZoom @@ -99,24 +130,24 @@ function setupMap(controller) { 'Topo': esri_topography_layer, 'Blank': blank_layer }; - const overlayMaps = { - 'Nodes': LeafletLayer.nodeLayers, - 'Lines': LeafletLayer.lineLayers, - 'Parent-Child Lines': LeafletLayer.parentChildLineLayers, - } + // - overlayMaps is still needed for GeoJSON layers + const overlayMaps = {} LeafletLayer.control = L.control.layers(baseMaps, overlayMaps, { position: 'topleft', collapsed: false, }); LeafletLayer.control.addTo(LeafletLayer.map); + // - I have to add the cluster control before LeafletLayer instances are created. I have no choice + const clusterControl = new ClusterControlClass(controller); + LeafletLayer.map.addControl(clusterControl); + LeafletLayer.clusterControl = clusterControl; + // - Create layers for all visible objects controller.observableGraph.getObservables().forEach(ob => { if (!ob.isConfigurationObject()) { // - Here, the first observer is added to every visible feature LeafletLayer.createAndGroupLayer(ob, controller); } }); - // - Keep nodes on top of lines - LeafletLayer.map.on('overlayadd', () => LeafletLayer.nodeLayers.bringToFront()); LeafletLayer.map.fitBounds(LeafletLayer.nodeLayers.getBounds()); // - Disable the following annoying default Leaflet keyboard shortcuts: // - TODO: do a better job and stop the event(s) from propagating in text inputs instead @@ -129,93 +160,7 @@ function setupMap(controller) { ].includes(e.key)) { e.stopPropagation(); } - }; -} - -/** - * - Set up the controls in the top left hand corner of the screen - */ -function setupControls(controller) { - if (!(controller instanceof FeatureController)) { - throw Error('The "controller" argument must be instanceof controller.'); - } - addZoomControl(controller); - addMultiselectControl(controller); - addClusterControl(controller); - addRuler(); - addGeocoding(); - // - Prevent mouse events from propagating from controls to the map - for (const div of [...document.getElementsByClassName('leaflet-control')]) { - L.DomEvent.on(div, 'mousedown', function (e) { - L.DomEvent.stopPropagation(e); - }); - L.DomEvent.on(div, 'mouseup', function (e) { - L.DomEvent.stopPropagation(e); - }); - L.DomEvent.on(div, 'click', function (e) { - L.DomEvent.stopPropagation(e); - }); - } -} - -/** - * @returns {undefined} - */ -function addMenuEventHandlers() { - // - Add event listeners to only allow either the file or edit menu to be open - const fileMenu = document.getElementById('fileMenu'); - let fileButton = null; - if (fileMenu !== null) { - fileButton = fileMenu.getElementsByTagName('button')[0]; - fileButton.addEventListener('click', function() { - if (this.classList.contains('expanded')) { - if (editButton !== null && editButton.classList.contains('expanded')) { - editButton.click(); - } - } - }); - } - const editMenu = document.getElementById('editMenu'); - let editButton = null; - if (editMenu !== null) { - editButton = editMenu.getElementsByTagName('button')[0]; - editButton.addEventListener('click', function() { - if (this.classList.contains('expanded')) { - if (fileButton !== null && fileButton.classList.contains('expanded')) { - fileButton.click(); - } - } - }); - } - // - Save before rendering the interface to remove any previous error files, but only in "online mode" - if (gIsOnline) { - document.getElementById('saveDiv').click(); - } -} - -/** - * @param {FeatureController} controller - a FeatureController instance - * @param {Nav} nav - a Nav instance - * @returns {undefined} - */ -function setupNav(controller, nav) { - if (!(controller instanceof FeatureController)) { - throw TypeError('"controller" argument must be instanceof FeatureController.'); - } - if (!(nav instanceof Nav)) { - throw TypeError('"nav" argument must be instanceof Nav.'); - } - const header = document.getElementsByTagName('header')[0]; - if (gIsOnline) { - nav.topNav.setHomepageName(`"${gThisFeederName}" from ${gThisModelName}`); - } else { - nav.topNav.setHomepageName(''); - } - header.prepend(nav.topNavNavElement); - const main = document.getElementsByTagName('main')[0]; - main.prepend(nav.sideNavArticleElement); - main.prepend(nav.sideNavDivElement); - main.prepend(nav.sideNavNavElement); + }; } /** @@ -226,13 +171,13 @@ function setupNav(controller, nav) { */ function createSearchModal(controller, nav, topTab) { if (!(controller instanceof FeatureController)) { - throw TypeError('"controller" argument must be instanceof FeatureController.'); + throw TypeError('The "controller" argument must be instanceof FeatureController.'); } if (!(nav instanceof Nav)) { - throw TypeError('"nav" argument must be instanceof Nav.'); + throw TypeError('The "nav" argument must be instanceof Nav.'); } if (!(topTab instanceof TopTab)) { - throw TypeError('"topTab" argument must be instanceof TopTab.'); + throw TypeError('The "topTab" argument must be instanceof TopTab.'); } nav.sideNavNavElement.appendChild(topTab.divElement); // - Add tab for searching existing features @@ -240,6 +185,8 @@ function createSearchModal(controller, nav, topTab) { topTab.addTab('Search Objects', searchTab); topTab.selectTab(topTab.getTab('Search Objects').tab); let searchModal = new SearchModal(controller); + // - add static reference + SearchModal.searchModal = searchModal; searchTab.appendChild(searchModal.getDOMElement()); let searchResultsDiv = document.createElement('div'); searchTab.appendChild(searchResultsDiv); @@ -264,16 +211,14 @@ function createSearchModal(controller, nav, topTab) { return feature; }); searchModal = new SearchModal(controller, components); + // - Add static reference + SearchModal.componentModal = searchModal; componentTab.appendChild(searchModal.getDOMElement()); searchResultsDiv = document.createElement('div'); componentTab.appendChild(searchResultsDiv); searchResultsDiv.appendChild(searchModal.getConfigSearchResultsDiv()); searchResultsDiv.appendChild(searchModal.getNodeSearchResultsDiv()); searchResultsDiv.appendChild(searchModal.getLineSearchResultsDiv()); - // - Add map div - let div = document.createElement('div'); - div.id = 'map'; - nav.sideNavArticleElement.prepend(div); // - Add legend insert const legendInsert = document.createElement('div'); legendInsert.id = 'legendInsert'; @@ -284,6 +229,128 @@ function createSearchModal(controller, nav, topTab) { document.getElementsByTagName('main')[0].appendChild(multiselectInsert); } +/** + * - Set up the controls in the top left hand corner of the screen + */ +function setupControls(controller) { + if (!(controller instanceof FeatureController)) { + throw Error('The "controller" argument must be instanceof controller.'); + } + addCustomRadioControl(controller); + addZoomControl(controller); + addMultiselectControl(controller); + addRuler(); + addGeocoding(); + // - Prevent mouse events from propagating from controls to the map + for (const div of [...document.getElementsByClassName('leaflet-control')]) { + L.DomEvent.on(div, 'mousedown', function (e) { + L.DomEvent.stopPropagation(e); + }); + L.DomEvent.on(div, 'mouseup', function (e) { + L.DomEvent.stopPropagation(e); + }); + L.DomEvent.on(div, 'click', function (e) { + L.DomEvent.stopPropagation(e); + }); + } +} + +function addCustomRadioControl(controller) { + if (!(controller instanceof FeatureController)) { + throw Error('The "controller" argument must be instanceof controller.'); + } + // - Create div for separator line + let div = document.createElement('div'); + div.classList.add('leaflet-control-layers-separator'); + document.querySelector('section.leaflet-control-layers-list').append(div); + // - Create div for custom radio buttons + div = document.createElement('div'); + document.querySelector('section.leaflet-control-layers-list').append(div); + // - Create circuit radio button + let label = document.createElement('label'); + div.append(label); + let outerSpan = document.createElement('span'); + label.append(outerSpan); + let radio = document.createElement('input'); + radio.type = 'radio'; + radio.name = 'circuitDisplay' + radio.checked = true; + radio.value = 'displayCircuit'; + radio.addEventListener('change', function() { + LeafletLayer.resetLayerGroups(controller); + }); + outerSpan.append(radio); + let innerSpan = document.createElement('span'); + innerSpan.textContent = 'Display full circuit'; + outerSpan.append(innerSpan); + // - Create search results radio button + label = document.createElement('label'); + div.append(label); + outerSpan = document.createElement('span'); + label.append(outerSpan); + radio = document.createElement('input'); + radio.type = 'radio'; + radio.name = 'circuitDisplay'; + radio.value = 'displaySearch'; + radio.addEventListener('change', function() { + SearchModal.searchModal.filterLayerGroups(); + }); + outerSpan.append(radio); + innerSpan = document.createElement('span'); + innerSpan.textContent = 'Display search results' + outerSpan.append(innerSpan); +} + +function addZoomControl(controller) { + if (!(controller instanceof FeatureController)) { + throw Error('The "controller" argument must be instanceof controller.'); + } + const zoomControl = new ZoomControlClass(controller); + LeafletLayer.map.addControl(zoomControl); +} + +function addMultiselectControl(controller) { + if (!(controller instanceof FeatureController)) { + throw Error('The "controller" argument must be instanceof controller.'); + } + const multiselectControl = new MultiselectControlClass(controller); + LeafletLayer.map.addControl(multiselectControl); +} + +function addRuler() { + var options = { + position: 'topleft', + lengthUnit: { + display: 'm', + decimal: 3, + factor: 1000, + label: 'Distance (m):' + }, + angleUnit: { + display: '°', + decimal: 3, + factor: null, + label: 'Angle:' + } + }; + L.control.ruler(options).addTo(LeafletLayer.map); +} + +function addGeocoding() { + const provider = new GeoSearch.OpenStreetMapProvider(); + const search = new GeoSearch.GeoSearchControl({ + provider: provider, + position: 'topleft', + updateMap: false // - don't update the map because we don't like the default zoom level + }); + // - Custom zoom behavior + LeafletLayer.map.on('geosearch/showlocation', function(e) { + // - The max zoom level without losing the map is 19 + LeafletLayer.map.flyTo([e.location.y, e.location.x], 19, {duration: .3}); + }); + LeafletLayer.map.addControl(search); +} + function createHelpMenu() { const div = document.createElement('div'); div.style.fontSize = '13px'; @@ -341,6 +408,41 @@ function createEditMenu(controller, nav, topTab) { dropdownDiv.insertElement(getGeojsonDiv(controller)); } +/** + * @returns {undefined} + */ +function addMenuEventHandlers() { + // - Add event listeners to only allow either the file or edit menu to be open + const fileMenu = document.getElementById('fileMenu'); + let fileButton = null; + if (fileMenu !== null) { + fileButton = fileMenu.getElementsByTagName('button')[0]; + fileButton.addEventListener('click', function() { + if (this.classList.contains('expanded')) { + if (editButton !== null && editButton.classList.contains('expanded')) { + editButton.click(); + } + } + }); + } + const editMenu = document.getElementById('editMenu'); + let editButton = null; + if (editMenu !== null) { + editButton = editMenu.getElementsByTagName('button')[0]; + editButton.addEventListener('click', function() { + if (this.classList.contains('expanded')) { + if (fileButton !== null && fileButton.classList.contains('expanded')) { + fileButton.click(); + } + } + }); + } + // - Save before rendering the interface to remove any previous error files, but only in "online mode" + if (gIsOnline) { + document.getElementById('saveDiv').click(); + } +} + /** * @param {FeatureController} controller - a FeatureController instance * @returns {undefined} @@ -372,64 +474,6 @@ function hideModalInsert() { modalInsert.classList.remove('visible'); } -function addZoomControl(controller) { - if (!(controller instanceof FeatureController)) { - throw Error('The "controller" argument must be instanceof controller.'); - } - const zoomControl = new ZoomControlClass(controller); - LeafletLayer.map.addControl(zoomControl); -} - -function addClusterControl(controller) { - if (!(controller instanceof FeatureController)) { - throw Error('The "controller" argument must be instanceof controller.'); - } - const clusterControl = new ClusterControlClass(controller); - LeafletLayer.map.addControl(clusterControl); -} - -function addMultiselectControl(controller) { - if (!(controller instanceof FeatureController)) { - throw Error('The "controller" argument must be instanceof controller.'); - } - const multiselectControl = new MultiselectControlClass(controller); - LeafletLayer.map.addControl(multiselectControl); -} - -function addGeocoding() { - const provider = new GeoSearch.OpenStreetMapProvider(); - const search = new GeoSearch.GeoSearchControl({ - provider: provider, - position: 'topleft', - updateMap: false // - don't update the map because we don't like the default zoom level - }); - // - Custom zoom behavior - LeafletLayer.map.on('geosearch/showlocation', function(e) { - // - The max zoom level without losing the map is 19 - LeafletLayer.map.flyTo([e.location.y, e.location.x], 19, {duration: .3}); - }); - LeafletLayer.map.addControl(search); -} - -function addRuler() { - var options = { - position: 'topleft', - lengthUnit: { - display: 'm', - decimal: 3, - factor: 1000, - label: 'Distance (m):' - }, - angleUnit: { - display: '°', - decimal: 3, - factor: null, - label: 'Angle:' - } - }; - L.control.ruler(options).addTo(LeafletLayer.map); -} - (function loadInterface() { const modalInsert = document.createElement('div'); modalInsert.id = 'modalInsert'; diff --git a/omf/static/geoJsonMap/v3/modalFeatures.js b/omf/static/geoJsonMap/v3/modalFeatures.js index ade73d97e..0e7fa016b 100644 --- a/omf/static/geoJsonMap/v3/modalFeatures.js +++ b/omf/static/geoJsonMap/v3/modalFeatures.js @@ -92,17 +92,17 @@ function _getAnonymizationModal(observable, controller) { const horizontalTranslationInput = document.createElement('input'); horizontalTranslationInput.name = 'translateRight'; horizontalTranslationInput.placeholder = '(+/-)meters'; - horizontalTranslationInput.pattern = '[-+]?\\d+'; + horizontalTranslationInput.pattern = '[\\-+]?\\d+'; // - Vertical translation input const verticalTranslationInput = document.createElement('input'); verticalTranslationInput.name = 'translateUp' verticalTranslationInput.placeholder = '(+/-)meters'; - verticalTranslationInput.pattern = '[-+]?\\d+'; + verticalTranslationInput.pattern = '[\\-+]?\\d+'; // - Rotation input const rotationInput = document.createElement('input'); rotationInput.name = 'rotate'; rotationInput.placeholder = '(+/-)angleĀ°'; - rotationInput.pattern = '[-+]?\\d+(\\.\\d+)?' + rotationInput.pattern = '[\\-+]?\\d+(\\.\\d+)?' // - Scale input const scaleInput = document.createElement('input'); scaleInput.name = 'scale'; @@ -533,10 +533,10 @@ function getRawDataDiv(controller) { */ function _getRenameModal(observable, controller) { if (!(observable instanceof Feature)) { - throw TypeError('"observable" argument must be instanceof Feature.'); + throw TypeError('The "observable" argument must be instanceof Feature.'); } if (!(controller instanceof FeatureController)) { - throw TypeError('"controller" argument must be instanceof FeatureController.'); + throw TypeError('The "controller" argument must be instanceof FeatureController.'); } // - Input const input = _getNameInput(observable, function(newName) { @@ -1955,7 +1955,7 @@ function _getMenuDiv(text) { */ function _getNameInput(observable, func) { const input = document.createElement('input'); - input.pattern = '(?:[\\w-]+\\s{0,1})+'; + input.pattern = '(?:\\w+-?\\s?)+'; input.placeholder = 'New name'; input.required = true; input.title = 'The new name must have one or more alphanumeric characters. Single spaces, hyphens, and underscores are allowed.'; diff --git a/omf/static/geoJsonMap/v3/searchModal.js b/omf/static/geoJsonMap/v3/searchModal.js index 10d0a1ff9..54cc6d38a 100644 --- a/omf/static/geoJsonMap/v3/searchModal.js +++ b/omf/static/geoJsonMap/v3/searchModal.js @@ -6,17 +6,20 @@ import { FeatureDropdownDiv } from './featureDropdownDiv.js'; import { FeatureGraph } from './featureGraph.js'; import { getCirclePlusSvg, getTrashCanSvg } from './featureEditModal.js'; import { Modal } from './modal.js'; +import { LeafletLayer } from './leafletLayer.js'; class SearchModal { - #configDropdownDiv; // - A DropdownDiv instance - #controller; // - ControllerInterface instance for this SearchModal - #keySelects; // - An array of HTMLSelectElement instances - #lineDropdownDiv; // - A DropdownDiv instance - #modal; // - A single Modal instance for this SearchModal - #nodeDropdownDiv; // - A DropdownDiv instance - #observables; // - An array of ObservableInterface instances (i.e. components) or an array containing a FeatureGraph - #removed; // - Whether this SearchModal instance has already been deleted - #searchResults; // - An array of all of the ObservableInterface instances that matched the search. This is necessary to build additional functionality (e.g. coloring, etc.) + #configDropdownDiv; // - A DropdownDiv instance + #controller; // - ControllerInterface instance for this SearchModal + #keySelects; // - An array of HTMLSelectElement instances + #lineDropdownDiv; // - A DropdownDiv instance + #modal; // - A single Modal instance for this SearchModal + #nodeDropdownDiv; // - A DropdownDiv instance + #observables; // - An array of ObservableInterface instances (i.e. components) or an array containing a FeatureGraph + #removed; // - Whether this SearchModal instance has already been deleted + #searchResults; // - An array of all of the ObservableInterface instances that matched the search. This is necessary to build additional functionality (e.g. coloring, etc.) + static searchModal = null; + static componentModal = null; /** * @param {FeatureController} controller - a ControllerInterface instance @@ -25,10 +28,10 @@ class SearchModal { */ constructor(controller, observables=null) { if (!(controller instanceof FeatureController)) { - throw TypeError('"controller" argument must be instanceof FeatureController.'); + throw TypeError('The "controller" argument must be instanceof FeatureController.'); } if (!(observables instanceof Array) && observables !== null) { - throw TypeError('"observables" argument must be an array or null.'); + throw TypeError('The "observables" argument must be an array or null.'); } this.#configDropdownDiv = new DropdownDiv(); this.#configDropdownDiv.addStyleClasses(['sideNav', 'searchCategory'], 'divElement'); @@ -67,7 +70,7 @@ class SearchModal { handleDeletedObservable(observable) { // - The function signature above is part of the ObserverInterface API. The implementation below is not if (!(observable instanceof Feature)) { - throw TypeError('"observable" argument must be instanceof Feature.'); + throw TypeError('The "observable" argument must be instanceof Feature.'); } this.#searchResults = this.#searchResults.filter(ob => ob !== observable); this.refreshContent(); @@ -89,7 +92,7 @@ class SearchModal { handleNewObservable(observable) { // - The function signature above is part of the ObserverInterface API. The implementation below is not if (!(observable instanceof Feature)) { - throw TypeError('"observable" argument must be instanceof FeatureGraph.'); + throw TypeError('The "observable" argument must be instanceof FeatureGraph.'); } this.refreshContent(); } @@ -104,10 +107,10 @@ class SearchModal { handleUpdatedCoordinates(observable, oldCoordinates) { // - The function signature above is part of the ObserverInterface API. The implementation below is not if (!(observable instanceof Feature) && !(observable instanceof FeatureGraph)) { - throw TypeError('"observable" argument must be instanceof Feature or FeatureGraph.'); + throw TypeError('The "observable" argument must be instanceof Feature or FeatureGraph.'); } if (!(oldCoordinates instanceof Array)) { - throw TypeError('"oldCoordinates" argument must be an array.'); + throw TypeError('The "oldCoordinates" argument must be an array.'); } this.refreshContent(); } @@ -126,13 +129,13 @@ class SearchModal { // - A SearchModal can either directly observer Features or a FeatureGraph. Since in both cases the SearchModal response is the same, this is // a special case where the observable can be one of two classes if (!(observable instanceof Feature) && !(observable instanceof FeatureGraph)) { - throw TypeError('"observable" argument must be instanceof Feature or FeatureGraph.'); + throw TypeError('The "observable" argument must be instanceof Feature or FeatureGraph.'); } if (typeof propertyKey !== 'string') { - throw TypeError('"propertyKey" argument must be a string.'); + throw TypeError('The "propertyKey" argument must be a string.'); } if (typeof namespace !== 'string') { - throw TypeError('"namespace" argument must be a string.'); + throw TypeError('The "namespace" argument must be a string.'); } this.refreshContent(); } @@ -204,6 +207,7 @@ class SearchModal { this.#handleKeySelectChange(keySelect); modal.addStyleClasses(['centeredTable'], 'tableElement'); modal.insertElement(this.#getSearchButton()); + modal.insertElement(this.#getResetButton()); modal.addStyleClasses(['verticalFlex', 'centerMainAxisFlex', 'centerCrossAxisFlex'], 'containerElement'); if (this.#modal === null) { this.#modal = modal; @@ -250,10 +254,10 @@ class SearchModal { */ #appendFeatureDropdownDivs(dropdownDiv, observables) { if (!(dropdownDiv instanceof DropdownDiv)) { - throw TypeError('"dropdown" argument must be instanceof DropdownDiv'); + throw TypeError('The "dropdown" argument must be instanceof DropdownDiv'); } if (!(observables instanceof Array)) { - throw TypeError('"observables" argument must be an Array'); + throw TypeError('The "observables" argument must be an Array'); } observables.forEach(ob => { const featureDropdownDiv = new FeatureDropdownDiv(ob, this.#controller); @@ -355,6 +359,11 @@ class SearchModal { #getDeleteRowButton() { const btn = document.createElement('button'); btn.classList.add('delete'); + if (this.#observables[0] instanceof FeatureGraph) { + btn.classList.add('searchModalDeleteButton'); + } else { + btn.classList.add('componentModalDeleteButton'); + } btn.classList.add('horizontalFlex'); btn.classList.add('centerMainAxisFlex'); btn.classList.add('centerCrossAxisFlex'); @@ -382,7 +391,7 @@ class SearchModal { */ #getKeySelect(oldKeySelect=null) { if (!(oldKeySelect instanceof HTMLSelectElement) && oldKeySelect !== null) { - throw TypeError('"oldKeySelect" argument must be instanceof HTMLKeySelect or null.'); + throw TypeError('The "oldKeySelect" argument must be instanceof HTMLKeySelect or null.'); } const keySelect = document.createElement('select'); keySelect.dataset.role = 'keySelect'; @@ -493,6 +502,37 @@ class SearchModal { return select; } + /** + * @returns {HTMLButtonElement} + */ + #getResetButton() { + const btn = this.#getWideButton(); + btn.appendChild(getResetSvg()); + const span = document.createElement('span'); + span.textContent = 'Clear' + btn.appendChild(span); + const that = this; + btn.addEventListener('click', () => { + if (this.#observables[0] instanceof FeatureGraph) { + const button = document.querySelector('button.searchModalDeleteButton'); + if (button !== null) { + button.click(); + } + document.querySelector('input.searchModalValueInput').value = ''; + } else { + const button = document.querySelector('button.componentModalDeleteButton'); + if (button !== null) { + button.click(); + } + document.querySelector('input.componentModalValueInput').value = ''; + } + this.#search(); + }); + const div = this.#getWideButtonDiv(); + div.appendChild(btn); + return div; + } + /** * @returns {HTMLButtonElement} */ @@ -518,7 +558,7 @@ class SearchModal { */ #getSearchFunction(searchCriteria) { if (!(searchCriteria instanceof Array)) { - throw TypeError('"searchCriteria" argumet must be an array.'); + throw TypeError('The "searchCriteria" argumet must be instanceof Array.'); } const func = function(ob) { if (ob.getProperty('treeKey', 'meta') === 'omd') { @@ -538,25 +578,14 @@ class SearchModal { return func.bind(this); } - /** - * @returns {HTMLButtonElement} - */ - #getSettingsButton() { - const btn = document.createElement('button'); - btn.classList.add('horizontalFlex'); - btn.classList.add('centerMainAxisFlex'); - btn.classList.add('centerCrossAxisFlex'); - btn.appendChild(getGearSvg()); - const that = this; - btn.addEventListener('click', function() { - // - TODO: use a settings modal - }); - return btn; - } - #getValueTextInput() { const input = document.createElement('input'); input.dataset.role = 'valueInput'; + if (this.#observables[0] instanceof FeatureGraph) { + input.classList.add('searchModalValueInput'); + } else { + input.classList.add('componentModalValueInput'); + } return input } @@ -591,7 +620,7 @@ class SearchModal { */ #handleKeySelectChange(select) { if (!(select instanceof HTMLSelectElement)) { - throw TypeError('"select" argument must be instanceof HTMLSelectElement'); + throw TypeError('The "select" argument must be instanceof HTMLSelectElement'); } let parentElement = select.parentElement; while (!(parentElement instanceof HTMLTableRowElement)) { @@ -623,17 +652,18 @@ class SearchModal { } /** - * - x + * - Set the contents of this.#searchResults * @returns {undefined} */ #search() { // - Clear the old FeatureDropdownDivs - this.#searchResults.forEach(observable => { - // - One time I saw a bug where an observable had multiple FeatureDropdownDiv observers, so just remove all of them - observable.getObservers().filter(observer => observer instanceof FeatureDropdownDiv).forEach(fdd => fdd.remove()); - }); + // - One time I saw a bug where an observable had multiple FeatureDropdownDiv observers, so just remove all of them + this.#searchResults.forEach(observable => observable.getObservers().filter(observer => observer instanceof FeatureDropdownDiv).forEach(fdd => fdd.remove())); // - Clear the old search results this.#searchResults = []; + if (this.#observables[0] instanceof FeatureGraph) { + LeafletLayer.resetLayerGroups(this.#controller); + } // - Clear the old search criteria and build new search criteria const searchCriteria = []; for (const tr of this.#modal.divElement.getElementsByTagName('tr')) { @@ -675,7 +705,6 @@ class SearchModal { if (!isNaN(+value)) { return +value === +valueInputValue; } else { - //return value === valueInputValue; return value.toString().toLowerCase() === valueInputValue.toLowerCase(); } } @@ -689,7 +718,6 @@ class SearchModal { if (!isNaN(+value)) { return +value !== +valueInputValue; } else { - //return value !== valueInputValue; return value.toString().toLowerCase() !== valueInputValue.toLowerCase(); } } @@ -774,6 +802,17 @@ class SearchModal { } else { this.#searchResults = this.#observables.filter(this.#getSearchFunction(searchCriteria)); } + // - Only filter the layer groups if the user wants to view the search results + let showSearchResults = document.querySelector('input[type="radio"][name="circuitDisplay"][value="displaySearch"]'); + // - If showSearchResults is null, it's because this is the first search that happens during page load + if (showSearchResults === null) { + showSearchResults = false; + } else { + showSearchResults = showSearchResults.checked; + } + if (this.#observables[0] instanceof FeatureGraph && showSearchResults) { + this.filterLayerGroups(); + } // - Don't attach event handlers here because then I'll be adding a new event handler after every search! const {configs, nodes, lines} = this.#getCategorizedSearchResults(); [ @@ -787,6 +826,38 @@ class SearchModal { } }); } + + /** + * - Filter the layer groups so that only the search results are shown + */ + filterLayerGroups() { + LeafletLayer.nodeLayers.clearLayers(); + LeafletLayer.nodeClusterLayers.clearLayers(); + LeafletLayer.lineLayers.clearLayers(); + LeafletLayer.parentChildLineLayers.clearLayers(); + for (const observable of this.#searchResults) { + if (observable.isNode() && !observable.isConfigurationObject()) { + const ll = observable.getObservers().filter(observer => observer instanceof LeafletLayer)[0]; + if (LeafletLayer.clusterControl._on) { + LeafletLayer.nodeClusterLayers.addLayer(ll.getLayer()); + } else { + LeafletLayer.nodeLayers.addLayer(ll.getLayer()); + } + } else if (observable.isLine()) { + const ll = observable.getObservers().filter(observer => observer instanceof LeafletLayer)[0]; + if (observable.isParentChildLine()) { + LeafletLayer.parentChildLineLayers.addLayer(ll.getLayer()); + } else { + LeafletLayer.lineLayers.addLayer(ll.getLayer()); + } + } + } + // - Force redraw + for (const layer of [LeafletLayer.parentChildLineLayers, LeafletLayer.lineLayers, LeafletLayer.nodeLayers, LeafletLayer.nodeClusterLayers]) { + LeafletLayer.map.removeLayer(layer); + LeafletLayer.map.addLayer(layer); + } + } } /** @@ -796,7 +867,7 @@ function getEyeGlassSvg() { const svg = document.createElementNS('http://www.w3.org/2000/svg','svg'); svg.setAttribute('width', '22px'); svg.setAttribute('height', '22px'); - svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('fill', 'none'); const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('d', "M4 11C4 7.13401 7.13401 4 11 4C14.866 4 18 7.13401 18 11C18 14.866 14.866 18 11 18C7.13401 18 4 14.866 4 11ZM11 2C6.02944 2 2 6.02944 2 11C2 15.9706 6.02944 20 11 20C13.125 20 15.078 19.2635 16.6177 18.0319L20.2929 21.7071C20.6834 22.0976 21.3166 22.0976 21.7071 21.7071C22.0976 21.3166 22.0976 20.6834 21.7071 20.2929L18.0319 16.6177C19.2635 15.078 20 13.125 20 11C20 6.02944 15.9706 2 11 2Z"); @@ -811,20 +882,25 @@ function getEyeGlassSvg() { return svg } -/** - * - https://www.svgrepo.com - */ -function getGearSvg() { +function getResetSvg() { const svg = document.createElementNS('http://www.w3.org/2000/svg','svg'); svg.setAttribute('width', '22px'); svg.setAttribute('height', '22px'); - svg.setAttribute('viewBox', '0 0 32 32'); + svg.setAttribute('viewBox', '-4 -4 24 24'); svg.setAttribute('fill', 'none'); - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - path.setAttribute('d', 'M29 11.756h-1.526c-0.109-0.295-0.229-0.584-0.361-0.87l1.087-1.076c0.441-0.389 0.717-0.956 0.717-1.587 0-0.545-0.206-1.042-0.545-1.417l0.002 0.002-3.178-3.178c-0.373-0.338-0.87-0.544-1.415-0.544-0.632 0-1.199 0.278-1.587 0.718l-0.002 0.002-1.081 1.080c-0.285-0.131-0.573-0.251-0.868-0.36l0.008-1.526c0.003-0.042 0.005-0.091 0.005-0.141 0-1.128-0.884-2.049-1.997-2.109l-0.005-0h-4.496c-1.119 0.059-2.004 0.981-2.004 2.11 0 0.049 0.002 0.098 0.005 0.147l-0-0.007v1.524c-0.295 0.109-0.584 0.229-0.87 0.361l-1.074-1.084c-0.389-0.443-0.957-0.722-1.589-0.722-0.545 0-1.042 0.206-1.416 0.545l0.002-0.002-3.179 3.179c-0.338 0.373-0.544 0.87-0.544 1.415 0 0.633 0.278 1.2 0.719 1.587l0.002 0.002 1.078 1.079c-0.132 0.287-0.252 0.576-0.362 0.872l-1.525-0.007c-0.042-0.003-0.091-0.005-0.14-0.005-1.128 0-2.050 0.885-2.11 1.998l-0 0.005v4.495c0.059 1.119 0.982 2.005 2.111 2.005 0.049 0 0.098-0.002 0.146-0.005l-0.007 0h1.525c0.109 0.295 0.229 0.584 0.361 0.87l-1.084 1.071c-0.443 0.39-0.721 0.958-0.721 1.592 0 0.545 0.206 1.043 0.545 1.418l-0.002-0.002 3.179 3.178c0.339 0.337 0.806 0.545 1.322 0.545 0.007 0 0.014-0 0.021-0h-0.001c0.653-0.013 1.24-0.287 1.662-0.722l0.001-0.001 1.079-1.079c0.287 0.132 0.577 0.252 0.873 0.362l-0.007 1.524c-0.003 0.042-0.005 0.091-0.005 0.14 0 1.128 0.885 2.050 1.998 2.11l0.005 0h4.496c1.118-0.060 2.003-0.981 2.003-2.109 0-0.050-0.002-0.099-0.005-0.147l0 0.007v-1.526c0.296-0.11 0.585-0.23 0.872-0.362l1.069 1.079c0.423 0.435 1.009 0.709 1.66 0.723l0.002 0h0.002c0.006 0 0.014 0 0.021 0 0.515 0 0.982-0.207 1.323-0.541l3.177-3.177c0.335-0.339 0.541-0.805 0.541-1.32 0-0.009-0-0.018-0-0.028l0 0.001c-0.013-0.651-0.285-1.236-0.718-1.658l-0.001-0-1.080-1.081c0.131-0.285 0.251-0.573 0.36-0.868l1.525 0.007c0.042 0.003 0.090 0.005 0.139 0.005 1.129 0 2.051-0.885 2.11-1.999l0-0.005v-4.495c-0.060-1.119-0.981-2.004-2.11-2.004-0.049 0-0.098 0.002-0.147 0.005l0.007-0zM28.75 17.749l-2.162-0.011c-0.026 0-0.048 0.013-0.074 0.015-0.093 0.009-0.179 0.026-0.261 0.053l0.008-0.002c-0.31 0.068-0.565 0.263-0.711 0.527l-0.003 0.005c-0.048 0.071-0.091 0.152-0.124 0.238l-0.003 0.008c-0.008 0.024-0.027 0.041-0.034 0.066-0.23 0.804-0.527 1.503-0.898 2.155l0.025-0.048c-0.014 0.025-0.013 0.054-0.026 0.080-0.029 0.063-0.053 0.138-0.071 0.215l-0.001 0.008c-0.023 0.072-0.040 0.156-0.048 0.242l-0 0.005c-0.002 0.027-0.004 0.058-0.004 0.089 0 0.209 0.061 0.404 0.166 0.568l-0.003-0.004c0.045 0.088 0.096 0.163 0.154 0.232l-0.001-0.002c0.017 0.019 0.022 0.043 0.040 0.061l1.529 1.531-2.469 2.467-1.516-1.529c-0.020-0.021-0.048-0.027-0.069-0.046-0.060-0.050-0.128-0.096-0.2-0.135l-0.006-0.003c-0.195-0.109-0.429-0.173-0.677-0.173-0.002 0-0.004 0-0.006 0h0c-0.076 0.008-0.145 0.022-0.211 0.040l0.009-0.002c-0.102 0.020-0.192 0.050-0.276 0.089l0.007-0.003c-0.022 0.011-0.047 0.010-0.069 0.022-0.606 0.346-1.307 0.644-2.043 0.859l-0.070 0.017c-0.027 0.008-0.045 0.027-0.071 0.037-0.084 0.033-0.157 0.071-0.224 0.116l0.004-0.003c-0.075 0.041-0.139 0.085-0.199 0.135l0.002-0.002c-0.053 0.052-0.102 0.11-0.145 0.171l-0.003 0.004c-0.103 0.113-0.176 0.254-0.206 0.411l-0.001 0.005c-0.024 0.074-0.043 0.16-0.051 0.249l-0 0.005c-0.002 0.026-0.015 0.048-0.015 0.075l-0.001 2.162h-3.491l0.011-2.156c0-0.028-0.014-0.052-0.016-0.079-0.008-0.092-0.026-0.177-0.052-0.258l0.002 0.008c-0.068-0.313-0.265-0.57-0.531-0.717l-0.006-0.003c-0.070-0.047-0.15-0.089-0.235-0.122l-0.008-0.003c-0.024-0.008-0.042-0.027-0.067-0.034-0.806-0.23-1.507-0.528-2.161-0.9l0.048 0.025c-0.023-0.013-0.050-0.012-0.073-0.023-0.072-0.033-0.156-0.061-0.244-0.079l-0.008-0.001c-0.092-0.029-0.198-0.045-0.308-0.045-0.221 0-0.426 0.066-0.597 0.18l0.004-0.002c-0.076 0.040-0.141 0.084-0.201 0.134l0.002-0.002c-0.021 0.019-0.048 0.025-0.068 0.045l-1.529 1.529-2.47-2.469 1.532-1.516c0.020-0.020 0.027-0.047 0.045-0.067 0.053-0.063 0.101-0.134 0.142-0.209l0.003-0.006c0.037-0.058 0.071-0.124 0.099-0.194l0.003-0.008c0.038-0.14 0.062-0.301 0.066-0.467l0-0.003c-0.008-0.083-0.023-0.158-0.044-0.231l0.002 0.009c-0.020-0.094-0.047-0.177-0.083-0.255l0.003 0.007c-0.012-0.025-0.011-0.052-0.025-0.076-0.347-0.605-0.645-1.305-0.858-2.041l-0.017-0.068c-0.007-0.026-0.027-0.045-0.036-0.070-0.034-0.086-0.072-0.16-0.118-0.228l0.003 0.005c-0.040-0.074-0.084-0.138-0.133-0.197l0.002 0.002c-0.052-0.053-0.109-0.101-0.169-0.144l-0.004-0.003c-0.060-0.051-0.128-0.097-0.2-0.136l-0.006-0.003c-0.057-0.026-0.126-0.049-0.196-0.066l-0.008-0.002c-0.077-0.026-0.167-0.045-0.259-0.053l-0.005-0c-0.026-0.002-0.047-0.015-0.073-0.015l-2.162-0.001v-3.492l2.162 0.011c0.16-0.002 0.311-0.035 0.45-0.092l-0.008 0.003c0.054-0.024 0.099-0.048 0.142-0.075l-0.005 0.003c0.090-0.047 0.168-0.1 0.239-0.16l-0.002 0.001c0.043-0.039 0.082-0.079 0.118-0.122l0.002-0.002c0.056-0.065 0.106-0.138 0.147-0.215l0.003-0.007c0.027-0.047 0.054-0.102 0.076-0.159l0.003-0.008c0.010-0.028 0.029-0.050 0.037-0.078 0.23-0.805 0.527-1.506 0.899-2.159l-0.025 0.048c0.014-0.024 0.013-0.052 0.025-0.076 0.031-0.067 0.057-0.147 0.075-0.229l0.001-0.008c0.020-0.086 0.032-0.185 0.032-0.287 0-0.317-0.113-0.607-0.3-0.834l0.002 0.002c-0.017-0.020-0.023-0.045-0.042-0.063l-1.527-1.529 2.469-2.469 1.518 1.531c0.055 0.045 0.116 0.087 0.18 0.122l0.006 0.003c0.042 0.033 0.089 0.065 0.138 0.094l0.006 0.003c0.16 0.088 0.35 0.142 0.551 0.148l0.002 0 0.005 0.001c0.012 0 0.023-0.009 0.034-0.009 0.186-0.008 0.359-0.056 0.513-0.135l-0.007 0.003c0.022-0.011 0.047-0.006 0.070-0.018 0.605-0.346 1.305-0.645 2.041-0.858l0.069-0.017c0.025-0.007 0.042-0.026 0.066-0.034 0.091-0.035 0.17-0.076 0.243-0.125l-0.004 0.003c0.069-0.038 0.128-0.079 0.183-0.124l-0.002 0.002c0.058-0.056 0.11-0.117 0.156-0.183l0.003-0.004c0.046-0.056 0.089-0.119 0.126-0.185l0.003-0.006c0.028-0.062 0.053-0.135 0.070-0.21l0.002-0.008c0.024-0.073 0.042-0.158 0.050-0.247l0-0.005c0.002-0.027 0.015-0.049 0.015-0.076l0.001-2.162h3.491l-0.011 2.156c-0 0.028 0.014 0.051 0.015 0.079 0.008 0.093 0.026 0.178 0.052 0.26l-0.002-0.008c0.019 0.084 0.044 0.157 0.075 0.227l-0.003-0.008c0.040 0.073 0.082 0.136 0.13 0.194l-0.002-0.002c0.048 0.070 0.101 0.131 0.158 0.187l0 0c0.053 0.044 0.112 0.084 0.174 0.12l0.006 0.003c0.068 0.046 0.147 0.087 0.23 0.119l0.008 0.003c0.025 0.009 0.043 0.028 0.069 0.035 0.804 0.229 1.503 0.527 2.155 0.899l-0.047-0.025c0.022 0.012 0.046 0.007 0.068 0.018 0.147 0.076 0.32 0.124 0.503 0.132l0.003 0c0.012 0 0.024 0.009 0.036 0.009l0.028-0.008c0.193-0.008 0.372-0.059 0.531-0.143l-0.007 0.003c0.059-0.033 0.109-0.066 0.156-0.104l-0.003 0.002c0.068-0.037 0.127-0.076 0.181-0.12l-0.002 0.002 1.531-1.528 2.469 2.47-1.531 1.516c-0.020 0.020-0.027 0.047-0.046 0.068-0.053 0.063-0.101 0.134-0.142 0.209l-0.003 0.006c-0.084 0.123-0.138 0.272-0.148 0.434l-0 0.002c-0.013 0.056-0.020 0.121-0.020 0.187 0 0.097 0.016 0.19 0.045 0.277l-0.002-0.006c0.020 0.094 0.047 0.176 0.083 0.254l-0.003-0.007c0.012 0.025 0.011 0.053 0.025 0.078 0.347 0.604 0.645 1.303 0.858 2.038l0.017 0.068c0.008 0.030 0.028 0.052 0.038 0.080 0.024 0.062 0.049 0.113 0.077 0.163l-0.003-0.006c0.211 0.397 0.619 0.665 1.090 0.674l0.001 0 2.162 0.001zM16 10.75c-2.899 0-5.25 2.351-5.25 5.25s2.351 5.25 5.25 5.25c2.899 0 5.25-2.351 5.25-5.25v0c-0.004-2.898-2.352-5.246-5.25-5.25h-0zM16 18.75c-1.519 0-2.75-1.231-2.75-2.75s1.231-2.75 2.75-2.75c1.519 0 2.75 1.231 2.75 2.75v0c-0.002 1.518-1.232 2.748-2.75 2.75h-0z'); - path.setAttribute('fill', '#FFFFFF'); - path.setAttribute('stroke-width', '1.5'); - path.setAttribute('stroke-width', '1.5'); + let path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', 'm4.5 1.5c-2.4138473 1.37729434-4 4.02194088-4 7 0 4.418278 3.581722 8 8 8s8-3.581722 8-8-3.581722-8-8-8'); + path.setAttribute('stroke', '#FFFFFF'); + path.setAttribute('stroke-width', '2'); + path.setAttribute('stroke-width', '2'); + path.setAttribute('stroke-linecap', 'round'); + path.setAttribute('stroke-linejoin', 'round'); + svg.appendChild(path) + path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', 'm4.5 5.5v-4h-4'); + path.setAttribute('stroke', '#FFFFFF'); + path.setAttribute('stroke-width', '2'); + path.setAttribute('stroke-width', '2'); path.setAttribute('stroke-linecap', 'round'); path.setAttribute('stroke-linejoin', 'round'); svg.appendChild(path); diff --git a/omf/static/geoJsonMap/v3/zoomControl.js b/omf/static/geoJsonMap/v3/zoomControl.js index 52f308b8c..b78973956 100644 --- a/omf/static/geoJsonMap/v3/zoomControl.js +++ b/omf/static/geoJsonMap/v3/zoomControl.js @@ -20,7 +20,13 @@ const ZoomControlClass = L.Control.extend({ this._div.title = '- The focus button pans the map to show the circuit'; // - Attach listeners this._div.addEventListener('click', () => { - LeafletLayer.map.fitBounds(LeafletLayer.nodeLayers.getBounds()); + if (LeafletLayer.nodeClusterLayers.getLayers().length > 0) { + LeafletLayer.map.fitBounds(LeafletLayer.nodeClusterLayers.getBounds()); + } else if (LeafletLayer.nodeLayers.getLayers().length > 0) { + LeafletLayer.map.fitBounds(LeafletLayer.nodeLayers.getBounds()); + } else { + LeafletLayer.map.fitBounds(LeafletLayer.lineLayers.getBounds()); + } }); if (options !== null) { L.setOptions(this, options); diff --git a/omf/templates/geoJson.html b/omf/templates/geoJson.html index af1500123..5e782bc92 100644 --- a/omf/templates/geoJson.html +++ b/omf/templates/geoJson.html @@ -17,6 +17,10 @@ + + + + @@ -26,10 +30,6 @@ - - - - diff --git a/omf/templates/geoJson_offline.html b/omf/templates/geoJson_offline.html index 54a562304..f4e697236 100644 --- a/omf/templates/geoJson_offline.html +++ b/omf/templates/geoJson_offline.html @@ -17,6 +17,10 @@ + + + + {{ css }}