ZYEL LAB / FIELD GUIDE 02
Build a territory dashboard
you can explain.
By Chason Arthur · Engineering walkthrough · Synthetic business data
A map becomes useful when the person using it can ask a question and verify the answer. We will build that loop: define a territory, inspect the matching locations, compare regional load, and keep the map and record list on the same data contract.
This is a technical demonstration, with generated dealer names and business values. It does not establish production scale, procurement compliance, or a hosted GIS operating history. ArcGIS is the chosen mapping platform for this exercise. The public raster basemap is the only geographic service used; a fixture demonstrates feature-service limits.
Open the working demo → · Inspect the spatial functions · Read the benchmark harness
1. Set the contract before drawing the map
Objective and prerequisites. Start from the existing Zyel repository, preserve the Celebration route, and work on an isolated branch. Start from the reviewed Lab checkout or its explicitly supplied committed reference, which must contain this demo and lab/tools/serve.mjs. The draft package can precede its merge into the default branch. The file preflight below stops before creating a worktree if the selected commit lacks either asset. Uncommitted owner edits are preserved in the original checkout and are not copied to a new worktree. You also need a supported Node.js runtime for tests and a local HTTP server for browser modules. No ArcGIS account, API key or database is needed for this fixture.
Link: Static preview and test commands
# Begin in the reviewed Lab checkout/branch with committed assets.
# Check its status; preserve any owner edits there.
git status --short
$territoryRef = git rev-parse --verify HEAD
if ($LASTEXITCODE -ne 0) { throw 'Open the reviewed Lab checkout first.' }
foreach ($asset in @('lab/tools/serve.mjs', 'lab/territory/index.html')) {
git cat-file -e "${territoryRef}:$asset"
if ($LASTEXITCODE -ne 0) { throw "Missing $asset in the selected Lab ref." }
}
git worktree add -b demo/territory-explorer ../zyel-territory $territoryRef
if ($LASTEXITCODE -ne 0) { throw 'Use an unused example branch and directory.' }
Set-Location ../zyel-territory
# From this isolated repository root:
# Reuse an existing preview, or choose an unused loopback port.
$env:LAB_PORT='4205'
node lab/tools/serve.mjs .
# Open http://127.0.0.1:4205/lab/territory/
# Run the tests below in a second terminal:
node --test lab/territory/tests/spatial.test.mjsUse the existing bounded Node static preview for this workflow. It serves allowlisted public Lab assets and deliberately excludes backend, private configuration, tests and tools. It does not execute PHP or prove a deployed backend. Reuse an already running preview instead of starting a duplicate port. A local preview is separate from an authorized production release. Choose an unused example branch, directory and port, or reuse the existing isolated preview. Resolve the reviewed reference explicitly; a fresh default-branch checkout is not evidence that this unmerged Lab package is present. The demo's files are under lab/territory/; shared shell, storage, synthetic data and table modules live under lab/shared/.
Link: Synthetic row and feature contract
// One row from the shared contract; values shown here are illustrative.
{
id: 'D001', name: 'Synthetic Dealer 01', city: 'Atlanta',
state: 'GA', region: 'Georgia', lat: 33.749, lon: -84.388,
tickets: 12, revenue: 48000, status: 'Active'
}
// ArcGIS uses its own numeric object ID; keep the business ID too.
const sourceFeatures = dealers.map(toFeature);Recommended implementation. Keep stable business IDs and explicit WGS84 longitude/latitude values. toFeature() adds a numeric ObjectID for the client FeatureLayer. The shared fixture is the source of truth; the map, records and charts derive from it. Approximate city centers are context, not geocoded customer addresses. Revenue and ticket counts are generated values.
Alternatives, tradeoffs and choice. GeoJSON is portable and easy to inspect; it adds another mapping step for business fields. A hosted FeatureLayer centralizes governance and updates; it introduces account, sharing, service limits and operating costs. A bundled fixture keeps this demonstration reproducible and avoids new infrastructure. Replace it only with a reviewed data source and documented provenance.
Common pitfalls. Swapping longitude and latitude places features on the wrong continent. Reusing array indexes as business IDs breaks cross-view selection after sorting. Do not copy customer addresses, keys, CRM records or production content into a public fixture.
Verification. Run the domain tests. Open the route over HTTP, confirm the synthetic-data label, and inspect the shared row schema. Open Celebration in a separate tab to check its existing route.
Result before proceeding: the route loads in an isolated checkout, the fixture is understandable, and the ID/coordinate contract is explicit.
2. Persist preferences, with a boundary
Objective and prerequisites. Save a person's geographic filters, layer visibility and view extent on this device. Start with the shared createStore() module and the contract from step 1. Persist preferences only; records and selected business details stay in memory.
Link: Validated preference store
const defaultState = {
filters: {...DEFAULT_FILTERS}, extent: null,
pointsVisible: true, basemapVisible: true
};
const store = createStore('territory:preferences', defaultState,
value => Boolean(normalizePreferences(value, regions, statuses)));
let state = store.get();
store.set(state);Recommended implementation. The shared store wraps its versioned, namespaced browser key and falls back to memory if storage is denied or full. normalizePreferences() allowlists modes, regions and statuses, restricts coordinates and radius, validates extent order, and copies known fields. The shell's appearance settings and this route's preferences have separate scopes. Table state has another per-table scope.
Alternatives, tradeoffs and choice. sessionStorage forgets settings when the browser session ends; it suits temporary workstations. Browser-local storage survives reloads and is simple; it does not sync accounts or devices. A server preference service could sync users, but needs authentication, retention and migration policies. Device-local preferences fit this demonstration's boundary.
Common pitfalls. Merely detecting window.localStorage does not prove writes will succeed. Invalid JSON, unavailable storage and quota exceptions need a usable fallback. Never save credentials, private records, conversations or raw untrusted HTML in settings. Scope migrations to owned keys so another route's data is not overwritten.
Verification. Change a region, reload, and confirm restoration. In a storage-denied/private-context test, verify the page still filters and announces memory-only preferences. Reset the demo and confirm another Lab route's preferences remain intact. The preference unit test rejects invalid extents, unbounded numbers and unrecognized region values.
Result before proceeding: settings reload safely, reset has a precise scope, and storage failure does not disable the workflow.
3. Make the spatial question independently testable
Objective and prerequisites. Combine attribute filters with either a radius or captured map extent. You need the WGS84 fixture and pure functions; the map is optional at this stage.
const matches = filterDealers(dealers, {
region: 'all', status: 'all', mode: 'radius',
centerLat: 33.749, centerLon: -84.388, radiusKm: 150
}, null);
// The actual implementation also composes region/status predicates.
if (filters.mode === 'radius' &&
distanceKm(center, row) > filters.radiusKm) return false;Recommended implementation. Use the haversine formula for the local great-circle radius predicate, and inclusive longitude/latitude bounds for the extent predicate. The pure module spatial.mjs has no DOM or SDK dependency, so its invariants are cheap to test. The inputs are bounded to the supported coordinate range. With ArcGIS loaded, the same attributes are sent as safe numeric object IDs and the geometry is queried against the client FeatureLayer; its accepted response drives the linked views.
const result = await layer.queryFeatures({
objectIds: candidateObjectIds, returnGeometry: false,
outFields: ['ObjectID'],
geometry: {type: 'point', longitude, latitude,
spatialReference: {wkid: 4326}},
distance: radiusKm, units: 'kilometers',
spatialRelationship: 'intersects'
});The local radius is spherical; ArcGIS's geographic implementation may differ near a boundary. When the SDK is available, its query result becomes authoritative for the displayed set. An incrementing query generation prevents an older response from replacing a newer filter. Query timing includes the engine actually used and is presented as a local observation.
Alternatives, tradeoffs and choice. A projected planar distance is faster but requires a suitable projection and distortion analysis. A geodesic library provides more precise ellipsoidal behavior with an additional dependency. A routing service answers travel-time coverage, but it needs separate authorization, costs and network failure handling. Straight-line coverage is explicit and adequate for this synthetic exploration.
Common pitfalls. Do not call a radius a drive-time service area. Do not mix Web Mercator map units with WGS84 degrees. This demo does not support an extent spanning the antimeridian; unsupported bounds are rejected instead of returning misleading matches. Keep the application envelope separate from SDK geometry: arcgisExtentProperties() passes only xmin/ymin/xmax/ymax and nested spatialReference, dropping the application-only top-level wkid. The pinned SDK checks caught a zero extent when unsupported envelope fields were spread into its constructor. Verify both the actual returned IDs and the observed camera target; a resolved movement promise alone is not proof of the intended result. An empty result is valid and includes a recovery suggestion.
Verification. Check zero distance at the same coordinate, symmetry, a known approximate Atlanta–Charlotte distance, radius selection, combined region/status predicates, and inclusive bounds. Try a radius centered away from the dataset; the map, counts, charts and list should all have an explicit empty state.
Result before proceeding: the geographic question produces testable IDs even when WebGL or the network is unavailable.
4. Add the ArcGIS component only when it is needed
Objective and prerequisites. Render the synthetic points and query geometry while keeping the record workflow available before the SDK loads. Use a browser with supported WebGL and network access to the ArcGIS CDN/public basemap.
Link: ArcGIS component and layer
<!-- app.mjs injects this module only after the Load ArcGIS button. -->
<script type="module" src="https://js.arcgis.com/5.1.27/"></script>
<arcgis-map center="-83.4,33.3" zoom="6">
<arcgis-zoom slot="top-left"></arcgis-zoom>
</arcgis-map>
const FeatureLayer = await $arcgis.import(
'@arcgis/core/layers/FeatureLayer.js');
const layer = new FeatureLayer({
source: sourceFeatures, objectIdField: 'ObjectID',
geometryType: 'point', spatialReference: {wkid: 4326},
fields, renderer, popupEnabled: false
});Recommended implementation. app.mjs loads the SDK, creates a Map and explicit client-side FeatureLayer schema, assigns the map to arcgis-map, waits for view readiness, then obtains the layer view. A public raster street layer supplies context with its native attribution. The points layer and basemap have independent controls. A graphics layer draws the geodesic query circle.
Esri's current documentation recommends components for new apps. The checked release notes identified 5.1.27 as the current patch on October 25, 2026; the code pins that patch instead of following a moving minor URL. SDK load/view readiness has timeouts, a useful error message and retry control. Failure never removes the record alternative. The Lab shell and Celebration page do not download the SDK.
Alternatives, tradeoffs and choice. Bundling npm packages allows build-time dependency locking and host-controlled assets, with a larger build pipeline. A reviewed WebMap can centralize styling and metadata, but adds portal item lifecycle and sharing dependencies. The pinned CDN component is a small integration surface for this static Lab. A new widget-based implementation was avoided because the official migration plan deprecates that UI path.
Common pitfalls. Do not remove Esri attribution or represent the public basemap as owned data. Do not put a private service token in page source. A client-side layer needs an object-ID field, geometry type, spatial reference and field schema even when starting with an empty collection. Map readiness and table readiness are separate.
Verification. Before pressing Load ArcGIS, inspect network requests: no ArcGIS SDK should load. Then load the map, check that point count agrees with records, toggle layers, capture a query extent, save a view and restore it after a reload. Block the CDN and verify the non-map route still works. Test reduced motion; scripted map movement disables animation when requested.
Result before proceeding: the real ArcGIS component renders where supported, and its loading or failure never blocks the complete record workflow.
5. Link the views through IDs, not copied state
Objective and prerequisites. Keep the map, searchable DataTable and regional comparisons consistent. The shared table helper, accepted query result and stable ID maps from earlier steps must be available. In this focused integration excerpt, table, matches and renderComparisons() are the local collaborators. objectIdByDealer maps the source features' business IDs to their positive integer ObjectIDs. Initialize mapContext as undefined and mapPaused as false; set the context only after step 4 obtains its layer view, and pause it while a recording scene retains the hidden map.
Link: Linked table integration
table = await createTable({
element: document.getElementById('dealer-table'),
tableId: 'territory-dealers', columns, data: matches,
onSelect: selectRecord
});
function renderResult(rows) {
matches = rows;
table?.replace(matches);
renderComparisons();
if (mapContext && !mapPaused) {
// IDs are looked up in the validated fixture, never user SQL.
const ids = matches.map(d => objectIdByDealer.get(String(d.id)))
.filter(id => Number.isSafeInteger(id) && id > 0);
mapContext.layerView.filter = {where: ids.length
? `ObjectID IN (${ids.join(',')})` : '1=0'};
}
}This excerpt omits the aggregate text, selection cleanup and radius graphic updates to focus on the shared matching set. The complete app.mjs implementation retains those operations. Local table and comparison updates happen before the optional map guard. No SDK context is required, a paused map is not mutated, and an empty or unmapped set uses 1=0 rather than retaining an earlier filter.
Recommended implementation. The query owns the matching set. Counts, comparison bars, their equivalent data table, dealer records and the layer-view filter derive from that set. Selecting a map point or record resolves the same business ID, updates the detail panel, and highlights the corresponding point. The table's own text search and column controls are local presentation filters on that query result; they do not change the geographic totals.
The shared DataTables helper owns the table DOM. It stores presentation preferences under a route/table namespace, supplies named columns, and uses custom validated state callbacks. The integration chooses a 30-day retention policy rather than silently relying on DataTables' default 7200-second state duration. Refer to the shared tutorial/evidence for the tested DataTables and ColReorder versions and policy. No Vue v-for owns these rows.
Alternatives, tradeoffs and choice. A central application store works well for a larger SPA, but adds framework coordination for this small route. Separate independently filtered maps/charts are flexible but can present contradictory totals. Shared pure query results plus stable IDs keep the relationship visible without another framework. Accessible HTML bars and a numeric table avoid a second heavy chart dependency.
Common pitfalls. Replacing a table body manually after DataTables initializes gives two owners to the same DOM. Raw innerHTML for service fields creates an injection path; the implementation writes data with textContent and the shared table escapes values. Hidden layers should not silently change query totals. Clear a selection that falls outside the next query. Preserve a person's table search when a map selection is hidden by it; provide an explicit reveal action. That action calls table.clearPresentationFilters(), which clears each named column search, the group search and global search, then synchronizes the controls. DataTables 3 treats plural columns().search() as group search, so using it alone can leave an individual column filter active.
Verification. Select a point, then the same row, and inspect identical details. Use keyboard record selection and verify a visible focus indicator. Filter to one region and compare exact table totals. Sort ascending, descending and by multiple columns; change search/page length/visibility/order, reload and confirm restoration. These presentation preferences must not alter another demo's table.
Result before proceeding: the map is an optional view of the same records, and every comparison value has a readable equivalent.
6. Treat transfer limits as part of the data contract
Objective and prerequisites. Prove that a feature-service response cannot silently truncate a result. Begin with service.mjs and an injected request adapter; this demo uses a deterministic adapter with a 17-feature response limit.
Link: Bounded feature retrieval
const ids = await request({returnIdsOnly: true, where: '1=1'});
// Unique, numeric IDs; bounded total; stable order.
for (const batch of boundedIdBatches) {
const response = await request({objectIds: batch, outFields,
returnGeometry: true});
if (response.exceededTransferLimit) {
// Split and retry each half. A one-ID truncated response fails.
}
}
// Reject disappearing/unexpected IDs rather than accepting partial data.Recommended implementation. collectFeatures() enumerates IDs, checks a total budget, batches at the smaller of the configured limit and 1000, recursively splits a truncated batch, and verifies that every requested ID returned. It rejects unexpected IDs and changed/incomplete data. The caller owns an AbortController and a five-second operation budget for the demonstration. Results are accepted only when complete.
The visible fixture starts with batches of 50 while enforcing 17 internally, so the truncation path actually executes. Its timing includes injected latency and is labeled accordingly. No external feature service is queried by that button. For a live service, bind the request adapter to a reviewed fixed origin, read its capabilities/limits, restrict output fields, and implement the existing platform's approved authentication flow. Do not accept arbitrary user URLs.
Alternatives, tradeoffs and choice. Offset pagination is straightforward when the service supports stable sorting, but can miss or duplicate rows during edits. ID enumeration gives an explicit retrieval set and clear completeness checks; a changing dataset may require a revision, snapshot or retry policy. Server aggregation avoids transferring every feature for large summary dashboards. The bounded ID strategy is concrete and testable for this demonstration.
Common pitfalls. A successful HTTP status does not mean all records arrived. Do not retry a rate-limited service indefinitely or in parallel without limits. Missing records should not be reported as zero business activity. Live adapters need server timeout/429 handling, bounded retry with jitter, cancellation, cached public responses where suitable, and an operations budget.
Verification. Run the pagination fixture and check that the retrieved count matches the enumerated count. The unit suite also covers cancellation, empty service, unexpected/malformed IDs, feature-budget exhaustion and a disappearing record. All fail or complete explicitly; none silently show partial data as complete.
Result before proceeding: the service boundary is honest about completeness and can stop safely under limits, cancellation or change.
7. Measure the code you actually built
Objective and prerequisites. Establish reproducible evidence without inventing throughput or scale claims. You need the local fixture, Node.js, the browser preview and the delivered test scripts.
node --test lab/territory/tests/spatial.test.mjs
node --check lab/territory/app.mjs
node lab/territory/benchmark.mjs
# Browser: test /lab/territory/ at desktop and narrow widths.
# Verify both the normal network and an unavailable ArcGIS CDN.Recommended implementation. The domain suite checks spatial invariants and service failure paths. The benchmark warms each case 30 times, then records 200 radius-filter samples for the shared fixture and a 1000-times repetition of it. It reports measured p50/p95 with runtime, platform, row count and timestamp as JSON. The measured run on October 25, 2026 at 01:28:58 UTC used Node.js v24.19.0 on Windows x64. For 72 rows it recorded p50 0.0085 ms and p95 0.0140 ms; for 72,000 repeated synthetic rows it recorded p50 3.0149 ms and p95 3.4410 ms. The raw benchmark JSON preserves the runtime and method. The UI independently reports the current operation with an engine-aware label. Local/scene/restored WGS84 values measure filtering before rendering; a rounded value can be 0.00 ms. ArcGIS flow measures elapsed time from the original query start, including the initial local filtering/rendering and the awaited SDK client-layer query. It is not isolated SDK latency, provider response time, or production throughput. Compare like operations and inspect the metric title for its scope. The eight passing domain/service tests and rendered live-map test record provide the associated evidence.
The larger case repeats the same synthetic coordinates. It tests a pure JavaScript predicate, not real geographic distribution, map render speed, network throughput, hosted-service capacity, concurrent users, database queries, memory pressure or production reliability. Timing can vary with hardware, background load and engine warmup. No fixed speed target is claimed.
Alternatives, tradeoffs and choice. A real-user monitoring service measures production behavior but needs consent, data minimization and deployed traffic. A controlled browser performance trace includes rendering but is more environment-sensitive. Pure-function timing isolates the algorithm and makes a modest, reproducible claim. Keep all three distinct when a production service exists.
Common pitfalls. Do not publish the best single run as typical latency. A warm local filter is not a cold CDN load. An accessibility scan alone does not verify keyboard flow or useful empty/error content. A passed fixture is not proof that a live service's auth, sharing, quota or hosting works.
Verification. Retain raw test output and benchmark JSON. Check the rendered desktop/narrow UI, labels, focus, comparisons, selection and restored preferences. Explicitly record whether the real ArcGIS map rendered, failed due to the environment, or was not tested. The integrated review requires at least five substantive builder/reviewer cycles, each with actionable critique, a concrete fix and rendered re-verification; list remaining defects.
Result before proceeding: every performance or capability statement can point to its method, observed result and limitation.
8. Release a bounded feature and keep a rollback
Objective and prerequisites. Prepare reviewed assets for the existing Zyel host. You need the approved release process, integration review, a known source commit and a rollback version. This tutorial does not grant deployment, new credentials, feature-service publishing or infrastructure changes.
└─ query / stable IDs ─┬─ optional ArcGIS client FeatureLayer
├─ escaped DataTables records
└─ comparisons + numeric equivalent
Browser-local preferences → validated route/table scopes
Future approved service → fixed-origin, bounded adapter → query contract
Recommended implementation. Serve this route as static assets beside Celebration using the existing Zyel deployment path. Ship a reviewed source commit and its exact generated evidence together. Keep heavy SDK code lazy. Preserve existing routes and owner edits; deploy only within the authorization for the release. Read back the deployed URL and assets before calling it live.
The synthetic data update path is a reviewed repository change: validate stable IDs/coordinates/types, run tests, compare totals and rebuild evidence. For a future real dataset, document owner, license, field-level sensitivity, update frequency, validation/quarantine, revision IDs, failure notifications and rollback before scheduling ingestion. A hosted feature service also requires approved account ownership, sharing, index/limit/caching choices, cost controls and monitoring. Those are architecture decisions, not features silently provisioned here.
Alternatives, tradeoffs and choice. A static fixture has no update job or database and is easy to roll back; data changes require releases. A live service separates data updates from UI releases but adds availability, access and cost duties. A server-generated snapshot can retain reproducibility and caching while reducing live client dependencies. The repository fixture is the smallest complete story for this Lab.
Common pitfalls. A prepared branch, passing tests, independent review and production deployment are different statuses. Do not imply a single prototype fulfills a procurement scope requiring multiple dashboards and hosting. The full ARC828 documents were not verified here; this demo makes no RFP compliance claim. Do not modify the concurrent CRM deployment to make this static route work.
Verification. Before publication, review changed paths and confirm no private config, customer records or secrets are present. Recheck Celebration and navigation. After an authorized release, confirm the live route, pinned SDK asset and source/evidence revision. Exercise the record workflow when the basemap is unavailable.
Recovery and maintenance. Roll back the reviewed Lab asset set to the previous source version using the existing host runbook. Do not clear all browser storage: migrate or remove only owned versioned keys. Recheck SDK/DataTables release notes and security guidance during a planned dependency update, retain the pin, and rerun domain and rendered tests. Revalidate basemap terms and fixture provenance when data sources change.
Result before proceeding: the release is reviewable, its update owner is clear, and rollback does not depend on rewriting unrelated site state.
Sources and dependency record
Official documentation reviewed October 25, 2026. ArcGIS CDN is pinned to 5.1.27. The tested shared table dependencies are DataTables 3.1.4 and ColReorder 3.1.2, self-hosted under lab/shared/vendor/, with a 30-day state retention policy. The shared table implementation and integrated release evidence record the contract and verification. spatial.mjs and service.mjs use platform JavaScript only. Browser rendering and live service access are separate checks in the evidence.
- Git: worktrees and explicit starting references; Git: file/object existence preflight.
- Esri: component transition plan and current release patch record.
- Esri: CDN/component setup and client-side FeatureLayer schema and queries.
- Esri: feature-service queries, output parameters and limits.
- DataTables: stateSave, stateDuration and custom state callbacks.
- Esri: client layer-view filtering and readiness; MDN: user-triggered clipboard writes, secure contexts and failure handling. Copy controls offer selectable code when access is denied; no clipboard read is requested.
- MDN: storage availability and exception handling.