Skip to content

Repository files navigation

EV Charger Utilization Map

A single-page map for municipal EV charger utilization data. Upload a CSV or Excel export and it renders immediately — everything is parsed and drawn in the browser. There is no backend, no database, and no upload. The site is a static Next.js build, so Vercel serves it with no server-side state at all.

It is a web port of a Colab notebook, so the output should look familiar: CartoDB Positron basemap, viridis colour ramp, a coloured radius around each site, and a colour bar.

Running it

npm install
npm run dev        # http://localhost:3000
npm test           # parsing, interpolation, schedule and map-style checks
npm run build      # production build

Deploying to Vercel needs no configuration and no environment variables — import the repo and it builds. Every basemap is keyless.

Data format

The minimum is a site name, a latitude, a longitude, and a utilization rate:

site_name,address,lat,long,rate,period
Community Centre A,"100 First Ave, Example City, BC",49.24,-123.1,0.22,2026-07

Column names are matched case- and separator-insensitively, so site_name, Station, Site, and Location all resolve to the name column, and long, lng, longitude all resolve to longitude. A UTF-8 BOM (which Excel writes, and which the notebook had to work around with "site_name") is stripped automatically.

  • Rates may be fractions (0.22) or percentages (22, 22%). The scale is detected once across the whole column, so a file where every site is under 1% is not mistaken for fractions.
  • period is optional. With it, rows for the same site across several months collapse into a time series and a period selector appears. 2026-07, Jul 2026, and an Excel date cell all normalize to the same key.
  • Rows that cannot be used are never silently dropped — they are listed in the sidebar with the reason and the row number.

The app ships no data files, so a first visit is an empty map and an upload prompt. It does ship a site list — see below.

The site registry

A rate table carries its own lat/long, so it maps with no further setup. A charging-session export does not — it names sites and nothing else — so coordinates, port counts and opening hours have to come from somewhere.

That somewhere is the site registry in lib/registry.ts, which ships populated so the deployment works with no setup. Upload a session export and any site name it does not recognise appears in the sidebar with a short form: latitude, longitude, ports, schedule. Fill it in once and the site is remembered in localStorage for every later upload; the map redraws as each one is added. The registry panel lists what is loaded and resets to the built-in list.

Be deliberate about what goes in DEFAULT_REGISTRY. The repo is public and the build inlines the list into the client bundle, so every entry is published — put in only what is fine to publish. scripts/test-sessions.mjs carries its own fixture rather than leaning on the seed, so the tests do not constrain what the list holds.

The registry is configuration, not data. It is the only thing the app persists — session uploads live in memory for as long as the tab is open and are never written anywhere.

How it maps to the notebook

Notebook Here
ctx.add_basemap(CartoDB.Positron) lib/basemaps.ts — keyless raster tiles, four options
plt.colormaps["viridis"] + Normalize(vmin, vmax) lib/colors.ts — the ramp is a MapLibre expression, so colouring runs on the GPU
The radial-gradient loop, alpha = (1 - d/r)**1.5 lib/geo.ts + the halo layer — a circle layer whose radius is pinned to real metres and whose circle-blur produces the fade
scipy.interpolate.griddata, linear with a nearest-neighbour fill lib/surface.ts — inverse-distance weighting on a grid, rasterised to a canvas. IDW needs no triangulation and degrades to nearest outside the site hull, which is what the nearest-fill was doing
fig.colorbar components/Legend.tsx
The schedule cell (BC stat holidays → monthly open hours) lib/schedule.ts

Halo radius, edge softness, opacity, colour ramp, and the colour-scale range are all adjustable in the sidebar rather than being constants to edit.

Two corrections to the notebook's schedule cell

lib/schedule.ts computes the denominator of the utilization rate — the hours a site was actually open. Checking it against the Time Available columns in the source workbook surfaced two bugs in the notebook's site_schedules:

  • rec-centre-a's Saturday is 15 h, not 16.5 h. With 16.5 the month totals 484 h for October 2025; the workbook records 478 h.
  • village-library has no saturday key, so schedule.get("saturday", schedule.get("weekend", 0)) falls all the way through to 0. Saturday is 10 h, and the omission costs about 40 h a month.

With both corrected, the rule reproduces the workbook exactly for all four schedules in October 2025, and for 39 of the 59 site-months recorded. The BC statutory holidays are generated from their rules rather than hard-coded, and the generated 2026 set matches the notebook's hand-written list exactly.

The remaining 20 site-months are not rule errors — they are facility closures no weekday schedule can derive: December (Dec 24 and 26 closed or shortened, 18–43 h), the Victoria Day week in May (14–16.5 h), and a small reduced-hours day in July 2026 on rec-centre-c (35 h). Run npm run test:schedule to print the current list. Because of this, an uploaded hours-available column should always win over the computed estimate.

One artifact worth knowing about: the Oct/Nov 2026 differences are equal and opposite (−4/+4 h on rec-centre-a, −2/+2 on rec-centre-b, −1/+1 on village-library) and each pair sums to the rule's total, which looks like a row offset in the workbook rather than a real closure.

The schedule labels are generic, but the hour figures are the ones the rule has to reproduce — which is what makes the check worth running. Point each registry entry at whichever schedule matches that site.

MapLibre's worker and Turbopack

public/maplibre/ is generated by scripts/sync-maplibre-worker.mjs and also committed. MapLibre locates its own web worker by resolving a sibling module against import.meta.url; Turbopack (which Next 16 uses for both dev and build) rewrites that to a non-http value, so the lookup returns "" and MapLibre ends up calling new Worker(""). The worker object exists but never executes, so every message to it hangs forever.

The failure is quiet and easy to misread: background and raster layers paint normally, because they need no worker, while GeoJSON sources stay empty and no circle or symbol layer ever appears. The map looks like a blank basemap.

scripts/sync-maplibre-worker.mjs copies the standalone worker into public/ and components/MapView.tsx points setWorkerUrl at it, sidestepping bundler worker handling entirely. It runs from predev, prebuild, and postinstall, so the copy cannot drift from the installed maplibre-gl version — any drift shows up as a git diff.

The output is committed as well as generated. A host that caches node_modules may skip postinstall, and one that runs next build rather than npm run build skips prebuild too; with the files committed the worker ships either way. If it were ever missing in production the map would render a basemap and nothing else, which is a slow failure to diagnose.

Layout

app/            page shell and styling
components/     MapView (MapLibre), UploadPanel, Controls, Legend, SiteTable
lib/            parse, colors, geo, surface, basemaps, schedule, registry, features, types
scripts/        test and validation scripts, run by `npm test`
public/maplibre/ the standalone MapLibre worker (see above)

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages