Projekt

Obecné

Profil

Stáhnout (19.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
// dictionary for names of datasets
27
const datasetDictNameDisplayName = {}
28
var datasetSelected = []
29

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

    
33
// loading information for async operations
34
let loading = 0
35

    
36
// default loader showup delay
37
const defaultLoaderDelay = 1000
38

    
39
// marks for all datasets
40
const dataSourceMarks = {}
41

    
42
const globalMarkersHolder = {}
43
// all marker from which popup was removed
44
// contains: {key:[L.circle,L.pupup]}
45
// key: x and y, x + '' + y string
46
let globalMarkersChanged = {}
47

    
48
const loadingCallbackNested = (func, delay) => {
49
  setTimeout(() => {
50
    func(loading)
51
    if (loading) {
52
      loadingCallbackNested(func, delay)
53
    }
54
  }, delay)
55
}
56
const loadingY = (delay = defaultLoaderDelay) => {
57
  loading++
58
  // check after nms if there is something that is loading
59
  loadingCallbackNested(() => loadingCallbackNested((isLoading) => loadingTimeline(isLoading), delay))
60
}
61
const loadingN = (delay = defaultLoaderDelay) => {
62
  loading--
63
  loadingCallbackNested(() => loadingCallbackNested((isLoading) => loadingTimeline(isLoading)), delay)
64
}
65

    
66
const changeCurrentTime = (time = null) => {
67
  if (time !== null) {
68
    currentTime = time
69
  } else {
70
    currentTime = parseInt($('#dropdown-time input[type="radio"]:checked').val())
71
  }
72
}
73

    
74
const changeCurrentDate = (date = null) => {
75
  if (date) {
76
    currentDate = new Date(date)
77
  } else {
78
    currentDate = new Date($('#date').val())
79
  }
80

    
81
  $('#date').val(currentDateToString())
82
  $('#player-date span').html(`${currentDate.getDate()}. ${currentDate.getMonth() + 1}. ${currentDate.getFullYear()}`)
83

    
84
  data = []
85
}
86
const currentDayToString = () => {
87
  const day = currentDate.getDate()
88
  return day > 9 ? `${day}` : `0${day}`
89
}
90
const currentMonthToString = () => {
91
  const month = currentDate.getMonth() + 1
92
  return month > 9 ? `${month}` : `0${month}`
93
}
94
const currentDateToString = () => `${currentDate.getFullYear()}-${currentMonthToString()}-${currentDayToString()}`
95
const addDayToCurrentDate = (day) => {
96
  currentDate.setDate(currentDate.getDate() + day)
97
  changeCurrentDate(currentDate)
98
}
99
const toggleDayLock = () => {
100
  lockedDay = !lockedDay
101
  $('#player-date').toggleClass('lock')
102
}
103

    
104
const fetchByNameDate = async (baseRoute, name, date, currentTime) => {
105
  const headers = new Headers()
106
  const myRequest = new Request(baseRoute + '/' + name + '/' + date + '/' + currentTime, {
107
    method: 'GET',
108
    headers: headers
109
  })
110
  const beforeJson = await fetch(myRequest)
111
  return beforeJson.json()
112
}
113

    
114
const fetchDataSourceMarks = async (positionRoute, datasetName) => {
115
  const headers = new Headers()
116
  const myRequest = new Request(positionRoute + '/' + datasetName, {
117
    method: 'GET',
118
    headers: headers
119
  })
120
  const beforeJson = await fetch(myRequest)
121
  return beforeJson.json()
122
}
123

    
124
const genPopUpControlButtons = (currentPage, numPages, onNextClick, onPreviousClick) => ({
125
  previousButton: '<button id="previous-info-btn" class="circle-button" onclick="previousInfo()"></button>',
126
  nextButton: '<button id="next-info-btn" class="circle-button next" onclick="nextInfo()"></button>',
127
  posInfo: `<div id="count-info">${currentPage} z ${numPages}</div>`
128
})
129

    
130
const genPopUpControls = (controls) => {
131
  return `<div class="popup-controls">${controls ? controls.reduce((sum, item) => sum + item, '') : ''}</div>`
132
}
133

    
134
const genMultipleDatasetsPopUp = (sum, currentPos, maxPos, datasetName) => {
135
  const popupHeader = `<strong id="dataset-info">${datasetName}</strong>`
136
  const popupData = `<div id="number-info"><span id="digit-info">${sum}</span></div>`
137
  const { previousButton, nextButton, posInfo } = genPopUpControlButtons(currentPos, maxPos)
138

    
139
  return `
140
  ${popupHeader}
141
  ${popupData}
142
  ${genPopUpControls([previousButton, posInfo, nextButton])}
143
  `
144
}
145

    
146
const prepareLayerPopUp = (lat, lng, num, className) => L.popup({
147
  autoPan: false,
148
  className: className
149
}).setLatLng([lat / num, lng / num])
150

    
151
const genPopUp = (datasetName, place, count, sum, currentPos, maxPos) => {
152
  const popupHeader = `
153
    <strong>${datasetName}</strong>
154
    <div id="place-info">${place}</div>`
155
  const popupData = `
156
    <div id="number-info">
157
      <span id="digit-info">${count}</span>
158
      <span id="total-info">${(sum && (sum != count)) ? '/' + sum : ''}</span>
159
    </div>`
160
  const { previousButton, nextButton, posInfo } = genPopUpControlButtons(currentPos, maxPos)
161

    
162
  return `
163
  ${popupHeader}
164
  ${popupData}
165
  ${genPopUpControls(maxPos > 1 ? [previousButton, posInfo, nextButton] : null)}
166
  `
167
}
168

    
169
const onCheckboxClicked = async (checkbox) => {
170
  if ($(checkbox).prop('checked')) {
171
    loadCurrentTimeHeatmap(dataSourceRoute, positionsSourceRoute, 0)
172
    changeUrl()
173
  } else {
174
    loadCheckboxDatasetNameData()
175

    
176
    data.forEach((item, index) => {
177
      Object.keys(item).forEach((datasetName) => {
178
        if (datasetName === $(checkbox).val()) {
179
          delete data[index][datasetName]
180
        }
181
      })
182
      drawHeatmap(data[currentTime])
183
    })
184

    
185
    changeUrl()
186
  }
187
}
188

    
189
const debounce = (func, delay) => {
190
  let inDebounce
191
  return function () {
192
    const context = this
193
    const args = arguments
194
    clearTimeout(inDebounce)
195
    inDebounce = setTimeout(() => func.apply(context, args), delay)
196
  }
197
}
198

    
199
const onValueChangeRegister = () => {
200
  $('#date').change(function () {
201
    changeCurrentDate($(this).val())
202
    loadCurrentTimeHeatmap(dataSourceRoute, positionsSourceRoute, 0)
203
    changeUrl()
204
  })
205

    
206
  $('#dropdown-time input[type="radio"]').each(function () {
207
    $(this).change(function () {
208
      changeHour(parseInt($(this).val()))
209
      drawHeatmap(data[currentTime])
210
    })
211
  })
212

    
213
  $('#dropdown-dataset input[type="checkbox"]').each(function () {
214
    $(this).change(
215
      debounce(() => onCheckboxClicked(this), 1000)
216
    )
217
  })
218
}
219

    
220
/**
221
 * Initialize leaflet map on start position which can be default or set based on user action
222
 */
223
// eslint-disable-next-line no-unused-vars
224
function initMap () {
225
  startX = localStorage.getItem('lat') || startX
226
  startY = localStorage.getItem('lng') || startY
227
  startZoom = localStorage.getItem('zoom') || startZoom
228

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

    
231
  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
232
    attribution: '',
233
    maxZoom: 19
234
  }).addTo(mymap)
