From 3cd6e0611545c96d65fcf03586e66c75136d4787 Mon Sep 17 00:00:00 2001 From: Nat Hillard <72811320+NatHillardUSDS@users.noreply.github.com> Date: Wed, 14 Jul 2021 11:26:12 -0400 Subject: [PATCH] Parameterize zoom experiments (#339) * Adding ability to set flags in url * parameterizing tile layers --- client/src/components/J40Header.tsx | 4 +- client/src/components/J40Map.tsx | 7 +- client/src/contexts/FlagContext.test.tsx | 12 +- client/src/contexts/FlagContext.tsx | 21 +- client/src/data/constants.tsx | 7 +- client/src/data/mapStyle.tsx | 300 ++++++++++++----------- 6 files changed, 188 insertions(+), 163 deletions(-) diff --git a/client/src/components/J40Header.tsx b/client/src/components/J40Header.tsx index 7af97fc5..d2ba8618 100644 --- a/client/src/components/J40Header.tsx +++ b/client/src/components/J40Header.tsx @@ -25,7 +25,7 @@ const J40Header = () => { const toggleMobileNav = (): void => setMobileNavOpen((prevOpen) => !prevOpen); - const headerLinks = (flags: string[] | undefined) => { + const headerLinks = (flags: {[key: string] : any} | undefined) => { // static map of all possible menu items. Originally, it was all strings, // but we need to handle both onsite and offsite links. const menuData = new Map([ @@ -64,7 +64,7 @@ const J40Header = () => { // select which items from the above map to show, right now it's only two // possibilities so it's simple. Note: strings are used as react keys const menu = - flags?.includes('sprint3') ? + ('sprint3' in flags!) ? ['about', 'cejst', 'methodology', 'contact'] : ['about', 'cejst', 'methodology', 'contact']; // TODO: make feature flags flags work. diff --git a/client/src/components/J40Map.tsx b/client/src/components/J40Map.tsx index 7e3e7ff5..7eab6acc 100644 --- a/client/src/components/J40Map.tsx +++ b/client/src/components/J40Map.tsx @@ -7,10 +7,12 @@ import maplibregl, {LngLatBoundsLike, Popup, LngLatLike, MapboxGeoJSONFeature} from 'maplibre-gl'; -import mapStyle from '../data/mapStyle'; +import {makeMapStyle} from '../data/mapStyle'; import PopupContent from './popupContent'; import * as constants from '../data/constants'; import ReactDOM from 'react-dom'; +import {useFlags} from '../contexts/FlagContext'; + import 'maplibre-gl/dist/maplibre-gl.css'; import * as styles from './J40Map.module.scss'; @@ -27,11 +29,12 @@ const J40Map = () => { const mapRef = useRef() as React.MutableRefObject; const selectedFeature = useRef(); const [zoom, setZoom] = useState(constants.GLOBAL_MIN_ZOOM); + const flags = useFlags(); useEffect(() => { const initialMap = new Map({ container: mapContainer.current!, - style: mapStyle, + style: makeMapStyle(flags), center: constants.DEFAULT_CENTER as LngLatLike, zoom: zoom, minZoom: constants.GLOBAL_MIN_ZOOM, diff --git a/client/src/contexts/FlagContext.test.tsx b/client/src/contexts/FlagContext.test.tsx index 89c6a335..9edfa789 100644 --- a/client/src/contexts/FlagContext.test.tsx +++ b/client/src/contexts/FlagContext.test.tsx @@ -6,7 +6,7 @@ describe('URL params are parsed and passed to children', () => { describe('when the URL has a "flags" parameter set', () => { // We artificially set the URL to localhost?flags=1,2,3 beforeEach(() => { - window.history.pushState({}, 'Test Title', '/?flags=1,2,3'); + window.history.pushState({}, 'Test Title', '/?flags=1,2,3,test=4'); }); describe('when using useFlags', () => { beforeEach(() => { @@ -14,10 +14,11 @@ describe('URL params are parsed and passed to children', () => { const flags = useFlags(); return ( <> -
{flags.includes('1') ? 'yes1' : 'no1'}
-
{flags.includes('2') ? 'yes2' : 'no2'}
-
{flags.includes('3') ? 'yes3' : 'no3'}
-
{flags.includes('4') ? 'yes4' : 'no4'}
+
{'1' in flags ? 'yes1' : 'no1'}
+
{'2' in flags ? 'yes2' : 'no2'}
+
{'3' in flags ? 'yes3' : 'no3'}
+
{'4' in flags ? 'yes4' : 'no4'}
+
{flags['test'] == 4 ? 'yes5' : 'no5'}
); }; @@ -33,6 +34,7 @@ describe('URL params are parsed and passed to children', () => { expect(screen.queryByText('yes2')).toBeInTheDocument(); expect(screen.queryByText('yes3')).toBeInTheDocument(); expect(screen.queryByText('yes4')).not.toBeInTheDocument(); + expect(screen.queryByText('yes5')).toBeInTheDocument(); }); }); }); diff --git a/client/src/contexts/FlagContext.tsx b/client/src/contexts/FlagContext.tsx index 0c710e63..71656a3c 100644 --- a/client/src/contexts/FlagContext.tsx +++ b/client/src/contexts/FlagContext.tsx @@ -1,6 +1,8 @@ import * as React from 'react'; import * as queryString from 'query-string'; +export type FlagContainer = { [key: string]: any }; + /** * FlagContext stores feature flags and passes them to consumers */ @@ -8,7 +10,7 @@ import * as queryString from 'query-string'; /** * Contains a list of all currently-active flags */ - flags: string[]; + flags: FlagContainer; } const FlagContext = React.createContext({flags: []}); @@ -16,9 +18,9 @@ const FlagContext = React.createContext({flags: []}); /** * `useFlags` returns all feature flags. * - * @return {Flags[]} flags All project feature flags + * @return {FlagContainer} flags All project feature flags */ -const useFlags = () : string[] => { +const useFlags = () : FlagContainer => { const {flags} = React.useContext(FlagContext); return flags; }; @@ -39,9 +41,18 @@ interface IURLFlagProviderProps { **/ const URLFlagProvider = ({children, location}: IURLFlagProviderProps) => { const flagString = queryString.parse(location.search).flags; - let flags: string[] = []; + const flags : FlagContainer = {}; + let flagList: string[] = []; if (flagString && typeof flagString === 'string') { - flags = (flagString as string).split(','); + flagList = (flagString as string).split(','); + } + for (const flag of flagList) { + if (flag.includes('=')) { + const [key, value] = flag.split('='); + flags[key] = value; + } else { + flags[flag] = true; + } } console.log(JSON.stringify(location), JSON.stringify(flags)); diff --git a/client/src/data/constants.tsx b/client/src/data/constants.tsx index c3e6b55a..426a6a3c 100644 --- a/client/src/data/constants.tsx +++ b/client/src/data/constants.tsx @@ -1,8 +1,11 @@ // URLS export const FEATURE_TILE_BASE_URL = 'https://d2zjid6n5ja2pt.cloudfront.net'; const XYZ_SUFFIX = '{z}/{x}/{y}.pbf'; -export const FEATURE_TILE_HIGH_ZOOM_URL = `${FEATURE_TILE_BASE_URL}/0629_demo/${XYZ_SUFFIX}`; -export const FEATURE_TILE_LOW_ZOOM_URL = `${FEATURE_TILE_BASE_URL}/tiles_low/${XYZ_SUFFIX}`; +export const featureURLForTilesetName = (tilesetName :string ) : string => { + return `${FEATURE_TILE_BASE_URL}/${tilesetName}/${XYZ_SUFFIX}`; +}; +export const FEATURE_TILE_HIGH_ZOOM_URL = featureURLForTilesetName('0629_demo'); +export const FEATURE_TILE_LOW_ZOOM_URL = featureURLForTilesetName('tiles_low'); // Performance markers diff --git a/client/src/data/mapStyle.tsx b/client/src/data/mapStyle.tsx index c4da6550..c8486601 100644 --- a/client/src/data/mapStyle.tsx +++ b/client/src/data/mapStyle.tsx @@ -1,6 +1,7 @@ import {Style, FillPaint} from 'maplibre-gl'; import chroma from 'chroma-js'; import * as constants from '../data/constants'; +import {FlagContainer} from '../contexts/FlagContext'; // eslint-disable-next-line require-jsdoc function hexToHSLA(hex:string, alpha:number) { @@ -45,172 +46,177 @@ function makePaint({ const imageSuffix = constants.isMobile ? '' : '@2x'; -const mapStyle : Style = { - 'version': 8, - 'sources': { - 'carto': { - 'type': 'raster', - 'tiles': +export const makeMapStyle = (flagContainer: FlagContainer) : Style => { + return { + 'version': 8, + 'sources': { + 'carto': { + 'type': 'raster', + 'tiles': [ `https://a.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}${imageSuffix}.png`, `https://b.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}${imageSuffix}.png`, `https://c.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}${imageSuffix}.png`, `https://d.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}${imageSuffix}.png`, ], - 'minzoom': constants.GLOBAL_MIN_ZOOM, - 'maxzoom': constants.GLOBAL_MAX_ZOOM, - }, - 'geo': { - 'type': 'raster', - 'tiles': [ - 'https://mt0.google.com/vt/lyrs=p&hl=en&x={x}&y={y}&z={z}', - ], - 'minzoom': constants.GLOBAL_MIN_ZOOM, - 'maxzoom': constants.GLOBAL_MAX_ZOOM, - }, - [constants.HIGH_SCORE_SOURCE_NAME]: { + 'minzoom': constants.GLOBAL_MIN_ZOOM, + 'maxzoom': constants.GLOBAL_MAX_ZOOM, + }, + 'geo': { + 'type': 'raster', + 'tiles': [ + 'https://mt0.google.com/vt/lyrs=p&hl=en&x={x}&y={y}&z={z}', + ], + 'minzoom': constants.GLOBAL_MIN_ZOOM, + 'maxzoom': constants.GLOBAL_MAX_ZOOM, + }, + [constants.HIGH_SCORE_SOURCE_NAME]: { // "Score-high" represents the full set of data // at the census block group level. It is only shown // at high zoom levels to avoid performance issues at lower zooms - 'type': 'vector', - // Our current tippecanoe command does not set an id. - // The below line promotes the GEOID10 property to the ID - 'promoteId': constants.GEOID_PROPERTY, - 'tiles': [ - constants.FEATURE_TILE_HIGH_ZOOM_URL, - ], - // Seeting maxzoom here enables 'overzooming' - // e.g. continued zooming beyond the max bounds. - // More here: https://docs.mapbox.com/help/glossary/overzoom/ - 'minzoom': constants.GLOBAL_MIN_ZOOM_HIGH, - 'maxzoom': constants.GLOBAL_MAX_ZOOM_HIGH, - }, - [constants.LOW_SCORE_SOURCE_NAME]: { + 'type': 'vector', + // Our current tippecanoe command does not set an id. + // The below line promotes the GEOID10 property to the ID + 'promoteId': constants.GEOID_PROPERTY, + 'tiles': [ + 'high_tiles' in flagContainer ? + constants.featureURLForTilesetName(flagContainer['high_tiles']) : + constants.FEATURE_TILE_HIGH_ZOOM_URL, + ], + // Seeting maxzoom here enables 'overzooming' + // e.g. continued zooming beyond the max bounds. + // More here: https://docs.mapbox.com/help/glossary/overzoom/ + 'minzoom': constants.GLOBAL_MIN_ZOOM_HIGH, + 'maxzoom': constants.GLOBAL_MAX_ZOOM_HIGH, + }, + [constants.LOW_SCORE_SOURCE_NAME]: { // "Score-low" represents a tileset at the level of bucketed tracts. // census block group information is `dissolve`d into tracts, then // each tract is `dissolve`d into one of ten buckets. It is meant // to give us a favorable tradeoff between performance and fidelity. - 'type': 'vector', - 'promoteId': constants.GEOID_PROPERTY, - 'tiles': [ - constants.FEATURE_TILE_LOW_ZOOM_URL, + 'type': 'vector', + 'promoteId': constants.GEOID_PROPERTY, + 'tiles': [ + 'low_tiles' in flagContainer ? + constants.featureURLForTilesetName(flagContainer['low_tiles']) : + constants.FEATURE_TILE_LOW_ZOOM_URL, // For local development, use: // 'http://localhost:8080/data/tl_2010_bg_with_data/{z}/{x}/{y}.pbf', - ], - 'minzoom': constants.GLOBAL_MIN_ZOOM_LOW, - 'maxzoom': constants.GLOBAL_MAX_ZOOM_LOW, - }, - 'labels': { - 'type': 'raster', - 'tiles': [ - `https://cartodb-basemaps-a.global.ssl.fastly.net/light_only_labels/{z}/{x}/{y}${imageSuffix}.png`, - `https://cartodb-basemaps-b.global.ssl.fastly.net/light_only_labels/{z}/{x}/{y}${imageSuffix}.png`, - `https://cartodb-basemaps-c.global.ssl.fastly.net/light_only_labels/{z}/{x}/{y}${imageSuffix}.png`, - `https://cartodb-basemaps-d.global.ssl.fastly.net/light_only_labels/{z}/{x}/{y}${imageSuffix}.png`, - ], - }, - }, - 'layers': [ - { - 'id': 'carto', - 'source': 'carto', - 'type': 'raster', - 'minzoom': constants.GLOBAL_MIN_ZOOM, - 'maxzoom': constants.GLOBAL_MAX_ZOOM, - }, - { - 'id': 'geo', - 'source': 'geo', - 'type': 'raster', - 'layout': { - // Make the layer invisible by default. - 'visibility': 'none', + ], + 'minzoom': constants.GLOBAL_MIN_ZOOM_LOW, + 'maxzoom': constants.GLOBAL_MAX_ZOOM_LOW, }, - 'minzoom': constants.GLOBAL_MIN_ZOOM, - 'maxzoom': constants.GLOBAL_MAX_ZOOM, - }, - { - 'id': constants.HIGH_SCORE_LAYER_NAME, - 'source': constants.HIGH_SCORE_SOURCE_NAME, - 'source-layer': constants.SCORE_SOURCE_LAYER, - 'type': 'fill', - 'filter': ['all', - ['>', constants.SCORE_PROPERTY_HIGH, constants.SCORE_BOUNDARY_THRESHOLD], - ], - 'paint': makePaint({ - field: constants.SCORE_PROPERTY_HIGH, - minRamp: constants.SCORE_BOUNDARY_LOW, - medRamp: constants.SCORE_BOUNDARY_THRESHOLD, - maxRamp: constants.SCORE_BOUNDARY_PRIORITIZED, - }), - 'minzoom': constants.GLOBAL_MIN_ZOOM_HIGH, - }, - { - 'id': constants.LOW_SCORE_LAYER_NAME, - 'source': constants.LOW_SCORE_SOURCE_NAME, - 'source-layer': constants.SCORE_SOURCE_LAYER, - 'type': 'fill', - 'filter': ['all', - ['>', constants.SCORE_PROPERTY_LOW, constants.SCORE_BOUNDARY_THRESHOLD], - ], - 'paint': makePaint({ - field: constants.SCORE_PROPERTY_LOW, - minRamp: constants.SCORE_BOUNDARY_LOW, - medRamp: constants.SCORE_BOUNDARY_THRESHOLD, - maxRamp: constants.SCORE_BOUNDARY_PRIORITIZED, - }), - 'minzoom': constants.GLOBAL_MIN_ZOOM_LOW, - 'maxzoom': constants.GLOBAL_MAX_ZOOM_LOW, - }, - { - // "Score-highlights" represents the border - // around given tiles that appears at higher zooms - 'id': 'score-highlights-layer', - 'source': constants.HIGH_SCORE_SOURCE_NAME, - 'source-layer': constants.SCORE_SOURCE_LAYER, - 'type': 'line', - 'layout': { - 'visibility': 'visible', - 'line-join': 'round', - 'line-cap': 'round', - }, - 'paint': { - 'line-color': constants.DEFAULT_OUTLINE_COLOR, - 'line-width': 0.8, - 'line-opacity': 0.5, - }, - 'minzoom': constants.GLOBAL_MIN_ZOOM_HIGHLIGHT, - 'maxzoom': constants.GLOBAL_MAX_ZOOM_HIGHLIGHT, - }, - { - // "score-border-highlight" is used to highlight - // the currently-selected feature - 'id': 'score-border-highlight-layer', - 'type': 'line', - 'source': constants.HIGH_SCORE_SOURCE_NAME, - 'source-layer': constants.SCORE_SOURCE_LAYER, - 'layout': {}, - 'paint': { - 'line-color': constants.BORDER_HIGHLIGHT_COLOR, - 'line-width': [ - 'case', - ['boolean', ['feature-state', constants.SELECTED_PROPERTY], false], - constants.HIGHLIGHT_BORDER_WIDTH, - 0, + 'labels': { + 'type': 'raster', + 'tiles': [ + `https://cartodb-basemaps-a.global.ssl.fastly.net/light_only_labels/{z}/{x}/{y}${imageSuffix}.png`, + `https://cartodb-basemaps-b.global.ssl.fastly.net/light_only_labels/{z}/{x}/{y}${imageSuffix}.png`, + `https://cartodb-basemaps-c.global.ssl.fastly.net/light_only_labels/{z}/{x}/{y}${imageSuffix}.png`, + `https://cartodb-basemaps-d.global.ssl.fastly.net/light_only_labels/{z}/{x}/{y}${imageSuffix}.png`, ], }, - 'minzoom': constants.GLOBAL_MIN_ZOOM_HIGH, - 'maxzoom': constants.GLOBAL_MAX_ZOOM_HIGH, }, - { + 'layers': [ + { + 'id': 'carto', + 'source': 'carto', + 'type': 'raster', + 'minzoom': constants.GLOBAL_MIN_ZOOM, + 'maxzoom': constants.GLOBAL_MAX_ZOOM, + }, + { + 'id': 'geo', + 'source': 'geo', + 'type': 'raster', + 'layout': { + // Make the layer invisible by default. + 'visibility': 'none', + }, + 'minzoom': constants.GLOBAL_MIN_ZOOM, + 'maxzoom': constants.GLOBAL_MAX_ZOOM, + }, + { + 'id': constants.HIGH_SCORE_LAYER_NAME, + 'source': constants.HIGH_SCORE_SOURCE_NAME, + 'source-layer': constants.SCORE_SOURCE_LAYER, + 'type': 'fill', + 'filter': ['all', + ['>', constants.SCORE_PROPERTY_HIGH, constants.SCORE_BOUNDARY_THRESHOLD], + ], + 'paint': makePaint({ + field: constants.SCORE_PROPERTY_HIGH, + minRamp: constants.SCORE_BOUNDARY_LOW, + medRamp: constants.SCORE_BOUNDARY_THRESHOLD, + maxRamp: constants.SCORE_BOUNDARY_PRIORITIZED, + }), + 'minzoom': constants.GLOBAL_MIN_ZOOM_HIGH, + }, + { + 'id': constants.LOW_SCORE_LAYER_NAME, + 'source': constants.LOW_SCORE_SOURCE_NAME, + 'source-layer': constants.SCORE_SOURCE_LAYER, + 'type': 'fill', + 'filter': ['all', + ['>', constants.SCORE_PROPERTY_LOW, constants.SCORE_BOUNDARY_THRESHOLD], + ], + 'paint': makePaint({ + field: constants.SCORE_PROPERTY_LOW, + minRamp: constants.SCORE_BOUNDARY_LOW, + medRamp: constants.SCORE_BOUNDARY_THRESHOLD, + maxRamp: constants.SCORE_BOUNDARY_PRIORITIZED, + }), + 'minzoom': constants.GLOBAL_MIN_ZOOM_LOW, + 'maxzoom': constants.GLOBAL_MAX_ZOOM_LOW, + }, + { + // "Score-highlights" represents the border + // around given tiles that appears at higher zooms + 'id': 'score-highlights-layer', + 'source': constants.HIGH_SCORE_SOURCE_NAME, + 'source-layer': constants.SCORE_SOURCE_LAYER, + 'type': 'line', + 'layout': { + 'visibility': 'visible', + 'line-join': 'round', + 'line-cap': 'round', + }, + 'paint': { + 'line-color': constants.DEFAULT_OUTLINE_COLOR, + 'line-width': 0.8, + 'line-opacity': 0.5, + }, + 'minzoom': constants.GLOBAL_MIN_ZOOM_HIGHLIGHT, + 'maxzoom': constants.GLOBAL_MAX_ZOOM_HIGHLIGHT, + }, + { + // "score-border-highlight" is used to highlight + // the currently-selected feature + 'id': 'score-border-highlight-layer', + 'type': 'line', + 'source': constants.HIGH_SCORE_SOURCE_NAME, + 'source-layer': constants.SCORE_SOURCE_LAYER, + 'layout': {}, + 'paint': { + 'line-color': constants.BORDER_HIGHLIGHT_COLOR, + 'line-width': [ + 'case', + ['boolean', ['feature-state', constants.SELECTED_PROPERTY], false], + constants.HIGHLIGHT_BORDER_WIDTH, + 0, + ], + }, + 'minzoom': constants.GLOBAL_MIN_ZOOM_HIGH, + 'maxzoom': constants.GLOBAL_MAX_ZOOM_HIGH, + }, + { // We put labels last to ensure prominence - 'id': 'labels-only-layer', - 'type': 'raster', - 'source': 'labels', - 'minzoom': constants.GLOBAL_MIN_ZOOM, - 'maxzoom': constants.GLOBAL_MAX_ZOOM, - }, - ], + 'id': 'labels-only-layer', + 'type': 'raster', + 'source': 'labels', + 'minzoom': constants.GLOBAL_MIN_ZOOM, + 'maxzoom': constants.GLOBAL_MAX_ZOOM, + }, + ], + }; }; -export default mapStyle;