1

I have been working with code from this posting to determine my coordinate location within a Google Visualization table. Works great on a table without paging.

After enabling paging, the coordinates work when clicking on cells within page 1 but stop working after page 2 is activated.

var table = new google.visualization.ChartWrapper({
    chartType: 'Table',
    containerId: 'div_table',
    options: {
      allowHtml: true,
      page: 'enable', //THIS HAS BEEN ADDED
      pageSize: '3'      
    }
});

Any clue how I can make the coordinates work on page 2?

By clicking on the first record on page 2, I would need to know I'm in row 3 column x. Thank you as always!

Here is my full code example (ORIGINAL).

google.charts.load('current', {
  'packages': ['corechart', 'table', 'gauge', 'controls', 'charteditor']
});

renderChart_onPageLoad();

function renderChart_onPageLoad() {
  google.charts.setOnLoadCallback(function() {
    drawTable();
  }); //END setOnLoadCallback
} //END function renderChart_onEvent

function drawTable() {
  var jsonArray = jsonDataArray_1to1(json);

  //Modify header row to include id and label
  jsonArray = arrayHeaderRow_id_label_date(jsonArray);
  console.log('jsonArray'); console.log(jsonArray);

  var data = google.visualization.arrayToDataTable(jsonArray, false); // 'false' means that the first row contains labels, not data.
  //console.log('data');
  //console.log(data);

  var dashboard = new google.visualization.Dashboard(document.getElementById('div_dashboard'));

  var categoryPicker1 = new google.visualization.ControlWrapper({
    'controlType': 'CategoryFilter',
    'containerId': 'div_categoryPicker1',
    'matchType': 'any',
    'options': {
      'filterColumnIndex': 0, //Column used in control
      'ui': {
        //'label': 'Actual State:',
        //'labelSeparator': ':',
        'labelStacking': 'vertical',
        'selectedValuesLayout': 'belowWrapping',
        'allowTyping': false,
        'allowMultiple': false,
        'allowNone': true
      }
    }
  });

  var table = new google.visualization.ChartWrapper({
    chartType: 'Table',
    containerId: 'div_table',
    options: {
      allowHtml: true,
      page: 'enable',
      pageSize: '3'      
    }
  });

  dashboard.bind([categoryPicker1], [table]);
  dashboard.draw(data);

  google.visualization.events.addListener(table, 'ready', function() {
    var container = document.getElementById(table.getContainerId());
    Array.prototype.forEach.call(container.getElementsByTagName('TD'), function(cell) {
      cell.addEventListener('click', selectCell);
    });

    function selectCell(sender) {
      var cell = sender.target;
      var row = cell.closest('tr');
      document.getElementById('output1').innerHTML = "Row: " + (row.rowIndex - 1) + " Column: " + cell.cellIndex;

      //NEW additional requirements

      var tableDataView = table.getDataTable();

      var selectedRow = row.rowIndex - 1;  // adjust for header row (-1)
      var selectedCol = cell.cellIndex;

      document.getElementById('output2').innerHTML = "selectedRow: " + selectedRow + " selectedCol: " + selectedCol;

      var colID = tableDataView.getColumnId(selectedCol);
      var colLabel = tableDataView.getColumnLabel(selectedCol);

      document.getElementById('output3').innerHTML = "colID: " + colID + " colLabel: " + colLabel;
    }

  });

}





//Library

function jsonDataArray_1to1(json) {
  //DYNAMIC JSON ARRAY

  var dataArray_cln = [];

  //A. The desired table requires the fixed columns of ..... to ..... these are directly taken from the JSON.
  var dataArray_keys = Object.keys(json[0]);

  dataArray_cln.push(dataArray_keys);

  //Add rows 1 to json.length with null values
  var dataArray_rows = json.length;
  var dataArray_cols = dataArray_keys.length;

  for (i = 0; i < dataArray_rows; i++) {
    dataArray_cln.push(Array(dataArray_cols).fill(null));
  }

  //Update array from json data
  for (i = 0; i < dataArray_rows; i++) {
    //[i + 1] because row 0 is the header, push begins with row 1
    //dataArray_cln[row][col]

    //Content in row "i" is positioned into dataArray_cln[row][col] incrementing "j" to pull each key name from dataArray_keys
    for (var j = 0; j < dataArray_keys.length; j++) {
      eval('dataArray_cln[i + 1][' + j + '] = json[i].' + dataArray_keys[j]);
    }
  }

  //console.log(dataArray_cln);
  return dataArray_cln;
}

