diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index 93905f0d..e5e10886 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -1,4 +1,4 @@ -# Project Instructions + # Project Instructions ## Samples diff --git a/README.md b/README.md index 17c6af0f..f0c951d5 100644 --- a/README.md +++ b/README.md @@ -1 +1,56 @@ -Add a readme for your story map here. Include content overview, data citations, and any relevant technical details. \ No newline at end of file +# Of Bodegas and Big Chains + +An interactive story map exploring the conversation sparked by Infatuation's *The Sameification of NYC* chart. It uses restaurant locations and neighborhood rent estimates to help readers investigate what storefront patterns can reveal about New York City and what cannot be established on their own. + +## Project Question + +Is there a relationship between where chains like Blank Street or Wonder open up and the neighborhood's rent prices? I want to explore what it means for the current state or trajectory of your neighborhood if a certain store opens up. This is also a data follow-up to the Infatuation article, acting as expansion data viz tool to the chart. + +## Story Slides + +1. **The Streetscape of NYC** introduces the city's mix of restaurants, bodegas, and storefronts. +2. **Explore the Current Streetscape** lets readers browse and search restaurant locations. +3. **Rent Varies Across Neighborhoods** introduces neighborhood median gross rent estimates and compares areas and changes over time. +4. **A Closer Look: Wonder** focuses on one rapidly expanding food-hall and delivery-platform business. +5. **Neighborhoods with Wonders** overlays Wonder locations with neighborhood rent change to prompt questions about where the chain operates and the context around those locations. +6. **Explore More** encourages readers to question publication claims, inspect public data, and consider whose experiences the datasets represent. + +## Interacting With the Map + +- Scroll through the story cards to move between chapters. +- Use the restaurant search control to filter by restaurant name. +- Click and drag the map to pan. +- Use the `+` and `-` controls to zoom. +- Click a neighborhood on the rent slides to see its name and rent information. +- Use **Reset view** to return to the opening map camera position. + +## Data and Interpretation + +The map is configured in `js/config.js`. +I used [PointsUnknown's tutorial](https://pointsunknown.nyc/web%20mapping/mapbox/2021/07/20/11A_MapboxStorytelling.html) as a starting point and configured my map in MapBox. + +- **Restaurant inspection locations:** a Mapbox style layer named `dohmh-new-york-city-restaurant-inspection-results-20260917`, with business names in the `DBA` property. The source dataset is NYC DOHMH's [NYC Restaurant Inspection Results](https://data.cityofnewyork.us/Health/DOHMH-NYC-Restaurant-Inspection/c9jv-y9ru/about_data). +- **Neighborhood rent estimates:** the `neighborhoods` layer, with neighborhood names and median gross rent attributes for 2018 and 2023. The map labels these estimates as inflation-adjusted 2024 dollars. The source GeoJSON is Sonia C. Q.'s [NYC median rent dataset](https://github.com/soniacq/urbanTrace/blob/main/data/geojson/NYC_median_rent.geojson). +- **Rent change:** the story map calculates the displayed difference as the 2023 estimate minus the 2018 estimate. + +## Run Locally + +The project is a static web map. Serve the repository root over HTTP rather than opening `index.html` directly, so the browser can load local assets and external map resources consistently. + +With Python installed, from the project root run: + +```sh +python3 -m http.server 8000 +``` + +Open in a browser. +Stop server with `Ctrl+C`. + +## Sources and Credits + +- [Infatuation: NYC Q2 2026 food trends](https://www.theinfatuation.com/new-york/features/nyc-restaurant-trends-q2-2026), the inspiration for the story map. +- [Infatuation's Sameification of NYC post](https://www.instagram.com/p/Daf0HKhMUOo/), the chart discussed in the introduction. +- [NYC DOHMH NYC Restaurant Inspection Results](https://data.cityofnewyork.us/Health/DOHMH-NYC-Restaurant-Inspection/c9jv-y9ru/about_data), used for mapped restaurant inspection locations. +- [NYC median rent GeoJSON](https://github.com/soniacq/urbanTrace/blob/main/data/geojson/NYC_median_rent.geojson) by [Sonia C. Q.](https://github.com/soniacq), used for neighborhood rent estimates. +- [Mapbox](https://www.mapbox.com/about/maps/) provides the map platform and custom style. +- [OpenStreetMap](https://www.openstreetmap.org/copyright) is credited through the map style. diff --git a/css/styles.css b/css/styles.css new file mode 100644 index 00000000..eccab75e --- /dev/null +++ b/css/styles.css @@ -0,0 +1,351 @@ +body { + margin: 0; + padding: 0; +} +#header .intro-layout { + display: flex; + align-items: flex-start; + gap: 2rem; + max-width: 1200px; + margin: 0 auto; +} + +#header .intro-copy { + flex: 1 1 60%; +} + +#main-img { + flex: 0 0 45=0%; + width: 40%; + height: auto; + display: block; + border-radius: 8px; +} +#map { + top: 0; + height: 100vh; + width: 100%; + position: fixed; +} +.reset-view-button { + position: fixed; + top: 12px; + right: 12px; + z-index: 1000; + min-width: 100px; + height: 36px; + padding: 0 10px; + color: #202623; + background: #fff; + border: 1px solid #69756e; + border-radius: 3px; + box-shadow: 0 1px 4px rgb(0 0 0 / 25%); + font: 600 14px 'Roboto Condensed', sans-serif; + white-space: nowrap; + cursor: pointer; +} +.map-tools-hidden { + visibility: hidden; + pointer-events: none; +} +.reset-view-button:focus-visible { + outline: 3px solid #e96138; + outline-offset: 2px; +} +.zoom-controls { + position: fixed; + top: 58px; + right: 12px; + z-index: 1000; + display: grid; + overflow: hidden; + border: 1px solid #69756e; + border-radius: 3px; + box-shadow: 0 1px 4px rgb(0 0 0 / 25%); +} +.zoom-controls button { + display: grid; + place-items: center; + width: 36px; + height: 36px; + padding: 0; + color: #202623; + background: #fff; + border: 0; + font: 600 22px/1 'Roboto Condensed', sans-serif; + cursor: pointer; +} +.zoom-controls button + button { + border-top: 1px solid #d6ddd8; +} +.zoom-controls button:hover { + background: #f2f5f2; +} +.zoom-controls button:focus-visible { + position: relative; + z-index: 1; + outline: 3px solid #e96138; + outline-offset: -3px; +} +.restaurant-search-control { + position: fixed; + top: 12px; + right: 124px; + z-index: 1000; + display: grid; + gap: 6px; + box-sizing: border-box; + width: min(280px, calc(100vw - 24px)); + padding: 10px; + color: #202623; + background: #fff; + border: 1px solid #69756e; + border-radius: 3px; + box-shadow: 0 1px 4px rgb(0 0 0 / 25%); + font: 14px 'Roboto Condensed', sans-serif; +} +@media (max-width: 750px) { + .restaurant-search-control { + top: 140px; + right: 12px; + } +} +.restaurant-search-control label { + font-weight: 600; +} +.restaurant-search-row { + display: flex; + gap: 6px; +} +.restaurant-search-row input { + flex: 1; + min-width: 0; + height: 34px; + padding: 0 8px; + border: 1px solid #69756e; + border-radius: 2px; + font: inherit; +} +.restaurant-search-row button { + padding: 0 9px; + color: #202623; + background: #f2f5f2; + border: 1px solid #69756e; + border-radius: 2px; + font: inherit; + cursor: pointer; +} +.restaurant-search-row :disabled { + cursor: wait; + opacity: 0.6; +} +.restaurant-search-control :focus-visible { + outline: 3px solid #e96138; + outline-offset: 2px; +} +.rent-change-legend { + position: fixed; + right: 12px; + bottom: 42px; + z-index: 1000; + display: grid; + gap: 5px; + box-sizing: border-box; + width: min(270px, calc(100vw - 24px)); + padding: 10px 12px; + color: #202623; + background: #fff; + border: 1px solid #69756e; + border-radius: 3px; + box-shadow: 0 1px 4px rgb(0 0 0 / 25%); + font: 13px 'Roboto Condensed', sans-serif; +} +.rent-change-legend > span { + color: #4b554f; + font-size: 12px; +} +.rent-change-ramp { + height: 12px; + background: linear-gradient(90deg, #2166ac, #92c5de, #f7f7f2, #f4a582, #b2182b); + border: 1px solid rgb(32 38 35 / 15%); +} +.rent-price-ramp { + height: 12px; + background: linear-gradient(90deg, #fff4cc, #fdb863, #e8753b, #bd3b32, #772b3a); + border: 1px solid rgb(32 38 35 / 15%); +} +.rent-change-labels { + display: flex; + justify-content: space-between; + gap: 6px; + font-size: 11px; +} +.rent-change-labels span:nth-child(2) { + text-align: center; +} +.rent-change-labels span:last-child { + text-align: right; +} +#header { + margin: auto; + min-height: 100vh; + width: 100%; + position: relative; + z-index: 5; + padding: 2vh 0vh 5vh 0vh; + background-color: #394053; + text-align: left; +} + +#features { + padding-top: 10vh; + padding-bottom: 10vh; +} +@media (max-width: 750px) { + #features { + width: 90vw; + margin: 0 auto; + } +} +#footer { + min-height: 2vh; + text-align: left; + line-height: 25px; + width: 100%; + position: relative; + z-index: 5; +} +#header h1 { + font-weight: 900; + font-size: 2.75rem; + text-transform: uppercase; + font-family: 'Merriweather', serif; + text-align: left; + margin: 0 auto; + max-width: 1200px; + +} +#header h2 { + font-weight: 700; + /* text-transform: uppercase; */ + font-family: 'Merriweather', serif; + text-align: left; + margin: 0.5em auto 0.8em auto; + max-width: 1200px; +} +#header h4 { + font-family: 'Roboto Condensed', sans-serif; + font-weight: 300; + margin-bottom: 0.5em; + margin-left: auto; + margin-right: auto; + text-align: left; + max-width: 600px; +} +#header p.byline { + font-weight: 300; + font-size: 0.9em; + margin: 0.5em auto 0.8em auto; +} +#header p { + font-family: 'Roboto Condensed', sans-serif; + font-weight: 400; + text-align: left; + font-size: 1.2em; + max-width: 600px; + margin: 1em auto 1em auto; +} +#features h3 { + font-weight: 700; + font-size:1.5rem; + text-transform: uppercase; + font-family: 'Merriweather', serif; + text-align: left; + max-width: 50vw; +} +#features p { + font-family: 'Roboto Condensed', sans-serif; + text-align: left; + font-size: 1.2em; + max-width: 50vw; +} +#features .imageCredit { + font-size: 0.9em; + margin-top: -5px; +} +#footer p { + font-family: 'Roboto Condensed', sans-serif; + font-size: 0.9em; + max-width: 600px; + padding: 0.75em 0em; + margin: 0px auto; +} +a, a:hover, a:visited { + color: #e96138; +} +.hidden { + visibility: hidden; +} +.centered { + width: 50vw; + margin: 0 auto; +} +.lefty { + width: 33vw; + margin-left: 5vw; +} +.righty { + width: 33vw; + margin-left: 62vw; +} +.fully { + width: 100%; + margin: auto; +} +.light { + color: #444; + background-color: #ffffff; +} +.dark { + color: #fafafa; + background-color: #444; +} +.step .dark { + color: #000; + background-color: #ffffff; +} +.step { + padding-bottom: 50vh; + opacity: 0.15; +} +.step.active { + opacity: 0.99; +} +.step div { + padding: 25px 50px; + line-height: 25px; + font-size: 13px; +} +.step img { + display: block; + width: 300px; + max-width: 100%; + height: auto; + margin: 0 auto 1rem; +} +@media (max-width: 750px) { + .centered, .lefty, .righty, .fully { + width: 90vw; + margin: 0 auto; + } +} +/* .image { + width: 100%; + max-width: 100%; + height: auto; +} */ +/* Fix issue on mobile browser where scroll breaks */ +.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate.mapboxgl-touch-drag-pan, +.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate.mapboxgl-touch-drag-pan .mapboxgl-canvas { + touch-action: unset; +} \ No newline at end of file diff --git a/images/infat_chart.png b/images/infat_chart.png new file mode 100644 index 00000000..94afba41 Binary files /dev/null and b/images/infat_chart.png differ diff --git a/images/melting_pot.jpg b/images/melting_pot.jpg new file mode 100644 index 00000000..2d488d25 Binary files /dev/null and b/images/melting_pot.jpg differ diff --git a/index.html b/index.html new file mode 100644 index 00000000..5c0dd80e --- /dev/null +++ b/index.html @@ -0,0 +1,26 @@ + + + + + + The Sameification of NYC + + + + + + + + + + + +
+
+ + + + + + + \ No newline at end of file diff --git a/js/config.js b/js/config.js new file mode 100644 index 00000000..504680d3 --- /dev/null +++ b/js/config.js @@ -0,0 +1,314 @@ + +let topTitleDiv = "MUSA 6110 | Story Narrative | Fall 2026"; + +let titleDiv = + "What Does the Sameification of NYC Tell Us About the City?"; +let subtitleDiv = " \n"; + +let bylineDiv = ''; + +let descriptionDiv = + '
' + + '
' + + '

OF BODEGAS AND BIG CHAINS

' + + '

A Data Follow-Up to Infatuation\'s Sameification of NYC

' + + '

In August 2026, Infatuation, a NYC-based publication that focuses on restaurant recommendations and guides, posted a chart titled "The Sameification of NYC." The chart plotted out which food chains (think Blank Street, 7th Street Burger) were present in which neighborhoods.

' + + '

This sparked conversation over gentrification and the death of small businesses as private equity-backed businesses take up more space in a city that fewer and fewer businesses are able to operate in.

' + + '

The response to this chart was varied and passionate. The comments were filled with a spectrum of opinions from folks assenting that private-equity businesses are taking over neighborhoods to folks defending NYC-founded chains.

' + + '

This story map looks at the places behind that conversation and asks what restaurant patterns can and can\’t tell us about neighborhood change. It will supplement the Infatuation chart with data on NYC restaurants (derived from NYC Inspection data), rent and income data, and neighborhood data.

' + + + '

scroll to continue
▼

' + + '
' + + 'The Sameification of NYC chart' + + '
'; + + +let footerDiv = + '

This story is based on The Same-ification of NYC chart posted by Infatuation NYC as part of their Q2 2026 food trends article.

' + + '

© Mapbox | © OpenStreetMap' + +var config = { + // style: 'mapbox://styles/mapbox/streets-v12', + // mapbox://styles/mapbox/standard + // leave commented to use Mapbox Standard Style + + style: "mapbox://styles/ctrac/cmuam725600dd01s52xsu0mwq", + // accessToken only works on specified site URLs + accessToken: "pk.eyJ1IjoiY3RyYWMiLCJhIjoiY211YmwyNTNmMDM2ejJ4cHZoeHNoMGhiZiJ9.YCUukMS6kMZWXslJJRfktA", + //accessToken: 'YOUR_MAPBOX_ACCESS_TOKEN', + showMarkers: true, + markerColor: '#3FB1CE', + //topTitle: topTitleDiv, + //title: titleDiv, + subtitle: subtitleDiv, + byline: bylineDiv, + description: descriptionDiv, + footer: footerDiv, + //projection: 'equirectangular', + //Read more about available projections here + //https://docs.mapbox.com/mapbox-gl-js/example/projections/ + inset: false, + insetOptions: { + markerColor: 'orange' + }, + insetPosition: 'bottom-right', + theme: 'dark', + use3dTerrain: false, //set true for enabling 3D maps. + auto: false, + chapters: [ + { + id: 'slug-style-id', + alignment: 'left', + hidden: false, + title: 'THE STREETSCAPE OF NYC', + // + description: 'NYC\'s melting pot of cultures is reflected in the cuisines, bodegas, and shops you can find all across the city. As you look at the map, you\'ll see recognizable chains, but also plenty of smaller businesses you have yet to discover.' + + '

