Projekt

Obecné

Profil

Stáhnout (18.2 KB) Statistiky
| Větev: | Revize:
1
/* global L */
2
/* global $ */
3

    
4
var mymap
5
var heatmapLayer = null
6
var marksLayer = null
7

    
8
var startX = 49.7248
9
var startY = 13.3521
10
var startZoom = 17
11

    
12
var dataSourceRoute
13
let positionsSourceRoute
14

    
15
let currentTime
16

    
17
let currentDate
18

    
19
var timer
20
var isAnimationRunning = false
21
var data = []
22

    
23
var info = []
24
let currentInfo = 0
25

    
26
const datasetDictNameDisplayName = {}
27
var datasetSelected = []
28

    
29
// data only for one day
30
let lockedDay = false
31

    
32
let loading = 0
33

    
34
// marks for all datasets
35
const dataSourceMarks = {}
36

    
37
const globalMarkersHolder = {}
38
// all marker from which popup was removed
39
// contains: {key:[L.circle,L.pupup]}
40
// key: x and y, x + '' + y string
41
let globalMarkersChanged = {}
42

    
43
const loadingY = () => {
44
  loading++
45
}
46
const loadingN = () => {
47
  loading--
48
}
49

    
50
const changeCurrentTime = (time = null) => {
51
  if (time !== null) {
52
    currentTime = time
53
  } else {
54
    currentTime = parseInt($('#dropdown-time input[type="radio"]:checked').val())
55
  }
56
}
57

    
58
const changeCurrentDate = (date = null) => {
59
  if (date) {
60
    currentDate = new Date(date)
61
  } else {
62
    currentDate = new Date($('#date').val())
63
  }
64

    
65
  $('#date').val(currentDateToString())
66
  $('#player-date span').html(`${currentDate.getDate()}. ${currentDate.getMonth() + 1}. ${currentDate.getFullYear()}`)
67

    
68
  data = []
69
}
70
const currentDayToString = () => {
71
  const day = currentDate.getDate()
72
  return day > 9 ? `${day}` : `0${day}`
73
}
74
const currentMonthToString = () => {
75
  const month = currentDate.getMonth() + 1
76
  return month > 9 ? `${month}` : `0${month}`
77
}
78
const currentDateToString = () => `${currentDate.getFullYear()}-${currentMonthToString()}-${currentDayToString()}`
79
const addDayToCurrentDate = (day) => {
80
  currentDate.setDate(currentDate.getDate() + day)
81
  changeCurrentDate(currentDate)
82
}
83
const toggleDayLock = () => {
84
  lockedDay = !lockedDay
85
  $('#player-date').toggleClass('lock')
86
}
87

    
88
const fetchByNameDate = async (baseRoute, name, date, currentTime) => {
89
  const headers = new Headers()
90
  const myRequest = new Request(baseRoute + '/' + name + '/' + date + '/' + currentTime, {
91
    method: 'GET',
92
    headers: headers
93
  })
94
  const beforeJson = await fetch(myRequest)
95
  return beforeJson.json()
96
}
97

    
98
const fetchDataSourceMarks = async (positionRoute, datasetName) => {
99
  const headers = new Headers()
100
  const myRequest = new Request(positionRoute + '/' + datasetName, {
101
    method: 'GET',
102
    headers: headers
103
  })
104
  const beforeJson = await fetch(myRequest)
105
  return beforeJson.json()
106
}
107

    
108
const genPopUpControlButtons = (currentPage, numPages, onNextClick, onPreviousClick) => ({
109
  previousButton: '<button id="previous-info-btn" class="circle-button" onclick="previousInfo()"></button>',
110
  nextButton: '<button id="next-info-btn" class="circle-button next" onclick="nextInfo()"></button>',
111
  posInfo: `<div id="count-info">${currentPage} z ${numPages}</div>`
112
})
113

    
114
const genPopUpControls = (controls) => {
115
  return `<div class="popup-controls">${controls ? controls.reduce((sum, item) => sum + item, '') : ''}</div>`
116
}
117

    
118
const genMultipleDatasetsPopUp = (sum, currentPos, maxPos, datasetName) => {
119
  const popupHeader = `<strong id="dataset-info">${datasetName}</strong>`
120
  const popupData = `<div id="number-info"><span id="digit-info">${sum}</span></div>`
121
  const { previousButton, nextButton, posInfo } = genPopUpControlButtons(currentPos, maxPos)
122

    
123
  return `
124
  ${popupHeader}
125
  ${popupData}
126
  ${genPopUpControls([previousButton, posInfo, nextButton])}
127
  `
128
}
129

    
130
const prepareLayerPopUp = (lat, lng, num, className) => L.popup({
131
  autoPan: false,
132
  className: className
133
}).setLatLng([lat / num, lng / num])
134

    
135
const genPopUp = (datasetName, place, count, sum, currentPos, maxPos) => {
136
  const popupHeader = `
137
    <strong>${datasetName}</strong>
138
    <div id="place-info">${place}</div>`
139
  const popupData = `
140
    <div id="number-info">
141
      <span id="digit-info">${count}</span>
142
      <span id="total-info">${(sum && (sum != count)) ? '/' + sum : ''}</span>
143
    </div>`
144
  const { previousButton, nextButton, posInfo } = genPopUpControlButtons(currentPos, maxPos)
145

    
146
  return `
147
  ${popupHeader}
148
  ${popupData}
149
  ${genPopUpControls(maxPos > 1 ? [previousButton, posInfo, nextButton] : null)}
150
  `
151
}
152

    
153
const onCheckboxClicked = async (checkbox) => {
154
  if ($(checkbox).prop('checked')) {
155
    loadCurrentTimeHeatmap(dataSourceRoute, positionsSourceRoute)
156
    changeUrl()
157
  } else {
158
    loadCheckboxDatasetNameData()
159

    
160
    data.forEach((item, index) => {
161
      Object.keys(item).forEach((datasetName) => {
162
        if (datasetName === $(checkbox).val()) {
163
          delete data[index][datasetName]
164
        }
165
      })
166
      drawHeatmap(data[currentTime])
167
    })
168

    
169
    changeUrl()
170
  }
171
}
172

    
173
const debounce = (func, delay) => {
174
  let inDebounce
175
  return function () {
176
    const context = this
177
    const args = arguments
178
    clearTimeout(inDebounce)
179
    inDebounce = setTimeout(() => func.apply(context, args), delay)
180
  }
181
}
182

    
183
const onValueChangeRegister = () => {
184
  $('#date').change(function () {
185
    changeCurrentDate($(this).val())
186
    loadCurrentTimeHeatmap(dataSourceRoute, positionsSourceRoute)
187
    changeUrl()
188
  })
189

    
190
  $('#dropdown-time input[type="radio"]').each(function () {
191
    $(this).change(function () {
192
      changeHour(parseInt($(this).val()))
193
      drawHeatmap(data[currentTime])
194
    })
195
  })
196

    
197
  $('#dropdown-dataset input[type="checkbox"]').each(function () {
198
    $(this).change(
199
      debounce(() => onCheckboxClicked(this), 1000)
200
    )
201
  })
202
}
203

    
204
/**
205
 * Initialize leaflet map on start position which can be default or set based on user action
206
 */