function arrayHeaderRow_id_label_date(arr) {
  for (var i = 0; i < arr[0].length; i++) {
    var valueOrig = arr[0][i];
    var valueNew;
    switch (true) {
      case valueOrig === 'wd':
        valueNew = JSON.parse('{"id":"' + valueOrig + '", "label":"' + valueOrig + '", "type": "date"}');
        break;
      default:
        valueNew = JSON.parse('{"id":"' + valueOrig + '", "label": "' + valueOrig + '"}');
    }
    arr[0][i] = valueNew;
  }
  return arr;
}
<script src="https://www.gstatic.com/charts/loader.js"></script>
<div id='div_dashboard'>
  <div id='div_categoryPicker1'></div>
  <div id='div_table'></div>
</div>

<div id="output1"></div><br/>
<div id="output2"></div><br/>
<div id="output3"></div><br/>

<script>
  var json = [{
      "division": "GS",
      "m1": 100.000000,
      "m2": 100.000000,
      "m3": null,
      "m4": null,
      "m5": null,
      "m6": null,
      "m7": null,
      "m8": null,
      "m9": null,
      "m10": null,
      "m11": null,
      "m12": null,
    },
    {
      "division": "GS",
      "m1": 100.000000,
      "m2": 90.000000,
      "m3": null,
      "m4": null,
      "m5": null,
      "m6": null,
      "m7": null,
      "m8": null,
      "m9": null,
      "m10": null,
      "m11": null,
      "m12": null,
    },
    {
      "division": "PS",
      "m1": null,
      "m2": null,
      "m3": 100.000000,
      "m4": null,
      "m5": 100.000000,
      "m6": 100.000000,
      "m7": 75.000000,
      "m8": null,
      "m9": null,
      "m10": null,
      "m11": null,
      "m12": null,
    },
    {
      "division": "PS",
      "m1": null,
      "m2": null,
      "m3": 100.000000,
      "m4": 100.000000,
      "m5": 100.000000,
      "m6": 100.000000,
      "m7": 80.000000,
      "m8": null,
      "m9": null,
      "m10": null,
      "m11": null,
      "m12": null,
    },
    {
      "division": "PS",
      "m1": null,
      "m2": null,
      "m3": 100.000000,
      "m4": 100.000000,
      "m5": 100.000000,
      "m6": 100.000000,
      "m7": 80.000000,
      "m8": null,
      "m9": null,
      "m10": null,
      "m11": null,
      "m12": null,
    }, {
      "division": "PS",
      "m1": null,
      "m2": null,
      "m3": 100.000000,
      "m4": 100.000000,
      "m5": 100.000000,
      "m6": 100.000000,
      "m7": 80.000000,
      "m8": null,
      "m9": null,
      "m10": null,
      "m11": null,
      "m12": null,
    }, {
      "division": "PS",
      "m1": null,
      "m2": null,
      "m3": 100.000000,
      "m4": 100.000000,
      "m5": 100.000000,
      "m6": 100.000000,
      "m7": 80.000000,
      "m8": null,
      "m9": null,
      "m10": null,
      "m11": null,
      "m12": null,
    }
  ];

</script>
cmill
  • 849
  • 7
  • 20

1 Answers1

1

the coordinates stop working on the next page,
because the cells that were assigned the 'click' event,
have been replaced with new cells.
(same thing happens when the table is sorted by clicking a column heading)

to enable coordinates on every page and after every sort,
listen for the table's 'page' & 'sort' events,
then re-apply the 'click' event on the new cells.

the page event has a property for the selected page.
save the page number and multiply it by the page size,
to get the correct row index for the page displayed.

following is the relevant code...