Storefronts are one visible way neighborhoods change. Shifts in a city\'s streetscape can raise questions about who gets to shape a neighborhood—and who can afford to stay.
', + image: 'images/melting_pot.jpg', + + location: { + center: [-73.97964, 40.75507], + zoom: 10, + pitch: 40, + bearing: 0, + }, + mapAnimation: 'flyTo', + rotateAnimation: true, + callback: '', + onChapterEnter: [ + { + layer: 'dohmh-new-york-city-restaurant-inspection-results-20260917', + opacity: 0.5, + duration: 5000, + + }, + { + layer: 'neighborhoods', + opacity: 0, + duration: 5000 + }, + { + layer: 'nyc', + opacity: 0.3, + duration: 5000 + } + ], + onChapterExit: [ + // { + // layer: 'layer-name', + // opacity: 0 + // } + ] + }, + { + id: 'second-identifier', + alignment: 'left', + hidden: false, + title: 'Explore the Current Streetscape', + //image: 'images/infat_chart.png', + description: 'Infatuation calls it sameification, but what this discourse signals is genetrification. Gentrification can first become visible in everyday places: a longtime shop closes, a new cafe opens, or familiar storefronts begin to look different. One new business does not prove a neighborhood is gentrifying, but a changing mix of storefronts can be an early sign residents notice.

Explore the map to see the current restaurant-scape of NYC.

Filter by restaurants in the upper right corner.
Click and drag to move around the map.
', + location: { + center: [-73.97964, 40.75507], + zoom: 13, + pitch: 40, + bearing: 0, + // flyTo additional controls- + // These options control the flight curve, making it move + // slowly and zoom out almost completely before starting + // to pan. + //speed: 2, // make the flying slow + //curve: 1, // change the speed at which it zooms out + }, + mapAnimation: 'flyTo', + rotateAnimation: false, + callback: '', + onChapterEnter: [ + { + layer: 'dohmh-new-york-city-restaurant-inspection-results-20260917', + opacity: 0.9, + duration: 5000, + }, + { + layer: 'neighborhoods', + opacity: 0, + duration: 5000 + }, + { + layer: 'nyc', + opacity: 0, + duration: 5000 + } + ], + onChapterExit: [] + }, + { + id: 'median_rent', + alignment: 'left', + hidden: false, + title: 'Rent Varies Across Neighborhoods', + //image: 'images/infat_chart.png', + description: 'Before looking at the chains mentioned in the Infatuation chart, let\'s look at the neighborhood rent landscape.

Click on neighborhoods on the map to see the change in median monthly rent.

The mapped estimates show how sharply rents can vary.
In 2023, median gross rent was about $3,077 in Brooklyn Heights/Fort Greene and $1,343 in the Lower East Side/Chinatown.

Changes also varied from place to place. From 2018 to 2023, the estimate median gross income rose about $493 in Brooklyn Heights/Fort Greene, while it dipped about $49 in the Lower East Side/Chinatown.

Next, we will use Wonder as a case study and ask how its locations relate to this uneven neighborhood landscape.', + location: { + center: [-73.97964, 40.75507], + zoom: 13, + pitch: 40, + bearing: 0, + // flyTo additional controls- + // These options control the flight curve, making it move + // slowly and zoom out almost completely before starting + // to pan. + //speed: 2, // make the flying slow + //curve: 1, // change the speed at which it zooms out + }, + mapAnimation: 'flyTo', + rotateAnimation: false, + callback: '', + onChapterEnter: [ + { + layer: 'dohmh-new-york-city-restaurant-inspection-results-20260917', + opacity: 0.9, + duration: 5000 + }, + { + layer: 'neighborhoods', + opacity: 0.75, + duration: 5000 + }, + { + layer: 'nyc', + opacity: 0, + duration: 500 + } + ], + onChapterExit: [] + }, + { + id: 'median_rent_wonder', + alignment: 'left', + hidden: false, + title: 'A CLOSER LOOK: WONDER', + //image: 'images/infat_chart.png', + description: 'Wonder, a food-hall that\'s also a delivery platform, is mentioned in the Infatuation chart and has been the center of recent discourse on private-equity backed food vendors expanding across the city. Rather than a food hall where each vendor has its own operation, Wonder operates as one entity, one kitchen.

Wonder’s rapid expansion across New York makes it a useful case study in how a restaurant concept can quickly become part of the city’s streetscape. Its growing footprint raises questions: Why these neighborhoods? What does the model bring to local food options, and what might it displace?

', + location: { + center: [-73.97964, 40.75507], + zoom: 11, + pitch: 40, + bearing: 0, + // flyTo additional controls- + // These options control the flight curve, making it move + // slowly and zoom out almost completely before starting + // to pan. + //speed: 2, // make the flying slow + //curve: 1, // change the speed at which it zooms out + }, + mapAnimation: 'flyTo', + rotateAnimation: false, + callback: '', + onChapterEnter: [ + { + layer: 'dohmh-new-york-city-restaurant-inspection-results-20260917', + opacity: 0.9, + duration: 5000, + }, + { + layer: 'neighborhoods', + opacity: 0.1, + duration: 5000 + }, + { + layer: 'nyc', + opacity: 0, + duration: 5000 + } + ], + onChapterExit: [] + }, + { + id: 'third-identifier', + alignment: 'left', + hidden: false, + title: 'Neighborhoods with Wonders', + description: 'All Wonder locations are located in neighborhoods that had an increase in median rent price from 2018-2023.

Compare Wonder locations with neighborhood rent patterns and consider what the map suggests.

What does this model add to local food options and what might it displace? Is it seeking a particular clientele, dense foot traffic, delivery demand, or access to commercial space?', + + location: { + center: [-73.97964, 40.75507], + zoom: 12.52, + pitch: 8.01, + bearing: 0.00 + }, + mapAnimation: 'flyTo', + rotateAnimation: false, + callback: '', + onChapterEnter: [ + { + layer: 'dohmh-new-york-city-restaurant-inspection-results-20260917', + opacity: 0.9, + duration: 5000 + }, + { + layer: 'neighborhoods', + opacity: 0.75, + duration: 5000 + }, + { + layer: 'nyc', + opacity: 0, + duration: 500 + } + ], + onChapterExit: [] + }, + /* { + id: 'fourth-chapter', + alignment: 'fully', + hidden: false, + title: '', + description: '', + location: { + center: [-73.97964, 40.75507], + zoom: 4, + pitch: 0, + bearing: 0 + }, + mapAnimation: 'flyTo', + rotateAnimation: false, + callback: '', + onChapterEnter: [], + onChapterExit: [] + }, */ + { + id: 'last-chapter', + alignment: 'left', + hidden: false, + title: 'EXPLORE MORE', + description: 'When a publication makes a claim about the city, treat it as an invitation to get curious. What evidence supports it? What’s missing? Who gets represented, and whose experience might not show up in the data?