207
// eslint-disable-next-line no-unused-vars
208
function initMap () {
209
  startX = localStorage.getItem('lat') || startX
210
  startY = localStorage.getItem('lng') || startY
211
  startZoom = localStorage.getItem('zoom') || startZoom
212

    
213
  mymap = L.map('heatmap').setView([startX, startY], startZoom)
214

    
215
  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
216
    attribution: '',
217
    maxZoom: 19
218
  }).addTo(mymap)
219

    
220
  mymap.on('click', showInfo)
221
}
222

    
223
const getInfoLength = () => {
224
  const infoKeys = Object.keys(info)
225
  if (infoKeys.length === 1) {
226
    // return number of records in one dataset (one dataset in area)
227
    return info[infoKeys[0]].items.length
228
  }
229
  // return number of datasets (agregation of all datasets in area)
230
  return infoKeys.length
231
}
232

    
233
const getElFromObjectInfo = (position) => {
234
  const keys = Object.keys(info)
235
  return info[keys[position]]
236
}
237

    
238
const hasInfoMultipleDatasets = () => {
239
  return Object.keys(info).length > 1
240
}
241

    
242
function showInfo (e) {
243
  info = []
244
  currentInfo = 0
245

    
246
  // https://wiki.openstreetmap.org/wiki/Zoom_levels
247
  // Todo change to variable - it is used in heatmap init
248
  const stile = 40075016.686 * Math.cos(startX) / Math.pow(2, mymap.getZoom())
249
  const radius = 25 * stile / 256
250

    
251
  let i = 0
252
  let lat = 0
253
  let lng = 0
254

    
255
  let total = 0
256

    
257
  const datasetsInRadius = {}
258

    
259
  Object.keys(data[currentTime]).forEach((key) => {
260
    const namedData = data[currentTime][key]
261
    namedData.items.forEach(element => {
262
      if (e.latlng.distanceTo(new L.LatLng(element.x, element.y)) < radius) {
263
        lat += element.x
264
        lng += element.y
265
        info[i] = { place: element.place, number: element.number, datasetName: key }
266
        total += parseInt(element.number)
267
        i++
268
        datasetsInRadius[key] = true
269
      }
270
    })
271
  })
272

    
273
  // Process info for more then one dataset
274

    
275
  info = info.reduce((acc, item) => {
276
    if (!acc[item.datasetName]) {
277
      acc[item.datasetName] = {
278
        items: [],
279
        number: 0,
280
        datasetName: item.datasetName
281
      }
282
    }
283

    
284
    acc[item.datasetName].items.push(item)
285
    acc[item.datasetName].number += Number(item.number)
286
    return acc
287
  }, {})
288

    
289
  // There is one dataset
290

    
291
  const numDatasets = Object.keys(datasetsInRadius).length
292

    
293
  if (!numDatasets) {
294
    return
295
  }
296

    
297
  if (numDatasets === 1) {
298
    const infoDict = getElFromObjectInfo(0)
299
    const info_ = infoDict.items
300
    const { place, number } = info_[currentInfo]
301
    prepareLayerPopUp(lat, lng, i, `popup-${infoDict.datasetName}`)
302
      .setContent(genPopUp(datasetDictNameDisplayName[infoDict.datasetName], place, number, total, currentInfo + 1, info_.length))
303
      .openOn(mymap)
304

    
305
    if (info_.length === 1) {
306
      $('#previous-info-btn').prop('disabled', true)
307
      $('#next-info-btn').prop('disabled', true)
308
      $('.popup-controls').hide()
309
    }
310
  } else {
311
    const { datasetName, number } = getElFromObjectInfo(currentInfo)
312

    
313
    prepareLayerPopUp(lat, lng, i, `popup-${datasetName}`)
314
      .setContent(genMultipleDatasetsPopUp(number, currentInfo + 1, getInfoLength(), datasetDictNameDisplayName[datasetName]))
315
      .openOn(mymap)
316
  }
317
}
318

    
319
// eslint-disable-next-line no-unused-vars
320
function previousInfo () {
321
  const infoLength = getInfoLength()
322
  const previousCurrentInfo = currentInfo
323

    
324
  currentInfo = (currentInfo + infoLength - 1) % infoLength
325
  displayInfoText(previousCurrentInfo)
326
}
327

    
328
// eslint-disable-next-line no-unused-vars
329
function nextInfo () {
330
  const infoLength = getInfoLength()
331
  const previousCurrentInfo = currentInfo
332

    
333
  currentInfo = (currentInfo + 1) % infoLength
334
  displayInfoText(previousCurrentInfo)
335
}
336

    
337
function displayInfoText (previousInfoNum) {
338
  const previousInfo = hasInfoMultipleDatasets() ? getElFromObjectInfo(previousInfoNum) : getElFromObjectInfo(0).items[previousInfoNum]
339
  const info_ = hasInfoMultipleDatasets() ? getElFromObjectInfo(currentInfo) : getElFromObjectInfo(0).items[currentInfo]
340
  const infoLength = getInfoLength()
341
  const datasetInfo = $('#dataset-info')
342

    
343
  if (datasetInfo) {
344
    $(datasetInfo).html(datasetDictNameDisplayName[info_.datasetName])
345
  }
346

    
347
  $('#place-info').html(info_.place ? info_.place : info_.datasetName)
348
  $('#digit-info').html(info_.number)
349
  $('#count-info').html(currentInfo + 1 + ' z ' + infoLength)
350

    
351
  $('.leaflet-popup').removeClass(`popup-${previousInfo.datasetName}`)
352
  $('.leaflet-popup').addClass(`popup-${info_.datasetName}`)
353
}
354

    
355
// eslint-disable-next-line no-unused-vars
356
function setMapView (latitude, longitude, zoom) {
357
  localStorage.setItem('lat', latitude)
358
  localStorage.setItem('lng', longitude)
359
  localStorage.setItem('zoom', zoom)
360
  mymap.setView([latitude, longitude], zoom)
361
}
362

    
363
/**
364
 * Change animation start from playing to stopped or the other way round
365
 */
366
// eslint-disable-next-line no-unused-vars
367
function changeAnimationState () {
368
  isAnimationRunning = !isAnimationRunning
369

    
370
  if (isAnimationRunning) {
371
    $('#animate-btn').removeClass('play').addClass('pause')
372
    timer = setInterval(function () { next() }, 800)
373
  } else {
374
    clearTimeout(timer)
375
    $('#animate-btn').removeClass('pause').addClass('play')
376
  }
377
}
378

    
379
// eslint-disable-next-line no-unused-vars
380
async function previous () {
381
  if (loading) {
382
    return
383
  }
384
  currentTime = (currentTime + 23) % 24
385
  changeHour(currentTime)
386
  mymap.closePopup()
387
  if (!lockedDay && (currentTime === 23)) {
388
    addDayToCurrentDate(-1)
389
    await loadCurrentTimeHeatmap(dataSourceRoute, positionsSourceRoute)
390
  } else {
391
    drawHeatmap(data[currentTime])
392
  }
393
}
394

    
395
async function next () {
396
  if (loading) {
397
    return
398
  }
399
  currentTime = (currentTime + 1) % 24
400
  changeHour(currentTime)
401
  mymap.closePopup()
402
  if (!lockedDay && (currentTime === 0)) {
403
    addDayToCurrentDate(1)
404
    await loadCurrentTimeHeatmap(dataSourceRoute, positionsSourceRoute)
405
  } else {
406
    drawHeatmap(data[currentTime])
407
  }
408
}
409

    
410
/**
411
 * Change browser url based on animation step.
412
 */
413
function changeUrl () {
414
  window.history.pushState(
415
    '',
416
    document.title,
417
    window.location.origin + window.location.pathname + `?date=${currentDateToString()}&time=${currentTime}${datasetSelected.reduce((acc, current) => acc + '&type=' + current, '')}`
418
  )
419
}
420

    
421
function updateHeaderControls () {
422
  $(`#time_${currentTime}`).prop('checked', true)
423
  $('#dropdownMenuButtonTime').html((currentTime < 10 ? '0' : '') + `${currentTime}:00`)
424
}
425

    
426
function setTimeline () {
427
  $('#player-time').text(currentTime + ':00')
428
  $('#player-time').attr('class', 'time hour-' + currentTime)
429
}
430

    
431
const onChangeHour = (hour) => {
432
  changeHour(hour)
433
  drawHeatmap(data[currentTime])
434
}
435

    
436
const changeHour = (hour) => {
437
  changeCurrentTime(hour)
438
  updateHeaderControls()
439
  setTimeline()
440
  changeUrl()
441
}
442

    
443
/**
444
 * Load and display heatmap layer for current data
445
 * @param {string} opendataRoute route to dataset source
446
 * @param {string} positionsRoute  route to dataset postitions source
447
 */
448
// eslint-disable-next-line no-unused-vars
449
async function loadCurrentTimeHeatmap (opendataRoute, positionsRoute) {
450
  loadCheckboxDatasetNameData()
451

    
452
  dataSourceRoute = opendataRoute
453
  positionsSourceRoute = positionsRoute
454
  const allPromises = []
455
  data[currentTime] = {}
456

    
457
  const dataSelectedHandler = async (datasetName) => {
458
    if (!(datasetName in dataSourceMarks)) {
459
      dataSourceMarks[datasetName] = await fetchDataSourceMarks(positionsRoute, datasetName)
460
    }
461
    const datasetData = await fetchByNameDate(dataSourceRoute, datasetName, currentDateToString(), currentTime)
462
    data[currentTime][datasetName] = datasetData
463
  }
464
  datasetSelected.forEach((datasetName) => {
465
    allPromises.push(dataSelectedHandler(datasetName))
466
  })
467

    
468
  loadingY()
469
  Promise.all(allPromises).then(
470
    () => {
471
      loadingN()
472
      drawDataSourceMarks(dataSourceMarks)
473
      drawHeatmap(data[currentTime])
474
      preload(currentTime, 1, currentDateToString())
475
      preload(currentTime, -1, currentDateToString())
476
    }
477
  )
478
}
479

    
480
function drawDataSourceMarks (data) {
481
  if (marksLayer != null) {
482
    mymap.removeLayer(marksLayer)
483
  }
484

    
485
  marksLayer = L.layerGroup()
486

    
487
  Object.keys(data).forEach((key_) => {
488
    for (var key in data[key_]) {
489
      const { x, y, name } = data[key_][key]
490
      const pop =
491
          prepareLayerPopUp(x, y, 1, `popup-${key_}`)
492
            .setContent(genPopUp(datasetDictNameDisplayName[key_], name, 0, 0, 1, 1))
493
      const newCircle =
494
        L.circle([x, y], { radius: 2, fillOpacity: 0.8, color: '#004fb3', fillColor: '#004fb3', bubblingMouseEvents: true })
495
          .bindPopup(pop)
496
      globalMarkersHolder[x + '' + y] = [newCircle, pop] // add new marker to global holders
497
      marksLayer.addLayer(
498
        newCircle
499
      )
500
    }
501
  })
502

    
503
  marksLayer.setZIndex(-1).addTo(mymap)
504
}
505

    
506
async function preload (time, change, date) {
507
  loadingY()
508
  for (let nTime = time + change; nTime >= 0 && nTime <= 23; nTime = nTime + change) {
509
    if (!data[nTime]) {
510
      data[nTime] = {}
511
    }
512

    
513
    datasetSelected.forEach(async (datasetName) => {
514
      if (!data[nTime][datasetName]) {
515
        data[nTime][datasetName] = await fetchByNameDate(dataSourceRoute, datasetName, date, nTime)
516
      }
517
    })
518
  }
519
  loadingN()
520
}
521

    
522
function drawHeatmap (dataRaw) {
523
  // Todo still switched
524
  const dataDict = dataRaw
525
  const mergedPoints = []
526
  let max = 0
527

    
528
  if (Object.keys(globalMarkersChanged).length) {
529
    Object.keys(globalMarkersChanged).forEach(function (key) {
530
      globalMarkersChanged[key][0].bindPopup(globalMarkersChanged[key][1])
531
    })
532
    globalMarkersChanged = {}
533
  }
534

    
535
  Object.keys(dataDict).forEach((key) => {
536
    const data = dataDict[key]
537
    max = Math.max(max, data.max)
538

    
539
    if (data != null) {
540
    // Bind back popups for markers (we dont know if there is any data for this marker or not)
541
      const points = data.items.map((point) => {
542
        const { x, y, number } = point
543
        const key = x + '' + y
544
        const holder = globalMarkersHolder[key]
545

    
546
        if (!globalMarkersChanged[key] && number) {
547
        // There is data for this marker => unbind popup with zero value
548
          holder[0] = holder[0].unbindPopup()
549
          globalMarkersChanged[key] = holder
550
        }
551

    
552
        return [x, y, number]
553
      })
554
      mergedPoints.push(...points)
555
    } else {
556
      if (heatmapLayer != null) {
557
        mymap.removeLayer(heatmapLayer)
558
      }
559
    }
560
  })
561

    
562
  if (heatmapLayer != null) {
563
    mymap.removeLayer(heatmapLayer)
564
  }
565

    
566
  if (mergedPoints.length) {
567
    heatmapLayer = L.heatLayer(mergedPoints, { max: max, minOpacity: 0.5, radius: 35, blur: 30 }).addTo(mymap)
568
  }
569
}
570

    
571
/**
572
 * Checks dataset availibility
573
 * @param {string} route authority for datasets availibility checks
574
 */
575
// eslint-disable-next-line no-unused-vars
576
function checkDataSetsAvailability (route) {
577
  $.ajax({
578
    type: 'POST',
579
    // Todo it might be good idea to change db collections format
580
    url: route + '/' + currentDateToString(),
581
    success: function (result) {
582
      updateAvailableDataSets(result)
583
    }
584
  })
585
}
586

    
587
function updateAvailableDataSets (available) {
588
  let leastOneOptionEnabled = false
589

    
590
  $('#dropdown-dataset .dropdown-item').each(function () {
591
    const input = $(this).find('input')
592
    const inputVal = input[0].value
593

    
594
    if (!(inputVal in available)) {
595
      $(this).addClass('disabled')
596
      $(input).prop('checked', false)
597
    } else {
598
      leastOneOptionEnabled = true
599
      $(this).removeClass('disabled')
600
    }
601
  })
602

    
603
  $('#btn-update-heatmap').prop('disabled', !leastOneOptionEnabled)
604
}
605

    
606
function formatDate (date) {
607
  var day = String(date.getDate())
608
  var month = String(date.getMonth() + 1)
609

    
610
  if (day.length === 1) {
611
    day = '0' + day
612
  }
613

    
614
  if (month.length === 1) {
615
    month = '0' + month
616
  }
617

    
618
  return date.getFullYear() + '-' + month + '-' + day
619
}
620

    
621
// eslint-disable-next-line no-unused-vars
622
function initDatepicker (availableDatesSource) {
623
  var availableDates = ''
624

    
625
  $.ajax({
626
    type: 'GET',
627
    url: availableDatesSource,
628
    success: function (result) {
629
      availableDates = String(result).split(',')
630
    }
631
  }).then(function () {
632
    $('#date').datepicker({
633
      format: 'yyyy-mm-dd',
634
      language: 'cs',
635
      beforeShowDay: function (date) {
636
        if (availableDates.indexOf(formatDate(date)) < 0) {
637
          return { enabled: false, tooltip: 'Žádná data' }
638
        } else {
639
          return { enabled: true }
640
        }
641
      },
642
      autoclose: true
643
    })
644
  })
645
}
646

    
647
function initLocationsMenu () {
648
  var locationsWrapper = '.locations'
649
  var locationsDisplayClass = 'show'
650

    
651
  if ($(window).width() <= 480) {
652
    $(locationsWrapper).removeClass(locationsDisplayClass)
653
  } else {
654
    $(locationsWrapper).addClass(locationsDisplayClass)
655
  }
656
}
657

    
658
function onDocumentReady () {
659
  $('#dropdown-dataset').on('click', function (e) {
660
    e.stopPropagation()
661
  })
662

    
663
  $('#btn-update-heatmap').prop('name', '')
664
  changeCurrentTime()
665
  setTimeline()
666
  changeCurrentDate()
667
  onValueChangeRegister()
668
}
669

    
670
const loadCheckboxDatasetNameData = () => {
671
  datasetSelected = []
672
  $('#dropdown-dataset .dropdown-item').each(function () {
673
    const input = $(this).find('input')
674
    const inputVal = input[0].value
675

    
676
    if (input[0].checked) {
677
      datasetSelected.push(inputVal)
678
    }
679

    
680
    datasetDictNameDisplayName[inputVal] = $(input).data('dataset-display-name')
681
  })
682
}
(2-2/2)