google.visualization.events.addListener(table, 'ready', function() {
  // initialize page number and size
  var page = 0;
  var pageSize = 10;
  if (table.getOption('page') === 'enable') {
    page = table.getOption('startPage');
    pageSize = table.getOption('pageSize');
  }
  enableCoordinates();

  // page event
  google.visualization.events.addListener(table.getChart(), 'page', function(sender) {
    page = sender.page;  // save current page
    enableCoordinates();
  });

  // sort event
  google.visualization.events.addListener(table.getChart(), 'sort', function() {
    page = 0;  // reset back to first page
    enableCoordinates();
  });

  function enableCoordinates() {
    var container = document.getElementById(table.getContainerId());
    Array.prototype.forEach.call(container.getElementsByTagName('TD'), function(cell) {
      cell.addEventListener('click', selectCell);
    });
  }

  function selectCell(sender) {
    var cell = sender.target;
    var row = cell.closest('tr');
    document.getElementById('output1').innerHTML = "Row: " + (row.rowIndex - 1) + " Column: " + cell.cellIndex;

    //NEW additional requirements

    var tableDataView = table.getDataTable();

    var selectedRow = row.rowIndex - 1;  // adjust for header row (-1)
    selectedRow = (page * pageSize) + selectedRow;  // adjust for page number
    var selectedCol = cell.cellIndex;

    document.getElementById('output2').innerHTML = "selectedRow: " + selectedRow + " selectedCol: " + selectedCol;

    var colID = tableDataView.getColumnId(selectedCol);
    var colLabel = tableDataView.getColumnLabel(selectedCol);

    document.getElementById('output3').innerHTML = "colID: " + colID + " colLabel: " + colLabel;
  }
});

see following working snippet...

var json = [{
    "division": "GS",
    "m1": 100.000000,
    "m2": 100.000000,
    "m3": null,
    "m4": null,
    "m5": null,
    "m6": null,
    "m7": null,
    "m8": null,
    "m9": null,
    "m10": null,
    "m11": null,
    "m12": null,
  },
  {
    "division": "GS",
    "m1": 100.000000,
    "m2": 90.000000,
    "m3": null,
    "m4": null,
    "m5": null,
    "m6": null,
    "m7": null,
    "m8": null,
    "m9": null,
    "m10": null,
    "m11": null,
    "m12": null,
  },
  {
    "division": "PS",
    "m1": null,
    "m2": null,
    "m3": 100.000000,
    "m4": null,
    "m5": 100.000000,
    "m6": 100.000000,
    "m7": 75.000000,
    "m8": null,
    "m9": null,
    "m10": null,
    "m11": null,
    "m12": null,
  },
  {
    "division": "PS",
    "m1": null,
    "m2": null,
    "m3": 100.000000,
    "m4": 100.000000,
    "m5": 100.000000,
    "m6": 100.000000,
    "m7": 80.000000,
    "m8": null,
    "m9": null,
    "m10": null,
    "m11": null,
    "m12": null,
  },
  {
    "division": "PS",
    "m1": null,
    "m2": null,
    "m3": 100.000000,
    "m4": 100.000000,
    "m5": 100.000000,
    "m6": 100.000000,
    "m7": 80.000000,
    "m8": null,
    "m9": null,
    "m10": null,
    "m11": null,
    "m12": null,
  }, {
    "division": "PS",
    "m1": null,
    "m2": null,
    "m3": 100.000000,
    "m4": 100.000000,
    "m5": 100.000000,
    "m6": 100.000000,
    "m7": 80.000000,
    "m8": null,
    "m9": null,
    "m10": null,
    "m11": null,
    "m12": null,
  }, {
    "division": "PS",
    "m1": null,
    "m2": null,
    "m3": 100.000000,
    "m4": 100.000000,
    "m5": 100.000000,
    "m6": 100.000000,
    "m7": 80.000000,
    "m8": null,
    "m9": null,
    "m10": null,
    "m11": null,
    "m12": null,
  }
];

google.charts.load('current', {
  packages: ['corechart', 'table', 'gauge', 'controls', 'charteditor']
}).then(drawTable);

