02
ALL WORK
02

DATA VISUALIZATION · 2025

ICOC Admin Map Dashboard

Solo freelance delivery of a Leaflet-based choropleth dashboard for an internal lab network — from requirements gathering through geographic data normalization, batched Firestore aggregation, and client handoff.

ReactJavaScriptLeafletreact-leafletFirebase / FirestoreTailwind CSS

1

PERSON TEAM

Leaflet

MAPPING ENGINE

6

GEOJSON SOURCES MERGED

2025

DELIVERED

MY CONTRIBUTION

Requirements & Scoping

Led

UI / UX Design

Led

Frontend Development

Led

Backend / Firestore Data Layer

Led

Client Communication

Led

Led
Collaborated
Supported

BEFORE / AFTER

BEFORE

No existing tool. Administrative staff tracked lab locations, chain-of-custody (COC) records, and sample counts by working directly in spreadsheets — no spatial view, no drill-down from country to state, no way to see where COC volume was concentrated.

AFTER

Interactive Leaflet choropleth with country- and state/province-level drill-down, lab/COC/user directories filterable by region and status, and a classed density scale tuned separately for active vs. inactive labs. Delivered solo end-to-end; client onboarded independently after a single handoff session.

01

Background

ICOC needed an internal tool for seeing where its lab network was concentrated and managing the records tied to each location — chain-of-custody paperwork (COCs) linking samples to labs, and the admin users generating them. None of that existed as a system before this: staff answered questions like "how many active labs do we have in Texas" or "where is COC volume concentrated this month" by filtering spreadsheets by hand. There was no spatial view and no drill-down path from a world view down to a single lab. This was a greenfield build with nothing to inherit, and I was the only person on it — brought in to gather requirements directly from the client and then own everything through delivery.

Country-level view: North America choropleth with the US and Alaska highlighted, COCs Overview panel showing 1,407 total COCs, 1 active user, 22 active labs, and a Top COCs table ranked by analysis category
Country-level view — the top-level rollup staff previously had no way to see
02

My Role

I handled every part of this: the initial requirements conversations, information architecture, UI design, the Firestore data layer, frontend development, testing, and the handoff documentation the client used to onboard independently afterward. There was no backend engineer to hand data-shape questions to and no designer to review a mockup against — every decision, including less visible ones like how to reconcile inconsistent geographic name data, was mine to make and live with.

Users Overview admin panel: 31 total users, date filter (All Time / Today / This Week / This Month / This Year), sortable user list with registration timestamps, North America choropleth in the background
One of several directory views built alongside the map — labs, COCs, and users all share the same data layer
KEY DECISION

Leaflet + react-leaflet over Mapbox GL JS

DECISION

Chose Leaflet, via react-leaflet, rendering OpenStreetMap raster tiles — over Mapbox GL JS as the mapping engine.

REASONING

The dataset was a few hundred country and state/province polygons, not a large point cloud — nowhere near the scale where Mapbox's WebGL renderer earns its complexity budget. react-leaflet's <GeoJSON> component takes a plain style(feature) callback, which matched how the choropleth's color logic was already written as a function of feature.properties.labCount. Mapbox GL JS would have added an API key, a usage-billed account, and a style-spec learning curve to an internal tool with no ongoing platform budget and one maintainer after handoff. Leaflet was the boring, correct choice at this polygon count.

Full continental US and Canada choropleth map with California, Ohio, Pennsylvania, and Tennessee highlighted by lab density, no side panel open, Labs density legend and Quick Actions panel visible

FIG. 02 — Project overview

03

Classifying a Skewed Distribution

The map's shading comes down to one function: bucket a region's lab count into one of eight colors. That's trivial to say and easy to get wrong, because the actual distribution is heavily skewed — most countries have zero labs, a handful have a couple dozen, and one or two outliers have 200+. An equal-interval scale (eight buckets spanning 0–200 in steps of 25) would put nearly every region in the bottom bucket and tell you nothing. Instead I hand-tuned the break points to the real shape of the data: 0, 1–4, 5–10, 11–20, 21–50, 51–100, 101–200, 200+. Each step roughly doubles or more — the same instinct behind quantile or natural-breaks (Jenks) classification in GIS tooling, just tuned by eye against the real export instead of computed by an algorithm, since the dataset was small and stable enough that eyeballing it was faster and more legible than wiring up a classification library. The thresholds also aren't fixed. Active and inactive labs have very different volume profiles — inactive lab counts per region run much lower across the board — so reusing the active thresholds for the inactive view would collapse almost everything into the bottom bucket. The color function takes a labMode argument and swaps in a lower threshold set for inactive labs, so the same eight-color ramp stays legible no matter which subset of labs the admin is looking at.