A lot of information used to explore these questions is public. You don’t need to be an expert to look at a map, compare neighborhoods, or ask whether a pattern holds up.

Continue to explore on this map. ', + location: { + center: [-73.97964, 40.75507], + zoom: 12.52, + pitch: 8.01, + bearing: 0.00 + }, + mapAnimation: 'flyTo', + rotateAnimation: false, + callback: '', + onChapterEnter: [ + { + layer: 'dohmh-new-york-city-restaurant-inspection-results-20260917', + opacity: 0.9, + duration: 5000, + }, + { + layer: 'neighborhoods', + opacity: 0.1, + duration: 5000 + }, + { + layer: 'nyc', + opacity: 0, + duration: 5000 + } + ], + onChapterExit: [] + } + ] +}; diff --git a/js/map.js b/js/map.js new file mode 100644 index 00000000..6cdf40c6 --- /dev/null +++ b/js/map.js @@ -0,0 +1,598 @@ +/* First, define what constitutes a small screen. +This will affect the zoom parameter for each chapter. */ + +var smallMedia = window.matchMedia("(max-width: 600px)").matches; + +var initLoad = true; + +// var that holds diff types of layers available to Mapbox and opacity attributes +var layerTypes = { + 'fill': ['fill-opacity'], + 'line': ['line-opacity'], + 'circle': ['circle-opacity', 'circle-stroke-opacity'], + 'symbol': ['icon-opacity', 'text-opacity'], + 'raster': ['raster-opacity'], + 'fill-extrusion': ['fill-extrusion-opacity'], + 'heatmap': ['heatmap-opacity'] +} + +// var that holds possible alignments +var alignments = { + 'left': 'lefty', + 'center': 'centered', + 'right': 'righty', + 'full': 'fully' +} + +// gets the type of layer +function getLayerPaintType(layer) { + var layerType = map.getLayer(layer).type; + return layerTypes[layerType]; +} + +// adjusts layer's opacity +function setLayerOpacity(layer) { + var paintProps = getLayerPaintType(layer.layer); + paintProps.forEach(function (prop) { + var options = {}; + if (layer.duration) { + var transitionProp = prop + "-transition"; + options = { "duration": layer.duration }; + map.setPaintProperty(layer.layer, transitionProp, options); + } + map.setPaintProperty(layer.layer, prop, layer.opacity, options); + }); +} + +// main 'story', 'features' and 'header' elements +var story = document.getElementById('story'); +var features = document.createElement('div'); +features.setAttribute('id', 'features'); +var header = document.createElement('div'); + +// If the content exists, then assign it to the 'header' element +// Note how each one of these are assigning 'innerHTML' +if (config.topTitle) { + var titleText = document.createElement('h4'); + titleText.innerText = config.topTitle; + header.appendChild(titleText); +} + +if (config.title) { + var titleText = document.createElement('h1'); + titleText.innerText = config.title; + header.appendChild(titleText); +} + +if (config.subtitle) { + var subtitleText = document.createElement('h2'); + subtitleText.innerText = config.subtitle; + header.appendChild(subtitleText); +} + +if (config.byline) { + var bylineText = document.createElement('p'); + bylineText.innerText = config.byline; + header.appendChild(bylineText); +} + +if (config.description) { + var descriptionText = document.createElement("div"); + descriptionText.innerHTML = config.description; + header.appendChild(descriptionText); +} + +// If after this, the header has anything in it, it gets appended to the story +if (header.innerText.length > 0) { + header.classList.add(config.theme); + header.setAttribute('id', 'header'); + story.appendChild(header); +} + +/* After building the elements and assigning content to the header these +functions will loop through the chapters in the config.js file, +create the vignette elements and assign them their respective content */ +config.chapters.forEach((record, idx) => { + // 2 variables that hold each vignette, the chapter + // element will go in the container element + var container = document.createElement('div'); + var chapter = document.createElement('div'); + + // Adds a class to the vignette + chapter.classList.add("br3"); + // Adds all the content to the vignette's div + chapter.innerHTML = record.chapterDiv; + // Sets the id for the vignette and adds the step css attribute + container.setAttribute("id", record.id); + container.classList.add("step"); + // only use chapterDiv if it exists + if (record.chapterDiv) { + chapter.innerHTML = record.chapterDiv; + } + + chapter.innerHTML = record.chapterDiv || ''; + + if (record.title) { + var title = document.createElement('h3'); + title.innerText = record.title; + chapter.appendChild(title); + } + + if (record.description) { + var story = document.createElement('p'); + story.innerHTML = record.description; + chapter.appendChild(story); + } + + if (record.image) { + var image = new Image(); + image.src = record.image; + chapter.appendChild(image); + } + + + container.setAttribute('id', record.id); + + // If the chapter is the first one, set it to active + container.classList.add('step'); + if (idx === 0) { + container.classList.add('active'); + } + // Adds the overall theme to the chapter element + chapter.classList.add(config.theme); + container.appendChild(chapter); + container.classList.add(alignments[record.alignment] || 'centered'); + if (record.hidden) { + container.classList.add('hidden'); + } + features.appendChild(container); +}); + +// Appends the features element (with the vignettes) to the story element +story.appendChild(features); + +/* Next, this section creates the footer element and assigns it +its content based on the config.js file */ +var footer = document.createElement('div'); + +// This assigns all the content to the footer element +if (config.footer) { + var footerText = document.createElement('p'); + footerText.innerHTML = config.footer; + footer.appendChild(footerText); +} + +// If the footer element contains any content, add it to the story +if (footer.innerText.length > 0) { + footer.classList.add(config.theme); + footer.setAttribute('id', 'footer'); + story.appendChild(footer); +} + +// Adds the Mapbox access token +mapboxgl.accessToken = config.accessToken; + +// Honestly, don't know what this does +const transformRequest = (url) => { + const hasQuery = url.indexOf("?") !== -1; + const suffix = hasQuery + ? "&pluginName=scrollytellingV2" + : "?pluginName=scrollytellingV2"; + return { + url: url + suffix, + }; +}; + +// COMMENT OUT IF ZOOM IS WEIRD +// Creates a variable to hold the starting zoom value +var startingZoom; +// If the screen size is small, it uses the `zoomSmall` value +if (smallMedia) { + startingZoom = config.chapters[0].location.zoomSmall; +} else { + startingZoom = config.chapters[0].location.zoom; +} + +const initialView = { + center: config.chapters[0].location.center, + zoom: config.chapters[0].location.zoom, + bearing: config.chapters[0].location.bearing, + pitch: config.chapters[0].location.pitch, +}; + +var map = new mapboxgl.Map({ + container: 'map', + style: config.style, + center: initialView.center, + zoom: initialView.zoom, + //zoom: startingZoom, + bearing: initialView.bearing, + pitch: initialView.pitch, + interactive: true, + projection: config.projection, + transformRequest: transformRequest + +}); + +map.dragPan.enable(); +map.scrollZoom.disable(); +map.boxZoom.enable(); +map.dragRotate.disable(); +map.keyboard.disable(); +map.doubleClickZoom.disable(); +map.touchZoomRotate.disable(); + +// Create a inset map if enabled in config.js +if (config.inset) { + map.addControl( + new GlobeMinimap({ ...config.insetOptions }), + config.insetPosition + ); +} + +if (config.showMarkers) { + var marker = new mapboxgl.Marker({ color: config.markerColor }); + marker.setLngLat(config.chapters[0].location.center).addTo(map); +} + +const resetViewButton = document.createElement('button'); +resetViewButton.className = 'reset-view-button map-tools-hidden'; +resetViewButton.type = 'button'; +resetViewButton.textContent = 'Reset view'; +resetViewButton.setAttribute('aria-label', 'Reset map to its initial view'); +resetViewButton.title = 'Reset map to its initial view'; +resetViewButton.addEventListener('click', () => { + map.flyTo({ ...initialView, essential: true }); + if (config.showMarkers) { + marker.setLngLat(initialView.center); + } +}); +document.body.appendChild(resetViewButton); + +const zoomControls = document.createElement('div'); +zoomControls.className = 'zoom-controls map-tools-hidden'; + +const zoomInButton = document.createElement('button'); +zoomInButton.type = 'button'; +zoomInButton.textContent = '+'; +zoomInButton.setAttribute('aria-label', 'Zoom in'); +zoomInButton.title = 'Zoom in'; +zoomInButton.addEventListener('click', (event) => { + event.stopPropagation(); + map.zoomIn({ duration: 300 }); +}); + +const zoomOutButton = document.createElement('button'); +zoomOutButton.type = 'button'; +zoomOutButton.textContent = '−'; +zoomOutButton.setAttribute('aria-label', 'Zoom out'); +zoomOutButton.title = 'Zoom out'; +zoomOutButton.addEventListener('click', (event) => { + event.stopPropagation(); + map.zoomOut({ duration: 300 }); +}); + +zoomControls.append(zoomInButton, zoomOutButton); +document.body.appendChild(zoomControls); + +// set ids for restaurant filters +const restaurantLayerId = 'dohmh-new-york-city-restaurant-inspection-results-20260917'; +const restaurantMarkerLayerId = 'restaurant-search-markers'; +const restaurantSearchControl = document.createElement('div'); +restaurantSearchControl.className = 'restaurant-search-control map-tools-hidden'; + +const rentChangeLegend = document.createElement('div'); +rentChangeLegend.className = 'rent-change-legend map-tools-hidden'; +const rentPriceLegendContent = + 'Median Monthly Rent' + + '2023 estimate, 2024 dollars' + + '

