From 07069b6bacbcd12f350d988073bc726e364dfcd1 Mon Sep 17 00:00:00 2001 From: Shane Loeffler Date: Thu, 4 Dec 2025 13:45:47 -0600 Subject: [PATCH 01/21] add map to project page --- components/constants.js | 8 + components/map.js | 148 +++++++++++++++++ components/project.js | 12 ++ components/use-map-theme.js | 147 +++++++++++++++++ package-lock.json | 309 ++++++++++++++++++++++++++++++++++++ package.json | 3 + 6 files changed, 627 insertions(+) create mode 100644 components/map.js create mode 100644 components/use-map-theme.js diff --git a/components/constants.js b/components/constants.js index 50d79ec..c681ce3 100644 --- a/components/constants.js +++ b/components/constants.js @@ -25,6 +25,14 @@ export const LABELS = { }, } +export const BOUNDARY_PMTILES_URL = + 'https://carbonplan-offsets-db.s3.us-west-2.amazonaws.com/miscellaneous/project-boundaries.pmtiles' + +export const BOUNDARY_LAYER_ID = 'project-boundaries' + +export const BASEMAP_PMTILES_URL = + 'https://carbonplan-maps.s3.us-west-2.amazonaws.com/basemaps/pmtiles/global.pmtiles' + export const COLORS = { agriculture: 'orange', forest: 'green', diff --git a/components/map.js b/components/map.js new file mode 100644 index 0000000..16783a5 --- /dev/null +++ b/components/map.js @@ -0,0 +1,148 @@ +import { useEffect, useRef } from 'react' +import { Box, get, useThemeUI } from 'theme-ui' +import maplibregl from 'maplibre-gl' +import 'maplibre-gl/dist/maplibre-gl.css' +import { Protocol } from 'pmtiles' +import { + BOUNDARY_PMTILES_URL, + BASEMAP_PMTILES_URL, + BOUNDARY_LAYER_ID, + COLORS, +} from './constants' +import { useMapTheme } from './use-map-theme' +import { getProjectCategory } from './utils' + +const Map = ({ project }) => { + const mapContainer = useRef(null) + const map = useRef(null) + const mapLayers = useMapTheme() + const { theme } = useThemeUI() + + const colorName = COLORS[getProjectCategory(project)] ?? COLORS.other + const color = get(theme, `rawColors.${colorName}`, colorName) + const secondary = get(theme, 'rawColors.secondary') + const attributionControl = useRef(null) + const mapControlStyles = { + '& .maplibregl-control-container': { + fontSize: [0, 0, 0, 0], + fontFamily: 'faux', + letterSpacing: 'faux', + '& [class*="maplibregl-ctrl-"]': { zIndex: 0 }, + '& .maplibregl-ctrl-attrib': { + bg: 'hinted', + alignItems: 'center', + border: `1px solid`, + borderColor: 'muted', + color: 'secondary', + display: 'flex', + '& a': { color: 'secondary' }, + '& .maplibregl-ctrl-attrib-button': { + bg: 'hinted', + backgroundImage: `url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill-rule='evenodd' viewBox='0 0 20 20'%3E%3Cpath fill='${encodeURIComponent( + secondary + )}' d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")`, + }, + }, + }, + } + const bounds = [ + [project.bbox.xmin, project.bbox.ymin], + [project.bbox.xmax, project.bbox.ymax], + ] + + useEffect(() => { + if (!mapContainer.current || map.current) return + + const setupMap = async () => { + const protocol = new Protocol() + maplibregl.addProtocol('pmtiles', protocol.tile) + + const isProject = ['==', ['get', 'project_id'], project.project_id] + const boundaryLayers = [ + { + id: 'project-boundaries-fill', + type: 'fill', + source: 'project-boundaries', + 'source-layer': BOUNDARY_LAYER_ID, + paint: { + 'fill-color': ['case', isProject, color, secondary], + 'fill-opacity': 0.2, + }, + }, + { + id: 'project-boundaries-outline', + type: 'line', + source: 'project-boundaries', + 'source-layer': BOUNDARY_LAYER_ID, + paint: { + 'line-color': ['case', isProject, color, secondary], + 'line-width': ['case', isProject, 1, 0.1], + }, + }, + ] + + map.current = new maplibregl.Map({ + container: mapContainer.current, + bounds, + fitBoundsOptions: { padding: 10 }, + style: { + version: 8, + glyphs: + 'https://carbonplan-maps.s3.us-west-2.amazonaws.com/basemaps/fonts/{fontstack}/{range}.pbf', + sources: { + basemap: { + type: 'vector', + url: `pmtiles://${BASEMAP_PMTILES_URL}`, + attribution: + 'Protomaps © OpenStreetMap', + }, + 'project-boundaries': { + type: 'vector', + url: `pmtiles://${BOUNDARY_PMTILES_URL}`, + attribution: 'CarbonPlan, TK ref/link', + }, + }, + layers: [...mapLayers, ...boundaryLayers], + }, + attributionControl: false, + }) + + attributionControl.current = new maplibregl.AttributionControl({ + compact: true, + }) + map.current.addControl(attributionControl.current, 'bottom-right') + } + + setupMap() + + return () => { + if (map.current) { + if (attributionControl.current) { + try { + map.current.removeControl(attributionControl.current) + } catch (err) { + console.error('Error removing attribution control', err) + } + } + map.current.remove() + map.current = null + } + if (maplibregl.removeProtocol) { + maplibregl.removeProtocol('pmtiles') + } + } + }, [project.bbox, project.project_id, color, bounds, mapLayers]) + + return ( + + ) +} + +export default Map diff --git a/components/project.js b/components/project.js index 6be73cd..fcab92d 100644 --- a/components/project.js +++ b/components/project.js @@ -8,6 +8,7 @@ import ProjectOverview from './project-overview' import Timeline from './timeline' import BackButton from './back-button' import Quantity from './quantity' +import Map from './map' import { getProjectCategory } from './utils' const Project = ({ project }) => { @@ -115,6 +116,17 @@ const Project = ({ project }) => { + {project.bbox && ( + <> + + Boundary + + + + + + )} + Transactions diff --git a/components/use-map-theme.js b/components/use-map-theme.js new file mode 100644 index 0000000..ccbf6a8 --- /dev/null +++ b/components/use-map-theme.js @@ -0,0 +1,147 @@ +import { useMemo } from 'react' +import { useColorMode, useThemeUI, get } from 'theme-ui' +import { layers, namedFlavor } from '@protomaps/basemaps' + +const language = 'en' + +export const useMapTheme = () => { + const [colorMode] = useColorMode() + const { theme } = useThemeUI() + const isDark = colorMode === 'dark' + const flavorName = isDark ? 'black' : 'white' + const transparent = 'transparent' + const hinted = get(theme, 'rawColors.hinted') + const primary = get(theme, 'rawColors.primary') + const secondary = get(theme, 'rawColors.secondary') + const muted = get(theme, 'rawColors.muted') + const background = get(theme, 'rawColors.background') + + const mapTheme = useMemo( + () => ({ + ...namedFlavor(flavorName), + buildings: muted, + background: transparent, + park_a: transparent, + park_b: transparent, + hospital: transparent, + industrial: transparent, + school: transparent, + wood_a: transparent, + wood_b: transparent, + pedestrian: transparent, + scrub_a: transparent, + scrub_b: transparent, + glacier: transparent, + sand: transparent, + beach: transparent, + aerodrome: transparent, + runway: transparent, + earth: transparent, + zoo: transparent, + military: transparent, + + landcover: { + barren: transparent, + farmland: transparent, + forest: transparent, + glacier: transparent, + grassland: transparent, + scrub: transparent, + urban_area: transparent, + }, + + water: hinted, + + bridges_other_casing: background, + bridges_minor_casing: background, + bridges_link_casing: background, + bridges_major_casing: background, + bridges_highway_casing: background, + bridges_other: muted, + bridges_minor: muted, + bridges_link: muted, + bridges_major: muted, + bridges_highway: muted, + + minor_service_casing: background, + minor_casing: background, + link_casing: background, + major_casing_late: background, + highway_casing_late: background, + other: muted, + minor_service: muted, + minor_a: muted, + minor_b: muted, + link: muted, + major_casing_early: background, + major: muted, + highway_casing_early: background, + highway: muted, + pier: muted, + + railway: muted, + boundaries: secondary, + + roads_label_minor: secondary, + roads_label_minor_halo: background, + roads_label_major: secondary, + roads_label_major_halo: background, + ocean_label: secondary, + subplace_label: [ + 'interpolate', + ['linear'], + ['zoom'], + 8, + secondary, + 22, + primary, + ], + subplace_label_halo: background, + city_label: [ + 'interpolate', + ['linear'], + ['zoom'], + 8, + secondary, + 22, + primary, + ], + city_label_halo: background, + state_label: secondary, + state_label_halo: background, + country_label: secondary, + + address_label: secondary, + address_label_halo: background, + + regular: 'Relative Pro Book', + bold: 'Relative Pro Book', + italic: 'Relative Pro Book', + }), + [flavorName, hinted, secondary, muted, background, primary] + ) + + const mapLayers = useMemo(() => { + const baseLayers = layers('basemap', mapTheme, { + lang: language, + }) + + return baseLayers.map((layer) => { + if (layer.id === 'places_locality' && layer.type === 'symbol') { + return { + ...layer, + layout: { + ...layer.layout, + 'text-anchor': 'center', + 'text-justify': 'center', + }, + } + } + return layer + }) + }, [mapTheme]) + + return mapLayers +} + +export default useMapTheme diff --git a/package-lock.json b/package-lock.json index f0ba3b6..fc822a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,12 +20,15 @@ "@mdx-js/loader": "^2.1.5", "@mdx-js/react": "^2.1.5", "@next/mdx": "^12.3.1", + "@protomaps/basemaps": "^5.7.0", "@theme-ui/color": "^0.15.3", "@theme-ui/match-media": "^0.15.3", "d3-brush": "^3.0.0", "d3-format": "^3.1.0", "d3-selection": "^3.0.0", + "maplibre-gl": "^5.14.0", "next": "^14.2.23", + "pmtiles": "^4.3.0", "polished": "^4.2.2", "react": "^18.2.0", "react-animate-height": "^3.2.2", @@ -400,6 +403,109 @@ "@jridgewell/sourcemap-codec": "1.4.14" } }, + "node_modules/@mapbox/geojson-rewind": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", + "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", + "license": "ISC", + "dependencies": { + "get-stream": "^6.0.1", + "minimist": "^1.2.6" + }, + "bin": { + "geojson-rewind": "geojson-rewind" + } + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz", + "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz", + "integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.1" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "24.3.1", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.3.1.tgz", + "integrity": "sha512-TUM5JD40H2mgtVXl5IwWz03BuQabw8oZQLJTmPpJA0YTYF+B+oZppy5lNMO6bMvHzB+/5mxqW9VLG3wFdeqtOw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "rw": "^1.3.3", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/mlt": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.2.tgz", + "integrity": "sha512-SQKdJ909VGROkA6ovJgtHNs9YXV4YXUPS+VaZ50I2Mt951SLlUm2Cv34x5Xwc1HiFlsd3h2Yrs5cn7xzqBmENw==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.1.0.tgz", + "integrity": "sha512-9LjFAoWtxdGRns8RK9vG3Fcw/fb3eHMxvAn2jffwn3jnVO1k49VOv6+FEza70rK7WzF8GnBiKa0K39RyfevKUw==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/vector-tile": "^2.0.4", + "@types/geojson-vt": "3.2.5", + "@types/supercluster": "^7.1.3", + "geojson-vt": "^4.0.2", + "pbf": "^4.0.1", + "supercluster": "^8.0.1" + } + }, "node_modules/@mdx-js/loader": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@mdx-js/loader/-/loader-2.1.5.tgz", @@ -627,6 +733,15 @@ "node": ">= 10" } }, + "node_modules/@protomaps/basemaps": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@protomaps/basemaps/-/basemaps-5.7.0.tgz", + "integrity": "sha512-vIInnzVSxHuOcvj1BFGkCjlFxG/9a1GV23t98kGEVcPUM7aEqTnf6loUHTRJYX5eCz+WCO16N0aibr1SLg830Q==", + "license": "BSD-3-Clause", + "bin": { + "generate_style": "src/cli.ts" + } + }, "node_modules/@styled-system/background": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@styled-system/background/-/background-5.1.2.tgz", @@ -896,6 +1011,21 @@ "@types/estree": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/geojson-vt": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz", + "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/hast": { "version": "2.3.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.4.tgz", @@ -967,6 +1097,15 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/unist": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz", @@ -1636,6 +1775,12 @@ "node": ">=0.3.1" } }, + "node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, "node_modules/electron-to-chromium": { "version": "1.4.284", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.284.tgz", @@ -1833,6 +1978,12 @@ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "peer": true }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", @@ -1843,11 +1994,35 @@ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" }, + "node_modules/geojson-vt": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz", + "integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==", + "license": "ISC" + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/github-slugger": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-1.5.0.tgz", "integrity": "sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==" }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/glob-to-regexp": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", @@ -2125,6 +2300,18 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "peer": true }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -2167,6 +2354,44 @@ "loose-envify": "cli.js" } }, + "node_modules/maplibre-gl": { + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.14.0.tgz", + "integrity": "sha512-O2ok6N/bQ9NA9nJ22r/PRQQYkUe9JwfDMjBPkQ+8OwsVH4TpA5skIAM2wc0k+rni5lVbAVONVyBvgi1rF2vEPA==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.0.7", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/maplibre-gl-style-spec": "^24.3.1", + "@maplibre/mlt": "^1.1.2", + "@maplibre/vt-pbf": "^4.1.0", + "@types/geojson": "^7946.0.16", + "@types/geojson-vt": "3.2.5", + "@types/supercluster": "^7.1.3", + "earcut": "^3.0.2", + "geojson-vt": "^4.0.2", + "gl-matrix": "^3.4.4", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "supercluster": "^8.0.1", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, "node_modules/markdown-extensions": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-1.1.1.tgz", @@ -2918,6 +3143,15 @@ "node": ">= 0.6" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -2931,6 +3165,12 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.8", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", @@ -3079,6 +3319,18 @@ "node": ">=8" } }, + "node_modules/pbf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", + "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/periscopic": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.0.4.tgz", @@ -3093,6 +3345,15 @@ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" }, + "node_modules/pmtiles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.3.0.tgz", + "integrity": "sha512-wnzQeSiYT/MyO63o7AVxwt7+uKqU0QUy2lHrivM7GvecNy0m1A4voVyGey7bujnEW5Hn+ZzLdvHPoFaqrOzbPA==", + "license": "BSD-3-Clause", + "dependencies": { + "fflate": "^0.8.2" + } + }, "node_modules/polished": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/polished/-/polished-4.2.2.tgz", @@ -3132,6 +3393,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, "node_modules/prettier": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.2.tgz", @@ -3153,6 +3420,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", + "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", @@ -3162,6 +3435,12 @@ "node": ">=6" } }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -3334,6 +3613,21 @@ "node": ">=4" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -3522,6 +3816,15 @@ "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.1.3.tgz", "integrity": "sha512-GP6WDNWf+o403jrEp9c5jibKavrtLW+/qYGhFxFrG8maXhwTBI7gLLhiBb0o7uFccWN+EOS9aMO6cGHWAO07OA==" }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -3631,6 +3934,12 @@ "react": ">=18" } }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, "node_modules/to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", diff --git a/package.json b/package.json index cdc874d..a914727 100644 --- a/package.json +++ b/package.json @@ -32,12 +32,15 @@ "@mdx-js/loader": "^2.1.5", "@mdx-js/react": "^2.1.5", "@next/mdx": "^12.3.1", + "@protomaps/basemaps": "^5.7.0", "@theme-ui/color": "^0.15.3", "@theme-ui/match-media": "^0.15.3", "d3-brush": "^3.0.0", "d3-format": "^3.1.0", "d3-selection": "^3.0.0", + "maplibre-gl": "^5.14.0", "next": "^14.2.23", + "pmtiles": "^4.3.0", "polished": "^4.2.2", "react": "^18.2.0", "react-animate-height": "^3.2.2", From bf907af03bda0219871b368b8e75b8a015fbfd90 Mon Sep 17 00:00:00 2001 From: Shane Loeffler Date: Thu, 4 Dec 2025 13:55:43 -0600 Subject: [PATCH 02/21] below labels --- components/map.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/components/map.js b/components/map.js index 16783a5..2040da6 100644 --- a/components/map.js +++ b/components/map.js @@ -102,11 +102,17 @@ const Map = ({ project }) => { attribution: 'CarbonPlan, TK ref/link', }, }, - layers: [...mapLayers, ...boundaryLayers], + layers: mapLayers, }, attributionControl: false, }) + map.current.on('load', () => { + boundaryLayers.forEach((layer) => { + map.current.addLayer(layer, 'address_label') + }) + }) + attributionControl.current = new maplibregl.AttributionControl({ compact: true, }) From ead6f45619d5c4e2f988648777d4f5131a61ac63 Mon Sep 17 00:00:00 2001 From: Shane Loeffler Date: Thu, 11 Dec 2025 11:50:17 -0600 Subject: [PATCH 03/21] hover and click --- components/map.js | 331 +++++++++++++++++++++++++++++++++--------- components/project.js | 2 +- 2 files changed, 261 insertions(+), 72 deletions(-) diff --git a/components/map.js b/components/map.js index 2040da6..4a7abd4 100644 --- a/components/map.js +++ b/components/map.js @@ -1,5 +1,9 @@ -import { useEffect, useRef } from 'react' +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import { Box, get, useThemeUI } from 'theme-ui' +import { Badge } from '@carbonplan/components' +import { Arrow } from '@carbonplan/icons' +import { useRouter } from 'next/router' import maplibregl from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' import { Protocol } from 'pmtiles' @@ -12,74 +16,218 @@ import { import { useMapTheme } from './use-map-theme' import { getProjectCategory } from './utils' +const createBoundaryLayers = (projectId, color, secondary) => { + const isProject = ['==', ['get', 'project_id'], projectId] + return [ + { + id: 'project-boundaries-fill', + type: 'fill', + source: 'project-boundaries', + 'source-layer': BOUNDARY_LAYER_ID, + paint: { + 'fill-color': ['case', isProject, color, secondary], + 'fill-opacity': 0.2, + }, + }, + { + id: 'project-boundaries-outline', + type: 'line', + source: 'project-boundaries', + 'source-layer': BOUNDARY_LAYER_ID, + paint: { + 'line-color': ['case', isProject, color, secondary], + 'line-width': [ + 'case', + ['==', ['feature-state', 'hover'], true], + ['case', isProject, 1, 1], + ['case', isProject, 1, 0.1], + ], + }, + }, + ] +} + const Map = ({ project }) => { const mapContainer = useRef(null) const map = useRef(null) + const popup = useRef(null) + const popupContainerRef = useRef(null) + const boundaryEventsCleanup = useRef(null) const mapLayers = useMapTheme() + const [hoveredProjectId, setHoveredProjectId] = useState(null) const { theme } = useThemeUI() + const router = useRouter() const colorName = COLORS[getProjectCategory(project)] ?? COLORS.other const color = get(theme, `rawColors.${colorName}`, colorName) const secondary = get(theme, 'rawColors.secondary') - const attributionControl = useRef(null) - const mapControlStyles = { - '& .maplibregl-control-container': { - fontSize: [0, 0, 0, 0], - fontFamily: 'faux', - letterSpacing: 'faux', - '& [class*="maplibregl-ctrl-"]': { zIndex: 0 }, - '& .maplibregl-ctrl-attrib': { - bg: 'hinted', - alignItems: 'center', - border: `1px solid`, - borderColor: 'muted', - color: 'secondary', - display: 'flex', - '& a': { color: 'secondary' }, - '& .maplibregl-ctrl-attrib-button': { + + const mapControlStyles = useMemo( + () => ({ + '& .maplibregl-control-container': { + fontSize: [0, 0, 0, 0], + fontFamily: 'faux', + letterSpacing: 'faux', + '& [class*="maplibregl-ctrl-"]': { zIndex: 0 }, + '& .maplibregl-ctrl-attrib': { bg: 'hinted', - backgroundImage: `url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill-rule='evenodd' viewBox='0 0 20 20'%3E%3Cpath fill='${encodeURIComponent( - secondary - )}' d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")`, + alignItems: 'center', + border: `1px solid`, + borderColor: 'muted', + color: 'secondary', + display: 'flex', + '& a': { color: 'secondary' }, + '& .maplibregl-ctrl-attrib-button': { + bg: 'hinted', + backgroundImage: `url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill-rule='evenodd' viewBox='0 0 20 20'%3E%3Cpath fill='${encodeURIComponent( + secondary + )}' d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")`, + }, }, }, - }, - } - const bounds = [ - [project.bbox.xmin, project.bbox.ymin], - [project.bbox.xmax, project.bbox.ymax], - ] + '& .maplibregl-popup': { + zIndex: 1, + pointerEvents: 'none', + }, + '& .maplibregl-popup-content': { + padding: 0, + background: 'transparent', + boxShadow: 'none', + pointerEvents: 'none', + }, + '& .maplibregl-popup-tip': { + display: 'none', + }, + }), + [secondary] + ) + const bounds = useMemo( + () => [ + [project.bbox.xmin, project.bbox.ymin], + [project.bbox.xmax, project.bbox.ymax], + ], + [project.bbox] + ) useEffect(() => { if (!mapContainer.current || map.current) return - const setupMap = async () => { - const protocol = new Protocol() - maplibregl.addProtocol('pmtiles', protocol.tile) + let hoveredFeatureId = null - const isProject = ['==', ['get', 'project_id'], project.project_id] - const boundaryLayers = [ + const setFeatureHover = (featureId, hover) => { + if (!featureId || !map.current) return + map.current.setFeatureState( { - id: 'project-boundaries-fill', - type: 'fill', source: 'project-boundaries', - 'source-layer': BOUNDARY_LAYER_ID, - paint: { - 'fill-color': ['case', isProject, color, secondary], - 'fill-opacity': 0.2, - }, - }, - { - id: 'project-boundaries-outline', - type: 'line', - source: 'project-boundaries', - 'source-layer': BOUNDARY_LAYER_ID, - paint: { - 'line-color': ['case', isProject, color, secondary], - 'line-width': ['case', isProject, 1, 0.1], - }, + sourceLayer: BOUNDARY_LAYER_ID, + id: featureId, }, + { hover } + ) + } + + const clearHoveredFeature = () => { + if (!hoveredFeatureId) return + setFeatureHover(hoveredFeatureId, false) + hoveredFeatureId = null + } + + const clearHoverInteraction = (shouldClearContainer = false) => { + clearHoveredFeature() + map.current.getCanvas().style.cursor = '' + setHoveredProjectId(null) + if (shouldClearContainer) { + popupContainerRef.current = null + } + if (popup.current) { + popup.current.remove() + } + } + + const ensurePopupContainer = () => { + if (!popupContainerRef.current) { + const container = document.createElement('div') + popupContainerRef.current = container + } + return popupContainerRef.current + } + + const queryHoveredFeature = (event) => { + const tolerance = 3 + const bbox = [ + [event.point.x - tolerance, event.point.y - tolerance], + [event.point.x + tolerance, event.point.y + tolerance], ] + const features = map.current.queryRenderedFeatures(bbox, { + layers: ['project-boundaries-fill'], + }) + return features[0] || null + } + + const handleHover = (feature, lngLat) => { + const projectId = feature.properties.project_id + const featureId = feature.id || feature.properties.project_id + + if (featureId && featureId !== hoveredFeatureId) { + clearHoveredFeature() + hoveredFeatureId = featureId + setFeatureHover(featureId, true) + } + + map.current.getCanvas().style.cursor = 'pointer' + ensurePopupContainer() + setHoveredProjectId((prev) => (prev === projectId ? prev : projectId)) + + popup.current + .setLngLat(lngLat) + .setDOMContent(popupContainerRef.current) + .addTo(map.current) + } + + const attachBoundaryEvents = () => { + const handleMouseMove = (event) => { + const feature = queryHoveredFeature(event) + if (!feature) { + clearHoverInteraction() + return + } + handleHover(feature, event.lngLat) + } + + const handleMouseLeave = () => clearHoverInteraction(true) + + const handleClick = (event) => { + const feature = event.features?.[0] + const clickedProjectId = feature?.properties?.project_id + if (clickedProjectId && clickedProjectId !== project.project_id) { + router.push(`/projects/${clickedProjectId}`) + } + } + + map.current.on('mousemove', 'project-boundaries-fill', handleMouseMove) + map.current.on('mouseleave', 'project-boundaries-fill', handleMouseLeave) + map.current.on('click', 'project-boundaries-fill', handleClick) + + return () => { + map.current.off('mousemove', 'project-boundaries-fill', handleMouseMove) + map.current.off( + 'mouseleave', + 'project-boundaries-fill', + handleMouseLeave + ) + map.current.off('click', 'project-boundaries-fill', handleClick) + } + } + + const setupMap = async () => { + const protocol = new Protocol() + maplibregl.addProtocol('pmtiles', protocol.tile) + + const boundaryLayers = createBoundaryLayers( + project.project_id, + color, + secondary + ) map.current = new maplibregl.Map({ container: mapContainer.current, @@ -107,47 +255,88 @@ const Map = ({ project }) => { attributionControl: false, }) + const attributionControl = new maplibregl.AttributionControl({ + compact: true, + }) + map.current.addControl(attributionControl, 'bottom-right') + + popup.current = new maplibregl.Popup({ + closeButton: false, + closeOnClick: false, + offset: 1, + }) + map.current.on('load', () => { boundaryLayers.forEach((layer) => { map.current.addLayer(layer, 'address_label') }) - }) - attributionControl.current = new maplibregl.AttributionControl({ - compact: true, + boundaryEventsCleanup.current = attachBoundaryEvents() }) - map.current.addControl(attributionControl.current, 'bottom-right') } setupMap() return () => { + boundaryEventsCleanup.current?.() + boundaryEventsCleanup.current = null + + popup.current?.remove() + popup.current = null + + setHoveredProjectId(null) + popupContainerRef.current = null + if (map.current) { - if (attributionControl.current) { - try { - map.current.removeControl(attributionControl.current) - } catch (err) { - console.error('Error removing attribution control', err) - } - } + clearHoveredFeature() map.current.remove() map.current = null } - if (maplibregl.removeProtocol) { - maplibregl.removeProtocol('pmtiles') - } + + maplibregl.removeProtocol?.('pmtiles') } - }, [project.bbox, project.project_id, color, bounds, mapLayers]) + }, [project.bbox, project.project_id, color, secondary, mapLayers]) return ( - + <> + + {hoveredProjectId && popupContainerRef.current + ? createPortal( + + {hoveredProjectId} + {hoveredProjectId !== project.project_id && ( + + )} + , + popupContainerRef.current + ) + : null} + ) } diff --git a/components/project.js b/components/project.js index fcab92d..aa0cd3b 100644 --- a/components/project.js +++ b/components/project.js @@ -118,7 +118,7 @@ const Project = ({ project }) => { {project.bbox && ( <> - + Boundary From 564098189564a11cd1c33edf9ff92f7734c172bf Mon Sep 17 00:00:00 2001 From: Shane Loeffler Date: Thu, 11 Dec 2025 12:19:11 -0600 Subject: [PATCH 04/21] add boundary filter --- components/queries.js | 25 ++++++++++++++++++++++++- components/use-fetcher.js | 7 +++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/components/queries.js b/components/queries.js index 79c35cd..33d26ec 100644 --- a/components/queries.js +++ b/components/queries.js @@ -1,4 +1,4 @@ -import { Column, Filter, Input, Link, Row } from '@carbonplan/components' +import { Column, Filter, Input, Link, Row, Tag } from '@carbonplan/components' import { useRouter } from 'next/router' import { createContext, useContext, useEffect, useState } from 'react' import { Box, Flex } from 'theme-ui' @@ -30,6 +30,7 @@ export const QueryProvider = ({ children }) => { ) const [projectType, setProjectType] = useState(null) const [complianceOnly, setComplianceOnly] = useState(null) + const [hasBoundaryData, setHasBoundaryData] = useState(false) const [search, setSearch] = useState('') const [beneficiarySearch, setBeneficiarySearch] = useState('') const [listingBounds, setListingBounds] = useState(null) @@ -59,6 +60,8 @@ export const QueryProvider = ({ children }) => { setRegistry, category, setCategory, + hasBoundaryData, + setHasBoundaryData, projectType, setProjectType, complianceOnly, @@ -118,6 +121,8 @@ const Queries = () => { setRegistry, complianceOnly, setComplianceOnly, + hasBoundaryData, + setHasBoundaryData, search, setSearch, beneficiarySearch, @@ -316,6 +321,24 @@ const Queries = () => { + + + Geography + + + + setHasBoundaryData(!hasBoundaryData)} + value={hasBoundaryData} + > + Has boundary + + + + ) } diff --git a/components/use-fetcher.js b/components/use-fetcher.js index b3df724..42f425e 100644 --- a/components/use-fetcher.js +++ b/components/use-fetcher.js @@ -22,6 +22,7 @@ const fetcher = ([ category, projectType, complianceOnly, + hasBoundaryData, search, listingBounds, transactionBounds, @@ -129,6 +130,10 @@ const fetcher = ([ protocols.forEach((protocol) => params.append('protocol', protocol)) } + if (hasBoundaryData) { + params.append('has_boundary_data', 'true') + } + const reqUrl = new URL( '/research/offsets-db/api/query', window.location.origin @@ -162,6 +167,7 @@ const useFetcher = ( category, projectType, complianceOnly, + hasBoundaryData, search, listingBounds, transactionBounds, @@ -176,6 +182,7 @@ const useFetcher = ( useDebounce(category), useDebounce(projectType), complianceOnly, + hasBoundaryData, useDebounce(search), useDebounce(listingBounds), useDebounce(transactionBounds), From 8776fb3804cc26113fffada1a540ed8084a287a1 Mon Sep 17 00:00:00 2001 From: Shane Loeffler Date: Thu, 11 Dec 2025 12:20:02 -0600 Subject: [PATCH 05/21] fix filter alignment throughout --- components/queries.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/components/queries.js b/components/queries.js index 33d26ec..502b16d 100644 --- a/components/queries.js +++ b/components/queries.js @@ -146,7 +146,7 @@ const Queries = () => { return ( {view === 'projects' && ( - + Project @@ -180,7 +180,7 @@ const Queries = () => { )} {view === 'transactions' && ( - + User @@ -213,7 +213,7 @@ const Queries = () => { )} - + Registry @@ -232,7 +232,7 @@ const Queries = () => { - + Category @@ -245,7 +245,7 @@ const Queries = () => { - + Type @@ -258,7 +258,7 @@ const Queries = () => { - + Country @@ -274,7 +274,7 @@ const Queries = () => { - + Protocol @@ -290,7 +290,7 @@ const Queries = () => { - + Program @@ -321,7 +321,7 @@ const Queries = () => { - + Geography From f5dac2bb70a1976fe12748ddbdb7c4a52b1bca79 Mon Sep 17 00:00:00 2001 From: Shane Loeffler Date: Thu, 11 Dec 2025 13:32:36 -0600 Subject: [PATCH 06/21] add citation link --- components/map.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/map.js b/components/map.js index 4a7abd4..33410d4 100644 --- a/components/map.js +++ b/components/map.js @@ -247,7 +247,8 @@ const Map = ({ project }) => { 'project-boundaries': { type: 'vector', url: `pmtiles://${BOUNDARY_PMTILES_URL}`, - attribution: 'CarbonPlan, TK ref/link', + attribution: + 'Karnik et al. (2025)', }, }, layers: mapLayers, From 58eb44969d3bba35459c5acb69e80b8379eca644 Mon Sep 17 00:00:00 2001 From: Shane Loeffler Date: Thu, 11 Dec 2025 13:34:21 -0600 Subject: [PATCH 07/21] rename to geography --- components/queries.js | 14 +++++++------- components/use-fetcher.js | 10 +++++----- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/components/queries.js b/components/queries.js index 502b16d..0dd0c50 100644 --- a/components/queries.js +++ b/components/queries.js @@ -30,7 +30,7 @@ export const QueryProvider = ({ children }) => { ) const [projectType, setProjectType] = useState(null) const [complianceOnly, setComplianceOnly] = useState(null) - const [hasBoundaryData, setHasBoundaryData] = useState(false) + const [hasGeography, setHasGeography] = useState(false) const [search, setSearch] = useState('') const [beneficiarySearch, setBeneficiarySearch] = useState('') const [listingBounds, setListingBounds] = useState(null) @@ -60,8 +60,8 @@ export const QueryProvider = ({ children }) => { setRegistry, category, setCategory, - hasBoundaryData, - setHasBoundaryData, + hasGeography, + setHasGeography, projectType, setProjectType, complianceOnly, @@ -121,8 +121,8 @@ const Queries = () => { setRegistry, complianceOnly, setComplianceOnly, - hasBoundaryData, - setHasBoundaryData, + hasGeography, + setHasGeography, search, setSearch, beneficiarySearch, @@ -331,8 +331,8 @@ const Queries = () => { tooltip='Filter projects by geographic boundary data availability.' > setHasBoundaryData(!hasBoundaryData)} - value={hasBoundaryData} + onClick={() => setHasGeography(!hasGeography)} + value={hasGeography} > Has boundary diff --git a/components/use-fetcher.js b/components/use-fetcher.js index 42f425e..e8a8956 100644 --- a/components/use-fetcher.js +++ b/components/use-fetcher.js @@ -22,7 +22,7 @@ const fetcher = ([ category, projectType, complianceOnly, - hasBoundaryData, + hasGeography, search, listingBounds, transactionBounds, @@ -130,8 +130,8 @@ const fetcher = ([ protocols.forEach((protocol) => params.append('protocol', protocol)) } - if (hasBoundaryData) { - params.append('has_boundary_data', 'true') + if (hasGeography) { + params.append('geography', 'true') } const reqUrl = new URL( @@ -167,7 +167,7 @@ const useFetcher = ( category, projectType, complianceOnly, - hasBoundaryData, + hasGeography, search, listingBounds, transactionBounds, @@ -182,7 +182,7 @@ const useFetcher = ( useDebounce(category), useDebounce(projectType), complianceOnly, - hasBoundaryData, + hasGeography, useDebounce(search), useDebounce(listingBounds), useDebounce(transactionBounds), From 80435e8c020a784df77fe29c5cba3bd21834e76a Mon Sep 17 00:00:00 2001 From: Shane Loeffler Date: Fri, 12 Dec 2025 12:26:49 -0600 Subject: [PATCH 08/21] switch to filter pattern matching compliance --- components/queries.js | 29 ++++++++++++++++++++++------- components/use-fetcher.js | 4 ++-- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/components/queries.js b/components/queries.js index 0dd0c50..985c21c 100644 --- a/components/queries.js +++ b/components/queries.js @@ -30,7 +30,7 @@ export const QueryProvider = ({ children }) => { ) const [projectType, setProjectType] = useState(null) const [complianceOnly, setComplianceOnly] = useState(null) - const [hasGeography, setHasGeography] = useState(false) + const [hasGeography, setHasGeography] = useState(null) const [search, setSearch] = useState('') const [beneficiarySearch, setBeneficiarySearch] = useState('') const [listingBounds, setListingBounds] = useState(null) @@ -330,12 +330,27 @@ const Queries = () => { top='4px' tooltip='Filter projects by geographic boundary data availability.' > - setHasGeography(!hasGeography)} - value={hasGeography} - > - Has boundary - + { + let value + if (obj.available && obj.missing) { + value = null + } else if (obj.available) { + value = true + } else if (obj.missing) { + value = false + } else { + return + } + setHasGeography(value) + }} + multiSelect + /> diff --git a/components/use-fetcher.js b/components/use-fetcher.js index e8a8956..ad3c5aa 100644 --- a/components/use-fetcher.js +++ b/components/use-fetcher.js @@ -130,8 +130,8 @@ const fetcher = ([ protocols.forEach((protocol) => params.append('protocol', protocol)) } - if (hasGeography) { - params.append('geography', 'true') + if (typeof hasGeography === 'boolean') { + params.append('geography', hasGeography ? 'true' : 'false') } const reqUrl = new URL( From f83917ec4ee083ce3163568f05604135f5e302be Mon Sep 17 00:00:00 2001 From: Shane Loeffler Date: Mon, 15 Dec 2025 11:14:18 -0600 Subject: [PATCH 09/21] add labels from centroids --- components/constants.js | 3 +- components/map.js | 417 +++++++++++++++--------------------- components/use-map-theme.js | 29 +-- 3 files changed, 183 insertions(+), 266 deletions(-) diff --git a/components/constants.js b/components/constants.js index c681ce3..3930cb4 100644 --- a/components/constants.js +++ b/components/constants.js @@ -28,7 +28,8 @@ export const LABELS = { export const BOUNDARY_PMTILES_URL = 'https://carbonplan-offsets-db.s3.us-west-2.amazonaws.com/miscellaneous/project-boundaries.pmtiles' -export const BOUNDARY_LAYER_ID = 'project-boundaries' +export const BOUNDARY_LAYER_ID = 'boundaries' +export const CENTROIDS_LAYER_ID = 'centroids' export const BASEMAP_PMTILES_URL = 'https://carbonplan-maps.s3.us-west-2.amazonaws.com/basemaps/pmtiles/global.pmtiles' diff --git a/components/map.js b/components/map.js index 33410d4..227a01b 100644 --- a/components/map.js +++ b/components/map.js @@ -1,8 +1,5 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react' -import { createPortal } from 'react-dom' +import React, { useEffect, useMemo, useRef } from 'react' import { Box, get, useThemeUI } from 'theme-ui' -import { Badge } from '@carbonplan/components' -import { Arrow } from '@carbonplan/icons' import { useRouter } from 'next/router' import maplibregl from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' @@ -11,56 +8,98 @@ import { BOUNDARY_PMTILES_URL, BASEMAP_PMTILES_URL, BOUNDARY_LAYER_ID, + CENTROIDS_LAYER_ID, COLORS, } from './constants' import { useMapTheme } from './use-map-theme' import { getProjectCategory } from './utils' -const createBoundaryLayers = (projectId, color, secondary) => { - const isProject = ['==', ['get', 'project_id'], projectId] - return [ - { - id: 'project-boundaries-fill', - type: 'fill', - source: 'project-boundaries', - 'source-layer': BOUNDARY_LAYER_ID, - paint: { - 'fill-color': ['case', isProject, color, secondary], - 'fill-opacity': 0.2, - }, - }, - { - id: 'project-boundaries-outline', - type: 'line', - source: 'project-boundaries', - 'source-layer': BOUNDARY_LAYER_ID, - paint: { - 'line-color': ['case', isProject, color, secondary], - 'line-width': [ - 'case', - ['==', ['feature-state', 'hover'], true], - ['case', isProject, 1, 1], - ['case', isProject, 1, 0.1], - ], - }, - }, - ] -} - const Map = ({ project }) => { const mapContainer = useRef(null) const map = useRef(null) - const popup = useRef(null) - const popupContainerRef = useRef(null) - const boundaryEventsCleanup = useRef(null) + const hoveredFeatureId = useRef(null) const mapLayers = useMapTheme() - const [hoveredProjectId, setHoveredProjectId] = useState(null) const { theme } = useThemeUI() const router = useRouter() const colorName = COLORS[getProjectCategory(project)] ?? COLORS.other const color = get(theme, `rawColors.${colorName}`, colorName) const secondary = get(theme, 'rawColors.secondary') + const background = get(theme, 'rawColors.background') + const hinted = get(theme, 'rawColors.hinted') + const primary = get(theme, 'rawColors.primary') + + const bounds = useMemo( + () => [ + [project.bbox.xmin, project.bbox.ymin], + [project.bbox.xmax, project.bbox.ymax], + ], + [project.bbox] + ) + + const projectLayers = useMemo(() => { + const isProject = ['==', ['get', 'project_id'], project.project_id] + return [ + { + id: 'project-boundaries-fill', + type: 'fill', + source: 'project-boundaries', + 'source-layer': BOUNDARY_LAYER_ID, + paint: { + 'fill-color': ['case', isProject, color, secondary], + 'fill-opacity': 0.2, + }, + }, + { + id: 'project-boundaries-outline', + type: 'line', + source: 'project-boundaries', + 'source-layer': BOUNDARY_LAYER_ID, + paint: { + 'line-color': ['case', isProject, color, secondary], + 'line-width': [ + 'case', + ['==', ['feature-state', 'hover'], true], + ['case', isProject, 1, 1], + ['case', isProject, 0.5, 0.1], + ], + }, + }, + { + id: 'project-centroids-label', + type: 'symbol', + source: 'project-boundaries', + 'source-layer': CENTROIDS_LAYER_ID, + layout: { + 'text-field': ['get', 'project_id'], + 'text-size': 16, + 'text-font': ['Relative Pro Book'], + 'text-anchor': 'center', + 'symbol-sort-key': ['case', isProject, 0, 1], + }, + paint: { + 'text-color': [ + 'case', + isProject, + color, + [ + 'case', + ['==', ['feature-state', 'hover'], true], + primary, + secondary, + ], + ], + 'text-halo-color': [ + 'case', + ['==', ['feature-state', 'hover'], true], + hinted, + background, + ], + 'text-halo-width': 2, + }, + }, + ] + }, [project.project_id, color, secondary, primary, background, hinted]) const mapControlStyles = useMemo( () => ({ @@ -85,74 +124,45 @@ const Map = ({ project }) => { }, }, }, - '& .maplibregl-popup': { - zIndex: 1, - pointerEvents: 'none', - }, - '& .maplibregl-popup-content': { - padding: 0, - background: 'transparent', - boxShadow: 'none', - pointerEvents: 'none', - }, - '& .maplibregl-popup-tip': { - display: 'none', - }, }), [secondary] ) - const bounds = useMemo( - () => [ - [project.bbox.xmin, project.bbox.ymin], - [project.bbox.xmax, project.bbox.ymax], - ], - [project.bbox] - ) useEffect(() => { if (!mapContainer.current || map.current) return - let hoveredFeatureId = null - const setFeatureHover = (featureId, hover) => { if (!featureId || !map.current) return - map.current.setFeatureState( - { - source: 'project-boundaries', - sourceLayer: BOUNDARY_LAYER_ID, - id: featureId, - }, - { hover } - ) - } - - const clearHoveredFeature = () => { - if (!hoveredFeatureId) return - setFeatureHover(hoveredFeatureId, false) - hoveredFeatureId = null + const layers = [BOUNDARY_LAYER_ID, CENTROIDS_LAYER_ID] + layers.forEach((sourceLayer) => { + map.current.setFeatureState( + { source: 'project-boundaries', sourceLayer, id: featureId }, + { hover } + ) + }) } - const clearHoverInteraction = (shouldClearContainer = false) => { - clearHoveredFeature() - map.current.getCanvas().style.cursor = '' - setHoveredProjectId(null) - if (shouldClearContainer) { - popupContainerRef.current = null + const clearHover = () => { + if (hoveredFeatureId.current) { + setFeatureHover(hoveredFeatureId.current, false) + hoveredFeatureId.current = null } - if (popup.current) { - popup.current.remove() + if (map.current) { + map.current.getCanvas().style.cursor = '' } } - const ensurePopupContainer = () => { - if (!popupContainerRef.current) { - const container = document.createElement('div') - popupContainerRef.current = container + const updateHover = (feature) => { + const featureId = feature.id || feature.properties.project_id + if (featureId !== hoveredFeatureId.current) { + clearHover() + hoveredFeatureId.current = featureId + setFeatureHover(featureId, true) } - return popupContainerRef.current + map.current.getCanvas().style.cursor = 'pointer' } - const queryHoveredFeature = (event) => { + const handleMouseMove = (event) => { const tolerance = 3 const bbox = [ [event.point.x - tolerance, event.point.y - tolerance], @@ -161,183 +171,98 @@ const Map = ({ project }) => { const features = map.current.queryRenderedFeatures(bbox, { layers: ['project-boundaries-fill'], }) - return features[0] || null + features[0] ? updateHover(features[0]) : clearHover() } - const handleHover = (feature, lngLat) => { - const projectId = feature.properties.project_id - const featureId = feature.id || feature.properties.project_id - - if (featureId && featureId !== hoveredFeatureId) { - clearHoveredFeature() - hoveredFeatureId = featureId - setFeatureHover(featureId, true) - } - - map.current.getCanvas().style.cursor = 'pointer' - ensurePopupContainer() - setHoveredProjectId((prev) => (prev === projectId ? prev : projectId)) - - popup.current - .setLngLat(lngLat) - .setDOMContent(popupContainerRef.current) - .addTo(map.current) + const handleMouseEnter = (event) => { + const feature = event.features?.[0] + if (feature) updateHover(feature) } - const attachBoundaryEvents = () => { - const handleMouseMove = (event) => { - const feature = queryHoveredFeature(event) - if (!feature) { - clearHoverInteraction() - return - } - handleHover(feature, event.lngLat) - } - - const handleMouseLeave = () => clearHoverInteraction(true) - - const handleClick = (event) => { - const feature = event.features?.[0] - const clickedProjectId = feature?.properties?.project_id - if (clickedProjectId && clickedProjectId !== project.project_id) { - router.push(`/projects/${clickedProjectId}`) - } - } - - map.current.on('mousemove', 'project-boundaries-fill', handleMouseMove) - map.current.on('mouseleave', 'project-boundaries-fill', handleMouseLeave) - map.current.on('click', 'project-boundaries-fill', handleClick) - - return () => { - map.current.off('mousemove', 'project-boundaries-fill', handleMouseMove) - map.current.off( - 'mouseleave', - 'project-boundaries-fill', - handleMouseLeave - ) - map.current.off('click', 'project-boundaries-fill', handleClick) + const handleClick = (event) => { + const clickedProjectId = event.features?.[0]?.properties?.project_id + if (clickedProjectId && clickedProjectId !== project.project_id) { + router.push(`/projects/${clickedProjectId}`) } } - const setupMap = async () => { - const protocol = new Protocol() - maplibregl.addProtocol('pmtiles', protocol.tile) - - const boundaryLayers = createBoundaryLayers( - project.project_id, - color, - secondary - ) - - map.current = new maplibregl.Map({ - container: mapContainer.current, - bounds, - fitBoundsOptions: { padding: 10 }, - style: { - version: 8, - glyphs: - 'https://carbonplan-maps.s3.us-west-2.amazonaws.com/basemaps/fonts/{fontstack}/{range}.pbf', - sources: { - basemap: { - type: 'vector', - url: `pmtiles://${BASEMAP_PMTILES_URL}`, - attribution: - 'Protomaps © OpenStreetMap', - }, - 'project-boundaries': { - type: 'vector', - url: `pmtiles://${BOUNDARY_PMTILES_URL}`, - attribution: - 'Karnik et al. (2025)', - }, + const protocol = new Protocol() + maplibregl.addProtocol('pmtiles', protocol.tile) + + map.current = new maplibregl.Map({ + container: mapContainer.current, + bounds, + fitBoundsOptions: { padding: 10 }, + style: { + version: 8, + glyphs: + 'https://carbonplan-maps.s3.us-west-2.amazonaws.com/basemaps/fonts/{fontstack}/{range}.pbf', + sources: { + basemap: { + type: 'vector', + url: `pmtiles://${BASEMAP_PMTILES_URL}`, + attribution: + 'Protomaps © OpenStreetMap', + }, + 'project-boundaries': { + type: 'vector', + url: `pmtiles://${BOUNDARY_PMTILES_URL}`, + promoteId: 'project_id', + attribution: + 'Karnik et al. (2025)', }, - layers: mapLayers, }, - attributionControl: false, - }) - - const attributionControl = new maplibregl.AttributionControl({ - compact: true, - }) - map.current.addControl(attributionControl, 'bottom-right') - - popup.current = new maplibregl.Popup({ - closeButton: false, - closeOnClick: false, - offset: 1, - }) - - map.current.on('load', () => { - boundaryLayers.forEach((layer) => { - map.current.addLayer(layer, 'address_label') - }) - - boundaryEventsCleanup.current = attachBoundaryEvents() - }) - } + layers: mapLayers, + }, + attributionControl: false, + }) + + map.current.addControl( + new maplibregl.AttributionControl({ compact: true }), + 'bottom-right' + ) + + map.current.on('load', () => { + // Add boundary layers before address labels + projectLayers + .filter((layer) => layer.id !== 'project-centroids-label') + .forEach((layer) => map.current.addLayer(layer, 'address_label')) + + // Add centroid labels on top of everything + const labelLayer = projectLayers.find( + (layer) => layer.id === 'project-centroids-label' + ) + if (labelLayer) map.current.addLayer(labelLayer) - setupMap() + map.current.on('mousemove', 'project-boundaries-fill', handleMouseMove) + map.current.on('mouseleave', 'project-boundaries-fill', clearHover) + map.current.on('click', 'project-boundaries-fill', handleClick) + map.current.on('mouseenter', 'project-centroids-label', handleMouseEnter) + map.current.on('mouseleave', 'project-centroids-label', clearHover) + map.current.on('click', 'project-centroids-label', handleClick) + }) return () => { - boundaryEventsCleanup.current?.() - boundaryEventsCleanup.current = null - - popup.current?.remove() - popup.current = null - - setHoveredProjectId(null) - popupContainerRef.current = null - if (map.current) { - clearHoveredFeature() + clearHover() map.current.remove() map.current = null } - maplibregl.removeProtocol?.('pmtiles') } - }, [project.bbox, project.project_id, color, secondary, mapLayers]) + }, [bounds, projectLayers, mapLayers, project.project_id, router]) return ( - <> - - {hoveredProjectId && popupContainerRef.current - ? createPortal( - - {hoveredProjectId} - {hoveredProjectId !== project.project_id && ( - - )} - , - popupContainerRef.current - ) - : null} - + ) } diff --git a/components/use-map-theme.js b/components/use-map-theme.js index ccbf6a8..f14aeae 100644 --- a/components/use-map-theme.js +++ b/components/use-map-theme.js @@ -12,7 +12,6 @@ export const useMapTheme = () => { const transparent = 'transparent' const hinted = get(theme, 'rawColors.hinted') const primary = get(theme, 'rawColors.primary') - const secondary = get(theme, 'rawColors.secondary') const muted = get(theme, 'rawColors.muted') const background = get(theme, 'rawColors.background') @@ -80,45 +79,37 @@ export const useMapTheme = () => { pier: muted, railway: muted, - boundaries: secondary, + boundaries: muted, - roads_label_minor: secondary, + roads_label_minor: muted, roads_label_minor_halo: background, - roads_label_major: secondary, + roads_label_major: muted, roads_label_major_halo: background, - ocean_label: secondary, + ocean_label: muted, subplace_label: [ 'interpolate', ['linear'], ['zoom'], 8, - secondary, + muted, 22, primary, ], subplace_label_halo: background, - city_label: [ - 'interpolate', - ['linear'], - ['zoom'], - 8, - secondary, - 22, - primary, - ], + city_label: ['interpolate', ['linear'], ['zoom'], 8, muted, 22, primary], city_label_halo: background, - state_label: secondary, + state_label: muted, state_label_halo: background, - country_label: secondary, + country_label: muted, - address_label: secondary, + address_label: muted, address_label_halo: background, regular: 'Relative Pro Book', bold: 'Relative Pro Book', italic: 'Relative Pro Book', }), - [flavorName, hinted, secondary, muted, background, primary] + [flavorName, hinted, muted, background, primary] ) const mapLayers = useMemo(() => { From 73a630564e0bcfeab1a43140bb23c9012d2891d6 Mon Sep 17 00:00:00 2001 From: Shane Loeffler Date: Tue, 16 Dec 2025 14:59:26 -0600 Subject: [PATCH 10/21] remove hover effects on current project --- components/map.js | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/components/map.js b/components/map.js index 227a01b..083e005 100644 --- a/components/map.js +++ b/components/map.js @@ -59,9 +59,9 @@ const Map = ({ project }) => { 'line-color': ['case', isProject, color, secondary], 'line-width': [ 'case', - ['==', ['feature-state', 'hover'], true], - ['case', isProject, 1, 1], - ['case', isProject, 0.5, 0.1], + isProject, + 1, + ['case', ['==', ['feature-state', 'hover'], true], 1, 0.5], ], }, }, @@ -91,9 +91,14 @@ const Map = ({ project }) => { ], 'text-halo-color': [ 'case', - ['==', ['feature-state', 'hover'], true], - hinted, + isProject, background, + [ + 'case', + ['==', ['feature-state', 'hover'], true], + hinted, + background, + ], ], 'text-halo-width': 2, }, @@ -154,12 +159,15 @@ const Map = ({ project }) => { const updateHover = (feature) => { const featureId = feature.id || feature.properties.project_id + const featureProjectId = feature.properties?.project_id if (featureId !== hoveredFeatureId.current) { clearHover() hoveredFeatureId.current = featureId setFeatureHover(featureId, true) } - map.current.getCanvas().style.cursor = 'pointer' + if (featureProjectId !== project.project_id) { + map.current.getCanvas().style.cursor = 'pointer' + } } const handleMouseMove = (event) => { From 456a770874283c5e56e91ef140ed3ae6b153b6b1 Mon Sep 17 00:00:00 2001 From: Shane Loeffler Date: Tue, 16 Dec 2025 15:14:21 -0600 Subject: [PATCH 11/21] update map fonts --- components/map.js | 64 +++++++++++++++++++++++++++++++++++-- components/use-map-theme.js | 23 +++++++++++-- 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/components/map.js b/components/map.js index 083e005..0bf79fa 100644 --- a/components/map.js +++ b/components/map.js @@ -73,7 +73,8 @@ const Map = ({ project }) => { layout: { 'text-field': ['get', 'project_id'], 'text-size': 16, - 'text-font': ['Relative Pro Book'], + 'text-font': ['Relative Mono Pro 11 Pitch'], + 'text-letter-spacing': 0.02, 'text-anchor': 'center', 'symbol-sort-key': ['case', isProject, 0, 1], }, @@ -92,7 +93,7 @@ const Map = ({ project }) => { 'text-halo-color': [ 'case', isProject, - background, + 'transparent', [ 'case', ['==', ['feature-state', 'hover'], true], @@ -128,9 +129,60 @@ const Map = ({ project }) => { )}' d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")`, }, }, + '& .maplibregl-ctrl-group': { + marginBottom: 0, + bg: 'hinted', + border: `1px solid`, + borderColor: 'muted', + borderRadius: '20px', + boxShadow: 'none', + overflow: 'hidden', + '& button': { + bg: 'hinted', + border: 'none', + borderBottom: `1px solid`, + borderColor: 'muted', + width: '24px', + height: '24px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + '&:last-child': { + borderBottom: 'none', + }, + '& .maplibregl-ctrl-icon': { + backgroundSize: '20px', + backgroundRepeat: 'no-repeat', + backgroundPosition: 'center', + }, + }, + '& .maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon': { + backgroundImage: `url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20'%3E%3Cpath stroke='${encodeURIComponent( + primary + )}' stroke-width='2' stroke-linecap='round' fill='none' d='M10 6v8M6 10h8'/%3E%3C/svg%3E")`, + }, + '& .maplibregl-ctrl-zoom-in:hover .maplibregl-ctrl-icon, & .maplibregl-ctrl-zoom-in:focus-visible .maplibregl-ctrl-icon': + { + backgroundImage: `url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20'%3E%3Cpath stroke='${encodeURIComponent( + secondary + )}' stroke-width='2' stroke-linecap='round' fill='none' d='M10 6v8M6 10h8'/%3E%3C/svg%3E")`, + }, + '& .maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon': { + backgroundImage: `url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20'%3E%3Cpath stroke='${encodeURIComponent( + primary + )}' stroke-width='2' stroke-linecap='round' fill='none' d='M6 10h8'/%3E%3C/svg%3E")`, + }, + '& .maplibregl-ctrl-zoom-out:hover .maplibregl-ctrl-icon, & .maplibregl-ctrl-zoom-out:focus-visible .maplibregl-ctrl-icon': + { + backgroundImage: `url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20'%3E%3Cpath stroke='${encodeURIComponent( + secondary + )}' stroke-width='2' stroke-linecap='round' fill='none' d='M6 10h8'/%3E%3C/svg%3E")`, + }, + }, }, }), - [secondary] + [primary, secondary] ) useEffect(() => { @@ -201,6 +253,7 @@ const Map = ({ project }) => { container: mapContainer.current, bounds, fitBoundsOptions: { padding: 10 }, + scrollZoom: false, style: { version: 8, glyphs: @@ -230,6 +283,11 @@ const Map = ({ project }) => { 'bottom-right' ) + map.current.addControl( + new maplibregl.NavigationControl({ showCompass: false }), + 'bottom-right' + ) + map.current.on('load', () => { // Add boundary layers before address labels projectLayers diff --git a/components/use-map-theme.js b/components/use-map-theme.js index f14aeae..4daf1e3 100644 --- a/components/use-map-theme.js +++ b/components/use-map-theme.js @@ -14,6 +14,7 @@ export const useMapTheme = () => { const primary = get(theme, 'rawColors.primary') const muted = get(theme, 'rawColors.muted') const background = get(theme, 'rawColors.background') + const secondary = get(theme, 'rawColors.secondary') const mapTheme = useMemo( () => ({ @@ -96,11 +97,27 @@ export const useMapTheme = () => { primary, ], subplace_label_halo: background, - city_label: ['interpolate', ['linear'], ['zoom'], 8, muted, 22, primary], + city_label: ['interpolate', ['linear'], ['zoom'], 0, muted, 8, secondary], city_label_halo: background, - state_label: muted, + state_label: [ + 'interpolate', + ['linear'], + ['zoom'], + 0, + muted, + 8, + secondary, + ], state_label_halo: background, - country_label: muted, + country_label: [ + 'interpolate', + ['linear'], + ['zoom'], + 0, + muted, + 8, + secondary, + ], address_label: muted, address_label_halo: background, From 8c3609f5d5427132552735f9b476fe26b54b69e8 Mon Sep 17 00:00:00 2001 From: Shane Loeffler Date: Tue, 16 Dec 2025 15:14:47 -0600 Subject: [PATCH 12/21] simplify hover handler --- components/map.js | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/components/map.js b/components/map.js index 0bf79fa..1a1b957 100644 --- a/components/map.js +++ b/components/map.js @@ -223,20 +223,24 @@ const Map = ({ project }) => { } const handleMouseMove = (event) => { + // Query both layers, prioritize labels over fills + const labels = map.current.queryRenderedFeatures(event.point, { + layers: ['project-centroids-label'], + }) + if (labels[0]) { + updateHover(labels[0]) + return + } + const tolerance = 3 const bbox = [ [event.point.x - tolerance, event.point.y - tolerance], [event.point.x + tolerance, event.point.y + tolerance], ] - const features = map.current.queryRenderedFeatures(bbox, { + const fills = map.current.queryRenderedFeatures(bbox, { layers: ['project-boundaries-fill'], }) - features[0] ? updateHover(features[0]) : clearHover() - } - - const handleMouseEnter = (event) => { - const feature = event.features?.[0] - if (feature) updateHover(feature) + fills[0] ? updateHover(fills[0]) : clearHover() } const handleClick = (event) => { @@ -300,11 +304,8 @@ const Map = ({ project }) => { ) if (labelLayer) map.current.addLayer(labelLayer) - map.current.on('mousemove', 'project-boundaries-fill', handleMouseMove) - map.current.on('mouseleave', 'project-boundaries-fill', clearHover) + map.current.on('mousemove', handleMouseMove) map.current.on('click', 'project-boundaries-fill', handleClick) - map.current.on('mouseenter', 'project-centroids-label', handleMouseEnter) - map.current.on('mouseleave', 'project-centroids-label', clearHover) map.current.on('click', 'project-centroids-label', handleClick) }) From f31361af998e2da44390a73f214b87bbe0532166 Mon Sep 17 00:00:00 2001 From: Shane Loeffler Date: Tue, 16 Dec 2025 15:22:27 -0600 Subject: [PATCH 13/21] improve label and font further --- components/map.js | 9 +++++++-- components/use-map-theme.js | 16 ++++++++++++---- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/components/map.js b/components/map.js index 1a1b957..448f558 100644 --- a/components/map.js +++ b/components/map.js @@ -93,7 +93,7 @@ const Map = ({ project }) => { 'text-halo-color': [ 'case', isProject, - 'transparent', + background, [ 'case', ['==', ['feature-state', 'hover'], true], @@ -125,8 +125,13 @@ const Map = ({ project }) => { '& .maplibregl-ctrl-attrib-button': { bg: 'hinted', backgroundImage: `url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill-rule='evenodd' viewBox='0 0 20 20'%3E%3Cpath fill='${encodeURIComponent( - secondary + primary )}' d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")`, + '&:hover, &:focus-visible': { + backgroundImage: `url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill-rule='evenodd' viewBox='0 0 20 20'%3E%3Cpath fill='${encodeURIComponent( + secondary + )}' d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")`, + }, }, }, '& .maplibregl-ctrl-group': { diff --git a/components/use-map-theme.js b/components/use-map-theme.js index 4daf1e3..7e07e48 100644 --- a/components/use-map-theme.js +++ b/components/use-map-theme.js @@ -91,13 +91,21 @@ export const useMapTheme = () => { 'interpolate', ['linear'], ['zoom'], - 8, + 12, muted, 22, primary, ], subplace_label_halo: background, - city_label: ['interpolate', ['linear'], ['zoom'], 0, muted, 8, secondary], + city_label: [ + 'interpolate', + ['linear'], + ['zoom'], + 0, + muted, + 12, + secondary, + ], city_label_halo: background, state_label: [ 'interpolate', @@ -105,7 +113,7 @@ export const useMapTheme = () => { ['zoom'], 0, muted, - 8, + 12, secondary, ], state_label_halo: background, @@ -115,7 +123,7 @@ export const useMapTheme = () => { ['zoom'], 0, muted, - 8, + 12, secondary, ], From 9a90bc0e73e7daf80e7f292b3b37f0abeb874142 Mon Sep 17 00:00:00 2001 From: Shane Loeffler Date: Tue, 16 Dec 2025 15:46:36 -0600 Subject: [PATCH 14/21] add arrow on hover --- components/map.js | 104 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 90 insertions(+), 14 deletions(-) diff --git a/components/map.js b/components/map.js index 448f558..4ad4e78 100644 --- a/components/map.js +++ b/components/map.js @@ -1,9 +1,11 @@ -import React, { useEffect, useMemo, useRef } from 'react' +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import { Box, get, useThemeUI } from 'theme-ui' import { useRouter } from 'next/router' import maplibregl from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' import { Protocol } from 'pmtiles' +import { Arrow } from '@carbonplan/icons' import { BOUNDARY_PMTILES_URL, BASEMAP_PMTILES_URL, @@ -18,6 +20,9 @@ const Map = ({ project }) => { const mapContainer = useRef(null) const map = useRef(null) const hoveredFeatureId = useRef(null) + const markerRef = useRef(null) + const [markerEl, setMarkerEl] = useState(null) + const [markerVisible, setMarkerVisible] = useState(false) const mapLayers = useMapTheme() const { theme } = useThemeUI() const router = useRouter() @@ -212,6 +217,7 @@ const Map = ({ project }) => { if (map.current) { map.current.getCanvas().style.cursor = '' } + setMarkerVisible(false) } const updateHover = (feature) => { @@ -227,6 +233,31 @@ const Map = ({ project }) => { } } + const updateMarkerPosition = (feature) => { + if (!markerRef.current || !feature) return + + const projectId = feature.properties?.project_id + if (projectId === project.project_id) { + setMarkerVisible(false) + return + } + + const coords = feature.geometry.coordinates + const point = map.current.project(coords) + + const fontSize = 16 + const letterSpacing = 0.02 // em + const charWidth = fontSize * (0.55 + letterSpacing) + const textWidth = projectId.length * charWidth + const offsetX = textWidth / 2 + 8 + console.log(offsetX) + + // Convert back to lng/lat with the offset + const offsetPoint = map.current.unproject([point.x + offsetX, point.y]) + markerRef.current.setLngLat(offsetPoint) + setMarkerVisible(true) + } + const handleMouseMove = (event) => { // Query both layers, prioritize labels over fills const labels = map.current.queryRenderedFeatures(event.point, { @@ -234,6 +265,7 @@ const Map = ({ project }) => { }) if (labels[0]) { updateHover(labels[0]) + updateMarkerPosition(labels[0]) return } @@ -245,7 +277,22 @@ const Map = ({ project }) => { const fills = map.current.queryRenderedFeatures(bbox, { layers: ['project-boundaries-fill'], }) - fills[0] ? updateHover(fills[0]) : clearHover() + if (fills[0]) { + updateHover(fills[0]) + // Find the visible centroid label for this project to position the marker + const projectId = fills[0].properties?.project_id + const centroids = map.current.queryRenderedFeatures(undefined, { + layers: ['project-centroids-label'], + filter: ['==', ['get', 'project_id'], projectId], + }) + if (centroids[0]) { + updateMarkerPosition(centroids[0]) + } else { + setMarkerVisible(false) + } + } else { + clearHover() + } } const handleClick = (event) => { @@ -298,23 +345,35 @@ const Map = ({ project }) => { ) map.current.on('load', () => { - // Add boundary layers before address labels projectLayers .filter((layer) => layer.id !== 'project-centroids-label') .forEach((layer) => map.current.addLayer(layer, 'address_label')) - // Add centroid labels on top of everything const labelLayer = projectLayers.find( (layer) => layer.id === 'project-centroids-label' ) if (labelLayer) map.current.addLayer(labelLayer) + const el = document.createElement('div') + markerRef.current = new maplibregl.Marker({ + element: el, + anchor: 'center', + }) + .setLngLat([0, 0]) + .addTo(map.current) + setMarkerEl(el) + map.current.on('mousemove', handleMouseMove) map.current.on('click', 'project-boundaries-fill', handleClick) map.current.on('click', 'project-centroids-label', handleClick) }) return () => { + if (markerRef.current) { + markerRef.current.remove() + markerRef.current = null + } + setMarkerEl(null) if (map.current) { clearHover() map.current.remove() @@ -325,16 +384,33 @@ const Map = ({ project }) => { }, [bounds, projectLayers, mapLayers, project.project_id, router]) return ( - + <> + + {markerEl && + createPortal( + , + markerEl + )} + ) } From b829f80184270033bcf5208778f5ecb3ebeaa2ca Mon Sep 17 00:00:00 2001 From: Shane Loeffler Date: Tue, 16 Dec 2025 15:53:39 -0600 Subject: [PATCH 15/21] rm log --- components/map.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/components/map.js b/components/map.js index 4ad4e78..ae8f916 100644 --- a/components/map.js +++ b/components/map.js @@ -250,9 +250,7 @@ const Map = ({ project }) => { const charWidth = fontSize * (0.55 + letterSpacing) const textWidth = projectId.length * charWidth const offsetX = textWidth / 2 + 8 - console.log(offsetX) - // Convert back to lng/lat with the offset const offsetPoint = map.current.unproject([point.x + offsetX, point.y]) markerRef.current.setLngLat(offsetPoint) setMarkerVisible(true) From ac8c8c6803913feedc457e45bc710befbc223031 Mon Sep 17 00:00:00 2001 From: Shane Loeffler Date: Tue, 16 Dec 2025 15:55:32 -0600 Subject: [PATCH 16/21] add cmd + scroll zoom --- components/map.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/components/map.js b/components/map.js index ae8f916..f776818 100644 --- a/components/map.js +++ b/components/map.js @@ -366,7 +366,22 @@ const Map = ({ project }) => { map.current.on('click', 'project-centroids-label', handleClick) }) + const handleWheel = (e) => { + if (e.metaKey || e.ctrlKey) { + e.preventDefault() + const delta = -e.deltaY * 0.01 + map.current?.zoomTo(map.current.getZoom() + delta, { duration: 100 }) + } + } + + mapContainer.current.addEventListener('wheel', handleWheel, { + passive: false, + }) + + const container = mapContainer.current + return () => { + container?.removeEventListener('wheel', handleWheel) if (markerRef.current) { markerRef.current.remove() markerRef.current = null From a04aed6fd5071014a17e327a3a913ddfbec9185b Mon Sep 17 00:00:00 2001 From: Shane Loeffler Date: Tue, 16 Dec 2025 16:05:16 -0600 Subject: [PATCH 17/21] fix map label jump on zoom --- components/use-map-theme.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/use-map-theme.js b/components/use-map-theme.js index 7e07e48..eea738a 100644 --- a/components/use-map-theme.js +++ b/components/use-map-theme.js @@ -144,10 +144,11 @@ export const useMapTheme = () => { return baseLayers.map((layer) => { if (layer.id === 'places_locality' && layer.type === 'symbol') { + const { 'text-variable-anchor': _, ...restLayout } = layer.layout return { ...layer, layout: { - ...layer.layout, + ...restLayout, 'text-anchor': 'center', 'text-justify': 'center', }, From 31d4466dd84868291ed5c4172f6da86b36aaff21 Mon Sep 17 00:00:00 2001 From: Shane Loeffler Date: Wed, 17 Dec 2025 09:52:32 -0600 Subject: [PATCH 18/21] try faux --- components/use-map-theme.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/components/use-map-theme.js b/components/use-map-theme.js index eea738a..b1c8c4b 100644 --- a/components/use-map-theme.js +++ b/components/use-map-theme.js @@ -130,9 +130,9 @@ export const useMapTheme = () => { address_label: muted, address_label_halo: background, - regular: 'Relative Pro Book', - bold: 'Relative Pro Book', - italic: 'Relative Pro Book', + regular: 'Relative Faux Pro Book', + bold: 'Relative Faux Pro Book', + italic: 'Relative Faux Pro Book', }), [flavorName, hinted, muted, background, primary] ) @@ -151,6 +151,16 @@ export const useMapTheme = () => { ...restLayout, 'text-anchor': 'center', 'text-justify': 'center', + 'text-letter-spacing': 0.05, + }, + } + } + if (layer.type === 'symbol' && layer.layout?.['text-field']) { + return { + ...layer, + layout: { + ...layer.layout, + 'text-letter-spacing': 0.05, }, } } From 239e7bd80736788371ef00a1323f41bdbd1b5655 Mon Sep 17 00:00:00 2001 From: Shane Loeffler Date: Wed, 17 Dec 2025 11:57:20 -0600 Subject: [PATCH 19/21] improve header spacing --- components/map.js | 1 + 1 file changed, 1 insertion(+) diff --git a/components/map.js b/components/map.js index f776818..ee3c756 100644 --- a/components/map.js +++ b/components/map.js @@ -401,6 +401,7 @@ const Map = ({ project }) => { Date: Wed, 17 Dec 2025 14:52:57 -0600 Subject: [PATCH 20/21] toggle scroll zoom setting instead of programmatic zoom --- components/map.js | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/components/map.js b/components/map.js index ee3c756..974ca34 100644 --- a/components/map.js +++ b/components/map.js @@ -366,22 +366,24 @@ const Map = ({ project }) => { map.current.on('click', 'project-centroids-label', handleClick) }) - const handleWheel = (e) => { - if (e.metaKey || e.ctrlKey) { - e.preventDefault() - const delta = -e.deltaY * 0.01 - map.current?.zoomTo(map.current.getZoom() + delta, { duration: 100 }) + const handleKeyDown = (e) => { + if ((e.metaKey || e.ctrlKey) && map.current) { + map.current.scrollZoom.enable() } } - mapContainer.current.addEventListener('wheel', handleWheel, { - passive: false, - }) + const handleKeyUp = (e) => { + if (!e.metaKey && !e.ctrlKey && map.current) { + map.current.scrollZoom.disable() + } + } - const container = mapContainer.current + window.addEventListener('keydown', handleKeyDown) + window.addEventListener('keyup', handleKeyUp) return () => { - container?.removeEventListener('wheel', handleWheel) + window.removeEventListener('keydown', handleKeyDown) + window.removeEventListener('keyup', handleKeyUp) if (markerRef.current) { markerRef.current.remove() markerRef.current = null From ac184c09ee75fb03c16bb62800c4941a6d8e37c8 Mon Sep 17 00:00:00 2001 From: Shane Loeffler Date: Wed, 17 Dec 2025 14:55:55 -0600 Subject: [PATCH 21/21] fix arrow position mismatch on zoom --- components/map.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/components/map.js b/components/map.js index 974ca34..4bdb719 100644 --- a/components/map.js +++ b/components/map.js @@ -20,6 +20,7 @@ const Map = ({ project }) => { const mapContainer = useRef(null) const map = useRef(null) const hoveredFeatureId = useRef(null) + const hoveredFeature = useRef(null) const markerRef = useRef(null) const [markerEl, setMarkerEl] = useState(null) const [markerVisible, setMarkerVisible] = useState(false) @@ -214,6 +215,7 @@ const Map = ({ project }) => { setFeatureHover(hoveredFeatureId.current, false) hoveredFeatureId.current = null } + hoveredFeature.current = null if (map.current) { map.current.getCanvas().style.cursor = '' } @@ -234,14 +236,19 @@ const Map = ({ project }) => { } const updateMarkerPosition = (feature) => { - if (!markerRef.current || !feature) return + if (!markerRef.current || !feature) { + hoveredFeature.current = null + return + } const projectId = feature.properties?.project_id if (projectId === project.project_id) { setMarkerVisible(false) + hoveredFeature.current = null return } + hoveredFeature.current = feature const coords = feature.geometry.coordinates const point = map.current.project(coords) @@ -256,6 +263,12 @@ const Map = ({ project }) => { setMarkerVisible(true) } + const handleZoom = () => { + if (hoveredFeature.current) { + updateMarkerPosition(hoveredFeature.current) + } + } + const handleMouseMove = (event) => { // Query both layers, prioritize labels over fills const labels = map.current.queryRenderedFeatures(event.point, { @@ -362,6 +375,7 @@ const Map = ({ project }) => { setMarkerEl(el) map.current.on('mousemove', handleMouseMove) + map.current.on('zoom', handleZoom) map.current.on('click', 'project-boundaries-fill', handleClick) map.current.on('click', 'project-centroids-label', handleClick) })