Laboratory Directory panel listing all labs sorted by COC count — AEMTEK at 293 COCs, Default Lab at 278 — next to the eight-step Labs density legend (0, 1-4, 5-10, 11-20, 21-50, 51-100, 101-200, 200+) used to shade the map
The lab list, sorted by COC count, showing the skew the classification scale had to account for
04

Merging Six Boundary Sources

Country-level view is one world GeoJSON file. State/province-level view isn't — it's six separate boundary files stitched into one FeatureCollection at runtime: US states, Canadian provinces, and four separate UK constituent-country files, since England, Scotland, Wales, and Ireland each ship their own provinces geometry. Those fetch in parallel, get tagged with a sourceCountry property so the sidebar can show which country a given province belongs to when you drill in, and get concatenated into a single feature array that Leaflet renders as one layer. The harder problem sits underneath that: matching those polygons back to Firestore data. Lab records store labCountry and labState as free-text strings admin staff typed in over time — "USA," "US," and "United States" all mean the same country; state abbreviations, older postal abbreviations ("Calif," "Ariz," "N.C."), and full names all show up for the same place. GeoJSON boundary files use one canonical name per region. I built two lookup tables — one for countries, one covering all 50 states plus DC and the territories — that normalize every variant I found to the GeoJSON's canonical spelling before grouping labs by region. Because that mapping table is necessarily incomplete — new variants show up as staff enter new labs — I also wrote a small debug utility that diffs the set of region names coming out of Firestore against the set of names in the boundary files and logs anything left unmatched. It isn't client-facing; it's a tool I run against the data to catch a new "Massachusetts" vs. "Mass." mismatch before it silently shows up as a region with zero labs on the map.

Zoomed West Coast US view with California highlighted, COCs Overview panel drilled into SGS EHS North America (Hayward, CA) showing 49 total COCs grouped by category, including a Lead (Air, Bulk, Water) category with individual dated COC entries
State-level drill-down into a single lab — six boundary files merged into one layer, joined against normalized Firestore region names
05

Batched Firestore Aggregation

The map's numbers come out of a three-level join — Labs → COCs → Samples — done entirely client-side, since this stack has no server-side aggregation queries available. For each lab I need a COC count; for each COC I need a sample count; both have to be batched intelligently or the dashboard makes hundreds of round trips on load. Firestore's 'in' query operator caps out at 10 values per query. With lab and COC ID lists running well past that, I chunk each list into batches of 10, fire all the batches concurrently with Promise.all, and merge the per-batch results back into one lookup map keyed by ID. It's the same pattern in both the lab → COC count function and the COC → sample count function — a hard platform ceiling turned into a batched-parallel fetch instead of a serial loop. The COC dataset also gets a session-scoped cache, keyed with a version string, so that if I change the shape of what gets cached, old cached entries from a previous session are simply ignored instead of causing a shape mismatch downstream.

COCs Overview panel drilled into a single lab, AEMTEK in Fremont, CA — 293 total COCs, an Allergen Analysis category with 12 entries, and individual dated COC line items with client names
A single lab drilled all the way down to its individual COC records — the output of the batched Labs → COCs join
06

Interaction Design at the Layer Level

React re-rendering an entire GeoJSON layer on every mouse move would be slow and visually janky — Leaflet layers are imperative objects, not React state. So hover and click bypass React's render cycle entirely: each polygon gets native Leaflet mouseover/mouseout/click handlers bound directly to it, and each handler calls setStyle on that one layer. A ref-backed lookup of region name → layer keeps track of what's currently selected, so a click can restyle the previously-selected region back to its base color without touching every other polygon on the map. Hover has a short intentional delay before it updates the sidebar — none at country zoom, about 100ms at state zoom. State boundaries are denser and smaller on screen, so without the delay, moving the mouse across a cluster of small states fires a rapid sequence of sidebar updates that reads as flicker rather than information. The delay cancels if the mouse leaves the polygon before it fires, so a genuine hover still feels instant. Clicking a region also has to account for the sidebar drawer, which can cover up to a third of the screen. The map's fitBounds() padding is computed dynamically — wider on the left when the sidebar is open — so zooming into a selected region doesn't just zoom it behind the panel that's about to open to show its details.

07

Outcome

Delivered solo over about four months of part-time work — roughly 20 commits from the first Leaflet setup to the final client-feedback pass. The client was able to use the tool independently after a single handoff session, with no post-launch bugs escalated back to me.

08

Lessons Learned

Solo projects surface gaps in your own process fast. Without a reviewer, a data-modeling issue in how I was grouping labs by region slipped further than a second pair of eyes would have let it. I've since added a personal "adversarial review" checkpoint — a pass where I deliberately try to break my own assumptions about a schema or a name-normalization table before calling it done. Scope creep is also most dangerous when the client is friendly. The relationship here was easy enough that small additions — show inactive labs too, add COC history per lab — felt natural to say yes to mid-build. Both shipped fine, but a lightweight change-order habit earlier would have kept the line between v1 scope and "sure, let's add it" cleaner.