235

    
236
  mymap.on('click', showInfo)
237
}
238

    
239
const getInfoLength = () => {
240
  const infoKeys = Object.keys(info)
241
  if (infoKeys.length === 1) {
242
    // return number of records in one dataset (one dataset in area)
243
    return info[infoKeys[0]].items.length
244
  }
245
  // return number of datasets (agregation of all datasets in area)
246
  return infoKeys.length
247
}
248

    
249
const getElFromObjectInfo = (position) => {
250
  const keys = Object.keys(info)
251
  return info[keys[position]]
252
}
253

    
254
const hasInfoMultipleDatasets = () => {
255
  return Object.keys(info).length > 1
256
}
257

    
258
function showInfo (e) {
259
  info = []
260
  currentInfo = 0
261

    
262
  // https://wiki.openstreetmap.org/wiki/Zoom_levels
263
  // Todo change to variable - it is used in heatmap init
264
  const stile = 40075016.686 * Math.cos(startX) / Math.pow(2, mymap.getZoom())
265
  const radius = 25 * stile / 256
266

    
267
  let i = 0
268
  let lat = 0
269
  let lng = 0
270

    
271
  let total = 0
272

    
273
  const datasetsInRadius = {}
274

    
275
  Object.keys(data[currentTime]).forEach((key) => {
276
    const namedData = data[currentTime][key]
277
    namedData.items.forEach(element => {
278
      if (e.latlng.distanceTo(new L.LatLng(element.x, element.y)) < radius) {
279
        lat += element.x
280
        lng += element.y
281
        info[i] = { place: element.place, number: element.number, datasetName: key }
282
        total += parseInt(element.number)
283
        i++
284
        datasetsInRadius[key] = true
285
      }
286
    })
287
  })
288

    
289
  // Process info for more then one dataset
290

    
291
  info = info.reduce((acc, item) => {
292
    if (!acc[item.datasetName]) {
293
      acc[item.datasetName] = {
294
        items: [],
295
        number: 0,
296
        datasetName: item.datasetName
297
      }
298
    }
299

    
300
    acc[item.datasetName].items.push(item)
301
    acc[item.datasetName].number += Number(item.number)
302
    return acc
303
  }, {})
304

    
305
  // There is one dataset
306

    
307
  const numDatasets = Object.keys(datasetsInRadius).length
308

    
309
  if (!numDatasets) {
310
    return
311
  }
312

    
313
  if (numDatasets === 1) {
314
    const infoDict = getElFromObjectInfo(0)
315
    const info_ = infoDict.items
316
    const { place, number } = info_[currentInfo]
317
    prepareLayerPopUp(lat, lng, i, `popup-${infoDict.datasetName}`)
318
      .setContent(genPopUp(datasetDictNameDisplayName[infoDict.datasetName], place, number, total, currentInfo + 1, info_.length))
319
      .openOn(mymap)
320

    
321
    if (info_.length === 1) {
322
      $('#previous-info-btn').prop('disabled', true)
323
      $('#next-info-btn').prop('disabled', true)
324
      $('.popup-controls').hide()
325
    }
326
  } else {
327
    const { datasetName, number } = getElFromObjectInfo(currentInfo)
328

    
329
    prepareLayerPopUp(lat, lng, i, `popup-${datasetName}`)
330
      .setContent(genMultipleDatasetsPopUp(number, currentInfo + 1, getInfoLength(), datasetDictNameDisplayName[datasetName]))
331
      .openOn(mymap)
332
  }
333
}
334

    
335
// eslint-disable-next-line no-unused-vars
336
function previousInfo () {
337
  const infoLength = getInfoLength()
338
  const previousCurrentInfo = currentInfo
339

    
340
  currentInfo = (currentInfo + infoLength - 1) % infoLength
341
  displayInfoText(previousCurrentInfo)
342
}
343

    
344
// eslint-disable-next-line no-unused-vars
345
function nextInfo () {
346
  const infoLength = getInfoLength()
347
  const previousCurrentInfo = currentInfo
348

    
349
  currentInfo = (currentInfo + 1) % infoLength
350
  displayInfoText(previousCurrentInfo)
351
}
352

    
353
function displayInfoText (previousInfoNum) {
354
  const previousInfo = hasInfoMultipleDatasets() ? getElFromObjectInfo(previousInfoNum) : getElFromObjectInfo(0).items[previousInfoNum]
355
  const info_ = hasInfoMultipleDatasets() ? getElFromObjectInfo(currentInfo) : getElFromObjectInfo(0).items[currentInfo]
356
  const infoLength = getInfoLength()
357
  const datasetInfo = $('#dataset-info')
358

    
359
  if (datasetInfo) {
360
    $(datasetInfo).html(datasetDictNameDisplayName[info_.datasetName])
361
  }
362

    
363
  $('#place-info').html(info_.place ? info_.place : info_.datasetName)
364
  $('#digit-info').html(info_.number)
365
  $('#count-info').html(currentInfo + 1 + ' z ' + infoLength)
366

    
367
  $('.leaflet-popup').removeClass(`popup-${previousInfo.datasetName}`)
368
  $('.leaflet-popup').addClass(`popup-${info_.datasetName}`)
369
}
370

    
371
// eslint-disable-next-line no-unused-vars
372
function setMapView (latitude, longitude, zoom) {
373
  localStorage.setItem('lat', latitude)
374
  localStorage.setItem('lng', longitude)
375
  localStorage.setItem('zoom', zoom)
376
  mymap.setView([latitude, longitude], zoom)
377
}
378

    
379
/**
380
 * Change animation start from playing to stopped or the other way round
381
 */
382
// eslint-disable-next-line no-unused-vars
383
function changeAnimationState () {
384
  isAnimationRunning = !isAnimationRunning
385

    
386
  if (isAnimationRunning) {
387
    $('#animate-btn').removeClass('play').addClass('pause')
388
    timer = setInterval(function () { next() }, 800)
389
  } else {
390
    clearTimeout(timer)
391
    $('#animate-btn').removeClass('pause').addClass('play')
392
  }
393
}
394

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

    
411
async function next () {
412
  if (loading) {
413
    return
414
  }
415
  currentTime = (currentTime + 1) % 24
416
  changeHour(currentTime)
417
  mymap.closePopup()
418
  if (!lockedDay && (currentTime === 0)) {
419
    addDayToCurrentDate(1)
420
    await loadCurrentTimeHeatmap(dataSourceRoute, positionsSourceRoute)
421
  } else {
422
    drawHeatmap(data[currentTime])
423
  }
424
}
425

    
426
/**
427
 * Change browser url based on animation step.
428
 */
429
function changeUrl () {
430
  window.history.pushState(
431
    '',
432
    document.title,
433
    window.location.origin + window.location.pathname + `?date=${currentDateToString()}&time=${currentTime}${datasetSelected.reduce((acc, current) => acc + '&type=' + current, '')}`
434
  )
435
}
436

    
437
function updateHeaderControls () {
438
  $(`#time_${currentTime}`).prop('checked', true)
439
  $('#dropdownMenuButtonTime').html((currentTime < 10 ? '0' : '') + `${currentTime}:00`)
440
}
441

    
442
function setTimeline () {
443
  $('#player-time > span').text(currentTime + ':00')
444
  $('#player-time').attr('class', 'time hour-' + currentTime)
445
}
446
const loadingTimeline = (isLoading) => {
447
  if (isLoading) {
448
    loadingYTimeline()
449
  } else {
450
    loadingNTimeline()
451
  }
452
}
453
const loadingYTimeline = () => {
454
  $('#player-time > .spinner-border').removeClass('d-none')
455
  $('#player-time > span').text('')
456
}
457
const loadingNTimeline = () => {
458
  $('#player-time > .spinner-border').addClass('d-none')
459
  setTimeline()
460
}
461
const onChangeHour = (hour) => {
462
  changeHour(hour)
463
  drawHeatmap(data[currentTime])
464
}
465

    
466
const changeHour = (hour) => {
467
  changeCurrentTime(hour)
468
  updateHeaderControls()
469
  setTimeline()
470
  changeUrl()
471
}
472

    
473
/**
474
 * Load and display heatmap layer for current data
475
 * @param {string} opendataRoute route to dataset source
476
 * @param {string} positionsRoute  route to dataset postitions source
477
 */
