waibu-mpa
Version:
MPA support for Waibu Framework
515 lines (471 loc) • 17.2 kB
JavaScript
/* global _ */
class Wmpa {
constructor () {
this.lang = '<%= _meta.lang %>'
this.prefixVirtual = '<%= prefix.virtual %>'
this.prefixAsset = '<%= prefix.asset %>'
this.prefixMain = '<%= prefix.main %>'
this.accessTokenUrl = '<%= accessTokenUrl %>'
this.renderUrl = '<%= renderUrl %>'
this.apiPrefix = '<%= api.prefix %>'
this.apiExt = '<%= api.ext %>'
this.apiHeaderKey = '<%= api.headerKey %>'
this.apiDataKey = '<%= api.dataKey %>'
this.apiRateLimitCount = 0
this.apiRateLimitDelay = <%= api.rateLimitDelay %>
this.apiRateLimitRetry = <%= api.rateLimitRetry %>
this.formatOpts = <%= _jsonStringify(formatOpts, true) %>
this.fetchingApi = {}
this.formatTypes = <%= _jsonStringify(formatTypes, true) %>
this.formats = {
metric: {
speedFn: (val) => val,
speedUnit: 'kmh',
distanceFn: (val) => val,
distanceUnit: 'km',
areaFn: (val) => val,
areaUnit: 'km²',
degreeFn: (val) => val,
degreeUnit: '°',
degreeUnitSep: ''
},
imperial: {
speedFn: (val) => val / 1.609,
speedUnit: 'mih',
distanceFn: (val) => val / 1.609,
distanceUnit: 'mi',
areaFn: (val) => val / 2.59,
areaUnit: 'mi²',
degreeFn: (val) => val,
degreeUnit: '°',
degreeUnitSep: ''
},
nautical: {
speedFn: (val) => val / 1.852,
speedUnit: 'knot',
distanceFn: (val) => val / 1.852,
distanceUnit: 'nm',
areaFn: (val) => val / 2.92,
areaUnit: 'nm²',
degreeFn: (val) => val,
degreeUnit: '°',
degreeUnitSep: ''
}
}
this.init()
}
init = () => {
window.addEventListener('load', evt => {
if (window.hljs) window.hljs.highlightAll()
})
document.addEventListener('alpine:initializing', () => {
Alpine.store('wmpa', {
loading: false,
reqAborted: null
})
})
fetch(this.accessTokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'text/plain' }
}).then(resp => {
return resp.text()
}).then(token => {
this.accessToken = token
})
}
isSet = (value) => {
return ![undefined, null].includes(value)
}
randomRange = (min, max) => {
return Math.floor(Math.random() * (max - min + 1) + min)
}
randomId = (length = 10, noNum = true) => {
let result = ''
let chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
if (!noNum) chars += '0123456789'
const charsLength = chars.length
let counter = 0
while (counter < length) {
result += chars.charAt(Math.floor(Math.random() * charsLength))
counter += 1
}
return result
}
fetchRender = async (body) => {
if (_.isArray(body)) body = body.join('\n')
const resp = await fetch(this.renderUrl, {
method: 'POST',
headers: { 'Content-Type': 'text/plain', 'Waibu-Referer': window.location.href },
body
})
if (!resp.ok) throw new Error('Response status: ' + resp.status)
return await resp.text()
}
fetchApi = async (endpoint, opts, filter = {}) => {
let wait = 0
while(!this.accessToken && wait < 50) {
wait++
await this.delay(100)
}
Alpine.store('wmpa').reqAborted = null
const oendpoint = endpoint
const oopts = _.cloneDeep(opts)
const ofilter = _.cloneDeep(filter)
let options = _.cloneDeep(opts) ?? {}
options.fetching = options.fetching ?? {}
if (options.fetching) {
this.fetchingApi[oendpoint] = this.fetchingApi[oendpoint] ?? {}
if (this.fetchingApi[oendpoint].status === 'fetching') return
this.fetchingApi[oendpoint].status = 'fetching'
delete options.fetching
}
Alpine.store('wmpa').loading = true
endpoint = '/' + this.apiPrefix + endpoint + this.apiExt
options.headers = options.headers ?? {}
options.headers[this.apiHeaderKey] = this.accessToken
const abortCtrl = new AbortController()
options.signal = abortCtrl.signal
if (this.fetchingApi[oendpoint]) this.fetchingApi[oendpoint].abortCtrl = abortCtrl
const { mapSearch } = filter
if (mapSearch) {
Alpine.store('mapSearch').busy = mapSearch
delete filter.mapSearch
}
const qs = new URLSearchParams(filter)
endpoint += '?' + qs.toString()
const req = new Request(endpoint, options)
let resp
try {
resp = await fetch(req)
const result = await resp.json()
delete this.fetchingApi[oendpoint]
Alpine.store('wmpa').loading = false
if (mapSearch) Alpine.store('mapSearch').busy = false
if (resp.status >= 500) return []
if (resp.ok) {
this.apiRateLimitCount = 0
return result[this.apiDataKey]
}
if (resp.status === 429) {
this.apiRateLimitCount++
if (this.apiRateLimitCount > this.apiRateLimitRetry) {
this.apiRateLimitCount = 0
return []
}
await this.delay(this.apiRateLimitDelay)
return this.fetchApi(oendpoint, oopts, ofilter)
} else return []
} catch (err) {
if (mapSearch) Alpine.store('mapSearch').busy = false
if (req.signal.aborted) {
Alpine.store('wmpa').reqAborted = oendpoint
this.fetchingApi[oendpoint].status = 'abort:Request aborted'
}
if (err instanceof TypeError && err.message === 'Failed to fetch') {
window.location.reload()
}
return []
}
}
createComponentFromHtml = (html, wrapper) => {
if (wrapper) html = '<' + wrapper + '>' + html + '</' + wrapper + '>'
const tpl = document.createElement('template')
tpl.innerHTML = html
return tpl.content.firstElementChild
}
getElement = (selector) => {
return selector instanceof HTMLElement ? selector : document.querySelector(selector)
}
createComponent = async (body, wrapper) => {
if (_.isArray(body)) body = body.join('\n')
const html = await this.fetchRender(body)
return this.createComponentFromHtml(html, wrapper)
}
replaceWithComponentHtml = (html, selector, wrapper) => {
const cmp = this.createComponentFromHtml(html, wrapper)
const el = this.getElement(selector)
if (!el) return
el.replaceWith(cmp)
return cmp.getAttribute('id')
}
replaceWithComponent = async (body, selector, wrapper) => {
let cmp
if (_.isString(body) || _.isArray(body)) cmp = await this.createComponent(body, wrapper)
else cmp = body
const el = this.getElement(selector)
if (!el) return
el.replaceWith(cmp)
return cmp.getAttribute('id')
}
addComponentHtml = (html, selector = 'body', wrapper) => {
const cmp = this.createComponentFromHtml(html, wrapper)
const el = this.getElement(selector)
if (!el) return
el.appendChild(cmp)
return cmp.getAttribute('id')
}
addComponent = async (body, selector = 'body', wrapper) => {
let cmp
if (_.isString(body) || _.isArray(body)) cmp = await this.createComponent(body, wrapper)
else cmp = body
const el = this.getElement(selector)
if (!el) return
el.appendChild(cmp)
return cmp.getAttribute('id')
}
alpineScope = (selector) => {
if (!selector) selector = '#' + Alpine.store('map').id
const el = document.querySelector(selector)
if (!el) return
return _.get(el, '_x_dataStack.0')
}
alpineScopeMethod = (fnName, selector) => {
const scope = this.alpineScope(selector)
if (!scope) return
let [ns, method] = fnName.split('.')
if (!method) return scope[ns].bind(scope)
let obj = scope[ns]
if (_.isFunction(obj)) {
obj = obj()
return obj[method].bind(obj)
}
return obj[method].bind(scope)
}
t = async (...params) => {
let [text, ...value] = params
value = value.join('|')
const body = '<c:t value="' + value + '">' + text + '</c:t>'
return await this.fetchRender(body)
}
copyToClipboard = async (content, isSelector) => {
if (isSelector) {
try {
const el = document.querySelector(content)
content = el.textContent
} catch (err) {
await wbs.notify('Invalid selector!', { type: 'danger' })
return
}
}
await navigator.clipboard.writeText(content)
}
loadScript = async (url) => {
const script = document.createElement('script')
script.src = url
script.type = 'text/javascript'
document.getElementsByTagName('body')[0].appendChild(script)
}
isAsync = (fn) => {
return fn.constructor.name === 'AsyncFunction'
}
parseValue = (value, type) => {
try {
if (['integer', 'smallint'].includes(type)) value = parseInt(value)
else if (['float', 'double'].includes(type)) value = parseFloat(value)
else if (['datetime', 'date', 'time'].includes(type)) value = new Date(Date.parse(value))
else if (['array', 'object'].includes(type)) value = JSON.parse(value)
} catch (err) {}
return value
}
postForm = (params, path, method) => {
method = method ?? 'POST'
const form = document.createElement('form')
form.setAttribute('method', method)
if (path) form.setAttribute('action', path)
params._src = window.location.href
for (const key in params) {
const input = document.createElement('input')
input.setAttribute('type', 'hidden')
input.setAttribute('name', key)
input.setAttribute('value', params[key]) // TODO: sanitizing
form.appendChild(input)
}
document.body.appendChild(form)
form.submit()
}
delay = async (ms) => {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}
formatSpeed = (value) => {
return this.formatByType('speed', value, 'float', { withUnit: false })
}
formatDistance = (value) => {
return this.formatByType('distance', value, 'float', { withUnit: false })
}
formatArea = (value) => {
return this.formatByType('area', value, 'float', { withUnit: false })
}
getUnitFormat = (options = {}) => {
const measure = Alpine.store('map') ? Alpine.store('map').measure : undefined
let unitSys = options.unitSys ?? measure ?? 'metric'
if (!['imperial', 'nautical', 'metric'].includes(unitSys)) unitSys = 'metric'
return { unitSys, format: this.formats[unitSys] }
}
formatByType = (type, value, dataType, options = {}) => {
const { format } = this.getUnitFormat(options)
console
const { withUnit = true } = options
const lang = options.lang ?? this.lang
value = format[type + 'Fn'](value)
const unit = format[type + 'Unit']
const sep = format[type + 'UnitSep'] ?? ' '
if (!withUnit) return [value, unit, sep]
const setting = _.defaultsDeep(options[dataType], this.formatOpts[dataType])
value = new Intl.NumberFormat(lang, setting).format(value)
return value + sep + unit
}
format = (value, type, options = {}) => {
const { emptyValue = this.formatOpts.emptyValue } = options
const lang = options.lang ?? this.lang
options.withUnit = options.withUnit ?? true
let valueFormatted
if ([undefined, null, ''].includes(value)) return emptyValue
if (type === 'auto') {
if (value instanceof Date) type = 'datetime'
}
if (['float', 'double'].includes(type) && wmapsUtil) {
if (options.latitude) return wmapsUtil.decToDms(value)
if (options.longitude) return wmapsUtil.decToDms(value, { isLng: true })
}
if (['integer', 'smallint', 'float', 'double'].includes(type)) {
value = ['integer', 'smallint'].includes(type) ? parseInt(value) : parseFloat(value)
if (isNaN(value)) return emptyValue
for (const u of this.formatTypes) {
if (options[u]) valueFormatted = this.formatByType(u, value, type, options)
}
}
if (['integer', 'smallint'].includes(type)) {
const setting = _.defaultsDeep(options.integer, this.formatOpts.integer)
value = new Intl.NumberFormat(lang, setting).format(Math.round(value))
return valueFormatted && options.withUnit ? valueFormatted : value
}
if (['float', 'double'].includes(type)) {
const setting = _.defaultsDeep(options[type], this.formatOpts[type])
value = new Intl.NumberFormat(lang, setting).format(value)
return valueFormatted && options.withUnit ? valueFormatted : value
}
if (['datetime', 'date'].includes(type)) {
const setting = _.defaultsDeep(options[type], this.formatOpts[type])
return new Intl.DateTimeFormat(lang, setting).format(new Date(value))
}
if (['time'].includes(type)) {
const setting = _.defaultsDeep(options.time, this.formatOpts.time)
return new Intl.DateTimeFormat(lang, setting).format(new Date('1970-01-01T' + value + 'Z'))
}
if (['array'].includes(type)) return value.join(', ')
if (['object'].includes(type)) return JSON.stringify(value)
return value
}
formatProps = ({ props = {}, schema = {}, emptyValue = '-' }) => {
props = _.cloneDeep(props)
for (const s in schema) {
if (!_.has(props, s)) props[s] = null
}
for (const p in props) {
const opts = _.cloneDeep(this.formatOpts)
if (_.isFunction(schema[p])) props[p] = schema[p](props[p])
else {
const [type, subType] = (schema[p] ?? 'auto').split(':')
if (subType) opts[subType] = true
if (emptyValue) opts.emptyValue = emptyValue
props[p] = this.format(props[p], type, opts)
}
}
return props
}
formatTpl = ({ props = {}, tpl = '', schema = {} }) => {
props = this.formatProps({ props, schema })
const compiled = _.isFunction(tpl) ? tpl : _.template(tpl)
return compiled(props)
}
mergeArrays = (arr1, arr2) => {
return [...arr1.concat(arr2).reduce((m, o) => {
m.set(o.member, Object.assign(m.get(o.member) || {}, o))
}, new Map()).values()]
}
getAge = (dt, fullView) => {
const secNum = Math.abs(dayjs().diff(dt, 's'))
let hours = Math.floor(secNum / 3600)
let days = Math.floor(hours / 24)
hours = hours - (days * 24)
let minutes = Math.floor((secNum - (days * 86400) - (hours * 3600)) / 60)
let seconds = secNum - (days * 86400) - (hours * 3600) - (minutes * 60)
if (hours < 10) { hours = '0' + hours }
if (minutes < 10) { minutes = '0' + minutes }
if (seconds < 10) { seconds = '0'+ seconds }
if (!fullView) {
let parts = minutes + ':' + seconds
if (days === 0) {
if (hours !== '00') return hours + ':' + parts
return parts
}
return days + 'd ' + hours + ':' + parts
}
return days + 'd ' + hours + ':' + minutes + ':' + seconds
}
secToHms = (secs, ms) => {
let remain
if (ms) {
remain = secs % 1000
secs = Math.floor(secs / 1000)
}
const secNum = parseInt(secs, 10)
const hours = Math.floor(secNum / 3600)
const minutes = Math.floor(secNum / 60) % 60
const seconds = secNum % 60
let hms = [hours, minutes, seconds]
.map(v => v < 10 ? '0' + v : v)
.filter((v, i) => v !== '00' || i > 0)
.join(':')
if (ms) hms += '+' + _.padStart(remain, 3, '0')
return hms
}
pascalCase = (text) => {
return _.upperFirst(_.camelCase(text))
}
insertString = (text, insertedText, index) => {
return text.slice(0, index) + insertedText + text.slice(index)
}
copyArray = (arr = []) => {
return [...arr]
}
toBase64 = (str) => {
return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g,
function toSolidBytes(match, p1) {
return String.fromCharCode('0x' + p1)
}))
}
fromBase64 = (str) => {
return decodeURIComponent(atob(str).split('').map(function(c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
}).join(''))
}
getTableDataset = (selector, ignoreHeader = true) => {
const table = document.querySelector(selector)
if (!table) return []
const data = []
let row
for (let i = ignoreHeader ? 1 : 0; row = table.rows[i]; i++) {
let col
const d = {}
for (let j = 0; col = row.cells[j]; j++) {
if (!col.dataset.key) continue
let value = col.dataset.value
if (['integer', 'float', 'smallint', 'double'].includes(col.dataset.type)) value = Number(value)
else if (col.dataset.type === 'boolean') value = value === 'true'
d[col.dataset.key] = value
}
data.push(d)
}
return data
}
}
const wmpa = new Wmpa() // eslint-disable-line no-unused-vars
window.wmpa = wmpa
if (window._ && window._.VERSION) {
window._.templateSettings.evaluate = /\{\%(.+?)\%\}/g
window._.templateSettings.interpolate = /\{\%=(.+?)\%\}/g
window._.templateSettings.escape = /\{\%-(.+?)\%\}/g
}