' + + '
$1,100$2,400$3,600+
'; +const rentChangeLegendContent = + 'Median Rent Change' + + '2018 to 2023, monthly dollars' + + '' + + '
DecreaseNo changeIncrease
'; +rentChangeLegend.innerHTML = rentPriceLegendContent; +document.body.appendChild(rentChangeLegend); + +const restaurantSearchLabel = document.createElement('label'); +restaurantSearchLabel.htmlFor = 'restaurant-search'; +restaurantSearchLabel.textContent = 'Filter Restaurants'; + +const restaurantSearchRow = document.createElement('div'); +restaurantSearchRow.className = 'restaurant-search-row'; + +const restaurantSearch = document.createElement('input'); +restaurantSearch.id = 'restaurant-search'; +restaurantSearch.type = 'search'; +restaurantSearch.placeholder = 'Search by restaurant name'; +restaurantSearch.autocomplete = 'off'; +restaurantSearch.disabled = true; + +const clearRestaurantSearch = document.createElement('button'); +clearRestaurantSearch.type = 'button'; +clearRestaurantSearch.textContent = 'Clear'; +clearRestaurantSearch.disabled = true; + +restaurantSearchRow.append(restaurantSearch, clearRestaurantSearch); +restaurantSearchControl.append(restaurantSearchLabel, restaurantSearchRow); +document.body.appendChild(restaurantSearchControl); + +const updateMapToolsVisibility = () => { + const firstChapter = document.getElementById(config.chapters[0].id); + const showControls = firstChapter && + firstChapter.getBoundingClientRect().top < window.innerHeight * 0.6; + + resetViewButton.classList.toggle('map-tools-hidden', !showControls); + zoomControls.classList.toggle('map-tools-hidden', !showControls); + restaurantSearchControl.classList.toggle('map-tools-hidden', !showControls); +}; + +window.addEventListener('scroll', updateMapToolsVisibility, { passive: true }); +window.addEventListener('resize', updateMapToolsVisibility); +window.addEventListener('load', updateMapToolsVisibility); +const introHeader = document.getElementById('header'); +if (introHeader && 'ResizeObserver' in window) { + new ResizeObserver(updateMapToolsVisibility).observe(introHeader); +} +updateMapToolsVisibility(); + +const applyRestaurantSearch = (exactMatch = false) => { + if (!map.getLayer(restaurantLayerId)) return; + + const searchTerm = restaurantSearch.value.trim().toLowerCase(); + const restaurantName = ['downcase', ['to-string', ['get', 'DBA']]]; + const filter = !searchTerm + ? null + : exactMatch + ? ['==', restaurantName, searchTerm] + : ['>=', ['index-of', searchTerm, restaurantName], 0]; + + map.setFilter(restaurantLayerId, filter); + + if (map.getLayer(restaurantMarkerLayerId)) { + map.setFilter(restaurantMarkerLayerId, filter); + map.setLayoutProperty( + restaurantMarkerLayerId, + 'visibility', + searchTerm ? 'visible' : 'none' + ); + } +}; + +restaurantSearch.addEventListener('input', () => applyRestaurantSearch()); +clearRestaurantSearch.addEventListener('click', () => { + restaurantSearch.value = ''; + applyRestaurantSearch(); + restaurantSearch.focus(); +}); +map.once('load', () => { + const restaurantLayer = map.getLayer(restaurantLayerId); + if (restaurantLayer && !map.getLayer(restaurantMarkerLayerId)) { + map.addLayer({ + id: restaurantMarkerLayerId, + type: 'circle', + source: restaurantLayer.source, + 'source-layer': restaurantLayer['source-layer'], + layout: { + visibility: 'none', + }, + paint: { + 'circle-color': '#e96138', + 'circle-radius': 6, + 'circle-stroke-color': '#ffffff', + 'circle-stroke-width': 1.5, + }, + }); + } + + restaurantSearch.disabled = false; + clearRestaurantSearch.disabled = false; + applyRestaurantSearch(); +}); + +// instantiate the scrollama +var scroller = scrollama(); + +// add clicking on +map.on("load", function () { + map.on('click', 'neighborhoods', (event) => { + const activeRentChapter = ['median_rent', 'third-identifier', 'last-chapter'].some((id) => + document.getElementById(id)?.classList.contains('active') + ); + if (!activeRentChapter) return; + + const properties = event.features?.[0]?.properties; + if (!properties) return; + + const areaName = properties.Official_SBA_name || + properties['Sub-Borough Area'] || 'Unknown area'; + const rent2018 = Number(properties['2018']); + const rent2023 = Number(properties['2023']); + const rentChange = rent2023 - rent2018; + const formatRent = (rent) => Number.isFinite(rent) + ? `$${Math.round(rent).toLocaleString('en-US')}` + : 'Not available'; + const formatRentChange = (change) => Number.isFinite(change) + ? `${change > 0 ? '+' : change < 0 ? '−' : ''}$${Math.round(Math.abs(change)).toLocaleString('en-US')}` + : 'Not available'; + const escapedAreaName = String(areaName).replace(/[&<>"']/g, (character) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + })[character]); + + const activeChapterId = config.chapters.find((chapter) => + document.getElementById(chapter.id)?.classList.contains('active') + )?.id; + const popupContent = activeChapterId === 'median_rent' + ? `${escapedAreaName}
` + + `2023 median rent: ${formatRent(rent2023)}` + : `${escapedAreaName}
` + + `2018 median gross rent: ${formatRent(rent2018)}
` + + `2023 median gross rent: ${formatRent(rent2023)}
` + + `Change (2023 minus 2018): ${formatRentChange(rentChange)}`; + + new mapboxgl.Popup() + .setLngLat(event.lngLat) + .setHTML(popupContent) + .addTo(map); + }); + + map.on('mouseenter', 'neighborhoods', () => { + const rentChapterActive = ['median_rent', 'third-identifier', 'last-chapter'].some((id) => + document.getElementById(id)?.classList.contains('active') + ); + if (rentChapterActive) { + map.getCanvas().style.cursor = 'pointer'; + } + }); + + map.on('mouseleave', 'neighborhoods', () => { + map.getCanvas().style.cursor = ''; + }); + + if (config.use3dTerrain) { + map.addSource('mapbox-dem', { + 'type': 'raster-dem', + 'url': 'mapbox://mapbox.mapbox-terrain-dem-v1', + 'tileSize': 512, + 'maxzoom': 14 + }); + // add the DEM source as a terrain layer with exaggerated height + map.setTerrain({ 'source': 'mapbox-dem', 'exaggeration': 1.5 }); + + // add a sky layer that will show when the map is highly pitched + map.addLayer({ + 'id': 'sky', + 'type': 'sky', + 'paint': { + 'sky-type': 'atmosphere', + 'sky-atmosphere-sun': [0.0, 0.0], + 'sky-atmosphere-sun-intensity': 15 + } + }); + }; + + + // setup the instance, pass callback functions + scroller + .setup({ + step: '.step', + offset: 0.5, + progress: true + }) + .onStepEnter(async response => { + var current_chapter = config.chapters.findIndex(chap => chap.id === response.element.id); + var chapter = config.chapters[current_chapter]; + + const rentLegendChapters = [ + 'median_rent', + 'median_rent_wonder', + 'third-identifier', + 'last-chapter', + ]; + rentChangeLegend.classList.toggle( + 'map-tools-hidden', + !rentLegendChapters.includes(chapter.id) + ); + + if (chapter.id === 'median_rent_wonder' || chapter.id === 'third-identifier') { + restaurantSearch.value = 'Wonder'; + applyRestaurantSearch(true); + } + + if (chapter.id === 'median_rent' && map.getLayer('neighborhoods')) { + map.setPaintProperty('neighborhoods', 'fill-color', [ + 'interpolate', ['linear'], ['to-number', ['get', '2023']], + 1100, '#fff4cc', + 1700, '#fdb863', + 2400, '#e8753b', + 3000, '#bd3b32', + 3600, '#772b3a', + ]); + rentChangeLegend.innerHTML = rentPriceLegendContent; + } + + if (chapter.id === 'third-identifier' && map.getLayer('neighborhoods')) { + const rentChange = [ + '-', + ['coalesce', ['to-number', ['get', '2023']], 0], + ['coalesce', ['to-number', ['get', '2018']], 0], + ]; + map.setPaintProperty('neighborhoods', 'fill-color', [ + 'interpolate', ['linear'], rentChange, + -1000, '#2166ac', + -500, '#92c5de', + 0, '#f7f7f2', + 500, '#f4a582', + 1000, '#b2182b', + ]); + rentChangeLegend.innerHTML = rentChangeLegendContent; + } + + if (current_chapter === 0) { + resetViewButton.classList.remove('map-tools-hidden'); + zoomControls.classList.remove('map-tools-hidden'); + restaurantSearchControl.classList.remove('map-tools-hidden'); + } + + response.element.classList.add('active'); + map[chapter.mapAnimation || 'flyTo'](chapter.location); + + if (config.showMarkers) { + marker.setLngLat(chapter.location.center); + } + if (chapter.onChapterEnter.length > 0) { + chapter.onChapterEnter.forEach(setLayerOpacity); + } + if (chapter.callback) { + window[chapter.callback](); + } + if (chapter.rotateAnimation) { + map.once('moveend', () => { + const rotateNumber = map.getBearing(); + map.rotateTo(rotateNumber + 180, { + duration: 30000, easing: function (t) { + return t; + } + }); + }); + } + if (config.auto) { + var next_chapter = (current_chapter + 1) % config.chapters.length; + map.once('moveend', () => { + document.querySelectorAll('[data-scrollama-index="' + next_chapter.toString() + '"]')[0].scrollIntoView(); + }); + } + }) + .onStepExit(response => { + var chapter = config.chapters.find(chap => chap.id === response.element.id); + response.element.classList.remove('active'); + if ( + chapter.id === 'third-identifier' || + (chapter.id === 'median_rent_wonder' && response.direction === 'up') + ) { + restaurantSearch.value = ''; + applyRestaurantSearch(); + } + if (response.element.id === config.chapters[0].id && response.direction === 'up') { + resetViewButton.classList.add('map-tools-hidden'); + zoomControls.classList.add('map-tools-hidden'); + restaurantSearchControl.classList.add('map-tools-hidden'); + rentChangeLegend.classList.add('map-tools-hidden'); + } + if (chapter.onChapterExit.length > 0) { + chapter.onChapterExit.forEach(setLayerOpacity); + } + }); + + + if (config.auto) { + document.querySelectorAll('[data-scrollama-index="0"]')[0].scrollIntoView(); + } +}); + diff --git a/templates/scrollytelly/index.html b/templates/scrollytelly/index.html index a89a1921..ec68d606 100644 --- a/templates/scrollytelly/index.html +++ b/templates/scrollytelly/index.html @@ -18,7 +18,7 @@
-

Story Map title

+

311 Calls - What Situations Push People to Call 311?

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse molestie hendrerit quam, efficitur vestibulum justo vehicula a.

Nullam et metus eu eros facilisis commodo. Vivamus elementum nulla nisi, eget fermentum mauris placerat sed.

diff --git a/templates/scrollytelly/js/index.js b/templates/scrollytelly/js/index.js index 27b13dac..167f8a36 100644 --- a/templates/scrollytelly/js/index.js +++ b/templates/scrollytelly/js/index.js @@ -1,13 +1,25 @@ import { SlideDeck } from './slidedeck.js'; -const map = L.map('map', { scrollWheelZoom: false }).setView([0, 0], 0); +// 1. Initialize the map container +const map = L.map('map', { scrollWheelZoom: false }).setView([42.3126, -71.0407], 12); -// ## The Base Tile Layer -const baseTileLayer = L.tileLayer('https://tiles.stadiamaps.com/tiles/stamen_watercolor/{z}/{x}/{y}.jpg', { - maxZoom: 16, - attribution: '© Stadia Maps © Stamen Design © OpenStreetMap', +// 2. Define the base tile layer URL and options +var openStreetMap = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 15, + attribution: '© OpenStreetMap contributors' }); -baseTileLayer.addTo(map); + +// 3. Add the layer to the map +openStreetMap.addTo(map); + + + +// // ## The Base Tile Layer +// const baseTileLayer = L.tileLayer('https://tiles.stadiamaps.com/tiles/stamen_watercolor/{z}/{x}/{y}.jpg', { +// maxZoom: 16, +// attribution: '© Stadia Maps © Stamen Design © OpenStreetMap', +// }); +// baseTileLayer.addTo(map); // ## Interface Elements const container = document.querySelector('.slide-section');