function drawTable() {
  var jsonArray = jsonDataArray_1to1(json);

  //Modify header row to include id and label
  jsonArray = arrayHeaderRow_id_label_date(jsonArray);

  var data = google.visualization.arrayToDataTable(jsonArray);

  var dashboard = new google.visualization.Dashboard(document.getElementById('div_dashboard'));

  var categoryPicker1 = new google.visualization.ControlWrapper({
    'controlType': 'CategoryFilter',
    'containerId': 'div_categoryPicker1',
    'matchType': 'any',
    'options': {
      'filterColumnIndex': 0, //Column used in control
      'ui': {
        'labelStacking': 'vertical',
        'selectedValuesLayout': 'belowWrapping',
        'allowTyping': false,
        'allowMultiple': false,
        'allowNone': true
      }
    }
  });

  var table = new google.visualization.ChartWrapper({
    chartType: 'Table',
    containerId: 'div_table',
    options: {
      allowHtml: true,
      page: 'enable',
      pageSize: '3'
    }
  });

  dashboard.bind([categoryPicker1], [table]);
  dashboard.draw(data);

  google.visualization.events.addListener(table, 'ready', function() {
    // initialize page number and size
    var page = 0;
    var pageSize = 10;
    if (table.getOption('page') === 'enable') {
      page = table.getOption('startPage');
      pageSize = table.getOption('pageSize');
    }
    enableCoordinates();

    // page event
    google.visualization.events.addListener(table.getChart(), 'page', function(sender) {
      page = sender.page;  // save current page
      enableCoordinates();
    });

    // sort event
    google.visualization.events.addListener(table.getChart(), 'sort', function() {
      page = 0;  // reset back to first page
      enableCoordinates();
    });

    function enableCoordinates() {
      var container = document.getElementById(table.getContainerId());
      Array.prototype.forEach.call(container.getElementsByTagName('TD'), function(cell) {
        cell.addEventListener('click', selectCell);
      });
    }

    function selectCell(sender) {
      var cell = sender.target;
      var row = cell.closest('tr');
      document.getElementById('output1').innerHTML = "Row: " + (row.rowIndex - 1) + " Column: " + cell.cellIndex;

      //NEW additional requirements

      var tableDataView = table.getDataTable();

      var selectedRow = row.rowIndex - 1;  // adjust for header row (-1)
      selectedRow = (page * pageSize) + selectedRow;  // adjust for page number
      var selectedCol = cell.cellIndex;

      document.getElementById('output2').innerHTML = "selectedRow: " + selectedRow + " selectedCol: " + selectedCol;

      var colID = tableDataView.getColumnId(selectedCol);
      var colLabel = tableDataView.getColumnLabel(selectedCol);

      document.getElementById('output3').innerHTML = "colID: " + colID + " colLabel: " + colLabel;
    }

  });
}





//Library

function jsonDataArray_1to1(json) {
  //DYNAMIC JSON ARRAY

  var dataArray_cln = [];

  //A. The desired table requires the fixed columns of ..... to ..... these are directly taken from the JSON.
  var dataArray_keys = Object.keys(json[0]);

  dataArray_cln.push(dataArray_keys);

  //Add rows 1 to json.length with null values
  var dataArray_rows = json.length;
  var dataArray_cols = dataArray_keys.length;

  for (i = 0; i < dataArray_rows; i++) {
    dataArray_cln.push(Array(dataArray_cols).fill(null));
  }

  //Update array from json data
  for (i = 0; i < dataArray_rows; i++) {
    //[i + 1] because row 0 is the header, push begins with row 1
    //dataArray_cln[row][col]

    //Content in row "i" is positioned into dataArray_cln[row][col] incrementing "j" to pull each key name from dataArray_keys
    for (var j = 0; j < dataArray_keys.length; j++) {
      eval('dataArray_cln[i + 1][' + j + '] = json[i].' + dataArray_keys[j]);
    }
  }

  //console.log(dataArray_cln);
  return dataArray_cln;
}

function arrayHeaderRow_id_label_date(arr) {
  for (var i = 0; i < arr[0].length; i++) {
    var valueOrig = arr[0][i];
    var valueNew;
    switch (true) {
      case valueOrig === 'wd':
        valueNew = JSON.parse('{"id":"' + valueOrig + '", "label":"' + valueOrig + '", "type": "date"}');
        break;
      default:
        valueNew = JSON.parse('{"id":"' + valueOrig + '", "label": "' + valueOrig + '"}');
    }
    arr[0][i] = valueNew;
  }
  return arr;
}
<script src="https://www.gstatic.com/charts/loader.js"></script>
<div id="div_dashboard">
  <div id="div_categoryPicker1"></div>
  <div id="div_table"></div>
</div>

<div id="output1"></div><br/>
<div id="output2"></div><br/>
<div id="output3"></div><br/>

EDIT -- SORTING

when sorted, the underlying data table is not sorted,
just the chart on the screen.
afterwards, the row indexes displayed on the screen will not match the row indexes in the data table.