478
// eslint-disable-next-line no-unused-vars
479
async function loadCurrentTimeHeatmap (opendataRoute, positionsRoute, loaderDelay = defaultLoaderDelay) {
480
  loadCheckboxDatasetNameData()
481

    
482
  dataSourceRoute = opendataRoute
483
  positionsSourceRoute = positionsRoute
484
  const allPromises = []
485
  data[currentTime] = {}
486

    
487
  const dataSelectedHandler = async (datasetName) => {
488
    if (!(datasetName in dataSourceMarks)) {
489
      dataSourceMarks[datasetName] = await fetchDataSourceMarks(positionsRoute, datasetName)
490
    }
491
    const datasetData = await fetchByNameDate(dataSourceRoute, datasetName, currentDateToString(), currentTime)
492
    data[currentTime][datasetName] = datasetData
493
  }
494
  datasetSelected.forEach((datasetName) => {
495
    allPromises.push(dataSelectedHandler(datasetName))
496
  })
497

    
498
  loadingY(loaderDelay)
499
  Promise.all(allPromises).then(
500
    () => {
501
      loadingN(0)
502
      drawDataSourceMarks(dataSourceMarks)
503
      drawHeatmap(data[currentTime])
504
      preload(currentTime, 1, currentDateToString())
505
      preload(currentTime, -1, currentDateToString())
506
    }
507
  )
508
}
509

    
510
function drawDataSourceMarks (data) {
511
  if (marksLayer != null) {
512
    mymap.removeLayer(marksLayer)
513
  }
514

    
515
  marksLayer = L.layerGroup()
516

    
517
  Object.keys(data).forEach((key_) => {
518
    for (var key in data[key_]) {
519
      const { x, y, name } = data[key_][key]
520
      const pop =
521
          prepareLayerPopUp(x, y, 1, `popup-${key_}`)
522
            .setContent(genPopUp(datasetDictNameDisplayName[key_], name, 0, 0, 1, 1))
523
      const newCircle =
524
        L.circle([x, y], { radius: 2, fillOpacity: 0.8, color: '#004fb3', fillColor: '#004fb3', bubblingMouseEvents: true })
525
          .bindPopup(pop)
526
      globalMarkersHolder[x + '' + y] = [newCircle, pop] // add new marker to global holders
527
      marksLayer.addLayer(
528
        newCircle
529
      )
530
    }
531
  })
532

    
533
  marksLayer.setZIndex(-1).addTo(mymap)
534
}
535

    
536
async function preload (time, change, date) {
537
  loadingY()
538
  for (let nTime = time + change; nTime >= 0 && nTime <= 23; nTime = nTime + change) {
539
    if (!data[nTime]) {
540
      data[nTime] = {}
541
    }
542

    
543
    datasetSelected.forEach(async (datasetName) => {
544
      if (!data[nTime][datasetName]) {
545
        data[nTime][datasetName] = await fetchByNameDate(dataSourceRoute, datasetName, date, nTime)
546
      }
547
    })
548
  }
549
  loadingN()
550
}
551

    
552
function drawHeatmap (dataRaw) {
553
  // Todo still switched
554
  const dataDict = dataRaw
555
  const mergedPoints = []
556
  let max = 0
557

    
558
  if (Object.keys(globalMarkersChanged).length) {
559
    Object.keys(globalMarkersChanged).forEach(function (key) {
560
      globalMarkersChanged[key][0].bindPopup(globalMarkersChanged[key][1])
561
    })
562
    globalMarkersChanged = {}
563
  }
564

    
565
  Object.keys(dataDict).forEach((key) => {
566
    const data = dataDict[key]
567
    max = Math.max(max, data.max)
568

    
569
    if (data != null) {
570
    // Bind back popups for markers (we dont know if there is any data for this marker or not)
571
      const points = data.items.map((point) => {
572
        const { x, y, number } = point
573
        const key = x + '' + y
574
        const holder = globalMarkersHolder[key]
575

    
576
        if (!globalMarkersChanged[key] && number) {
577
        // There is data for this marker => unbind popup with zero value
578
          holder[0] = holder[0].unbindPopup()
579
          globalMarkersChanged[key] = holder
580
        }
581

    
582
        return [x, y, number]
583
      })
584
      mergedPoints.push(...points)
585
    } else {
586
      if (heatmapLayer != null) {
587
        mymap.removeLayer(heatmapLayer)
588
      }
589
    }
590
  })
591

    
592
  if (heatmapLayer != null) {
593
    mymap.removeLayer(heatmapLayer)
594
  }
595

    
596
  if (mergedPoints.length) {
597
    heatmapLayer = L.heatLayer(mergedPoints, { max: max, minOpacity: 0.5, radius: 35, blur: 30 }).addTo(mymap)
598
  }
599
}
600

    
601
/**
602
 * Checks dataset availibility
603
 * @param {string} route authority for datasets availibility checks
604
 */
