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.
npm install
npm run dev # http://localhost:3000
npm test # parsing, interpolation, schedule and map-style checks
npm run build # production buildDeploying to Vercel needs no configuration and no environment variables — import the repo and it builds. Every basemap is keyless.
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-07Column 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. periodis 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.
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.
| 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.
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-libraryhas nosaturdaykey, soschedule.get("saturday", schedule.get("weekend", 0))falls all the way through to0. 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.
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.
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)