in order to sync the two for getting data from the data table,
you must use the chart method --> getSortInfo()

getSortInfo() returns an object with the following properties...

{"column":0,"ascending":false,"sortedIndexes":[6,5,4,3,2,1,0]}

then we can use the selected row,
to pull the corresponding data table row index from "sortedIndexes".

selectedRow = sortInfo.sortedIndexes[selectedRow];

see following working snippet...

var json = [{
    "division": "GS1",
    "m1": 100.000000,
    "m2": 100.000000,
    "m3": null,
    "m4": null,
    "m5": null,
    "m6": null,
    "m7": null,
    "m8": null,
    "m9": null,
    "m10": null,
    "m11": null,
    "m12": null,
  },
  {
    "division": "GS2",
    "m1": 100.000000,
    "m2": 90.000000,
    "m3": null,
    "m4": null,
    "m5": null,
    "m6": null,
    "m7": null,
    "m8": null,
    "m9": null,
    "m10": null,
    "m11": null,
    "m12": null,
  },
  {
    "division": "PS1",
    "m1": null,
    "m2": null,
    "m3": 100.000000,
    "m4": null,
    "m5": 100.000000,
    "m6": 100.000000,
    "m7": 75.000000,
    "m8": null,
    "m9": null,
    "m10": null,
    "m11": null,
    "m12": null,
  },
  {
    "division": "PS2",
    "m1": null,
    "m2": null,
    "m3": 100.000000,
    "m4": 100.000000,
    "m5": 100.000000,
    "m6": 100.000000,
    "m7": 80.000000,
    "m8": null,
    "m9": null,
    "m10": null,
    "m11": null,
    "m12": null,
  },
  {
    "division": "PS3",
    "m1": null,
    "m2": null,
    "m3": 100.000000,
    "m4": 100.000000,
    "m5": 100.000000,
    "m6": 100.000000,
    "m7": 80.000000,
    "m8": null,
    "m9": null,
    "m10": null,
    "m11": null,
    "m12": null,
  }, {
    "division": "PS4",
    "m1": null,
    "m2": null,
    "m3": 100.000000,
    "m4": 100.000000,
    "m5": 100.000000,
    "m6": 100.000000,
    "m7": 80.000000,
    "m8": null,
    "m9": null,
    "m10": null,
    "m11": null,
    "m12": null,
  }, {
    "division": "PS5",
    "m1": null,
    "m2": null,
    "m3": 100.000000,
    "m4": 100.000000,
    "m5": 100.000000,
    "m6": 100.000000,
    "m7": 80.000000,
    "m8": null,
    "m9": null,
    "m10": null,
    "m11": null,
    "m12": null,
  }
];

google.charts.load('current', {
  packages: ['corechart', 'table', 'gauge', 'controls', 'charteditor']
}).then(drawTable);

