Vytváření ArcGIS API pro JavaScript aplikace pomocí NextJS

React je oblíbená knihovna pro vytváření webových aplikací. Je to však pouze knihovna, nikoli kompletní rámec. Tady se něco jako NextJS stává užitečným. NextJS je kompletní framework React pro vytváření aplikací. Dodává se s řadou funkcí, včetně směrování, generování statického webu a dokonce i vestavěných koncových bodů API, takže můžete do své aplikace psát kód na straně serveru, pokud jej potřebujete. Skvěle se spáruje s ArcGIS API pro JavaScript.

S NextJS můžete začít pomocí následujícího příkazu.

npx create-next-app@latest

Pro tuto aplikaci se podíváme na službu globálních elektráren. Pro uživatelskou zkušenost chceme zobrazit seznam elektráren podle typu a když uživatel klikne na typ elektrárny ze seznamu, zobrazí se mapa elektráren pro tento vybraný typ.

Zdrojový kód aplikace najdete v tomto příspěvku na blogu na github.

Cesta API

Abychom splnili první úkol získat seznam typů elektráren, můžeme napsat trasu API v prostředí NodeJS. Můžeme použít ArcGIS API for JavaScript v rozhraní API k dotazování služby a extrahování hodnot z výsledků.

import type { NextApiRequest, NextApiResponse } from "next";
import { executeQueryJSON } from "@arcgis/core/rest/query";

const PLANT_URL =
  "https://services1.arcgis.com/4yjifSiIG17X0gW4/arcgis/rest/services/PowerPlants_WorldResourcesInstitute/FeatureServer/0";

type Data = {
  types: string[];
};

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<Data>
) {
  const query = {
    outFields: ["fuel1"],
    where: "1=1",
    returnDistinctValues: true,
    returnGeometry: false
  };
  const results = await executeQueryJSON(PLANT_URL, query);
  const values = results.features
    .map((feature) => feature.attributes["fuel1"])
    .filter(Boolean)
    .sort();
  res.status(200).json({ types: values });
}

V této cestě API se budeme dotazovat na službu funkcí, omezíme výsledky pouze na pole primárního typu energie generované v závodě a extrahujeme to do jednoduchého seznamu. Nejlepší na tom je, že tento dotaz se provádí na serveru, takže klient nemá při spuštění tohoto dotazu žádnou latenci.

Redux a obchody

Ke správě stavu aplikace můžeme použít Redux. Pokud jste v minulosti používali Redux, možná si myslíte, že potřebujete nastavit spoustu kódu kotelního štítku pro konstanty, akce a redukce. Sada nástrojů Redux to pomáhá zjednodušit pomocí řezů s metodou createSlice(). To vám umožní definovat název řezu, počáteční stav a redukce nebo metody používané k aktualizaci stavu. Můžeme vytvořit ten, který bude použit pro naši aplikaci.

import { createSlice } from '@reduxjs/toolkit'

export interface AppState {
    types: string[];
    selected?: string;
}

const initialState: AppState = {
    types: []
}

export const plantsSlice = createSlice({
    name: 'plants',
    initialState,
    reducers: {
        updateTypes: (state, action) => {
            state.types = action.payload
        },
        updateSelected: (state, action) => {
            state.selected = action.payload
        }
    },
})

export const { updateTypes, updateSelected} = plantsSlice.actions

export default plantsSlice.reducer

S našimi definovanými plátky a redukcemi můžeme vytvořit úložiště React a háček, který použijeme v naší aplikaci pro redukci.

import { configureStore } from '@reduxjs/toolkit'
import plantsReducer from '../features/plants/plantsSlice'

const store = configureStore({
  reducer: {
      plants: plantsReducer
  },
})

export default store
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch

V tomto případě je jediným důvodem, proč skutečně potřebujeme vlastní háky, mít správné typování TypeScript.

Rozvržení

V tuto chvíli můžeme začít přemýšlet o tom, jak se bude aplikace a stránky zobrazovat. Můžeme začít se souborem rozložení.

import Head from 'next/head'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useAppSelector } from '../app/hooks'
import { useEffect, useState } from 'react'
import styles from './layout.module.css'

export default function Layout({ children }: any) {
    const router = useRouter()
    const selected = useAppSelector((state) => state.plants.selected)
    const [showPrevious, setShowPrevious] = useState(false)
    useEffect(() => {
        setShowPrevious(router.asPath.includes('/webmap'))
    }, [router])
    return (
        <>
            <Head>
                <title>Power Plants Explorer</title>
            </Head>
            <div className={styles.layout}>
                <header className={styles.header}>
                    {
                        showPrevious ?
                        <Link href="/">
                            <a>
                                <svg className={styles.link} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M14 5.25L3.25 16 14 26.75V20h14v-8H14zM27 13v6H13v5.336L4.664 16 13 7.664V13z"/><path fill="none" d="M0 0h32v32H0z"/></svg>
                            </a>
                        </Link>
                        : null
                    }
                    <div className={styles.container}>
                        <h3>Global Power Plants</h3>
                        {showPrevious  && selected ? <small className={styles.small}>({selected})</small> : null}
                    </div>
                </header>
                <main className={styles.main}>{children}</main>
            </div>
        </>
    )
}

Rozvržení bude definovat, jak budou všechny stránky vypadat. Na stránce budeme mít záhlaví s navigačním tlačítkem a nadpisem. To bude viditelné na všech stránkách naší aplikace. Poté můžeme definovat část rozvržení, která bude použita pro různý obsah.

Směrovač

Zde se také začneme dívat na dodaný router s NextJS. Když jsme na stránce, která zobrazuje mapu, chceme přidat tlačítko zpět pro návrat do seznamu elektráren. Stránka rozvržení vytváří záhlaví a hlavní prvek obsahu.

Můžeme použít rozložení v globální aplikaci pro NextJS.

import '../styles/globals.css'
import type { ReactElement, ReactNode } from 'react'
import type { NextPage } from 'next'
import type { AppProps } from 'next/app'
import { Provider } from 'react-redux'
import store from '../app/store'

type NextPageWithLayout = NextPage & {
  getLayout?: (page: ReactElement) => ReactNode
}

type AppPropsWithLayout = AppProps & {
  Component: NextPageWithLayout
}

export default function MyApp({ Component, pageProps }: AppPropsWithLayout) {
  const getLayout = Component.getLayout ?? ((page) => page)
  return (
    <Provider store={store}>
      {getLayout(<Component {...pageProps} />)}
    </Provider>
  )
}

V tomto globálním souboru aplikace můžeme přidat rozvržení a poskytovatele pro náš obchod Redux. Globální aplikace určí, zda existuje rozvržení nebo ne, a použije ho.

API

K načtení dat z našeho routovacího API můžeme použít swr, který poskytne React hook, který za nás zpracovává data. Není to povinné, ale je to užitečný nástroj, který pomáhá zabalit řadu funkcí načítání dat, jako je ukládání do mezipaměti a další.

import styles from '../../styles/Home.module.css'
import useSWR from 'swr'
import { useState, useEffect } from 'react'
import { useAppSelector, useAppDispatch } from '../../app/hooks'
import { updateTypes } from './plantsSlice'
import Loader from '../../components/loader'
import { useRouter } from 'next/router'

const fetcher = async (
    input: RequestInfo,
    init: RequestInit,
    ...args: any[]
  ) => {
        const res = await fetch(input, init)
        return res.json()
    }

const Plants = () => {
    const { data, error } = useSWR('/api/powerplants', fetcher)
    const types = useAppSelector((state) => state.plants.types)
    const dispatch = useAppDispatch()
    const [isLoading, setLoading] = useState(true)
    const router = useRouter()

    useEffect(() => {
        setLoading(true)
        if (data) {
            dispatch(updateTypes(data.types))
            setLoading(false)
        }
    }, [data, error, dispatch])

    if (isLoading)
        return (
            <div className={styles.loader}>
                <Loader />
            </div>
        )
    if (!types.length) return <p>No data</p>

    return (
        <ul className={styles.list}>
            {types.map((value, idx) => (
            <li
                className={styles.listItem}
                key={`${value}-${idx}`}
                onClick={() => router.push(`/webmap?type=${value}`)}
            >
                {value}
            </li>
            ))}
        </ul>
    )
}

export default Plants

Stránky

Komponenta elektrárny načte seznam elektráren a zobrazí je. Při načítání požadavku zobrazí jednoduchý animovaný zavaděč SVG. Když je ze seznamu vybrán typ elektrárny, přesměruje se na stránku, která zobrazuje mapu a bude filtrovat výsledky podle zvoleného typu elektrárny. Protože vstupní stránka této aplikace zobrazí seznam elektráren, můžeme tuto komponentu Plants použít v našem souboru index.tsx.

import styles from '../styles/Home.module.css'
import Layout from '../components/layout'
import { ReactElement } from 'react'
import Plants from '../features/plants/plants'

const Home = () => {
  return (
    <div className={styles.container}>
      <Plants />
    </div>
  )
}

Home.getLayout = function getLayout(page: ReactElement) {
  return <Layout>{page}</Layout>
}

export default Home

Náš soubor index.tsx odhaluje komponentu Home, která bude domovskou cestou pro naši aplikaci.

Dalším krokem je definování trasy naší webové mapy pro aplikaci. Tato stránka zobrazí naši webovou mapu a filtruje výsledky tak, aby zobrazovaly pouze ty typy elektráren, které byly vybrány ze seznamu na domovské stránce. Aby to bylo lépe konfigurovatelné, můžeme také přidat ?type= parametr do řetězce URL, abychom mohli tento odkaz později sdílet s ostatními uživateli.

import styles from '../styles/WebMap.module.css'
import Layout from '../components/layout'
import { ReactElement, useEffect, useRef } from 'react'
import { useRouter } from 'next/router'
import { useAppSelector, useAppDispatch } from '../app/hooks'
import { updateSelected } from '../features/plants/plantsSlice'

async function loadMap(container: HTMLDivElement, filter: string) {
    const { initialize } = await import('../data/mapping')
    return initialize(container, filter)
}

const WebMap = () => {
    const mapRef = useRef<HTMLDivElement>(null)
    const router = useRouter()
    const { type } = router.query
    const selected = useAppSelector((state) => state.plants.selected)
    const dispatch = useAppDispatch()

    useEffect(() => {
        dispatch(updateSelected(type))
    }, [type, dispatch])

    useEffect(() => {
        let asyncCleanup: Promise<(() => void)>
        if (mapRef.current && selected) {
            asyncCleanup = loadMap(mapRef.current, selected)
        }
        return () => {
            asyncCleanup && asyncCleanup.then((cleanup) => cleanup())
        }
    }, [mapRef, selected])

    return (
        <div className={styles.container}>
            <div className={styles.viewDiv} ref={mapRef}></div>
        </div>
    )
}

WebMap.getLayout = function getLayout(page: ReactElement) {
  return <Layout>{page}</Layout>
}

export default WebMap

Děje se tu málo věcí. K získání parametrů dotazu používáme poskytnuté háčky routeru od NextJS. Také se nám podařilo trochu stavu zobrazit tlačítko pro přechod zpět na domovskou stránku. Všimněte si, že v této komponentě není žádný odkaz na ArcGIS API for JavaScript. Máme metodu loadMap(), která dynamicky importuje mapovací modul. Pomocí tohoto mapovacího modulu komunikujeme s moduly z ArcGIS API for JavaScript.

import config from '@arcgis/core/config'
import ArcGISMap from '@arcgis/core/Map'
import FeatureLayer from '@arcgis/core/layers/FeatureLayer'
import MapView from '@arcgis/core/views/MapView'
import Extent from '@arcgis/core/geometry/Extent'
import { watch } from '@arcgis/core/core/reactiveUtils'
import Expand from '@arcgis/core/widgets/Expand'
import Legend from '@arcgis/core/widgets/Legend';
import LayerList from '@arcgis/core/widgets/LayerList';

