initial air quality block
This commit is contained in:
parent
31cbbfb3a4
commit
3b9df81a56
7 changed files with 460 additions and 0 deletions
126
app/blocks/power_ups/air_quality.js
Normal file
126
app/blocks/power_ups/air_quality.js
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import airQualityMixin from "./air_quality_mixin.js"
|
||||
|
||||
|
||||
export default {
|
||||
type: "airQuality",
|
||||
bytecodeKey: "airQuality",
|
||||
name: "Air Quality",
|
||||
colour: 360,
|
||||
ioPlus: true,
|
||||
description: "Fetch current or forecast air quality conditions at the specified location using Open-Meteo Air Quality API.",
|
||||
|
||||
connections: {
|
||||
mode: "value",
|
||||
output: "expression",
|
||||
},
|
||||
|
||||
mixins: [
|
||||
'replaceDropdownOptions',
|
||||
{ airQualityMixin }
|
||||
],
|
||||
|
||||
extensions: {
|
||||
prepareAirQuality: ({ block, observeData, data: { airQualityLocationOptions } }) => {
|
||||
// populate air quality locations
|
||||
if(!airQualityLocationOptions?.length) {
|
||||
airQualityLocationOptions = [[ "No locations! Visit Power-Ups -> Air Quality", "" ]]
|
||||
block.setEnabled(false)
|
||||
|
||||
} else if(airQualityLocationOptions[0][1] != "") {
|
||||
airQualityLocationOptions.unshift([ "Select Location", "" ])
|
||||
}
|
||||
|
||||
block.replaceDropdownOptions("POWER_UP_ID", airQualityLocationOptions)
|
||||
|
||||
// skip the rest if we're in the toolbox
|
||||
if(block.isInFlyout) { return }
|
||||
|
||||
// yield so fields can populate, flags can be set
|
||||
setTimeout(() => {
|
||||
// nope out for insertion markers
|
||||
if(block.isInsertionMarker()) { return }
|
||||
|
||||
// auto-disable block, if necessary
|
||||
block.setEnabledByLocation()
|
||||
|
||||
// react to incoming forecast data
|
||||
const unobserve = observeData('currentAirQualityByLocation', (newData = {}) => {
|
||||
// if this block is disposed, clean up this listener
|
||||
if (block.isDisposed()) { unobserve(); return }
|
||||
// update the reference to the injected/updated extension data
|
||||
block.currentAirQualityByLocation = newData
|
||||
// re-run the things that use the data
|
||||
block.refreshPropertyOptions({})
|
||||
})
|
||||
}, 1)
|
||||
}
|
||||
},
|
||||
|
||||
template: `
|
||||
Air Quality |CENTER
|
||||
At: %POWER_UP_ID
|
||||
When: %AIR_QUALITY_TIME
|
||||
Metric: %AIR_QUALITY_PROPERTY
|
||||
%AIR_QUALITY_PROPERTY_HELP
|
||||
`,
|
||||
|
||||
fields: {
|
||||
POWER_UP_ID: {
|
||||
description: "Select a location from those defined by the Air Quality Power-Up",
|
||||
options: [
|
||||
[ "Loading locations...", "" ],
|
||||
]
|
||||
},
|
||||
|
||||
AIR_QUALITY_TIME: {
|
||||
description: "Select which kind of forecast to query",
|
||||
options: [
|
||||
[ "Now", "current" ],
|
||||
[ "Today", "forecast_today" ],
|
||||
[ "Tomorrow", "forecast_tomorrow" ],
|
||||
[ "In 2 days", "forecast_day_2" ],
|
||||
[ "In 3 days", "forecast_day_3" ],
|
||||
[ "In 4 days", "forecast_day_4" ],
|
||||
[ "In 5 days", "forecast_day_5" ]
|
||||
]
|
||||
},
|
||||
|
||||
AIR_QUALITY_PROPERTY: {
|
||||
description: "Select which metric of the air quality to use.",
|
||||
label: ""
|
||||
},
|
||||
|
||||
AIR_QUALITY_PROPERTY_HELP: {
|
||||
label: ""
|
||||
},
|
||||
},
|
||||
|
||||
generators: {
|
||||
json: block => {
|
||||
const
|
||||
powerUpId = parseInt(block.getFieldValue('POWER_UP_ID'), 10),
|
||||
airQualityTime = block.getFieldValue('AIR_QUALITY_TIME'),
|
||||
airQualityProperty = block.getFieldValue('AIR_QUALITY_PROPERTY'),
|
||||
payload = { airQuality: {
|
||||
powerUpId, airQualityTime, airQualityProperty
|
||||
}}
|
||||
|
||||
return [ JSON.stringify(payload), 0 ]
|
||||
}
|
||||
},
|
||||
|
||||
regenerators: {
|
||||
json: blockObject => {
|
||||
const payload = blockObject.airQuality
|
||||
|
||||
return {
|
||||
type: "airQuality",
|
||||
fields: {
|
||||
POWER_UP_ID: String(payload.powerUpId),
|
||||
AIR_QUALITY_TIME: payload.airQualityTime,
|
||||
AIR_QUALITY_PROPERTY: payload.airQualityProperty,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
272
app/blocks/power_ups/air_quality_mixin.js
Normal file
272
app/blocks/power_ups/air_quality_mixin.js
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
// helpers for the air quality block
|
||||
// simplifies juggling the air quality api properties by location and period
|
||||
export default {
|
||||
onchange: function({ blockId, type, name, element, newValue }) {
|
||||
// only change events, for this block, unless it is a marker
|
||||
if(this.id !== blockId || type !== "change" || this.isInsertionMarker()) { return }
|
||||
|
||||
// double-check anytime this block gets enabled (disableOrphans)
|
||||
if(element === "disabled" && newValue === false) {
|
||||
this.setEnabledByLocation()
|
||||
|
||||
} else if(element === "field") {
|
||||
if (name === "POWER_UP_ID") {
|
||||
// enable/disabled based on location change
|
||||
this.setEnabledByLocation()
|
||||
this.refreshPropertyOptions({ locationKey: newValue })
|
||||
|
||||
} else if (name === "AIR_QUALITY_TIME") {
|
||||
// update available metrics when forecast changes
|
||||
this.refreshPropertyOptions({ timeKey: newValue })
|
||||
|
||||
} else if (name === "AIR_QUALITY_PROPERTY") {
|
||||
// update help text when the metric changes
|
||||
this.updateHelpTextForAirQualityProperty({ propertyKey: newValue })
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
setEnabledByLocation: function() {
|
||||
// must have a location and a parent (copacetic with disableOrphans)
|
||||
if(this.getFieldValue("POWER_UP_ID") === "" || !this.getParent()) {
|
||||
this.disabled || this.setEnabled(false)
|
||||
} else {
|
||||
this.disabled && this.setEnabled(true)
|
||||
}
|
||||
},
|
||||
|
||||
// helper to humanize camelCase and snake_case strings
|
||||
keyToLabel: function(key) {
|
||||
// Handle special cases first
|
||||
const specialCases = {
|
||||
'european_aqi': 'European AQI',
|
||||
'us_aqi': 'US AQI',
|
||||
'pm10': 'PM10',
|
||||
'pm2_5': 'PM2.5'
|
||||
}
|
||||
|
||||
if (specialCases[key]) {
|
||||
return specialCases[key]
|
||||
}
|
||||
|
||||
const label = key
|
||||
// replace underscores with spaces
|
||||
.replaceAll('_', '\u00A0')
|
||||
// capitalize the first letter of each word (handles both spaces and non-breaking spaces)
|
||||
.replace(/(^|[\s\u00A0])[a-z]/g, (match) => match.toUpperCase())
|
||||
|
||||
return label
|
||||
},
|
||||
|
||||
keyToHelpObject: function(key) {
|
||||
const keyWithoutDayPart = key.split(":").pop()
|
||||
|
||||
return this.HELP_TEXT_BY_PROP[keyWithoutDayPart] || {}
|
||||
},
|
||||
|
||||
keyToTooltip: function(key) {
|
||||
const { description="" } = this.keyToHelpObject(key)
|
||||
|
||||
return `${this.keyToLabel(key)}:\n ${description}`
|
||||
},
|
||||
|
||||
keyToCurrent: function(key, { timeKey=null, locationKey=null }) {
|
||||
const
|
||||
locationId = locationKey || this.getFieldValue("POWER_UP_ID"),
|
||||
forecast = timeKey || this.getFieldValue("AIR_QUALITY_TIME"),
|
||||
currentValue = this.currentAirQualityByLocation[locationId]?.[forecast]?.[key]
|
||||
|
||||
// return a current value with "Now" label, if found
|
||||
if(currentValue !== undefined && currentValue !== null) {
|
||||
return `Now:\u00A0${currentValue}`
|
||||
}
|
||||
|
||||
// use example value with "e.g." label otherwise
|
||||
const { example="unknown" } = this.keyToHelpObject(key)
|
||||
|
||||
return `e.g.\u00A0${example}`
|
||||
},
|
||||
|
||||
refreshPropertyOptions: function({ timeKey=null, locationKey=null }) {
|
||||
timeKey = timeKey || this.getFieldValue("AIR_QUALITY_TIME")
|
||||
|
||||
if(!timeKey) {
|
||||
// If no timeKey is available, default to 'current'
|
||||
timeKey = 'current'
|
||||
}
|
||||
|
||||
let optionKeys
|
||||
if(timeKey === 'current') {
|
||||
optionKeys = this.CURRENT_PROPS
|
||||
|
||||
} else if(timeKey.match(/forecast_/)) {
|
||||
optionKeys = this.DAILY_PROPS
|
||||
|
||||
} else {
|
||||
throw new Error(`[mixins.airQuality] timeKey not recognized: ${timeKey}`)
|
||||
}
|
||||
|
||||
// TODO: is there a way to add tooltips for each option as well?
|
||||
const propertyOptions = optionKeys.reduce((acc, key) => {
|
||||
const
|
||||
name = this.keyToLabel(key),
|
||||
current = this.keyToCurrent(key, { timeKey, locationKey }),
|
||||
label = `${name}\u00A0(${current})`
|
||||
|
||||
acc.push([ label, key ])
|
||||
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
// update the property options and the property help
|
||||
this.replaceDropdownOptions("AIR_QUALITY_PROPERTY", propertyOptions)
|
||||
this.updateHelpTextForAirQualityProperty({ timeKey, locationKey })
|
||||
},
|
||||
|
||||
updateHelpTextForAirQualityProperty: function({ propertyKey=null, timeKey=null, locationKey=null }) {
|
||||
const
|
||||
propertyField = this.getField("AIR_QUALITY_PROPERTY"),
|
||||
helpField = this.getField("AIR_QUALITY_PROPERTY_HELP")
|
||||
|
||||
if(!propertyKey) {
|
||||
propertyKey = propertyField.getValue()
|
||||
}
|
||||
|
||||
const
|
||||
helpText = this.keyToTooltip(propertyKey),
|
||||
current = this.keyToCurrent(propertyKey, { timeKey, locationKey })
|
||||
|
||||
// set a metric tooltip on dropdown and help text
|
||||
propertyField.setTooltip(helpText)
|
||||
helpField.setTooltip(helpText)
|
||||
|
||||
// update the help text with examples for this metric
|
||||
helpField.setValue(current)
|
||||
},
|
||||
|
||||
// a placeholder for the incoming preview data from live open-meteo requests
|
||||
currentAirQualityByLocation: {},
|
||||
|
||||
CURRENT_PROPS: [
|
||||
'european_aqi',
|
||||
'us_aqi',
|
||||
'pm10',
|
||||
'pm2_5',
|
||||
'carbon_monoxide',
|
||||
'nitrogen_dioxide',
|
||||
'sulphur_dioxide',
|
||||
'ozone',
|
||||
'aerosol_optical_depth',
|
||||
'dust',
|
||||
'uv_index',
|
||||
'uv_index_clear_sky',
|
||||
'ammonia',
|
||||
'alder_pollen',
|
||||
'birch_pollen',
|
||||
'grass_pollen',
|
||||
'mugwort_pollen',
|
||||
'olive_pollen',
|
||||
'ragweed_pollen'
|
||||
],
|
||||
|
||||
DAILY_PROPS: [
|
||||
'european_aqi',
|
||||
'us_aqi',
|
||||
'pm10',
|
||||
'pm2_5',
|
||||
'carbon_monoxide',
|
||||
'nitrogen_dioxide',
|
||||
'sulphur_dioxide',
|
||||
'ozone',
|
||||
'aerosol_optical_depth',
|
||||
'dust',
|
||||
'uv_index',
|
||||
'uv_index_clear_sky',
|
||||
'ammonia',
|
||||
'alder_pollen',
|
||||
'birch_pollen',
|
||||
'grass_pollen',
|
||||
'mugwort_pollen',
|
||||
'olive_pollen',
|
||||
'ragweed_pollen'
|
||||
],
|
||||
|
||||
HELP_TEXT_BY_PROP: {
|
||||
european_aqi: {
|
||||
example: "25",
|
||||
description: "European Air Quality Index. Ranges from 0-20 (good), 20-40 (fair), 40-60 (moderate), 60-80 (poor), 80-100 (very poor) and exceeds 100 for extremely poor conditions."
|
||||
},
|
||||
us_aqi: {
|
||||
example: "45",
|
||||
description: "United States Air Quality Index. Ranges from 0-50 (good), 51-100 (moderate), 101-150 (unhealthy for sensitive groups), 151-200 (unhealthy), 201-300 (very unhealthy) and 301-500 (hazardous)."
|
||||
},
|
||||
pm10: {
|
||||
example: "15.2",
|
||||
description: "Particulate matter with diameter smaller than 10 µm (PM10) close to surface (10 meter above ground), measured in μg/m³."
|
||||
},
|
||||
pm2_5: {
|
||||
example: "8.7",
|
||||
description: "Particulate matter with diameter smaller than 2.5 µm (PM2.5) close to surface (10 meter above ground), measured in μg/m³."
|
||||
},
|
||||
carbon_monoxide: {
|
||||
example: "245.8",
|
||||
description: "Carbon monoxide (CO) concentration close to surface (10 meter above ground), measured in μg/m³."
|
||||
},
|
||||
nitrogen_dioxide: {
|
||||
example: "12.4",
|
||||
description: "Nitrogen dioxide (NO2) concentration close to surface (10 meter above ground), measured in μg/m³."
|
||||
},
|
||||
sulphur_dioxide: {
|
||||
example: "3.1",
|
||||
description: "Sulphur dioxide (SO2) concentration close to surface (10 meter above ground), measured in μg/m³."
|
||||
},
|
||||
ozone: {
|
||||
example: "98.5",
|
||||
description: "Ozone (O3) concentration close to surface (10 meter above ground), measured in μg/m³."
|
||||
},
|
||||
aerosol_optical_depth: {
|
||||
example: "0.15",
|
||||
description: "Aerosol optical depth at 550 nm of the entire atmosphere to indicate haze. Dimensionless value."
|
||||
},
|
||||
dust: {
|
||||
example: "2.3",
|
||||
description: "Saharan dust particles close to surface level (10 meter above ground), measured in μg/m³."
|
||||
},
|
||||
uv_index: {
|
||||
example: "6",
|
||||
description: "UV index considering clouds. See ECMWF UV Index recommendation for more information."
|
||||
},
|
||||
uv_index_clear_sky: {
|
||||
example: "8",
|
||||
description: "UV index for clear sky conditions (no clouds). See ECMWF UV Index recommendation for more information."
|
||||
},
|
||||
ammonia: {
|
||||
example: "1.8",
|
||||
description: "Ammonia (NH3) concentration close to surface (10 meter above ground), measured in μg/m³. Only available for Europe."
|
||||
},
|
||||
alder_pollen: {
|
||||
example: "12",
|
||||
description: "Alder pollen concentration, measured in grains/m³. Only available in Europe during pollen season with 4 days forecast."
|
||||
},
|
||||
birch_pollen: {
|
||||
example: "45",
|
||||
description: "Birch pollen concentration, measured in grains/m³. Only available in Europe during pollen season with 4 days forecast."
|
||||
},
|
||||
grass_pollen: {
|
||||
example: "78",
|
||||
description: "Grass pollen concentration, measured in grains/m³. Only available in Europe during pollen season with 4 days forecast."
|
||||
},
|
||||
mugwort_pollen: {
|
||||
example: "5",
|
||||
description: "Mugwort pollen concentration, measured in grains/m³. Only available in Europe during pollen season with 4 days forecast."
|
||||
},
|
||||
olive_pollen: {
|
||||
example: "23",
|
||||
description: "Olive pollen concentration, measured in grains/m³. Only available in Europe during pollen season with 4 days forecast."
|
||||
},
|
||||
ragweed_pollen: {
|
||||
example: "8",
|
||||
description: "Ragweed pollen concentration, measured in grains/m³. Only available in Europe during pollen season with 4 days forecast."
|
||||
}
|
||||
}
|
||||
}
|
||||
8
app/toolbox/air_quality.js
Normal file
8
app/toolbox/air_quality.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export default {
|
||||
name: 'Air Quality',
|
||||
colour: 360,
|
||||
|
||||
contents: [
|
||||
'airQuality'
|
||||
],
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import AirQuality from './air_quality.js'
|
||||
import Feeds from './feeds.js'
|
||||
import Logic from './logic.js'
|
||||
import Math from './math.js'
|
||||
|
|
@ -19,5 +20,6 @@ export default [
|
|||
Feeds,
|
||||
Notifications,
|
||||
Weather,
|
||||
AirQuality,
|
||||
Utility
|
||||
]
|
||||
|
|
|
|||
|
|
@ -63,6 +63,10 @@ export default defineConfig({
|
|||
"text": "Weather Locations",
|
||||
"link": "#"
|
||||
},
|
||||
{
|
||||
"text": "Air Quality Locations",
|
||||
"link": "#"
|
||||
},
|
||||
{
|
||||
"text": "IO Bytecode Explorer",
|
||||
"link": "#"
|
||||
|
|
|
|||
47
src/index.js
47
src/index.js
|
|
@ -74,12 +74,24 @@ const workspace = inject('blocklyDiv', {
|
|||
[ "Varick", "2" ],
|
||||
[ "Shenzhen", "3" ],
|
||||
],
|
||||
airQualityLocationOptions: [
|
||||
[ "Industry City", "1" ],
|
||||
[ "Varick", "2" ],
|
||||
[ "Shenzhen", "3" ],
|
||||
],
|
||||
currentWeatherByLocation: {
|
||||
1: {
|
||||
current: {
|
||||
cloudCover: "5.4321",
|
||||
}
|
||||
}
|
||||
},
|
||||
currentAirQualityByLocation: {
|
||||
1: {
|
||||
current: {
|
||||
european_aqi: "25",
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
injectOptions: {
|
||||
|
|
@ -180,6 +192,41 @@ workspace.addChangeListener(function({ blockId, type, name, element, newValue, o
|
|||
}, 1500)
|
||||
})
|
||||
|
||||
// air quality block live data fetcher/updater
|
||||
workspace.addChangeListener(function({ blockId, type, name, element, newValue, oldValue }) {
|
||||
// when an air quality block changes its location
|
||||
if(!blockId || type !== "change" || workspace.getBlockById(blockId).type !== "airQuality" || element !== "field" || name === "AIR_QUALITY_PROPERTY_HELP") {
|
||||
return
|
||||
}
|
||||
|
||||
// quick/dirty for demo
|
||||
// if it is changing now, use newValue, otherwise fetch from field
|
||||
const
|
||||
block = workspace.getBlockById(blockId),
|
||||
currentLocation = name === "POWER_UP_ID"
|
||||
? newValue
|
||||
: block.getFieldValue('POWER_UP_ID'),
|
||||
currentTimeKey = name === "AIR_QUALITY_TIME"
|
||||
? newValue
|
||||
: block.getFieldValue('AIR_QUALITY_TIME'),
|
||||
currentMetricKey = name === "AIR_QUALITY_PROPERTY"
|
||||
? newValue
|
||||
: block.getFieldValue('AIR_QUALITY_PROPERTY') // this can be wrong if time changed and props haven't been replaced yet
|
||||
|
||||
const newData = {
|
||||
[currentLocation]: {
|
||||
[currentTimeKey]: {
|
||||
[currentMetricKey]: Math.random().toString().slice(0,5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// delay to simulate a request happening
|
||||
setTimeout(() => {
|
||||
addExtensionData("currentAirQualityByLocation", newData)
|
||||
}, 1500)
|
||||
})
|
||||
|
||||
|
||||
setInterval(() => {
|
||||
const
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ describe("DefinitionSet", function() {
|
|||
it("has mixins, including inline mixins from blocks", function() {
|
||||
assert.isAbove(Object.keys(this.definitionSet.mixins).length, 1)
|
||||
assert.exists(this.definitionSet.mixins.weatherMixin, "Expected an inline mixin to be present")
|
||||
assert.exists(this.definitionSet.mixins.airQualityMixin, "Expected air quality inline mixin to be present")
|
||||
})
|
||||
|
||||
it("has extensions, including inline extensions from blocks", function() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue