From 8329160ee00cfad4b0f449ad30bf9c3d8fa1b0b2 Mon Sep 17 00:00:00 2001 From: Viktor Koves <3187531+vkoves@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:46:26 -0500 Subject: [PATCH 1/8] Add PropertyTypes page --- src/components/layout/AppHeader.vue | 2 + src/constants/property-type-helpers.vue | 70 +++++++ src/pages/PropertyTypes.vue | 246 ++++++++++++++++++++++++ src/templates/PropertyType.vue | 5 + 4 files changed, 323 insertions(+) create mode 100644 src/pages/PropertyTypes.vue diff --git a/src/components/layout/AppHeader.vue b/src/components/layout/AppHeader.vue index dd426c9f..8e50a499 100644 --- a/src/components/layout/AppHeader.vue +++ b/src/components/layout/AppHeader.vue @@ -84,6 +84,8 @@ export default class AppHeader extends Vue { Large Owners + Property Types + Cleanest Buildings diff --git a/src/constants/property-type-helpers.vue b/src/constants/property-type-helpers.vue index b9e546f8..f8571f06 100644 --- a/src/constants/property-type-helpers.vue +++ b/src/constants/property-type-helpers.vue @@ -11,4 +11,74 @@ const helpers = require('./property-type-helpers.js') as { slugifyPropertyType: (propertyType: string) => string; }; export const slugifyPropertyType = helpers.slugifyPropertyType; + +/** + * An emoji for each property type, used to make lists of property types easier + * to scan. Types not listed here fall back to PropertyTypeEmojiFallback. + */ +export const PropertyTypeEmoji: Record = { + 'Adult Education': 'πŸ“š', + 'Ambulatory Surgical Center': 'πŸ₯', + 'Automobile Dealership': 'πŸš—', + 'Bank Branch': '🏦', + 'College/University': 'πŸŽ“', + 'Convention Center': 'πŸŽͺ', + Courthouse: 'βš–οΈ', + 'Data Center': 'πŸ’Ύ', + 'Distribution Center': 'πŸ“¦', + 'Enclosed Mall': 'πŸ›οΈ', + 'Financial Office': '🏦', + 'Fitness Center/Health Club/Gym': 'πŸ‹οΈ', + 'Hospital (General Medical & Surgical)': 'πŸ₯', + Hotel: '🏨', + 'Ice/Curling Rink': '⛸️', + 'Indoor Arena': '🏟️', + 'K-12 School': '🏫', + Laboratory: 'πŸ”¬', + Library: 'πŸ“–', + 'Medical Office': '🩺', + 'Mixed Use Property': 'πŸ™οΈ', + 'Movie Theater': '🎬', + 'Multifamily Housing': '🏒', + Museum: 'πŸ›οΈ', + Office: '🏒', + Other: 'πŸ—οΈ', + 'Other - Education': 'πŸ“š', + 'Other - Entertainment/Public Assembly': '🎭', + 'Other - Lodging/Residential': '🏘️', + 'Other - Mall': 'πŸ›οΈ', + 'Other - Public Services': 'πŸ›οΈ', + 'Other - Recreation': '🎑', + 'Other - Services': 'πŸ”§', + 'Other - Specialty Hospital': 'πŸ₯', + 'Outpatient Rehabilitation/Physical Therapy': '🩼', + 'Performing Arts': '🎭', + 'Pre-school/Daycare': '🧸', + 'Prison/Incarceration': 'πŸ”’', + 'Repair Services (Vehicle, Shoe, Locksmith, etc.)': 'πŸ”§', + 'Residence Hall/Dormitory': 'πŸ›οΈ', + 'Residential Care Facility': 'πŸ§‘β€βš•οΈ', + 'Retail Store': 'πŸ›’', + 'Senior Care Community': 'πŸ‘΅', + 'Senior Living Community': 'πŸ‘΅', + 'Social/Meeting Hall': '🀝', + 'Stadium (Open)': '🏟️', + 'Strip Mall': '🏬', + 'Supermarket/Grocery Store': 'πŸ₯«', + 'Urgent Care/Clinic/Other Outpatient': 'πŸš‘', + 'Vehicle Dealership': 'πŸš—', + 'Wholesale Club/Supercenter': '🏬', + 'Worship Facility': 'πŸ›', +}; + +/** Emoji shown for any property type missing from PropertyTypeEmoji */ +export const PropertyTypeEmojiFallback = '🏒'; + +/** + * Get the emoji for a property type, falling back to a generic building emoji + * for any type we haven't explicitly mapped + */ +export function getPropertyTypeEmoji(propertyType: string): string { + return PropertyTypeEmoji[propertyType] ?? PropertyTypeEmojiFallback; +} diff --git a/src/pages/PropertyTypes.vue b/src/pages/PropertyTypes.vue new file mode 100644 index 00000000..5a3d05b2 --- /dev/null +++ b/src/pages/PropertyTypes.vue @@ -0,0 +1,246 @@ + + + + + diff --git a/src/templates/PropertyType.vue b/src/templates/PropertyType.vue index ceacb193..b7661b0e 100644 --- a/src/templates/PropertyType.vue +++ b/src/templates/PropertyType.vue @@ -336,6 +336,11 @@ export default class PropertyType extends Vue {
+ + + Back to All Property Types + +

{{ propertyType }} Stats

From 01a3d8025c4f653956c9df8b6b9eaca9e4874dd8 Mon Sep 17 00:00:00 2001 From: Viktor Koves <3187531+vkoves@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:15:09 -0500 Subject: [PATCH 2/8] Add images to property types home --- src/pages/PropertyTypes.vue | 205 ++++++++++++++++++++++++++++++++---- 1 file changed, 182 insertions(+), 23 deletions(-) diff --git a/src/pages/PropertyTypes.vue b/src/pages/PropertyTypes.vue index 5a3d05b2..4d4bc099 100644 --- a/src/pages/PropertyTypes.vue +++ b/src/pages/PropertyTypes.vue @@ -4,6 +4,8 @@ import { Component, Vue } from 'vue-property-decorator'; import BuildingsHero from '~/components/BuildingsHero.vue'; import DataSourceFootnote from '~/components/DataSourceFootnote.vue'; import { + IBuilding, + IBuildingNode, PropertyTypeStats, pluralizePropertyType, } from '../common-functions.vue'; @@ -11,6 +13,7 @@ import { getPropertyTypeEmoji, slugifyPropertyType, } from '../constants/property-type-helpers.vue'; +import { getBuildingImage } from '../constants/building-images.constant.vue'; import { generatePageMeta } from '../constants/meta-helpers.vue'; import BuildingStatsByPropertyType from '../data/dist/building-statistics-by-property-type.json'; import PropertyTypesConstant from '../data/dist/property-types.json'; @@ -26,6 +29,10 @@ interface IPropertyTypeSummary { /** Share of all benchmarked emissions, as a percent (e.g. 12.4) */ percentOfTotalEmissions: number; avgGHGIntensity: number; + /** The largest building of this type that has a photo, if any */ + featuredBuilding: IBuilding | null; + /** The photo URL for featuredBuilding, or null if no building has one */ + imgUrl: string | null; } /** @@ -57,18 +64,26 @@ const CitywideTotalEmissions = Object.values( }, }) export default class PropertyTypes extends Vue { + /** Set by Gridsome to results of the static GraphQL query */ + readonly $static!: { allBuilding: { edges: Array } }; + /** * All property types that have a generated page, sorted by building count * descending, since the largest categories are the most useful to browse */ - readonly propertyTypes: Array = - PropertyTypesConstant.propertyTypes + propertyTypes: Array = []; + + created(): void { + const featuredBuildings = this.buildFeaturedBuildingMap(); + + this.propertyTypes = PropertyTypesConstant.propertyTypes .map((propertyType: string) => { const stats = ( BuildingStatsByPropertyType as Record )[propertyType] as PropertyTypeStats | undefined; const totalGHGEmissions = stats?.TotalGHGEmissions?.total ?? 0; + const featuredBuilding = featuredBuildings[propertyType] ?? null; return { propertyType, @@ -81,9 +96,39 @@ export default class PropertyTypes extends Vue { ? (totalGHGEmissions / CitywideTotalEmissions) * 100 : 0, avgGHGIntensity: stats?.GHGIntensity?.mean ?? 0, + featuredBuilding, + imgUrl: featuredBuilding + ? (getBuildingImage(featuredBuilding)?.imgUrl ?? null) + : null, }; }) .sort((a, b) => b.buildingCount - a.buildingCount); + } + + /** + * Finds the largest building with a photo for each property type. The query + * is sorted by GrossFloorArea descending, so the first building we see with + * an image for a given type is the largest one we can show. + */ + private buildFeaturedBuildingMap(): Record { + const featuredBuildings: Record = {}; + + this.$static.allBuilding.edges.forEach(({ node }) => { + const propertyType = node.PrimaryPropertyType; + + if ( + !propertyType || + featuredBuildings[propertyType] || + !getBuildingImage(node) + ) { + return; + } + + featuredBuildings[propertyType] = node; + }); + + return featuredBuildings; + } get totalBuildingCount(): number { return this.propertyTypes.reduce( @@ -115,6 +160,26 @@ export default class PropertyTypes extends Vue { } + + + query { + allBuilding(sortBy: "GrossFloorArea") { + edges { + node { + ID + PropertyName + Address + PrimaryPropertyType + GrossFloorArea + } + } + } + } + +