function drawTable() {
  var jsonArray = jsonDataArray_1to1(json);

  //Modify header row to include id and label
  jsonArray = arrayHeaderRow_id_label_date(jsonArray);

  var data = google.visualization.arrayToDataTable(jsonArray);

  var dashboard = new google.visualization.Dashboard(document.getElementById('div_dashboard'));

  var categoryPicker1 = new google.visualization.ControlWrapper({
    'controlType': 'CategoryFilter',
    'containerId': 'div_categoryPicker1',
    'matchType': 'any',
    'options': {
      'filterColumnIndex': 0, //Column used in control
      'ui': {
        'labelStacking': 'vertical',
        'selectedValuesLayout': 'belowWrapping',
        'allowTyping': false,
        'allowMultiple': false,
        'allowNone': true
      }
    }
  });

  var table = new google.visualization.ChartWrapper({
    chartType: 'Table',
    containerId: 'div_table',
    options: {
      allowHtml: true,
      page: 'enable',
      pageSize: '3'
    }
  });

  dashboard.bind([categoryPicker1], [table]);
  dashboard.draw(data);

  google.visualization.events.addListener(table, 'ready', function() {
    // initialize page number and size
    var page = 0;
    var pageSize = 10;
    if (table.getOption('page') === 'enable') {
      page = table.getOption('startPage');
      pageSize = table.getOption('pageSize');
    }
    enableCoordinates();

    // page event
    google.visualization.events.addListener(table.getChart(), 'page', function(sender) {
      page = sender.page;  // save current page
      enableCoordinates();
    });

    // sort event
    google.visualization.events.addListener(table.getChart(), 'sort', function() {
      page = 0;  // reset back to first page
      enableCoordinates();
    });

    function enableCoordinates() {
      var container = document.getElementById(table.getContainerId());
      Array.prototype.forEach.call(container.getElementsByTagName('TD'), function(cell) {
        cell.addEventListener('click', selectCell);
      });
    }

    function selectCell(sender) {
      var cell = sender.target;
      var row = cell.closest('tr');
      document.getElementById('output1').innerHTML = "Row: " + (row.rowIndex - 1) + " Column: " + cell.cellIndex;

      //NEW additional requirements

      var tableDataView = table.getDataTable();

      var selectedRow = row.rowIndex - 1;  // adjust for header row (-1)
      selectedRow = (page * pageSize) + selectedRow;  // adjust for page number
      var sortInfo = table.getChart().getSortInfo();  // save sorted info
      if (sortInfo.sortedIndexes !== null) {
        selectedRow = sortInfo.sortedIndexes[selectedRow];
      }
      var selectedCol = cell.cellIndex;

      document.getElementById('output2').innerHTML = "selectedRow: " + selectedRow + " selectedCol: " + selectedCol;

      var colID = tableDataView.getColumnId(selectedCol);
      var colLabel = tableDataView.getColumnLabel(selectedCol);

      document.getElementById('output3').innerHTML = "colID: " + colID + " colLabel: " + colLabel;

      document.getElementById('output4').innerHTML = tableDataView.getValue(selectedRow, 0);
    }

  });
}

//Library

function jsonDataArray_1to1(json) {
  //DYNAMIC JSON ARRAY

  var dataArray_cln = [];

  //A. The desired table requires the fixed columns of ..... to ..... these are directly taken from the JSON.
  var dataArray_keys = Object.keys(json[0]);

  dataArray_cln.push(dataArray_keys);

  //Add rows 1 to json.length with null values
  var dataArray_rows = json.length;
  var dataArray_cols = dataArray_keys.length;

  for (i = 0; i < dataArray_rows; i++) {
    dataArray_cln.push(Array(dataArray_cols).fill(null));
  }

  //Update array from json data
  for (i = 0; i < dataArray_rows; i++) {
    //[i + 1] because row 0 is the header, push begins with row 1
    //dataArray_cln[row][col]

    //Content in row "i" is positioned into dataArray_cln[row][col] incrementing "j" to pull each key name from dataArray_keys
    for (var j = 0; j < dataArray_keys.length; j++) {
      eval('dataArray_cln[i + 1][' + j + '] = json[i].' + dataArray_keys[j]);
    }
  }

  //console.log(dataArray_cln);
  return dataArray_cln;
}

function arrayHeaderRow_id_label_date(arr) {
  for (var i = 0; i < arr[0].length; i++) {
    var valueOrig = arr[0][i];
    var valueNew;
    switch (true) {
      case valueOrig === 'wd':
        valueNew = JSON.parse('{"id":"' + valueOrig + '", "label":"' + valueOrig + '", "type": "date"}');
        break;
      default:
        valueNew = JSON.parse('{"id":"' + valueOrig + '", "label": "' + valueOrig + '"}');
    }
    arr[0][i] = valueNew;
  }
  return arr;
}
<script src="https://www.gstatic.com/charts/loader.js"></script>
<div id="div_dashboard">
  <div id="div_categoryPicker1"></div>
  <div id="div_table"></div>
</div>

<div id="output1"></div><br/>
<div id="output2"></div><br/>
<div id="output3"></div><br/>
<div id="output4"></div><br/>
WhiteHat
  • 59,912
  • 7
  • 51
  • 133
  • The paging works great. I think there's something up with the sorting. I changed json in your code snippet so that each record has a unique division (GS1, GS2, PS1, etc.). Also added code at end `var division ` to output the division of the row clicked. Click on header m1 to sort. PS1 will come to the top in row 0. When you click on a cell for division PS1, it returns GS1 which is lower in the table. – cmill Jan 31 '19 at 18:57
  • Hi, the edit corrected the behavior after sorting. Implemented it in my project and it's working great. Than k you again! – cmill Feb 19 '19 at 20:47