5. ArcGIS JavaScript API 4.x Foundations & App Skeleton#

Global aims

  • Understand the 4.x mental model: Map View Layers Widgets Popups.

  • Stand up a clean 2D web map using AMD (require()), then progressively add layers & UI.

  • Know when to use FeatureLayer vs MapImageLayer vs GraphicsLayer.

  • Wire up popups (layer-based & graphic-based) and view constraints.

  • Leave with a working skeleton you can reuse for real apps.

5.1. 0) Project setup (AMD)#

What/Why

  • ArcGIS JS 4.x ships via CDN. The simplest start uses AMD require() (no build tooling).

  • For production you may move to ESM + bundlers, but AMD is perfect for learning.

    <!DOCTYPE html>
    <html>
    <head>
    <meta charset="utf-8" />
    <title>ArcGIS JS 4.x Starter</title>
    <link rel="stylesheet" href="https://js.arcgis.com/4.23/esri/themes/light/main.css">
    <style>
        html, body, #viewDiv { height: 100%; margin: 0; padding: 0; }
    </style>
    <script src="https://js.arcgis.com/4.23/"></script>
    </head>
    <body>
    <div id="viewDiv"></div>

    <script>
    require([
        "esri/Map",
        "esri/views/MapView"
    ], function (Map, MapView) {

        const map = new Map({ basemap: "topo-vector" });

        const view = new MapView({
        container: "viewDiv",
        map,
        center: [-80.45, 38.75], // WV-ish
        zoom: 7,
        constraints: { minZoom: 6, maxZoom: 16 }
        });

    });
    </script>
    </body>
    </html>

Hands-On

  • Switch basemap to "hybrid" and "streets-navigation-vector".

    map.basemap = "hybrid";                // or "streets-navigation-vector"
  • Change center/zoom to your city.

  • try minZoom/maxZoom and observe behavior.

    view.center = [-122.419, 37.774];      // San Francisco
    view.zoom = 11;
    view.constraints = { minZoom: 4, maxZoom: 19 };

5.2. 1) Core objects: Map & MapView + UI regions#

What/Why

  • Map holds basemap + layer collection.

  • MapView renders the Map in 2D and manages user interaction & the widget UI.

  • The view has UI “corners” (top-left, top-right, etc.) for tool placement.

Demo: add a containered UI panel

    <div id="viewDiv"></div>
    <div id="legendDiv" style="position:absolute; bottom:15px; right:15px; z-index: 5; background:#fff; padding:8px; border-radius:6px"></div>
  • You can later pass container: "legendDiv" to widgets to render into this card.

5.2.1. Hands-On#

  • Create #scaleDiv and position it bottom-left.

  • Add a custom “Help” button (position: top-right) that just alert()s.

    <!-- in body -->
    <div id="scaleDiv"  style="position:absolute;bottom:15px;left:15px;z-index:5;background:#fff;padding:6px;border-radius:6px"></div>
    <button id="helpBtn" style="position:absolute;top:15px;right:15px;z-index:6">Help</button>

    document.getElementById("helpBtn").addEventListener("click", () => {
    alert("This is a demo help message.");
    });

5.3. 2) Layer types & when to use them#

What/Why

  • FeatureLayer: vector features from a Feature Service. Client-side rendering, querying, popups, editing. Use for interactive, attribute-rich content.

  • MapImageLayer: server-drawn map images with sublayers (think ArcGIS Server map service). Great for big cartography/indices without client rendering. Sublayers can still have popups.

  • GraphicsLayer: ad-hoc client graphics you create at runtime (selection, sketches, transient overlays).

5.3.1. 2.1 FeatureLayer (client features, popups, queries)#

    require([
    "esri/Map", "esri/views/MapView",
    "esri/layers/FeatureLayer", "esri/PopupTemplate"
    ], function(Map, MapView, FeatureLayer, PopupTemplate){

    const map = new Map({ basemap: "topo-vector" });
    const view = new MapView({ container: "viewDiv", map, center: [-80.45, 38.75], zoom: 7 });

    const countyPopup = new PopupTemplate({
        title: "{NAME} County",
        content: `
        <b>Population:</b> {POP}<br>
        <b>Source:</b> {SOURCE}
        `
    });

    const featureLayer = new FeatureLayer({
        url: "https://sampleserver6.arcgisonline.com/arcgis/rest/services/Census/MapServer/3",
        outFields: ["*"],
        popupTemplate: countyPopup
    });

    map.add(featureLayer);
    });

