From 3b9df81a56132691e10f74ec8a84c20d3e5b4010 Mon Sep 17 00:00:00 2001 From: Justin Cooper Date: Thu, 24 Jul 2025 16:27:53 -0500 Subject: [PATCH] initial air quality block --- app/blocks/power_ups/air_quality.js | 126 ++++++++++ app/blocks/power_ups/air_quality_mixin.js | 272 ++++++++++++++++++++++ app/toolbox/air_quality.js | 8 + app/toolbox/index.js | 2 + docs/.vitepress/config.js | 4 + src/index.js | 47 ++++ test/src/definition_set_test.js | 1 + 7 files changed, 460 insertions(+) create mode 100644 app/blocks/power_ups/air_quality.js create mode 100644 app/blocks/power_ups/air_quality_mixin.js create mode 100644 app/toolbox/air_quality.js diff --git a/app/blocks/power_ups/air_quality.js b/app/blocks/power_ups/air_quality.js new file mode 100644 index 0000000..bcf6fcd --- /dev/null +++ b/app/blocks/power_ups/air_quality.js @@ -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, + } + } + } + } +} \ No newline at end of file diff --git a/app/blocks/power_ups/air_quality_mixin.js b/app/blocks/power_ups/air_quality_mixin.js new file mode 100644 index 0000000..075e509 --- /dev/null +++ b/app/blocks/power_ups/air_quality_mixin.js @@ -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." + } + } +} diff --git a/app/toolbox/air_quality.js b/app/toolbox/air_quality.js new file mode 100644 index 0000000..cdbc184 --- /dev/null +++ b/app/toolbox/air_quality.js @@ -0,0 +1,8 @@ +export default { + name: 'Air Quality', + colour: 360, + + contents: [ + 'airQuality' + ], +} \ No newline at end of file diff --git a/app/toolbox/index.js b/app/toolbox/index.js index 67c73fa..c281724 100644 --- a/app/toolbox/index.js +++ b/app/toolbox/index.js @@ -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 ] diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index 9e6b6b6..a0cf76b 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -63,6 +63,10 @@ export default defineConfig({ "text": "Weather Locations", "link": "#" }, + { + "text": "Air Quality Locations", + "link": "#" + }, { "text": "IO Bytecode Explorer", "link": "#" diff --git a/src/index.js b/src/index.js index d851d0f..d7fcc3b 100644 --- a/src/index.js +++ b/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 diff --git a/test/src/definition_set_test.js b/test/src/definition_set_test.js index 521aa41..23f27d2 100644 --- a/test/src/definition_set_test.js +++ b/test/src/definition_set_test.js @@ -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() {