diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..cc21640 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,38 @@ +name: CI + +on: [push, pull_request] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22.x + - run: npm ci + - uses: actions/setup-python@v5 + - uses: pre-commit/action@v3.0.1 + + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Use Node.js 22.x + uses: actions/setup-node@v4 + with: + node-version: 22.x + - run: npm ci + - run: npm run typecheck + + build: + runs-on: ubuntu-latest + needs: [lint, typecheck] + steps: + - uses: actions/checkout@v4 + - name: Use Node.js 22.x + uses: actions/setup-node@v4 + with: + node-version: 22.x + - run: npm ci + - run: npm run build diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..6f0a32b --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,25 @@ +repos: + - repo: local + hooks: + - id: prettier + name: prettier + entry: npx prettier --write + language: system + files: "\\.(\ + js|jsx\ + |ts|tsx\ + |json\ + )$" + - id: lint + name: lint + entry: npx eslint --fix + language: system + files: "\\.(\ + js|jsx\ + |ts|tsx\ + )$" + - id: typecheck + name: typecheck + entry: npm run typecheck + language: system + pass_filenames: false diff --git a/README.md b/README.md index 6594ee0..5842d6d 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,10 @@ **explore building scale climate risk data** -This tool allows exploration of climate risk data we [developed](https://github.com/carbonplan/ocr). Read our [explainer](https://carbonplan.org/research/climate-risk-explainer), [methods](https://carbonplan.org/research/climate-risk-fire-methods), and [FAQ](https://carbonplan.org/research/climate-risk-faq) for more information. +This tool allows exploration of climate risk data we [developed](https://github.com/carbonplan/ocr). Read our [explainer](https://carbonplan.org/research/climate-risk-explainer), [methods](https://carbonplan.org/research/climate-risk-fire-methods), and [FAQ](https://carbonplan.org/research/climate-risk-faq) for more information. [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) - ## local development ```shell diff --git a/components/building-points.tsx b/components/building-points.tsx index ad6740f..fc73086 100644 --- a/components/building-points.tsx +++ b/components/building-points.tsx @@ -1,11 +1,8 @@ import { useCallback, useEffect, useMemo } from 'react' -import { - ExpressionSpecification, - MapMouseEvent, - MapSourceDataEvent, -} from 'maplibre-gl' +import { ExpressionSpecification, MapMouseEvent } from 'maplibre-gl' import { useStore } from '@/lib/store' import { LAYERS } from '@/lib/config' +import { ensureSourceLoaded } from '@/lib/map-utils' import { useColormap } from '@/lib/colormaps' import { getBuildingRiskKey } from '@/lib/risk-utils' import { useBuildingUtils } from '@/hooks/useBuildingUtils' @@ -71,21 +68,9 @@ const BuildingPoints = () => { if (feature.geometry.type !== 'Point') return const [lng, lat] = feature.geometry.coordinates - const handleMoveEnd = () => { - if (map.isSourceLoaded(LAYERS.buildings.sourceId)) { - highlightBuildingAtLocation(lng, lat, { easeTo: false }) - } else { - const handleSourceData = (e: MapSourceDataEvent) => { - if ( - e.sourceId === LAYERS.buildings.sourceId && - e.isSourceLoaded - ) { - map.off('sourcedata', handleSourceData) - highlightBuildingAtLocation(lng, lat, { easeTo: false }) - } - } - map.on('sourcedata', handleSourceData) - } + const handleMoveEnd = async () => { + await ensureSourceLoaded(map, LAYERS.buildings.sourceId) + highlightBuildingAtLocation(lng, lat, { easeTo: false }) } map.once('moveend', handleMoveEnd) diff --git a/components/geocode/geocode.tsx b/components/geocode/geocode.tsx index cde0abb..8db4ad9 100644 --- a/components/geocode/geocode.tsx +++ b/components/geocode/geocode.tsx @@ -1,7 +1,7 @@ import { useState, useRef, useEffect } from 'react' import { Box, Flex } from 'theme-ui' import { mix } from '@theme-ui/color' -import { MapSourceDataEvent } from 'maplibre-gl' +import { ensureSourceLoaded } from '@/lib/map-utils' //@ts-expect-error - carbonplan components types not available import { Button, Input, Row, Column } from '@carbonplan/components' //@ts-expect-error - carbonplan layouts types not available @@ -204,31 +204,14 @@ const Geocode = () => { // Highlight building after map movement completes if (location.address.houseNumber) { - const handleMoveEnd = () => { - if (map.isSourceLoaded(LAYERS.buildings.sourceId)) { - const success = highlightBuildingAtLocation( - location.position.lng, - location.position.lat, - { easeTo: false, fetchAddress: false }, - ) - if (success) setSelectedLocation(location) - } else { - const handleSourceData = (e: MapSourceDataEvent) => { - if ( - e.sourceId === LAYERS.buildings.sourceId && - e.isSourceLoaded - ) { - map.off('sourcedata', handleSourceData) - const success = highlightBuildingAtLocation( - location.position.lng, - location.position.lat, - { easeTo: false, fetchAddress: false }, - ) - if (success) setSelectedLocation(location) - } - } - map.on('sourcedata', handleSourceData) - } + const handleMoveEnd = async () => { + await ensureSourceLoaded(map, LAYERS.buildings.sourceId) + const success = highlightBuildingAtLocation( + location.position.lng, + location.position.lat, + { easeTo: false, fetchAddress: false }, + ) + if (success) setSelectedLocation(location) } map.once('moveend', handleMoveEnd) } diff --git a/components/geocode/menu.tsx b/components/geocode/menu.tsx index f30dd0d..ac8b5e8 100644 --- a/components/geocode/menu.tsx +++ b/components/geocode/menu.tsx @@ -18,13 +18,23 @@ interface Props { type Ref = HTMLDivElement const Menu = forwardRef( - ({ suggestions, selectedIndex, errorMessage, onSelectSuggestion, listboxId, errorId }, ref) => { + ( + { + suggestions, + selectedIndex, + errorMessage, + onSelectSuggestion, + listboxId, + errorId, + }, + ref, + ) => { return ( {(suggestions.length > 0 || errorMessage) && ( { setRiskRaster(e.target.checked)} - aria-label="Toggle risk raster visibility" + aria-label='Toggle risk raster visibility' /> { setSatellite(e.target.checked)} - aria-label="Toggle satellite imagery visibility" + aria-label='Toggle satellite imagery visibility' /> { const getInitialZoom = useCallback((): number => { const width = window.innerWidth const sidebarBreakpoint = theme?.breakpoints?.[1] ?? '64em' - const hasSidebar = window.matchMedia(`(min-width: ${sidebarBreakpoint})`).matches + const hasSidebar = window.matchMedia( + `(min-width: ${sidebarBreakpoint})`, + ).matches const mapWidth = hasSidebar ? width * (2 / 3) : width return Math.log2(mapWidth) - 6.3 }, [theme.breakpoints]) @@ -199,19 +201,11 @@ const MapComponent = () => { // initial region query useEffect(() => { if (!map) return - const handleIdle = () => { - const layerExists = map.getLayer(LAYERS.counties.layerIds.fill) - const sourceLoaded = map.isSourceLoaded('regions') - - if (layerExists && sourceLoaded) { - map.off('idle', handleIdle) - updateGeographies() - } - } - map.on('idle', handleIdle) - return () => { - map.off('idle', handleIdle) + const init = async () => { + await ensureSourceLoaded(map, LAYERS.regions.sourceId) + updateGeographies() } + init() }, [map, updateGeographies]) useEffect(() => { @@ -237,17 +231,15 @@ const MapComponent = () => { if (!selectionCoordinates) return const { lat, lng } = selectionCoordinates - const handleSourceData = (e: MapSourceDataEvent) => { - if (e.sourceId === LAYERS.buildings.sourceId && e.isSourceLoaded) { - map.off('sourcedata', handleSourceData) - const found = highlightBuildingAtLocation(lng, lat) - if (!found) { - clearSelections() - updateGeographies() - } + const init = async () => { + await ensureSourceLoaded(map, LAYERS.buildings.sourceId) + const found = highlightBuildingAtLocation(lng, lat) + if (!found) { + clearSelections() + updateGeographies() } } - map.on('sourcedata', handleSourceData) + init() }, [ map, router.isReady, diff --git a/components/results/download.tsx b/components/results/download.tsx index 5ed9163..6b29d19 100644 --- a/components/results/download.tsx +++ b/components/results/download.tsx @@ -117,11 +117,13 @@ export const Download = () => { document.body.removeChild(a) setLoading((prev) => ({ ...prev, [format]: false })) - } catch { - track('data_download_error', { - geography: selectedGeographyLevel, - geoid: geoid ?? '', - }) + } catch (error) { + if (!(error instanceof DOMException && error.name === 'AbortError')) { + track('data_download_error', { + geography: selectedGeographyLevel, + geoid: geoid ?? '', + }) + } setLoading((prev) => ({ ...prev, [format]: false })) } } diff --git a/components/results/other-factors.tsx b/components/results/other-factors.tsx index a4ebf29..ad71ec4 100644 --- a/components/results/other-factors.tsx +++ b/components/results/other-factors.tsx @@ -28,7 +28,8 @@ const OtherFactors = () => { The risk described above does not account for several important factors, including those below, which could influence the actual wildfire risk of a given location. For more information on these factors, see our{' '} - FAQ. + FAQ + . { const setZarrLoading = useStore((state) => state.setZarrLoading) const layerRef = useRef(null) - const riskAttribute = - timePeriod === 'current' ? 'rps_2011' : 'rps_2047' + const riskAttribute = timePeriod === 'current' ? 'rps_2011' : 'rps_2047' const customFrag = useMemo(() => { const boundaries = colorLimits.binBoundaries || [] diff --git a/lib/config.ts b/lib/config.ts index a71400c..c10e5be 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -21,6 +21,9 @@ export const DATA_URLS = { } export const LAYERS = { + regions: { + sourceId: 'regions', + }, buildings: { layerName: 'risk', sourceId: 'buildings', diff --git a/lib/map-utils.ts b/lib/map-utils.ts new file mode 100644 index 0000000..e5ef0d1 --- /dev/null +++ b/lib/map-utils.ts @@ -0,0 +1,15 @@ +import { Map, MapSourceDataEvent } from 'maplibre-gl' + +export const ensureSourceLoaded = (map: Map, sourceId: string) => { + if (map.getSource(sourceId) && map.isSourceLoaded(sourceId)) + return Promise.resolve() + return new Promise((resolve) => { + const handleSourceData = (e: MapSourceDataEvent) => { + if (e.sourceId === sourceId && e.isSourceLoaded) { + map.off('sourcedata', handleSourceData) + resolve() + } + } + map.on('sourcedata', handleSourceData) + }) +} diff --git a/lib/risk-utils.ts b/lib/risk-utils.ts index 95cbc6f..f383e14 100644 --- a/lib/risk-utils.ts +++ b/lib/risk-utils.ts @@ -25,8 +25,7 @@ export const getGeographyMedianRiskKey: ( ) => (typeof GEOGRAPHY_ATTRIBUTE_KEYS)[keyof typeof GEOGRAPHY_ATTRIBUTE_KEYS] = ( timePeriod: ScenarioKey, ) => { - const key = - timePeriod === 'current' ? 'rps_2011_median' : 'rps_2047_median' + const key = timePeriod === 'current' ? 'rps_2011_median' : 'rps_2047_median' return GEOGRAPHY_ATTRIBUTE_KEYS[key] } diff --git a/lib/store.ts b/lib/store.ts index 35a7115..a360d62 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -1,5 +1,6 @@ import { create } from 'zustand' import { Map } from 'maplibre-gl' +import { ensureSourceLoaded } from './map-utils' import { Location, Building, Geography, GeographyKey } from '../types/location' import { GEOGRAPHY_MIN_ZOOM, LAYERS, RISKS } from './config' import { clearSelectedBuildingUrl, updateMapViewUrl } from './url-utils' @@ -104,7 +105,8 @@ export const useStore = create((set, get) => ({ selectedGeographyLevel: 'nation', setSelectedGeographyLevel: (level) => set({ selectedGeographyLevel: level }), hasManuallySelectedGeography: false, - setHasManuallySelectedGeography: (value) => set({ hasManuallySelectedGeography: value }), + setHasManuallySelectedGeography: (value) => + set({ hasManuallySelectedGeography: value }), showGeographyHighlight: false, setShowGeographyHighlight: (show) => set({ showGeographyHighlight: show }), geographyLayerVisibility: { @@ -141,10 +143,12 @@ export const useStore = create((set, get) => ({ advancedMode: process.env.NEXT_PUBLIC_ADVANCED_MODE === 'true', toggleAdvancedMode: () => set((state) => ({ advancedMode: !state.advancedMode })), - queryGeographiesAtPoint: (lng: number, lat: number) => { + queryGeographiesAtPoint: async (lng: number, lat: number) => { const { map } = get() if (!map) return + await ensureSourceLoaded(map, LAYERS.regions.sourceId) + const zoom = map.getZoom() const point = map.project([lng, lat]) diff --git a/package.json b/package.json index cd3775b..92ff9a9 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "typecheck": "tsc --noEmit" }, "dependencies": { "@carbonplan/charts": "^3.1.1",