Hands-On

  • Add definitionExpression (e.g., POP > 50000) to filter server-side.

  • Set minScale so counties only appear when zoomed out.

    const counties = new FeatureLayer({
    url: "https://sampleserver6.arcgisonline.com/arcgis/rest/services/Census/MapServer/3",
    outFields: ["*"],
    popupTemplate: { title: "{NAME} County", content: "POP: {POP2007}" },
    definitionExpression: "POP2007 > 50000", // server filter
    minScale: 0,                              // show any scale
    maxScale: 4000000                         // hide when zoomed in closer than ~1:4M
    });
    map.add(counties);
  • Tip: To change filter later:

    counties.definitionExpression = "STATE_NAME = 'Texas'";

5.3.2. 2.2 MapImageLayer (server-drawn, fast, with sublayers)#

    require(["esri/layers/MapImageLayer", "esri/PopupTemplate"], function(MapImageLayer, PopupTemplate){

    const states = new MapImageLayer({
        url: "https://sampleserver6.arcgisonline.com/arcgis/rest/services/Census/MapServer",
        sublayers: [
        {
            id: 2, // States layer id
            visible: true,
            popupTemplate: new PopupTemplate({
            title: "{STATE_NAME}",
            content: "FIPS: {STATE_FIPS}"
            })
        }
        ],
        opacity: 0.8
    });

    map.add(states);
    });

Hands-On

  • Toggle a second sublayer at runtime; try sublayer.visible = false/true.

  • Change opacity to blend with basemap.

    const censusMIL = new MapImageLayer({
    url: "https://sampleserver6.arcgisonline.com/arcgis/rest/services/Census/MapServer",
    sublayers: [{ id: 2, visible: true }] // States
    });
    map.add(censusMIL);

    // Toggle
    const statesSL = censusMIL.sublayers.find(s => s.id === 2);
    statesSL.visible = !statesSL.visible;

5.3.3. 2.3 GraphicsLayer (ad-hoc graphics like selection/highlight)#

    require(["esri/layers/GraphicsLayer", "esri/Graphic"], function(GraphicsLayer, Graphic){

    const selectionLayer = new GraphicsLayer({ id: "selection" });
    map.add(selectionLayer);

    // Add a temporary polygon graphic with a symbol
    const poly = {
        type: "polygon",
        rings: [[-81,38],[-80,38],[-80,39],[-81,39],[-81,38]],
        spatialReference: { wkid: 4326 }
    };

    const symbol = {
        type: "simple-fill",
        color: [255,255,0,0.3],
        outline: { color: [255,0,0,1], width: 2 }
    };

    selectionLayer.add(new Graphic({ geometry: poly, symbol, attributes: { kind: "temp" } }));
    });

Hands-On

  • Add a click handler to remove all graphics from selectionLayer.

  • Try changing the symbol on mouseover vs mouseout.

    // Clear on click (anywhere)
    view.on("click", () => selectionLayer.removeAll());

5.4. 3) Widgets & UX (Home, ScaleBar, Legend, BasemapGallery)#

What/Why

  • Widgets give you polished UI quickly. Most take view and optional container.

Demo: add four common widgets

    require([
    "esri/widgets/Home",
    "esri/widgets/ScaleBar",
    "esri/widgets/Legend",
    "esri/widgets/BasemapGallery"
    ], function(Home, ScaleBar, Legend, BasemapGallery){

    const home = new Home({ view });
    view.ui.add(home, "top-left");

    const scaleBar = new ScaleBar({ view, unit: "dual" });
    view.ui.add(scaleBar, { position: "bottom-left" });

    const legend = new Legend({ view, container: "legendDiv" }); // render into our card
    // view.ui.add(legend, "bottom-right"); // alternative if not using container

    const basemapGallery = new BasemapGallery({ view });
    view.ui.add(basemapGallery, "top-right");
    });

Hands-On

  • Move BasemapGallery into a custom floating card (like legendDiv).

    <div id="basemapCard" style="position:absolute;top:60px;right:15px;background:#fff;padding:8px;border-radius:6px;z-index:5"></div>
    const basemapGallery = new BasemapGallery({ view, container: "basemapCard" });
  • Hide Legend on small screens (view.widthBreakpoint) for responsiveness.

    const legend = new Legend({ view });
    view.ui.add(legend, "bottom-right");

    view.watch("widthBreakpoint", bp => {
    legend.visible = (bp !== "xsmall"); // hide when extra-small
    });

5.5. 4) Popups 101 (FeatureLayer vs GraphicsLayer)#

What/Why

  • PopupTemplate formats content (HTML allowed).

  • FeatureLayer popups show when you click its features (if popupEnabled !== false).

  • GraphicsLayer needs graphics to carry popupTemplate (or set at the layer in 4.30+); otherwise, nothing shows by default.

Demo: feature popup

    const states = new FeatureLayer({
    url: "https://sampleserver6.arcgisonline.com/arcgis/rest/services/Census/MapServer/2",
    outFields: ["*"],
    popupTemplate: { title: "{STATE_NAME}", content: "FIPS: {STATE_FIPS}" }
    });
    map.add(states);

Demo: graphic popup

    require(["esri/Graphic"], function(Graphic){
    const g = new Graphic({
        geometry: { type: "point", longitude: -80.5, latitude: 38.7 },
        symbol: { type: "simple-marker", size: 8 },
        attributes: { label: "Hello WV" },
        popupTemplate: { title: "Marker", content: "{label}" }
    });
    selectionLayer.add(g);
    });

Hands-On

  • Create a polygon graphic with attributes and a popup listing area/perimeter you compute on the fly (just store precomputed values in attributes for now).

  • A) Geodesic (no projection step, great default)

        require(["esri/geometry/geometryEngine", "esri/Graphic"], function(geometryEngine, Graphic){

        const poly = {
            type: "polygon",
            rings: [[-80.6,38.6],[-80.2,38.6],[-80.2,38.9],[-80.6,38.9],[-80.6,38.6]],
            spatialReference: { wkid: 4326 }
        };

        // Accurate on the ellipsoid
        const area_km2     = geometryEngine.geodesicArea(poly, "square-kilometers");
        const perimeter_km = geometryEngine.geodesicLength(geometryEngine.boundary(poly), "kilometers");

        const attrs = {
            name: "Demo AOI",
            area_km2: Number(area_km2).toFixed(3),
            perimeter_km: Number(perimeter_km).toFixed(3)
        };

        selectionGL.add(new Graphic({
            geometry: poly,
            symbol: { type: "simple-fill", color:[0,0,0,0.1], outline:{ color:[0,0,0,1], width:1.5 }},
            attributes: attrs,
            popupTemplate: {
            title: "{name}",
            content: "Area: {area_km2} km²<br>Perimeter: {perimeter_km} km"
            }
        }));
        });
  • B) Projected (equal-area/UTM for planar measurements)

    • If you want planar area/length (e.g., in an equal-area projection):

        require([
        "esri/geometry/geometryEngine",
        "esri/geometry/projection",
        "esri/geometry/SpatialReference",
        "esri/Graphic"
        ], function(geometryEngine, projection, SpatialReference, Graphic){

        const poly = {
            type: "polygon",
            rings: [[-80.6,38.6],[-80.2,38.6],[-80.2,38.9],[-80.6,38.9],[-80.6,38.6]],
            spatialReference: { wkid: 4326 }
        };

        // Choose an equal-area SR for the U.S. (NAD83 / Conus Albers)
        const AEA = new SpatialReference({ wkid: 5070 }); // EPSG:5070

        projection.load().then(() => {
            const polyAEA = projection.project(poly, AEA);

            const area_km2 = geometryEngine.planarArea(polyAEA, "square-kilometers");
            const perim_km = geometryEngine.planarLength(geometryEngine.boundary(polyAEA), "kilometers");

            const attrs = {
            name: "Demo AOI (AEA)",
            area_km2: Number(area_km2).toFixed(3),
            perimeter_km: Number(perim_km).toFixed(3)
            };

            selectionGL.add(new Graphic({
            geometry: poly, // keep original for display; metrics come from projected geometry
            symbol: { type: "simple-fill", color:[0,0,0,0.1], outline:{ color:[0,0,0,1], width:1.5 }},
            attributes: attrs,
            popupTemplate: {
                title: "{name}",
                content: "Area: {area_km2} km²<br>Perimeter: {perimeter_km} km"
            }
            }));
        });
        });
  • Disable popups on a layer: featureLayer.popupEnabled = false, confirm others still work.

    counties.popupEnabled = false;

5.6. 5) View constraints & navigation helpers#

What/Why

  • Prevent users from zooming too far in/out or interacting out of scope.

  • Programmatically navigate to a geometry or coordinate pair.

Demo: constraints & goTo

    view.constraints = { minZoom: 6, maxZoom: 16, rotationEnabled: false };

    function goToWVCapitol(){
    view.goTo({ center: [-81.633, 38.336], zoom: 14 })
        .catch((e)=>console.warn("goTo canceled/failed", e));
    }

Hands-On

  • Add two buttons: “WV” and “My Area”. First calls goToWVCapitol().

  • Second reads ?x=&y=&wkid=&l= from URL and calls view.goTo() (hint: URLSearchParams).

    function goToFromURL() {
    const p = new URLSearchParams(location.search);
    const x = parseFloat(p.get("x"));
    const y = parseFloat(p.get("y"));
    const l = parseInt(p.get("l") || "12", 10);
    const wkid = parseInt(p.get("wkid") || "4326", 10);

    if (Number.isFinite(x) && Number.isFinite(y)) {
        const target = (wkid === 4326)
        ? { center: [x, y], zoom: l }
        : { center: { x, y, spatialReference:{ wkid } }, zoom: l };
        view.goTo(target).catch(()=>{});
    }
    }
    goToFromURL();

5.7. 6) Putting it together — a clean app skeleton#

What/Why

  • A tidy lifecycle helps scale: init() createMap() createLayers() createWidgets() wireEvents().

Demo (single file skeleton)

    <script>
    require([
    "esri/Map","esri/views/MapView",
    "esri/layers/FeatureLayer","esri/layers/MapImageLayer","esri/layers/GraphicsLayer",
    "esri/widgets/Home","esri/widgets/ScaleBar","esri/widgets/Legend","esri/widgets/BasemapGallery",
    "esri/PopupTemplate"
    ], function(Map, MapView, FeatureLayer, MapImageLayer, GraphicsLayer,
                Home, ScaleBar, Legend, BasemapGallery, PopupTemplate){

    const App = {
        map:null, view:null,
        layers:{}, ui:{},

        init(){
        this.createMap();
        this.createLayers();
        this.createWidgets();
        this.wireEvents();
        },

        createMap(){
        this.map = new Map({ basemap: "topo-vector" });
        this.view = new MapView({
            container: "viewDiv",
            map: this.map,
            center: [-80.45, 38.75],
            zoom: 7,
            constraints: { minZoom: 6, maxZoom: 16 }
        });
        },

        createLayers(){
        this.layers.selection = new GraphicsLayer({ id:"selection" });
        this.map.add(this.layers.selection);

        this.layers.feature = new FeatureLayer({
            url: "https://sampleserver6.arcgisonline.com/arcgis/rest/services/Census/MapServer/3",
            outFields: ["*"],
            popupTemplate: new PopupTemplate({
            title: "{NAME} County",
            content: "POP: {POP2007}"
            })
        });
        this.map.add(this.layers.feature);

        this.layers.mapImage = new MapImageLayer({
            url: "https://sampleserver6.arcgisonline.com/arcgis/rest/services/Census/MapServer",
            sublayers: [{ id: 2, visible: true }]
        });
        this.map.add(this.layers.mapImage);
        },

        createWidgets(){
        this.ui.home = new Home({ view: this.view });
        this.view.ui.add(this.ui.home, "top-left");

        this.ui.scaleBar = new ScaleBar({ view: this.view, unit: "dual" });
        this.view.ui.add(this.ui.scaleBar, "bottom-left");

        this.ui.legend = new Legend({ view: this.view, container: "legendDiv" });
        this.ui.basemapGallery = new BasemapGallery({ view: this.view });
        this.view.ui.add(this.ui.basemapGallery, "top-right");
        },

        wireEvents(){
        // simple demo: click to log features under cursor
        this.view.on("click", async (evt)=>{
            const hit = await this.view.hitTest(evt);
            console.log("HitTest results:", hit.results);
        });
        }
    };

    App.init();
    window.App = App;
    });
    </script>