605
// eslint-disable-next-line no-unused-vars
606
function checkDataSetsAvailability (route) {
607
  $.ajax({
608
    type: 'POST',
609
    // Todo it might be good idea to change db collections format
610
    url: route + '/' + currentDateToString(),
611
    success: function (result) {
612
      updateAvailableDataSets(result)
613
    }
614
  })
615
}
616

    
617
function updateAvailableDataSets (available) {
618
  let leastOneOptionEnabled = false
619

    
620
  $('#dropdown-dataset .dropdown-item').each(function () {
621
    const input = $(this).find('input')
622
    const inputVal = input[0].value
623

    
624
    if (!(inputVal in available)) {
625
      $(this).addClass('disabled')
626
      $(input).prop('checked', false)
627
    } else {
628
      leastOneOptionEnabled = true
629
      $(this).removeClass('disabled')
630
    }
631
  })
632

    
633
  $('#btn-update-heatmap').prop('disabled', !leastOneOptionEnabled)
634
}
635

    
636
function formatDate (date) {
637
  var day = String(date.getDate())
638
  var month = String(date.getMonth() + 1)
639

    
640
  if (day.length === 1) {
641
    day = '0' + day
642
  }
643

    
644
  if (month.length === 1) {
645
    month = '0' + month
646
  }
647

    
648
  return date.getFullYear() + '-' + month + '-' + day
649
}
650

    
651
// eslint-disable-next-line no-unused-vars
652
function initDatepicker (availableDatesSource) {
653
  var availableDates = ''
654

    
655
  $.ajax({
656
    type: 'GET',
657
    url: availableDatesSource,
658
    success: function (result) {
659
      availableDates = String(result).split(',')
660
    }
661
  }).then(function () {
662
    $('#date').datepicker({
663
      format: 'yyyy-mm-dd',
664
      language: 'cs',
665
      beforeShowDay: function (date) {
666
        if (availableDates.indexOf(formatDate(date)) < 0) {
667
          return { enabled: false, tooltip: 'Žádná data' }
668
        } else {
669
          return { enabled: true }
670
        }
671
      },
672
      autoclose: true
673
    })
674
  })
675
}
676

    
677
function initLocationsMenu () {
678
  var locationsWrapper = '.locations'
679
  var locationsDisplayClass = 'show'
680

    
681
  if ($(window).width() <= 480) {
682
    $(locationsWrapper).removeClass(locationsDisplayClass)
683
  } else {
684
    $(locationsWrapper).addClass(locationsDisplayClass)
685
  }
686
}
687

    
688
function onDocumentReady () {
689
  $('#dropdown-dataset').on('click', function (e) {
690
    e.stopPropagation()
691
  })
692

    
693
  $('#btn-update-heatmap').prop('name', '')
694
  changeCurrentTime()
695
  changeCurrentDate()
696
  onValueChangeRegister()
697
}
698

    
699
const loadCheckboxDatasetNameData = () => {
700
  datasetSelected = []
701
  $('#dropdown-dataset .dropdown-item').each(function () {
702
    const input = $(this).find('input')
703
    const inputVal = input[0].value
704

    
705
    if (input[0].checked) {
706
      datasetSelected.push(inputVal)
707
    }
708

    
709
    datasetDictNameDisplayName[inputVal] = $(input).data('dataset-display-name')
710
  })
711
}
(2-2/2)