config.apiKey = process.env.NEXT_PUBLIC_API_KEY as string

interface MapApp {
    view?: MapView;
    map?: ArcGISMap;
    layer?: FeatureLayer;
    savedExtent?: any;
}

const app: MapApp = {}

let handler: IHandle

export async function initialize(container: HTMLDivElement, filter: string) {
    if (app.view) {
        app.view.destroy()
    }

    const layer = new FeatureLayer({
        portalItem: {
            id: '848d61af726f40d890219042253bedd7'
        },
        definitionExpression: `fuel1 = '${filter}'`,
    })

    const map = new ArcGISMap({
        basemap: 'arcgis-dark-gray',
        layers: [layer]
    })

    const view = new MapView({
        map,
        container
    })

    const legend = new Legend({ view });
    const list = new LayerList({ view });

    view.ui.add(legend, 'bottom-right');
    view.ui.add(list, 'top-right');

    if(app.savedExtent) {
        view.extent = Extent.fromJSON(app.savedExtent)
    } else {
        layer.when(() => {
            view.extent = layer.fullExtent
        })
    }

    handler = watch(
        () => view.stationary && view.extent,
        () => {
            app.savedExtent = view.extent.toJSON()
        }
    )

    view.when(async () => {
        await layer.when()
        const element = document.createElement('div')
        element.classList.add('esri-component', 'esri-widget', 'esri-widget--panel', 'item-description')
        element.innerHTML = layer.portalItem.description
        const expand = new Expand({
            content: element,
            expandIconClass: 'esri-icon-description'
        })
        view.ui.add(expand, 'bottom-right')
    })

    app.map = map
    app.layer = layer
    app.view = view

    return cleanup
}

function cleanup() {
    handler?.remove()
    app.view?.destroy()
}

Tento modul mapování vytváří tenkou vrstvu API v naší aplikaci, která komunikuje s ArcGIS API for JavaScript a našimi aplikačními komponentami. Metoda initialize vytvoří mapu a vrstvu. Také ukládá rozsah jako objekt JSON, když se uživatel pohybuje po mapě. Když tedy uživatel přejde na domovskou stránku a vrátí se na mapu, jeho poslední zobrazená poloha bude uložena a znovu použita. Je to užitečný způsob, jak zajistit bezproblémovější uživatelský zážitek.

Takto vypadá hotová aplikace.

Nasazení

NextJS využívá to, čemu se říká funkce bez serveru. Funkce bez serveru jsou metody s krátkou životností, které trvají jen několik sekund, jsou roztočené pro použití a rychle zničeny. NextJS je používá pro směrování API při poskytování stránek. Toto budete muset mít na paměti při nasazování aplikace. Je třeba poznamenat, že NextJS je vyvinut společností Vercel a nabízí hostingové řešení, které pracuje s funkcemi bez serveru. Ostatní platformy jako Heroku a Amazon to dělají také. Je na vás, abyste se rozhodli, kam chcete svou aplikaci nasadit, abyste mohli používat tyto funkce bez serveru. Pro účely ukázky jsem aplikaci nasadil do Heroku zde.

Souhrn

NextJS je výkonný rámec React, který můžete použít k vytváření škálovatelných aplikací připravených na produkci pomocí ArcGIS API pro JavaScript. Můžete použít nástroje, jako je Redux, které vám pomohou spravovat stav vaší aplikace, a dokonce použít ArcGIS API pro JavaScript k dotazování mapovacích služeb ve funkcích bez serveru. Tato aplikace také poskytuje výhody rychlého načítání tím, že odkládá načítání mapy, dokud to nebude nutné.

Kombinace NextJS a ArcGIS API pro JavaScript poskytuje skvělou vývojářskou zkušenost, kterou vřele doporučuji vyzkoušet. Bavte se a vytvářejte úžasné aplikace!

Návod si můžete prohlédnout ve videu níže!