Hands-On

  • Ensure the Legend shows both the FeatureLayer and the MapImage sublayer (tip: Legend reads visible layers).

    const legend = new Legend({ view });
    view.ui.add(legend, "bottom-right");
    // Legend automatically lists visible layers (FeatureLayer + visible MapImage sublayers)
  • Add a toggle button that sets this.layers.mapImage.sublayers.items[0].visible = !visible.

    const firstSL = censusMIL.sublayers.items[0];
    firstSL.visible = !firstSL.visible;

5.8. 7) Popups: best practices & pitfalls (quick checklist)#

  • Always set outFields or use "*" during dev; trim to necessary fields for perf.

  • For GraphicsLayer, assign popupTemplate to each graphic (4.23).

  • Use layer order to control which thing is clicked first (map.reorder).

  • Use links responsibly: target="_blank" for external resources.

  • Keep HTML concise—avoid heavy inline CSS for performance/accessibility.

Hands-On

  • Add a second FeatureLayer with overlapping features; reorder to see hit priority change.

  • Demonstrate that disabling popupEnabled on the lower layer removes its popup but keeps the top one working.

5.9. Hands-On Lab (capstone for Class 1)#

5.9.1. Lab A — Build the base app#

  1. Create the AMD page (CDN includes).

  2. Add Map, MapView, constraints, custom background card (#legendDiv).

  3. Add one FeatureLayer (counties) with a PopupTemplate.

Success criteria: You can click counties and see the popup.

    const baseMap = new Map({ basemap: "topo-vector" });
    const baseView = new MapView({ container:"viewDiv", map:baseMap, center:[-80.45,38.75], zoom:7 });
    const countiesA = new FeatureLayer({
    url: "https://sampleserver6.arcgisonline.com/arcgis/rest/services/Census/MapServer/3",
    outFields: ["*"],
    popupTemplate: { title: "{NAME} County", content: "Population (2007): {POP2007}" }
    });
    baseMap.add(countiesA);

5.9.2. Lab B — Add a MapImageLayer with one sublayer + popup#

  1. Add a MapImageLayer census service with States sublayer.

  2. Give the sublayer a PopupTemplate (title + small content).

  3. Verify it appears in the legend and popups work when clicking states outside county coverage (or toggle county visibility).

Success criteria: State popups work; toggling visibility makes behavior intuitive.

    const censusB = new MapImageLayer({
    url: "https://sampleserver6.arcgisonline.com/arcgis/rest/services/Census/MapServer",
    sublayers: [{
        id: 2, visible: true,
        popupTemplate: { title: "{STATE_NAME}", content: "FIPS: {STATE_FIPS}" }
    }]
    });
    map.add(censusB);

5.9.3. Lab C — Add a GraphicsLayer and a “mark location” button#

  1. Add a button that drops a point Graphic at the view center.

  2. The graphic should have a simple marker symbol and a popup (“You clicked here”).

  3. Clicking that point should open your custom popup (proves graphic popups work).

  4. Add a “Clear Marks” button to remove all graphics.

Success criteria: You can add/clear ad-hoc markers and open their popups.

    const marks = new GraphicsLayer();
    map.add(marks);

    function markCenter() {
    const pt = view.center.clone();
    marks.add(new Graphic({
        geometry: pt,
        symbol: { type:"simple-marker", size:8 },
        attributes: { label: "You marked here" },
        popupTemplate: { title: "Mark", content: "{label}" }
    }));
    }
    document.getElementById("markBtn").onclick = markCenter;
    document.getElementById("clearMarksBtn").onclick = () => marks.removeAll();

5.9.4. Lab D — Navigation helpers#

  1. Implement goTo(x,y,zoom,wkid=4326) using URLSearchParams.

  2. If wkid !== 4326, pass { spatialReference: { wkid } } in the point.

  3. Add two example links on the page:

    • ?x=-81.633&y=38.336&l=14 (Charleston)

    • ?x=-79.955&y=39.63&l=14 (Morgantown)

Success criteria: Reloading with different params centers/zooms correctly.

   function goToParams(x,y,l,wkid=4326){
   const target = (wkid===4326) ? { center:[x,y], zoom:l }
                                 : { center:{x,y, spatialReference:{wkid}}, zoom:l };
   return view.goTo(target);
   }

   const p = new URLSearchParams(location.search);
   if (p.has("x") && p.has("y")) {
   goToParams(parseFloat(p.get("x")), parseFloat(p.get("y")), parseInt(p.get("l")||"12",10), parseInt(p.get("wkid")||"4326",10));
   }