// ==UserScript==
// @name numbeo-cost-of-living-comparison
// @namespace http://tampermonkey.net/
// @version 0.2.1
// @description Visualize cost-of-living data diff from numbeo.com
// @author neotan
// @match https://www.numbeo.com/*
// @grant GM_addStyle
// @grant GM_getResourceText
// @resource pureCss https://cdn.jsdelivr.net/npm/[email protected]/build/pure-min.min.css
// @resource tabulatorCss https://cdn.jsdelivr.net/npm/[email protected]/dist/css/tabulator.min.css
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/echarts.min.js
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/js/tabulator.min.js
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/ramda.min.js
// ==/UserScript==
;(async function() {
'use strict'
//------------------- Utilities START -------------------//
function renameKeys(cityName, rows) {
if (!rows) return
return R.map(
R.pipe(
R.toPairs,
R.map(([key, val]) => (['idx', 'item', 'category'].includes(key) ? [key, val] : [`${cityName}-${key}`, val])),
R.fromPairs
)
)(rows)
}
function camelize(str) {
if (!str) return str
return R.replace(/(?<=^|-)./g, R.toUpper)(str)
}
function toNumber(str) {
if (str == null) return
var numArr = str.trim().match(/[\d.-]/g)
return numArr == null ? numArr : parseFloat(numArr.join(''))
}
function scrollTo(htmlSelector) {
setTimeout(() => {
var scrollTop = $(htmlSelector).position().top || 0
$('html, body').animate({ scrollTop }, 'slow')
}, 3000)
}
function citiesStrToArray(citiesStr) {
if (!citiesStr) return
return R.pipe(R.split(','), R.map(R.pipe(R.trim, R.toLower, camelize)))(citiesStr)
}
function extractCostFromDoc(doc = '') {
var trs = $(doc).find('table.data_wide_table tr')
if (trs.length === 0) return
var category = 'Unknown'
return trs
.toArray()
.map((tr, idx) => {
var ths = $(tr).find('th')
var tds = $(tr).find('td')
var item
var median
var range
if (ths.length > 0) {
category = ths
.eq(0)
.text()
.trim()
} else if (tds.length > 0) {
item = tds
.eq(0)
.text()
.trim()
median = toNumber(
tds
.eq(1)
.text()
.trim()
)
range = tds
.eq(2)
.text()
.trim()
}
return item === null ? null : { idx, category, item, median, range }
})
.filter(row => row.item)
}
var cityCostUrl = 'https://www.numbeo.com/cost-of-living/in/'
async function fetchCityCostByName(cityName) {
try {
var response = await fetch(`${cityCostUrl}${cityName}?displayCurrency=USD`)
return response.text() // return a promise
} catch (err) {
console.warn(err)
}
}
function createSubColumns(data, showedSubColumnKeys = []) {
if (!data) return
return R.pipe(
R.mapObjIndexed((rows = [], cityName) => {
var columns = R.pipe(
R.prop(0),
R.pick(showedSubColumnKeys),
R.keys,
R.map(key => ({ title: key, field: `${cityName}-${key}`, sorter: 'number', align: 'right' }))
)(rows)
return {
title: cityName,
columns,
}
}),
R.values,
R.flatten
)(data)
}
var convertDataToRows = R.pipe(
R.mapObjIndexed((rows, cityName) => {
return renameKeys(cityName, rows)
}),
R.values,
R.reduce((acc, curr, i) => (acc == null ? curr : acc.map((obj, i) => R.mergeRight(obj, curr[i]))), null)
)
var barDefaultOptions = {
title: { text: 'Cost of Living' },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
toolbox: { show: true, feature: { dataView: { title: 'View Data', readOnly: false }, restore: { title: 'Restore' } } },
calculable: true,
legend: {
align: 'right',
selector: [
{ type: 'all', title: 'All' },
{ type: 'inverse', title: 'Inverse' },
],
itemGap: 20,
},
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { name: 'USD', type: 'value' },
yAxis: { type: 'category' },
dataZoom: [
{ show: true, top: '97%' },
{ show: true, yAxisIndex: 0, filterMode: 'none', width: 30, height: '80%', left: '95%', start: 80, end: 100 },
],
}
function getBarChartOption(cities = [], rows = [], options = {}) {
var sortedRows = R.pipe(
R.map(row => {
var sumMedian = R.pipe(
R.pickBy((_, key) => key.endsWith('-median')),
R.values,
R.sum
)(row)
return { ...row, sumMedian }
}),
R.sortBy(R.prop('sumMedian'))
)(rows)
var items = R.pluck('item')(sortedRows)
var series = R.map(city => {
var name = city
var data = R.pluck(`${city}-median`)(sortedRows)
return { name, data, type: 'bar' }
})(cities)
return R.pipe(R.assocPath(['legend', 'data'], cities), R.assocPath(['yAxis', 'data'], items), R.assoc('series', series))(options)
}
//------------------- Utilities END -------------------//
//------------------- Main Functions declaration START -------------------//
var defaultCities = 'irvine,seattle,los-angeles,austin,new-york,hoboken,Amsterdam,vancouver'
var defaultColumns
var showedSubColumnKeys
var domHtml
var costData
var barChart
function initEnv(cityNames) {
defaultColumns = [
{ title: 'Category', field: 'category' },
{ title: 'Item', field: 'item' },
]
showedSubColumnKeys = ['median']
domHtml = `
<form class="pure-form cities-form">
<label>
Cities: <input type="text" name="cities" size=100 placeholder="Input city names here." value=${cityNames.join(',')}>
</label>
<button type="submit" class="pure-button pure-button-primary" type="submit">Compare</button>
<span><a href="https://greasyfork.org/en/scripts/395215-numbeo-cost-of-living-comparison">☸</a></span>
</form>
<button class="pure-button pure-button-primary toggle-barchart">Toggle Bar-Chart</button>
<div id="echarts" style="width: 80%; height:700px;"/>
<button class="pure-button pure-button-primary toggle-table">Toggle Table</button>
<div id="tabulator-table" style="width: 80%;"/>
`
GM_addStyle(GM_getResourceText('pureCss'))
GM_addStyle(GM_getResourceText('tabulatorCss'))
$('body').prepend($(domHtml))
// initiate Charts
barChart = echarts.init(document.getElementById('echarts'))
barChart.showLoading({
text: 'Loading...',
})
// initiate listener
$('.toggle-barchart').click(() => $('#echarts').slideToggle('fast'))
$('.toggle-table').click(() => $('#tabulator-table').slideToggle('fast'))
$('.cities-form').submit(function(event) {
event.preventDefault()
var cityNames = R.pipe(() => $(this).serializeArray(), R.pathOr('', [0, 'value']), citiesStrToArray)(event)
if (cityNames) {
barChart.showLoading({
text: 'Loading...',
})
buildExtraDoms(cityNames)
return
}
})
console.log('init DONE!')
}
async function buildExtraDoms(cityNames) {
if (!cityNames || cityNames.length <= 0) return
try {
var promises = R.map(fetchCityCostByName)(cityNames)
var docs = await Promise.all(promises)
costData = R.pipe(R.zipObj(cityNames), R.map(extractCostFromDoc), R.reject(R.isNil))(docs)
var rows = convertDataToRows(costData)
// create Table
new Tabulator('#tabulator-table', {
// options ref: http://tabulator.info/examples/4.5
height: '500px',
movableColumns: true,
columns: [...defaultColumns, ...createSubColumns(costData, showedSubColumnKeys)],
data: convertDataToRows(costData),
})
// create Charts
var cities = R.keys(costData)
barChart.setOption(getBarChartOption(cities, rows, barDefaultOptions), true)
barChart.hideLoading()
scrollTo('.cities-form')
} catch (err) {
console.warn(err)
}
}
//------------------- Main Functions declaration END -------------------//
//------------------- Main Functions execution START -------------------//
var urlParams = new URLSearchParams(window.location.search)
var citiesStr = urlParams.get('cities')
var cityNames = citiesStrToArray(citiesStr || defaultCities)
initEnv(cityNames)
buildExtraDoms(cityNames)
})()