base.html 51 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088
  1. {% extends "defaults.jinja" %}
  2. {% block base %}
  3. <!DOCTYPE html>
  4. <html lang="en">
  5. <head>
  6. {% block head %}
  7. <title>{% block title %}{% endblock %} - {{ FLEXMEASURES_PLATFORM_NAME }}
  8. </title>
  9. {% endblock head %}
  10. <meta charset="windows-1252">
  11. {% if FLEXMEASURES_ENFORCE_SECURE_CONTENT_POLICY %}
  12. <meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">
  13. {% endif %}
  14. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  15. <link rel="icon" href="/favicon.ico" type="image/x-icon" />
  16. <link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
  17. {% block styles %}
  18. <style>
  19. :root {
  20. --primary-color: {{ primary_color }};
  21. --primary-border-color: {{ primary_border_color }};
  22. --primary-hover-color: {{ primary_hover_color}};
  23. --primary-transparent: {{ primary_transparent }};
  24. --secondary-color: {{ secondary_color }};
  25. --secondary-hover-color: {{ secondary_hover_color }};
  26. --secondary-transparent: {{ secondary_transparent }};
  27. --white: #FFF;
  28. --black: #000;
  29. --light-gray: #eeeeee;
  30. --gray: #bbb;
  31. --red: #c21431;
  32. --green: #14c231;
  33. /* colors by function */
  34. --nav-default-color: var(--white);
  35. --nav-default-background-color: var(--primary-color);
  36. --nav-hover-color: var(--secondary-hover-color);
  37. --nav-hover-background-color: var(--primary-hover-color);
  38. --nav-open-color: var(--secondary-color);
  39. --nav-open-background-color: var(--primary-color);
  40. --nav-current-color: var(--black);
  41. --nav-current-background-color: var(--secondary-color);
  42. --nav-current-hover-color: var(--black);
  43. --nav-current-hover-background-color: var(--secondary-hover-color);
  44. --create-color: var(--green);
  45. --delete-color: var(--red);
  46. }
  47. #asset_audit_log.nav-on-click tr {
  48. cursor: default;
  49. }
  50. #account_audit_log.nav-on-click tr {
  51. cursor: default;
  52. }
  53. #user_audit_log.nav-on-click tr {
  54. cursor: default;
  55. }
  56. </style>
  57. <!-- Leaflet -->
  58. <link href="https://cdn.jsdelivr.net/npm/leaflet@1.7.1/dist/leaflet.css" rel="stylesheet" />
  59. <link href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.css" rel="stylesheet" />
  60. <link href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.Default.css" rel="stylesheet" />
  61. <!-- Latest Bootstrap link-->
  62. <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
  63. <!-- Fonts -->
  64. <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"
  65. rel="stylesheet" />
  66. <link href="{{ url_for('flexmeasures_ui.static', filename='css/external/weather-icons.min.css') }}"
  67. rel="stylesheet" />
  68. <!-- Ion range slider -->
  69. <link href="https://cdn.jsdelivr.net/npm/ion-rangeslider@2.3.1/css/ion.rangeSlider.css"
  70. rel="stylesheet" />
  71. <!-- Custom CSS -->
  72. <link href="{{ url_for('flexmeasures_ui.static', filename='css/flexmeasures.css') }}?v={{ flexmeasures_version }}" rel="stylesheet" />
  73. {% if active_page == "tasks" %}
  74. <link href="{{ url_for('rq_dashboard.static', filename='css/main.css') }}" rel="stylesheet">
  75. <link href="{{ url_for('flexmeasures_ui.static', filename='css/external/rq-dashboard-bootstrap.min.css') }}" rel="stylesheet" />
  76. {% elif active_page in ("assets", "users", "accounts") %}
  77. <link href="https://cdn.datatables.net/1.10.22/css/jquery.dataTables.min.css" rel="stylesheet" />
  78. <link href="https://cdn.datatables.net/buttons/3.0.2/css/buttons.bootstrap5.min.css" rel="stylesheet"/>
  79. {% endif %}
  80. {% if extra_css %}
  81. <link href="{{ extra_css }}" rel="stylesheet" />
  82. {% endif %}
  83. {% endblock %}
  84. </head>
  85. <body class="d-flex flex-column min-vh-100">
  86. {% block body %}
  87. {% block nav %}
  88. <nav class="navbar navbar-expand-lg fixed-top mt-0 navbar-default mb-2 navbar-fixed-top " id="topnavbar">
  89. <div class="container-fluid" id="navbar-container">
  90. <div class="navbar-brand">
  91. <a href="/">
  92. <span class="navbar-tool-name">
  93. {% if menu_logo %}
  94. <img id="navbar-logo" src="{{menu_logo}}"/>
  95. {% else %}
  96. <img id="navbar-logo" src="https://artwork.lfenergy.org/projects/flexmeasures/horizontal/white/flexmeasures-horizontal-white.svg" alt="FlexMeasures"/>
  97. {% endif %}
  98. </span>
  99. </a>
  100. </div>
  101. <div
  102. class="navbar-toggler border-0"
  103. type="button"
  104. data-bs-toggle="collapse"
  105. data-bs-target="#navbarSupportedContent"
  106. aria-controls="navbarSupportedContent"
  107. aria-expanded="false"
  108. aria-label="Toggle navigation"
  109. >
  110. <i class="fa fa-bars"></i>
  111. </div>
  112. <div class="collapse navbar-collapse justify-content-end flex-row" id="navbarSupportedContent">
  113. <ul class="nav navbar-nav d-flex">
  114. {% for href, id, caption, tooltip, icon in navigation_bar %}
  115. {% if id == "tasks" %}
  116. <li {% if id == active_page %} class="nav-item dropdown active" {% else %} class="nav-item dropdown" {% endif %}>
  117. <a class="nav-link dropdown-toggle" href="#" id="tasksDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
  118. <span class="fa fa-tasks" aria-hidden="true"></span>
  119. Tasks
  120. </a>
  121. <ul class="dropdown-menu" aria-labelledby="tasksDropdown">
  122. {% for queue in queue_names %}
  123. <li {% if current_user.has_role('anonymous') %} class="disabled" {% endif %}>
  124. <a class="dropdown-item" {% if not current_user.has_role('anonymous') %} href="/tasks/0/view/jobs/{{ queue }}/started/10/asc/1" {% endif %}>
  125. {{ queue | capitalize }}
  126. </a>
  127. </li>
  128. {% endfor %}
  129. <li {% if current_user.has_role('anonymous') %} class="disabled" {% endif %}>
  130. <a class="dropdown-item" {% if not current_user.has_role('anonymous') %} href="/tasks/" {% endif %}>
  131. Overview
  132. </a>
  133. </li>
  134. </ul>
  135. </li>
  136. {% else %}
  137. <li {% if id == active_page %} class="nav-item active" {% else %} class="nav-item" {% endif %}
  138. data-bs-toggle="tooltip" title="{{ tooltip }}" data-placement="bottom"
  139. {% if id == 'upload' or (current_user.has_role('anonymous') and id in ('tasks', 'users')) %}
  140. class="disabled" {% endif %}>
  141. <a {% if not ( id == 'upload' or (current_user.has_role('anonymous') and id in ('tasks', 'users')) ) %} href="/{{ href|e }}" {% endif %}
  142. {% if id == 'docs' %} target="_blank" {% endif %} class="nav-link">
  143. <span class="fa fa-{{ icon }}" aria-hidden="true"></span>
  144. {{ caption|e }}
  145. {# use tooltip as caption for small screens showing a collapsed menu #}
  146. {% if not caption %}
  147. <span class="d-inline d-lg-none">{{ tooltip }}</span>
  148. {% endif %}
  149. </a>
  150. </li>
  151. {% endif %}
  152. {% endfor %}
  153. </ul>
  154. </div>
  155. </div>
  156. </nav>
  157. {% endblock nav %}
  158. <div
  159. class="position-fixed bottom-0 end-0 p-3"
  160. id="toast-container"
  161. role="alert"
  162. aria-live="assertive"
  163. aria-atomic="true"
  164. style="z-index: 1000000"
  165. >
  166. <button id="close-all-toasts" class="btn">Close All</button>
  167. </div>
  168. {% if message and message != "" %}
  169. <div class="col-md-12 alert alert-info">{{ message}} </div>
  170. {% endif %}
  171. {% if (msg is defined) and msg %}
  172. <div class="col-md-12 alert alert-info">{{ msg }}</div>
  173. {% endif %}
  174. <!-- loading this earlier so templates can use it -->
  175. <script src="https://ajax.aspnetcdn.com/ajax/jQuery/jquery-3.5.1.min.js"></script>
  176. <script src="https://cdnjs.cloudflare.com/ajax/libs/floatthead/2.2.1/jquery.floatThead.min.js"></script>
  177. {# Div blocks that child pages can reference #}
  178. {% block divs %}
  179. {% block breadcrumbs %}
  180. {% if breadcrumb_info is defined %}
  181. <nav aria-label="breadcrumb ">
  182. <ol class="breadcrumb p-2">
  183. {% for breadcrumb in breadcrumb_info["ancestors"] %}
  184. <li class="breadcrumb-item{% if loop.last %} dropdown active{% endif %}" {% if loop.last %}aria-current="page"{% endif %}>
  185. {% if breadcrumb["url"] is not none and not loop.last %}
  186. <a href="{{ breadcrumb['url'] }}">{{ breadcrumb['name'] }}</a>
  187. {% elif breadcrumb["url"] is none %}
  188. {{ breadcrumb['name'] }}
  189. {% else %}
  190. {% if breadcrumb_info["siblings"]|length > 0 %}
  191. <a href="{{ breadcrumb['url'] }}" class="dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false" role="button">{{ breadcrumb['name'] }}</a>
  192. <ul class="dropdown-menu">
  193. {% for sibling in breadcrumb_info["siblings"] %}
  194. <li><a class="p-3 dropdown-item {% if sibling['name'] == breadcrumb['name'] %}active{% endif %}" href="{{ sibling['url'] }}">{{ sibling["name"] }}</a></li>
  195. {% endfor %}
  196. </ul>
  197. {% else %}
  198. <a href="{{ breadcrumb['url'] }}" role="button">{{ breadcrumb['name'] }}</a>
  199. {% endif %}
  200. {% endif %}
  201. </li>
  202. {% endfor %}
  203. {% if breadcrumb_info["views"]|length > 0 %}
  204. <li class="breadcrumb-item dropdown active">
  205. <a href="#" class="dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false" role="button">{{ breadcrumb_info["current_asset_view"] }}</a>
  206. <ul class="dropdown-menu">
  207. {% for view in breadcrumb_info["views"] %}
  208. <li><a class="p-3 dropdown-item {% if view['name'] == breadcrumb_info['current_asset_view'] %}active{% endif %}" href="{{ view['url'] }}">{{ view["name"] }}</a></li>
  209. {% endfor %}
  210. </ul>
  211. </li>
  212. <div class="form-check form-switch" style="margin-left: auto;">
  213. <input class="form-check-input" type="checkbox" {% if breadcrumb_info['current_asset_view'] == session.get("default_asset_view") %} checked {% endif %} id="defaultAssetView" onchange="setDefaultAssetView(this, '{{ breadcrumb_info["current_asset_view"] }}')">
  214. <label class="form-check-label" for="defaultAssetView">Set as my default asset view</label>
  215. </div>
  216. {% endif %}
  217. </ol>
  218. </nav>
  219. {% endif %}
  220. {% endblock %}
  221. {% block forecastpicker %}
  222. <div class="form-group row">
  223. <div class="col-md-2">
  224. <div class="col-md-1"><i class="icon-binoculars center-icon"></i></div>
  225. </div>
  226. <div class="col-md-10">
  227. <label class="control-label">Forecast (rolling)</label>
  228. <!--<div class="btn-group btn-group-justified" role="group" aria-label="...">-->
  229. <!--<a role="button" href="#" class="btn btn-default forecast-toggle active" forecast-type="rolling">Rolling</a>-->
  230. <!--<a role="button" href="#" class="btn btn-default forecast-toggle" forecast-type="static">Static</a>-->
  231. <!--</div>-->
  232. <div>
  233. <form action="" method="POST" id="forecast_horizon_form">
  234. <select class="form-control" id="forecast_horizon" name="forecast_horizon"
  235. onchange="this.form.submit()">
  236. {% for horizon in forecast_horizons %}
  237. <option value="{{ horizon }}" {% if horizon == active_forecast_horizon %} selected="selected"
  238. {% endif %}>with a horizon of {{ horizon }}</option>
  239. {% endfor %}
  240. </select>
  241. </form>
  242. </div>
  243. </div>
  244. </div>
  245. {% endblock forecastpicker %}
  246. {% block leftsidepanel %}
  247. <script>
  248. // Set up swiping and clicking for the left sidepanel
  249. var leftSidepanels = document.getElementsByClassName('left-sidepanel');
  250. var leftSidepanelLabels = document.getElementsByClassName('left-sidepanel-label');
  251. async function openSidepanel(e) {
  252. if ( (e.target.classList.contains('sidepanel-container')) | (e.type == 'click') ) {
  253. for (var i = leftSidepanels.length - 1; i >= 0; i--) {
  254. leftSidepanels[i].classList.add('sidepanel-show');
  255. }
  256. }
  257. }
  258. async function closeSidepanel(e) {
  259. for (var i = leftSidepanels.length - 1; i >= 0; i--) {
  260. leftSidepanels[i].classList.remove('sidepanel-show');
  261. }
  262. }
  263. for (var i = leftSidepanelLabels.length - 1; i >= 0; i--) {
  264. leftSidepanelLabels[i].addEventListener("click", openSidepanel);
  265. }
  266. document.addEventListener('swiped-right', openSidepanel);
  267. document.addEventListener('swiped-left', closeSidepanel);
  268. </script>
  269. {% endblock leftsidepanel %}
  270. {% block sensorChartSetup %}
  271. <!-- Render Charts -->
  272. <script>
  273. // Define picker as a global variable so that other code on the page can access it, e.g. get the times
  274. var picker;
  275. </script>
  276. <script type="module" type="text/javascript">
  277. // Import local js (the FM version is used for cache-busting, causing the browser to fetch the updated version from the server)
  278. import { getUniqueValues, convertToCSV } from "{{ url_for('flexmeasures_ui.static', filename='js/data-utils.js') }}?v={{ flexmeasures_version }}";
  279. import { subtract, thisMonth, lastNMonths, countDSTTransitions, getOffsetBetweenTimezonesForDate } from "{{ url_for('flexmeasures_ui.static', filename='js/daterange-utils.js') }}?v={{ flexmeasures_version }}";
  280. import { partition, updateBeliefs, beliefTimedelta, setAbortableTimeout} from "{{ url_for('flexmeasures_ui.static', filename='js/replay-utils.js') }}?v={{ flexmeasures_version }}";
  281. let vegaView;
  282. let previousResult;
  283. let queryStartDate;
  284. let queryEndDate;
  285. let storeStartDate;
  286. let storeEndDate;
  287. {% if event_starts_after and event_ends_before %}
  288. storeStartDate = new Date('{{ event_starts_after }}');
  289. storeEndDate = new Date('{{ event_ends_before }}');
  290. checkDSTTransitions(storeStartDate, storeEndDate);
  291. {% endif %}
  292. let replaySpeed = 100
  293. let chartType = '{{ chart_type }}'; // initial chart type from session variable
  294. // Update chart type picker: active state, reload data event
  295. document.addEventListener('DOMContentLoaded', function() {
  296. var dropdownItems = document.querySelectorAll('#chart-type-picker .dropdown-item');
  297. for (var i = 0; i < dropdownItems.length; i++) {
  298. // Set initial chart type
  299. if (dropdownItems[i].getAttribute('data-chart-type') === chartType) {
  300. dropdownItems[i].classList.add('active');
  301. }
  302. // Add event listener
  303. dropdownItems[i].addEventListener('click', function(e) {
  304. e.preventDefault();
  305. chartType = this.getAttribute('data-chart-type');
  306. // Update the active state of the dropdown items
  307. var dropdownItems = document.querySelectorAll('.dropdown-item');
  308. dropdownItems.forEach(item => {
  309. if (item === this) {
  310. item.classList.add('active');
  311. } else {
  312. item.classList.remove('active');
  313. }
  314. });
  315. // Reload daterange
  316. picker.setDateRange(picker.getStartDate(), picker.getEndDate());
  317. });
  318. }
  319. });
  320. function checkDSTTransitions(startDate, endDate) {
  321. var numDSTTransitions = countDSTTransitions(startDate, endDate, 90)
  322. if (numDSTTransitions != 0) {
  323. document.getElementById('dstwarn').style.display = 'block';
  324. if (numDSTTransitions == 1) {
  325. document.getElementById('dstwarn').innerHTML = 'Please note that the sensor data you are viewing includes a daylight saving time (DST) transition.';
  326. } else {
  327. document.getElementById('dstwarn').innerHTML = 'Please note that the sensor data you are viewing includes ' + numDSTTransitions + ' daylight saving time (DST) transitions.';
  328. }
  329. }
  330. else {
  331. document.getElementById('dstwarn').style.display = 'none';
  332. }
  333. }
  334. function checkSourceMasking(data) {
  335. var sourceWarn = document.getElementById("sourcewarn");
  336. if (sourceWarn == null)
  337. return
  338. var uniqueSourceIds = getUniqueValues(data, 'source.id');
  339. if (chartType == 'daily_heatmap' && uniqueSourceIds.length > 1) {
  340. sourceWarn.style.display = 'block';
  341. sourceWarn.innerHTML = 'Please note that only data from the most prevalent source is shown.';
  342. }
  343. else {
  344. sourceWarn.style.display = 'none';
  345. }
  346. }
  347. async function embedAndLoad(chartSpecsPath, elementId, datasetName, previousResult, startDate, endDate) {
  348. await vegaEmbed('#'+elementId, chartSpecsPath + 'dataset_name=' + datasetName + '&combine_legend='+ combineLegend + '&width=container&include_sensor_annotations=false&include_asset_annotations=false&chart_type=' + chartType, {{ chart_options | safe }})
  349. .then(function (result) {
  350. // Create a custom menu item for exporting to CSV
  351. const exportToCSVAction = document.createElement('a');
  352. exportToCSVAction.id = 'exportToCSVAction';
  353. exportToCSVAction.href = '#';
  354. exportToCSVAction.download = datasetName + '.csv';
  355. exportToCSVAction.textContent = 'Save as CSV';
  356. exportToCSVAction.addEventListener('mousedown', async function (event) {
  357. event.preventDefault();
  358. const chartData = vegaView.data(datasetName);
  359. const csvContent = convertToCSV(chartData);
  360. const encodedUri = encodeURI(csvContent);
  361. exportToCSVAction.href = encodedUri;
  362. });
  363. // Append menu item to chart actions (hamburger menu)
  364. const vegaActions = document.querySelector('.vega-actions');
  365. if (vegaActions) {
  366. vegaActions.appendChild(exportToCSVAction);
  367. } else {
  368. console.log('Warning: CSV export functionality is not available, because no div with class=vega-actions was found to append export action to');
  369. }
  370. // result.view is the Vega View, chartSpecsPath is the original Vega-Lite specification
  371. vegaView = result.view;
  372. if (previousResult) {
  373. checkSourceMasking(previousResult);
  374. var slicedPreviousResult = previousResult.filter(item => {
  375. return item.event_start >= startDate.getTime() && item.event_start < endDate.getTime();
  376. })
  377. vegaView.change(datasetName, vega.changeset().remove(vega.truthy).insert(slicedPreviousResult)).resize().run();
  378. }
  379. });
  380. }
  381. // event listener to refresh graph
  382. document.addEventListener('sensorsToShowUpdated', async function() {
  383. await embedAndLoad(chartSpecsPath + 'event_starts_after=' + storeStartDate.toISOString() + '&event_ends_before=' + storeEndDate.toISOString() + '&', elementId, datasetName, previousResult, storeStartDate, storeEndDate);
  384. })
  385. var combineLegend = 'true';
  386. {% if active_page == "assets" %}
  387. var dataPath = '/api/v3_0/assets/' + {{ asset.id }};
  388. var dataDevPath = '/api/dev/asset/' + {{ asset.id }};
  389. var datasetName = 'asset_' + {{ asset.id }};
  390. {% set total_sensors = asset.sensors_to_show | map(attribute='sensors') | map('length') | sum %}
  391. {% if total_sensors > 7 %}
  392. combineLegend = 'false';
  393. {% endif %}
  394. {% elif active_page == "sensors" %}
  395. var dataPath = '/api/dev/sensor/' + {{ sensor.id }};
  396. var dataDevPath = '/api/dev/sensor/' + {{ sensor.id }};
  397. var datasetName = 'sensor_' + {{ sensor.id }};
  398. {% endif %}
  399. var elementId = 'sensorchart';
  400. var chartSpecsPath = dataPath + '/chart?';
  401. // Set up abort controller to cancel requests
  402. var controller = new AbortController();
  403. var signal = controller.signal;
  404. const initialData = fetch(dataPath + '/chart_data?event_starts_after=' + '{{ event_starts_after }}' + '&event_ends_before=' + '{{ event_ends_before }}', {
  405. method: "GET",
  406. headers: {"Content-Type": "application/json"},
  407. signal: signal,
  408. })
  409. .then(function(response) { return response.json(); });
  410. // Set session start and end
  411. {% if event_starts_after and event_ends_before %}
  412. var sessionStart = new Date('{{ event_starts_after }}');
  413. var sessionEnd = new Date('{{ event_ends_before }}');
  414. sessionEnd.setSeconds(sessionEnd.getSeconds() - 1); // -1 second in case most recent event ends at midnight
  415. sessionStart.setHours(0,0,0,0); // get start of first day
  416. sessionEnd.setHours(0,0,0,0); // get start of last day
  417. {% else %}
  418. var sessionStart = null
  419. var sessionEnd = null
  420. {% endif %}
  421. // Create date range picker and the logic for a new date range selection (mostly, fetching data and displaying the chart for it)
  422. const date = Date();
  423. picker = new Litepicker({
  424. element: document.getElementById('datepicker'),
  425. plugins: ['ranges', 'keyboardnav'],
  426. ranges: {
  427. customRanges: {
  428. 'Today': [new Date(date), new Date(date)],
  429. 'Last 7 days': [subtract(date, 6), new Date(date)],
  430. 'This month': thisMonth(date)
  431. },
  432. position: 'bottom'
  433. },
  434. autoRefresh: true,
  435. moduleRanges: true,
  436. showWeekNumbers: true,
  437. numberOfMonths: 1,
  438. numberOfColumns: 1,
  439. inlineMode: true,
  440. switchingMonths: 1,
  441. singleMode: false,
  442. startDate: sessionStart,
  443. endDate: sessionEnd,
  444. dropdowns: {
  445. years: true,
  446. months: true,
  447. },
  448. format: 'YYYY-MM-DD\\T00:00:00',
  449. });
  450. picker.on('selected', (startDate, endDate) => {
  451. startDate = startDate.toJSDate();
  452. endDate = endDate.toJSDate();
  453. endDate.setDate(endDate.getDate() + 1);
  454. storeStartDate = startDate;
  455. storeEndDate = endDate;
  456. var queryStartDate = (startDate != null) ? (startDate.toISOString()) : (null);
  457. var queryEndDate = (endDate != null) ? (endDate.toISOString()) : (null);
  458. stopReplay()
  459. $("#spinner").show();
  460. checkDSTTransitions(startDate, endDate)
  461. Promise.all([
  462. // Fetch time series data
  463. fetch(dataPath + '/chart_data?event_starts_after=' + queryStartDate + '&event_ends_before=' + queryEndDate, {
  464. method: "GET",
  465. headers: {"Content-Type": "application/json"},
  466. signal: signal,
  467. })
  468. .then(function(response) { return response.json(); }),
  469. /**
  470. // Fetch annotations
  471. fetch(dataPath + '/chart_annotations?event_starts_after=' + queryStartDate + '&event_ends_before=' + queryEndDate, {
  472. method: "GET",
  473. headers: {"Content-Type": "application/json"},
  474. signal: signal,
  475. })
  476. .then(function(response) { return response.json(); }),
  477. */
  478. // Embed chart
  479. embedAndLoad(chartSpecsPath + 'event_starts_after=' + queryStartDate + '&event_ends_before=' + queryEndDate + '&', elementId, datasetName, previousResult, startDate, endDate),
  480. ]).then(function(result) {
  481. $("#spinner").hide();
  482. vegaView.change(datasetName, vega.changeset().remove(vega.truthy).insert(result[0])).resize().run();
  483. previousResult = result[0];
  484. checkSourceMasking(previousResult);
  485. /**
  486. vegaView.change(datasetName + '_annotations', vega.changeset().remove(vega.truthy).insert(result[1])).resize().run();
  487. */
  488. }).catch(console.error);
  489. });
  490. /** If the page is done loading, get data (for the time range stored in the session, or the default range),
  491. * check for time zone differences, and then trigger the time range picker to display the correct range.
  492. */
  493. document.onreadystatechange = async () => {
  494. if (document.readyState === 'complete') {
  495. {% if event_starts_after and event_ends_before %}
  496. // Initialize picker to the date selection specified in the session
  497. $("#spinner").show();
  498. let fetchedInitialData = await Promise.all([
  499. initialData,
  500. embedAndLoad(chartSpecsPath + 'event_starts_after=' + '{{ event_starts_after }}' + '&event_ends_before=' + '{{ event_ends_before }}' + '&', elementId, datasetName, previousResult, sessionStart, sessionEnd),
  501. ]).then(function (result) {return result[0]}).catch(console.error);
  502. $("#spinner").hide();
  503. vegaView.change(datasetName, vega.changeset().remove(vega.truthy).insert(fetchedInitialData)).resize().run();
  504. previousResult = fetchedInitialData;
  505. checkSourceMasking(previousResult);
  506. var timerangeNotSetYet = false
  507. {% else %}
  508. var timerangeNotSetYet = true
  509. {% endif %}
  510. fetch(dataDevPath, {
  511. method: "GET",
  512. headers: {"Content-Type": "application/json"},
  513. })
  514. .then(function(response) { return response.json(); })
  515. .then(function(data) {
  516. var offsetDifference = getOffsetBetweenTimezonesForDate(new Date(), data.timezone, jstz.determine().name());
  517. if (offsetDifference != 0) {
  518. document.getElementById('tzwarn').style.display = 'block';
  519. var offsetNotice = (offsetDifference > 0) ? 'which is currently ahead by ' + offsetDifference + ' minutes' : 'which is currently behind by ' + offsetDifference + ' minutes';
  520. document.getElementById('tzwarn').innerHTML = 'Please note that the sensor data you are viewing is located in a different timezone (' + offsetNotice + ').<br/>To view the data from a local perspective, set your locale timezone to ' + data.timezone + '.';
  521. }
  522. {% if active_page == "assets" %}
  523. var timerangeVar = 'timerange_of_sensors_to_show';
  524. {% else %}
  525. var timerangeVar = 'timerange';
  526. {% endif %}
  527. if (timerangeVar in data) {
  528. var start = new Date(data[timerangeVar].start);
  529. var end = new Date(data[timerangeVar].end);
  530. end.setSeconds(end.getSeconds() - 1); // -1 second in case most recent event ends at midnight
  531. start.setHours(0,0,0,0); // get start of first day
  532. end.setHours(0,0,0,0); // get start of last day
  533. if (timerangeNotSetYet) {
  534. // Initialize picker to the last 2 days of sensor data
  535. var nearEnd = new Date(end)//.setDate(end.getDate() - 1);
  536. nearEnd.setDate(nearEnd.getDate() - 1);
  537. picker.setDateRange(
  538. nearEnd,
  539. end,
  540. );
  541. }
  542. // No use looking for data in years outside timerange of sensor data
  543. picker.setOptions({
  544. dropdowns: {
  545. minYear: start.getFullYear(),
  546. maxYear: end.getFullYear(),
  547. },
  548. });
  549. // Persist the range on the datepicker UI so correct range is shown when the user opens the datepicker
  550. var end_time = '{{event_ends_before}}';
  551. var new_end_date = new Date(end_time);
  552. // Adjusting the date by deducting one day to account for timezone discrepancies.
  553. new_end_date.setDate(new_end_date.getDate() - 1);
  554. var start_time = '{{event_starts_after}}';
  555. var new_start_date = new Date(start_time);
  556. picker.setDateRange(decodeURIComponent(toIsoString(new_start_date)), decodeURIComponent(toIsoString(new_end_date)));
  557. };
  558. });
  559. }
  560. };
  561. // Set up play/pause button for replay, incl. the complete replay logic
  562. let toggle = document.querySelector('#replay');
  563. toggle.addEventListener('click', function(e) {
  564. e.preventDefault();
  565. toggleReplay();
  566. });
  567. window.addEventListener('keypress', function(e) {
  568. // Do nothing if typing in an input field or textarea
  569. if (e.target.tagName.toLowerCase() === 'input' || e.target.tagName.toLowerCase() === 'textarea') {
  570. return;
  571. }
  572. // Start/pause/resume replay with 'p'
  573. if (e.key==='p') {
  574. toggleReplay();
  575. } else if (e.key==='s') {
  576. stopReplay();
  577. }
  578. }, false);
  579. function toggleReplay() {
  580. if (toggle.classList.contains('stopped')) {
  581. startReplay();
  582. } else if (toggle.classList.contains('playing')) {
  583. pauseReplay();
  584. } else {
  585. resumeReplay();
  586. }
  587. }
  588. function pauseReplay() {
  589. toggle.classList.remove('playing');
  590. toggle.classList.add('paused');
  591. }
  592. function resumeReplay() {
  593. toggle.classList.remove('paused');
  594. toggle.classList.add('playing');
  595. }
  596. async function stopReplay() {
  597. if (toggle.classList.contains('stopped')) {
  598. return;
  599. }
  600. toggle.classList.remove('playing');
  601. toggle.classList.remove('paused');
  602. toggle.classList.add('stopped');
  603. // Abort previous request and create abort controller for new request
  604. controller.abort();
  605. controller = new AbortController();
  606. signal = controller.signal;
  607. // Remove replay ruler and replay time
  608. vegaView.change('replay', vega.changeset().remove(vega.truthy).insert({'belief_time': null})).run().finalize();
  609. document.getElementById('replay-time').innerHTML = '';
  610. // Show previous results
  611. $("#spinner").show();
  612. await embedAndLoad(chartSpecsPath + 'event_starts_after=' + storeStartDate.toISOString() + '&event_ends_before=' + storeEndDate.toISOString() + '&', elementId, datasetName, previousResult, storeStartDate, storeEndDate);
  613. $("#spinner").hide();
  614. }
  615. function startReplay() {
  616. toggle.classList.remove('stopped');
  617. toggle.classList.add('playing');
  618. var beliefTime = new Date(storeStartDate);
  619. var numReplaySteps = Math.ceil((storeEndDate - storeStartDate) / beliefTimedelta);
  620. queryStartDate = (storeStartDate != null) ? (storeStartDate.toISOString()) : (null);
  621. queryEndDate = (storeEndDate != null) ? (storeEndDate.toISOString()) : (null);
  622. $("#spinner").show();
  623. Promise.all([
  624. // Fetch time series data (all data, not only the most recent beliefs)
  625. fetch(dataPath + '/chart_data?event_starts_after=' + queryStartDate + '&event_ends_before=' + queryEndDate + '&most_recent_beliefs_only=false', {
  626. method: "GET",
  627. headers: {"Content-Type": "application/json"},
  628. signal: signal,
  629. })
  630. .then(function(response) { return response.json(); }),
  631. ]).then(function(result) {
  632. $("#spinner").hide();
  633. replayBeliefsData(result[0]);
  634. }).catch(console.error);
  635. const timer = ms => new Promise(res => setAbortableTimeout(res, Math.max(ms, 0), signal));
  636. /**
  637. * Replays beliefs data.
  638. *
  639. * As we go forward in time in steps, replayedData is updated with newData that was known at beliefTime,
  640. * by splitting off newData from remainingData.
  641. * Then, replayedData is loaded into the chart.
  642. *
  643. * @param {Array} remainingData Array containing beliefs.
  644. */
  645. async function replayBeliefsData (remainingData) {
  646. var replayedData = [];
  647. for (var beliefTime = new Date(storeStartDate); beliefTime <= storeEndDate; beliefTime = new Date(beliefTime.getTime() + beliefTimedelta)) {
  648. while (document.getElementById('replay').classList.contains('paused') ) {
  649. await timer(1000);
  650. }
  651. if (document.getElementById('replay').classList.contains('stopped') ) {
  652. break;
  653. }
  654. var s = performance.now();
  655. // Split off one replay step of new data from the remaining data
  656. var newData;
  657. [newData, remainingData] = partition(
  658. remainingData,
  659. (item) => item.belief_time <= beliefTime.getTime(),
  660. );
  661. // Update beliefs in the replayed data given the new data
  662. replayedData = updateBeliefs(replayedData, newData);
  663. /** When selecting a longer time periode (more than a week), the replay slows down a bit. This
  664. * seems to be mainly from reloading the data into the graph. Slicing the data takes 10-30 ms, and
  665. * loading that data into the graph takes 30-200 ms, depending on how much data is shown in the
  666. * graph. After trying different approaches, we fell back to the original approach of telling vega
  667. * to remove all previous data and to insert a completely new dataset at each iteration. Updating
  668. * the view with removing only a few data points (representing obsolete beliefs) and inserting only
  669. * a few data points (representing the most recent new beliefs) actually made it slower.
  670. */
  671. vegaView.change(datasetName, vega.changeset().remove(vega.truthy).insert(replayedData));
  672. vegaView.change('replay', vega.changeset().remove(vega.truthy).insert({'belief_time': beliefTime}));
  673. vegaView.run().finalize();
  674. document.getElementById('replay-time').innerHTML = beliefTime;
  675. // Approximate constant speed
  676. var e = performance.now();
  677. var throttle = e - s;
  678. await timer(replaySpeed - throttle);
  679. }
  680. // Stop replay when finished
  681. stopReplay()
  682. }
  683. }
  684. </script>
  685. {% endblock sensorChartSetup %}
  686. {% block attributions %}
  687. <div id="att-text" style="display: none;">
  688. <ul>
  689. <li>Plots made with <a href="https://vega.github.io/vega-lite/">Vega-Lite</a>.</li>
  690. <li>Icons made by <a href="https://freepik.com">Freepik</a>, <a
  691. href="https://www.flaticon.com/authors/tomas-knop" title="Tomas Knop">Tomas Knop</a>, <a
  692. href="https://www.flaticon.com/authors/gregor-cresnar" title="Gregor Cresnar">Gregor Cresnar</a> and
  693. <a href="https://www.flaticon.com/authors/those-icons" title="Those Icons">Those Icons</a> from <a
  694. href="https://flaticon.com">www.flaticon.com</a>.</li>
  695. </ul>
  696. </div>
  697. {% endblock attributions %}
  698. {% endblock divs %}
  699. {#- Scripts used by all views (e.g. by navigation menu) -#}
  700. <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
  701. <script src="https://cdn.datatables.net/2.0.8/js/dataTables.min.js"></script>
  702. <script src="https://cdn.datatables.net/2.0.8/js/dataTables.bootstrap5.min.js"></script>
  703. <!-- Add the class touched to body when the user uses any touch event. -->
  704. <script>
  705. document.body.addEventListener('touchstart', function() {
  706. document.body.classList.add('touched');
  707. });
  708. </script>
  709. {% block scripts %}
  710. <!-- JS to enable tooltips -->
  711. <script type='text/javascript'>
  712. $(document).ready(function () {
  713. if ($("[rel=tooltip]").length) {
  714. $("[rel=tooltip]").tooltip();
  715. }
  716. });
  717. </script>
  718. <script>
  719. // Set default asset view
  720. function setDefaultAssetView(checkbox, view_name) {
  721. // Get the checked status of the checkbox
  722. const isChecked = checkbox.checked;
  723. const apiBasePath = window.location.origin;
  724. fetch(apiBasePath + '/api/v3_0/assets/default_asset_view', {
  725. method: 'POST',
  726. headers: {
  727. 'Content-Type': 'application/json',
  728. 'X-CSRFToken': '{{ csrf_token }}',
  729. },
  730. body: JSON.stringify({
  731. default_asset_view: view_name,
  732. use_as_default: isChecked
  733. })
  734. })
  735. .then(response => response.json())
  736. .catch(error => {
  737. console.error('Error during API call:', error);
  738. });
  739. }
  740. </script>
  741. <!-- JS to enable toast -->
  742. <script defer>
  743. const toastStack = document.getElementById("toast-container");
  744. const closeToastBtn = document.getElementById("close-all-toasts");
  745. // written like this since this script(order) is ontop of the main script
  746. document.addEventListener("DOMContentLoaded", function () {
  747. initiateToastCloseBtn(closeToastBtn);
  748. });
  749. function initiateToastCloseBtn(closeToastBtn){
  750. // hide button
  751. closeToastBtn.style.display = "none";
  752. closeToastBtn.addEventListener("click", function () {
  753. const toastElements = document.querySelectorAll(".toast");
  754. toastElements.forEach((toast) => {
  755. const toastInstance = bootstrap.Toast.getInstance(toast); // Get the toast instance
  756. if (toastInstance) {
  757. // destroy the toast
  758. toastInstance.dispose();
  759. toast.remove();
  760. }
  761. });
  762. // Hide the close button
  763. closeToastBtn.style.display = "none";
  764. });
  765. }
  766. function showAllToasts() {
  767. const toastElements = document.querySelectorAll(".toast");
  768. toastElements.forEach((toast) => {
  769. const toastInstance = new bootstrap.Toast(toast);
  770. toastInstance.show();
  771. });
  772. }
  773. function showToast(message, type) {
  774. let colorClass;
  775. let colorStyle = "";
  776. let title;
  777. // Determine the type of toast
  778. if (type == "error") {
  779. colorClass = "bg-danger";
  780. title = "Error";
  781. } else if (type == "success") {
  782. colorClass = "bg-success";
  783. title = "Success";
  784. } else {
  785. colorStyle =
  786. "background-color: {{ primary_color }};";
  787. title = "Info";
  788. }
  789. // Create the toast HTML
  790. const toast = document.createElement("div");
  791. toast.classList.add("toast", "mb-1");
  792. toast.setAttribute("data-bs-autohide", "true");
  793. toast.setAttribute("role", "alert");
  794. toast.setAttribute("aria-live", "assertive");
  795. toast.setAttribute("aria-atomic", "true");
  796. toast.innerHTML = `
  797. <div class="toast-header">
  798. <div class="rounded me-2 ${colorClass}" style="width: 20px; height: 20px; display: inline-block; ${colorStyle}"></div>
  799. <strong class="me-auto">${title}</strong>
  800. <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
  801. </div>
  802. <div class="toast-body">
  803. ${message}
  804. </div>
  805. `;
  806. // Append toast to the toast stack
  807. toastStack.insertAdjacentElement("afterbegin", toast);
  808. // Show the close all button
  809. closeToastBtn.style.display = "block";
  810. showAllToasts();
  811. // destroy only this toast if the close(X) button is clicked
  812. toast.querySelector(".btn-close").addEventListener("click", function () {
  813. const toastInstance = new bootstrap.Toast(toast);
  814. toastInstance.dispose();
  815. toast.remove();
  816. });
  817. }
  818. </script>
  819. <!-- External scripts -->
  820. <script src="https://cdn.jsdelivr.net/npm/ion-rangeslider@2.3.1/js/ion.rangeSlider.min.js"></script>
  821. <script src="https://d3js.org/d3.v6.min.js"></script>
  822. {% if js_versions %}
  823. <script src="https://cdn.jsdelivr.net/npm/vega@{{ js_versions.vega }}"></script>
  824. <script src="https://cdn.jsdelivr.net/npm/vega-lite@{{ js_versions.vegalite }}"></script>
  825. <script src="https://cdn.jsdelivr.net/npm/vega-embed@{{ js_versions.vegaembed }}"></script>
  826. {# Workaround for loading a NodeJS module without NodeJS #}
  827. <script>var module = { exports: {} };</script>
  828. <script src="https://cdn.jsdelivr.net/npm/currency-symbol-map@{{ js_versions.currencysymbolmap }}/map.js"></script>
  829. <script>const currencySymbolMap = module.exports;</script>
  830. {% endif %}
  831. <!-- Custom scripts -->
  832. <script src="{{ url_for('flexmeasures_ui.static', filename='js/swiped-events.min.js') }}"></script>
  833. <script src="{{ url_for('flexmeasures_ui.static', filename='js/flexmeasures.js') }}?v={{ flexmeasures_version }}"></script>
  834. {% endblock scripts %}
  835. {% block paginate_tables_script %}
  836. <script src="https://cdn.datatables.net/1.10.22/js/jquery.dataTables.min.js"></script>
  837. <script
  838. src="https://cdn.datatables.net/plug-ins/1.10.22/features/conditionalPaging/dataTables.conditionalPaging.js"></script>
  839. {% endblock paginate_tables_script %}
  840. <footer class="page-footer font-small pt-4 mt-auto">
  841. <div class="footer text-center">
  842. <div class="container-fluid">
  843. {% block copyright_notice %}
  844. FlexMeasures technology is created by <a href="https://seita.nl/">Seita Energy Flexibility</a>,
  845. in cooperation with <a href="https://aoneeng.com/">A1 Engineering</a>
  846. &copy
  847. <script>var CurrentYear = new Date().getFullYear(); document.write(CurrentYear)</script>.
  848. {% endblock copyright_notice %}
  849. {% block about %}
  850. <a href="#" data-bs-toggle="modal" data-bs-target="#About">About FlexMeasures</a>.
  851. <!-- The modal -->
  852. <div class="modal fade" id="About" tabindex="-1" role="dialog" aria-labelledby="modalLabelLarge" aria-hidden="true">
  853. <div class="modal-dialog modal-lg">
  854. <div class="modal-content">
  855. <div class="modal-header">
  856. <h4 class="modal-title" id="modalLabelLarge">About FlexMeasures</h4>
  857. <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
  858. </div>
  859. <div class="modal-body">
  860. <div class="justify">
  861. <p>
  862. In a world with renewable energy, flexibility is crucial for cost and CO₂ reduction.
  863. Planning ahead allows flexible devices to profit from scheduling the best flexible
  864. actions (such as shifting or curtailing energy use).
  865. </p>
  866. <p>
  867. The {{ FLEXMEASURES_PLATFORM_NAME }} Platform is a tool for businesses operating energy devices.
  868. Its purpose is to realise the best value for device owners by scheduling balancing actions.
  869. For instance, if the devices draw from or supply to the power grid, {{ FLEXMEASURES_PLATFORM_NAME }} can assist in
  870. selling balancing services to energy markets.
  871. It fulfills this purpose with three services: Monitoring, forecasting and scheduling.
  872. {{ FLEXMEASURES_PLATFORM_NAME }} is designed as open-source software to empower energy service companies while they
  873. maintain autonomy over their operations and their technology roadmap.
  874. Read more <a href="https://flexmeasures.readthedocs.io">here</a>.
  875. </p>
  876. <p>
  877. {{ FLEXMEASURES_PLATFORM_NAME }} is compliant with the Universal Smart Energy Framework (<a
  878. href="https://www.usef.energy/download-the-framework/a-flexibility-market-design/">USEF</a>), a
  879. “flexibility market design for the trading and commoditisation of energy flexibility and the
  880. architecture, tools and rules to make it work effectively.
  881. USEF fits on top of most market models and is already being adopted across Europe to accelerate and
  882. future-proof smart energy projects.”
  883. </p>
  884. {% if current_user.has_role('anonymous') and current_user.has_role('CPO') %}
  885. <p>
  886. This demo is tuned to operators of charge points for electric vehicles (EVs).
  887. The terminology used follows the Open Charge Point Interface (<a
  888. href="https://ocpi-protocol.org/">OCPI</a>).
  889. This dashboard shows you the locations of all charge points connected to the platform and how they
  890. are doing.
  891. Each charge point contains multiple chargers&mdash;called Electric Vehicle Supply Equipment (EVSE)
  892. in OCPI&mdash;which are monitored individually (click on a charge point location to reveal its
  893. EVSE).
  894. </p>
  895. <p>
  896. {{ FLEXMEASURES_PLATFORM_NAME }} supports automated scheduling of charging profiles through its API.
  897. Charging profiles are optimised against applicable market conditions like wholesale prices and
  898. contracted (time-of-use) tariffs.
  899. Charging preferences can be set to reflect whether EV owners are in a hurry or parking for some
  900. time.
  901. </p>
  902. {% endif %}
  903. </div>
  904. </div>
  905. </div>
  906. </div>
  907. </div>
  908. {% endblock about %}
  909. {% block credits %}
  910. <a href="#" data-bs-toggle="modal" data-bs-target="#Credits">Credits</a>.
  911. <!-- The modal -->
  912. <div class="modal fade" id="Credits" tabindex="-1" role="dialog" aria-labelledby="modalLabelLarge" aria-hidden="true">
  913. <div class="modal-dialog modal-lg">
  914. <div class="modal-content">
  915. <div class="modal-header">
  916. <h4 class="modal-title" id="modalLabelLarge">Credits</h4>
  917. <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
  918. </div>
  919. <div class="modal-body">
  920. <div class="row">
  921. <div class="col-md-6">
  922. <h3>Images from <a href="https://seita.nl">Seita</a></h3>
  923. <div>FlexMeasures on laptop - image by <a href="https://drakemultimedia.nl/" title="Bobby Drake">Bobby Drake</a></div>
  924. <h3>Images from <a href="https://unsplash.com/" title="Unsplash">Unsplash</a></h3>
  925. <div>Tesla charging station - image by <a href="https://www.chasealewis.com/" title="Chase Lewis">Chase Lewis</a></div>
  926. <div>Wind turbines on top of mountain - image by <a href="https://www.tbk-f.com/" title="TJ K.">TJ K.</a></div>
  927. </div>
  928. <div class="col-md-6">
  929. <h3>Icons from <a href="https://www.flaticon.com/" title="Flaticon">Flaticon</a></h3>
  930. <div><a href="https://www.flaticon.com/free-icon/wind-power_1085695">Wind turbine</a> - icon by <a href="https://www.flaticon.com/authors/good-ware" title="Good Ware">Good Ware</a> (made to spin by Seita)</div>
  931. <div><a href="https://www.flaticon.com/search?word=currency&author_id=258">Coins</a> - icons by <a href="https://www.flaticon.com/authors/pixelmeetup" title="Pixelmeetup">Pixelmeetup</a></div>
  932. <div>Other icons by <a href="https://www.freepik.com" title="Freepik">Freepik</a></div>
  933. </div>
  934. </div>
  935. </div>
  936. <div class="modal-header">
  937. We did our best to give credit to the original photographers and illustrators for the images included on this website. If we have not properly credited an original photographer or illustrator, please notifiy us and we will make the appropriate changes necessary.
  938. </div>
  939. </div>
  940. </div>
  941. </div>
  942. {% endblock credits %}
  943. {% block technical_info %}
  944. {# We might render templates from plugins, where this meta info is not available #}
  945. {% if app_running_since %}
  946. This app is running since {{ app_running_since }}
  947. {% endif %}
  948. {% if (flexmeasures_version or git_version) and not current_user.has_role('anonymous') %}
  949. {% if flexmeasures_version %}
  950. on version {{ flexmeasures_version }}.
  951. {% else %}
  952. {% if git_version != "Unknown" %}
  953. on version {{ git_version }}+{{ git_commits_since }}.
  954. {% else %}
  955. on revision {{ git_hash }}.
  956. {% endif %}
  957. {% endif %}
  958. {% endif %}
  959. {% if loaded_plugins %}
  960. Loaded plugins: {{ loaded_plugins }}.
  961. {% endif %}
  962. {% endblock technical_info %}
  963. </div>
  964. {% block logo %}
  965. <div>
  966. <a href="https://flexmeasures.io/"><img src="https://artwork.lfenergy.org/projects/flexmeasures/icon/white/flexmeasures-icon-white.svg" alt="FlexMeasures" class="footer-logo"></a>
  967. </div>
  968. {% endblock logo %}
  969. </div>
  970. </footer>
  971. {% endblock body %}
  972. </body>
  973. </html>
  974. {% endblock base %}