A small web app for viewing and editing TransTech PQI 380 asphalt density
(.pqidat) files, with measurement points plotted on a Norwegian Kartverket
map and automatic matching against the NVDB national road database (Statens
vegvesen).
Runs as either:
- a single-machine Docker Compose stack (your laptop, a VM, a NAS), or
- an Azure Container App with a managed Postgres backend (one-shot Cloud Shell deploy).
Ships with a mobile companion app on a second port for the operator's phone with three field tools — Capture (tie a measurement point to an NVDB road reference), Estimate (road-length + tonnage between two points), and AreaWalker (walk-the-perimeter area measurement with GPS corner-median averaging). See the Mobile companion section.
The bundled samples/demo-e6-svinesund.pqidat loaded into the app:
Kartverket topographic basemap on the left with recorded GPS (green/
orange/red circles, coloured by compaction) and NVDB road markers
(purple diamonds) joined by dashed pair-lines; editable per-row table
with the "Road match" column and snap buttons on the right.
Built so a field engineer can:
- upload a
.pqidatstraight off the device, - see every measurement on a real map,
- spot rows whose recorded GPS doesn't match the road marker the operator typed in,
- snap the GPS to the road marker with one click,
- fix typos in any cell, and
- export a
.pqidatthe device will accept back.
- Drop a
.pqidaton the upload area; it's parsed, stored in Postgres, and appears in the file list. Files persist across container restarts.
A custom control panel (top-right of the map) gives three base maps and two visibility toggles:
- Topographic (Kartverket WMTS, the default) for road context.
- Satellite (Esri World Imagery) for visual ground truth.
- Hybrid — satellite with the Kartverket topo layered on top at 55 % opacity, so road lines, tunnel dashes, contours and place names all stay readable over the imagery.
- Toggles for ● Recorded GPS and ◆ Road marker to show/hide the two marker types independently. The dashed pair-lines hide automatically when either endpoint is off.
- Green/orange/red circles = recorded GPS, coloured by the
Kompakteringcolumn (≥ 96 % / 92–96 % / < 92 %). - Purple diamonds = the position derived from the row's road marker (NVDB), with a dashed line joining each pair so a bad match is obvious at a glance.
- Click any blank spot on the map → the app does a reverse NVDB lookup for that coordinate and shows the road reference. The found point can be assigned to any row as its GPS (useful for rows the device recorded without a GPS fix).
- Click any cell to edit. Enter saves, Escape cancels. Changes round-trip
back to the
.pqidatexport. - A "Road match" column shows:
- the assembled NVDB reference (e.g.
9999 KV1234 S1D1 m100), - distance from the recorded GPS to that reference, colour-coded,
- a per-row Snap button.
- the assembled NVDB reference (e.g.
The app builds a vegsystemreferanse from the operator's columns and looks it
up against nvdbapiles.atlas.vegvesen.no. Sources used:
Sted på veienfor the road and (if embedded) the section/delstrekning, e.g.9999KV1234KS1D1.Beskrivelse2for the meter (M100,M100 F1, etc.).
Beskrivelse1 is intentionally ignored — in real PQI files it commonly
holds device-internal identifiers in a <digits>\<digits> shape that crash
NVDB with a 400.
Real operator data is messy, so a few fallbacks fire automatically:
- Missing section — if
Sted på veiendoesn't include a section, the app defaults toS1D1and shows a(section S1D1)hint on the row. - Missing kommune — operators sometimes type
KV1234S2D1instead of9999KV1234S2D1. The app scans the whole file for rows that DO have a kommune number, takes the most common one, and injects it into rows that don't. Affected rows are tagged(kommune inferred). - Combined ref in a comment cell — if
Sted på veienandBeskrivelse2don't yield anything, the app scans every other cell of the row for a fully-glued pattern like9999KV1234S1D1M200and uses that. - Manual override — if all of the above fail, the row exposes a text input where you can type the reference yourself and snap.
- Snap one row (per-row button) — overwrites that row's
GPScell with the NVDB position. If the row had no GPS, this fills it in. - Snap all — snaps every matchable row regardless of distance.
After every snap the table refreshes, a banner says exactly what happened, and the new road-match distance is recomputed.
The export endpoint serialises the in-database measurements back to PQI's exact wire format: UTF-8, semicolon-delimited, CRLF line endings, trailing newline. Round-trip with the device's own files is byte-for-byte identical.
/api/versionreturns the running build; the topbar shows it so you always know what's deployed.- Every request is logged to stdout with method, path, status and duration — helpful when diagnosing snap behaviour.
docker compose up --build -dThis starts two containers: pqi-app on http://localhost:8080 and
postgres:16-alpine as the database. Postgres data lives in the named
volume pqi-pgdata and survives container rebuilds. The app waits on a
pg_isready healthcheck before booting, so the first up is clean.
Stop (keep data):
docker compose downStop and wipe data:
docker compose down -vdocker build -t pqi-app .
docker run -d --name pqi-app -p 8080:8080 \
-e DATABASE_URL='postgres://user:pw@host:5432/pqi' \
pqi-appYou need Postgres running somewhere — the easiest path is docker compose up postgres -d then talk to it on localhost:5432.
Backend:
cd server
npm install
DATABASE_URL='postgres://pqi:pqi@localhost:5432/pqi' npm run dev
# API on http://localhost:8080Frontend (separate terminal):
cd client
npm install
npm run dev # http://localhost:5173 (proxies /api → :8080)A phone-friendly bundle lives on the same container image on port 8081 (Azure publishes it as a separate Container App with its own HTTPS URL — required so browser geolocation works without extra setup). The home screen has three tools:
The primary field flow — tie a physical measurement point to a
vegsystemreferanse.
- Phone opens the mobile URL, picks or creates a project.
- Kartverket topo map centres on the browser's geolocation; a live blue dot + accuracy ring update as the operator walks. The capture pin is draggable independently, and a ⌖ My location button snaps it back to the live position.
- As the operator moves, "Your road" shows the road they're currently on (debounced reverse-NVDB, 5 m / 1 s throttle). After each pin drag, "Marker road" shows the road the pin will save against.
- Tap Save capture. The server reverse-looks-up the lat/lon
against NVDB and mints the next per-project 2-digit code (
00,01, …). A modal shows the code in big text. - Operator types the code into the PQI device's Beskrivelse1 cell, then takes the asphalt density measurement.
- Back at base: upload the
.pqidatinto the same project from the desktop UI. The server joins each row to its capture by the Beskrivelse1 code and pre-fillsSted på veien,Beskrivelse2andGPSfrom the capture's resolved NVDB position. Rows whose code doesn't match a capture are imported unchanged.
Measure a stretch of road and get a tonnage estimate for a given thickness and material density.
- Tap two points on the map (or use "start here" / "end here" to pull in your live position). The client computes straight-line distance via haversine.
- If both endpoints land on the same NVDB section (
kategori,nummer,strekning,delstrekning), the server also returns a road-follow length using the meter delta fromvegsystemreferanse— shown in green next to the straight-line number so you can see how much the road bends between the two points. Multi-segment routing (crossing section boundaries) isn't implemented yet; those cases fall back to straight-line for the tonnage math. - Adjust width, thickness, and density. Result panel shows the road length, planar area, volume, and tonnage.
- 💾 Save to project stores the estimate against a project so it shows up on the desktop's Projects → detail page.
Measure the area of an irregular patch by physically walking its outline.
- Corner mode (default). Stand at each corner, tap 📍 Save corner. The phone collects up to 12 qualified GPS fixes over up to 15 s and stores the median as one point. Quality gates on every incoming fix: accuracy > 0, accuracy ≤ 10 m, timestamp < 5 s old. Repeat at each corner, tap Finish to close the polygon.
- Walk-and-log mode (opt-in checkbox). For very large or loose shapes: keep the phone in your hand and walk the perimeter; each qualified fix appended as a vertex.
- Live map shows the polygon so far. The result panel reports perimeter, area (m² and ha) and — in corner mode — the average per-corner spread, so you know how tight the number is.
- 💾 Save to project as with Estimate.
Once an operator saves an estimate or walked area to a project, the desktop Projects → detail page shows a "Saved measurements" section with type-appropriate summary columns. Each entry can be opened, renamed, or deleted from the desktop.
The browser Geolocation API can't request iOS Core Location's tightest
tiers (e.g. kCLLocationAccuracyBestForNavigation) — that requires a
native shell. enableHighAccuracy: true is the ceiling for a PWA.
Typical iOS Safari accuracy on a modern iPhone is 5–15 m; corner-median
gets that down to roughly 2–5 m per corner on a quiet fix. For
survey-grade work, use an external RTK GNSS receiver (Emlid Reach, Bad
Elf) with a native app — outside the scope of this project.
Desktop and mobile bundles share the same Postgres, the same NVDB client, and the same projects/captures/measurements REST API — they just present two different UIs from the same Node process.
One-shot deploy from Azure Cloud Shell (portal.azure.com → Cloud Shell). No local Docker, az CLI, or Bicep tooling needed:
curl -fsSL https://raw.githubusercontent.com/Transpolar/pqi-viewer/main/infra/deploy.sh | bashWhat it provisions in rg-pqiviewer (defaults to Norway East):
- Azure Container Registry (Basic SKU)
- Azure Database for PostgreSQL Flexible Server (
Standard_B1ms, 32 GiB, PG 16) - Log Analytics workspace
- Container Apps Environment + Container App (public HTTPS,
min=0 max=2replicas, scales to zero when idle)
The script runs in two Bicep passes around an az acr build so the image
exists before the Container App tries to pull it. Idempotent — rerun it
after pushing a code change to redeploy.
Cost: ≈ $13/mo Postgres (B1ms, can't go lower) + ≈ $5/mo Basic ACR + ≈ free Container App = ~$18-20/mo idle. More while serving traffic. Tear down everything with:
az group delete --name rg-pqiviewer --yesAfter a deploy you'll see commands at the bottom of the output for tailing
container logs, connecting to Postgres via psql, and rebuilding the image
without re-running the full Bicep.
See infra/main.bicep and infra/deploy.sh for the full template.
samples/demo-e6-svinesund.pqidat is a fully fabricated 10-row file
along Europavei 6 north of the Svinesund border crossing. Every row is
300 m apart along the road; each row's GPS is intentionally offset
10–40 m from its NVDB road marker so loading it into the app
immediately demonstrates the per-row Snap, Snap all, and the
green/orange/red compaction colouring. Project, operator and contact
fields are placeholders (DEMO_PROJECT, NN, etc.) — no real operator
data.
- Open the app, drag a
.pqidatonto the upload box (trysamples/demo-e6-svinesund.pqidatfor a guided tour). - Click the new file in the library to open the detail view.
- Look at the map. Any row whose purple diamond is far from its green circle is a candidate for snapping.
- Skim the "Road match" column for orange/red distances or "auto failed" rows.
- Use per-row Snap buttons to fix individual rows, or Snap all to snap every matchable row in one go.
- For rows that don't auto-resolve, either:
- type the correct reference (e.g.
9999 KV1234 S1D1 m100) into the row's manual-ref input and press Snap, or - click the right spot on the map → use "assign to row" from the popup.
- type the correct reference (e.g.
- Edit any other cells you need to fix by clicking them.
- Click Export .pqidat to download an edited copy. Load it back on the PQI 380.
The PQI 380 writes UTF-8 with CRLF endings:
1.0.1157;PQI3800XXXXXXXX
Prosjekt;Sted;Sted;Kontakt;Asfalttype;Steinstørrelse;Dybde;…;GPS;GPS Tid;Dato/Tid;Hulrom
PROJECT_NAME;PLACE_NAME;9999 KV1234;NN;AB11 0000;11mm - 15mm;50;…;00 00.00000 N 0 00.00000 E;…;0.0000000
…
(Example values are fully anonymised — real .pqidat files contain
project, operator and road data that is private to the operator.)
- Line 1 is
version;serial. - Line 2 is the (Norwegian) column header row. The device emits duplicate
headers (
Stedtwice,Operatørtwice); the parser stores cells by position rather than header name so no data is lost. - GPS is
DD MM.MMMMM N DD MM.MMMMM E(degrees + decimal minutes).
- Backend: Node.js 20, Express,
pg,multer. - Frontend: React 18, Vite, Leaflet, react-leaflet.
- Maps: Kartverket WMTS (topo) + Esri World Imagery (satellite) + CartoDB Voyager labels (hybrid overlay).
- Road database: NVDB API LES v4
(
X-Client: pqi-viewer). - DB: Postgres 16. Local dev runs
postgres:16-alpinein docker-compose; cloud deploys use Azure Database for PostgreSQL Flexible Server (B1ms). - Mobile companion: standalone Vite+React bundle in
mobile/client/, served from the same Node process on port 8081, shares the Postgres- NVDB client with the desktop.
- Infra (cloud): Azure Container Apps + ACR + Postgres Flexible
Server, defined in Bicep (
infra/main.bicep). Desktop and mobile get their own Container Apps on the same env so each gets its own HTTPS URL.
.
├── Dockerfile # multi-stage: client build → server runtime
├── docker-compose.yml # app + postgres for local self-hosting
├── infra/
│ ├── main.bicep # Azure resources: ACR, Postgres, Container Apps
│ └── deploy.sh # one-shot Cloud Shell deploy script
├── docs/
│ ├── demo-e6-svinesund.png # README screenshot (desktop)
│ └── mobile-capture.png # README screenshot (mobile)
├── samples/
│ └── demo-e6-svinesund.pqidat # fabricated demo file (safe to share)
├── mobile/
│ └── client/ # phone-side Vite+React field tools
│ ├── index.html
│ ├── vite.config.js
│ └── src/
│ ├── main.jsx
│ ├── App.jsx # hash router (Home / Capture / Estimate / Area)
│ ├── Home.jsx # 3-tile menu
│ ├── ProjectPicker.jsx
│ ├── CaptureScreen.jsx
│ ├── EstimateScreen.jsx # road-length + tonnage estimator
│ ├── AreaWalker.jsx # corner-median / walk-and-log area measurement
│ ├── SaveToProjectModal.jsx
│ ├── geo.js # haversine, polygon area, formatting helpers
│ ├── api.js
│ └── styles.css
├── server/
│ ├── index.js # Express + REST API + request logger
│ ├── db.js # Postgres schema + async helpers
│ ├── pqi.js # PQI parser + serializer (byte-perfect round-trip)
│ ├── roadref.js # NVDB reference assembly + kommune inference
│ ├── nvdb.js # NVDB API client + haversine distance
│ └── package.json
└── client/
├── index.html
├── vite.config.js
└── src/
├── main.jsx
├── App.jsx # hash router + build version
├── Library.jsx # file list + upload
├── FileDetail.jsx # map + table layout, snap toasts
├── MapView.jsx # Leaflet, layer switcher, click probe
├── DataTable.jsx # editable cells, road-match column
├── api.js
└── styles.css
| Method | Path | Purpose |
|---|---|---|
| GET | /api/version |
Build version string |
| GET | /api/health |
Health probe |
| GET | /api/files |
List uploaded files |
| GET | /api/files/:id |
File + all measurements (with assembled road ref) |
| POST | /api/files |
Upload (multipart file) |
| DELETE | /api/files/:id |
Delete a file |
| PATCH | /api/measurements/:id |
Edit one cell {columnIndex, value} |
| POST | /api/measurements/:id/snap |
Snap one row to its road marker. Optional body {ref} to override the auto-detected reference. |
| POST | /api/measurements/:id/set-gps |
Assign explicit {lat, lon} to one row. |
| GET | /api/files/:id/road-positions |
NVDB lookup for every row (no mutation). |
| POST | /api/files/:id/snap-all |
Batch snap; body {onlyIfDistanceOver?: number} to skip already-close rows. |
| GET | /api/road-position?lat=&lon= |
Reverse NVDB lookup for an arbitrary map click (shared by desktop + mobile). |
| POST | /api/road-distance |
Body {a:{lat,lon}, b:{lat,lon}}. Returns straight-line metres plus road_m from NVDB when both endpoints share the same section. |
| GET | /api/files/:id/export |
Download the edited .pqidat. |
| GET | /api/projects |
List projects. |
| POST | /api/projects |
Create a project. |
| GET | /api/projects/:id |
Project + captures + saved measurements + uploaded files. |
| DELETE | /api/projects/:id |
Delete a project (cascades to captures + saved measurements). |
| POST | /api/projects/:id/files |
Upload a .pqidat into a project (merges rows against captures by Beskrivelse1 code). |
| GET | /api/projects/:id/captures |
Captures for one project. |
| POST | /api/projects/:id/captures |
Create a capture (mints the next 2-digit code). |
| DELETE | /api/captures/:id |
Delete one capture. |
| POST | /api/projects/:id/measurements |
Save an estimate or walked area (body {type, name, data}). |
| DELETE | /api/measurements/saved/:id |
Delete one saved measurement. |
.pqidatfiles often contain project, operator and GPS information that the company considers sensitive. They live only in the Postgres database inside your deployment — the app does not send file contents anywhere except to NVDB (which only ever sees an assembled road reference string, never the file)..pqidatis in.gitignore. Don't commit operator data. The only exception, allowed via!samples/*.pqidat, is the fabricated demo file undersamples/.
- Kartverket tiles are free for general use; attribution is built into the map. Review Kartverket's terms before deploying publicly.
- Esri World Imagery is used under Esri's standard map attribution. For heavy commercial use, consider a paid satellite provider.
- CartoDB Voyager labels: attribution included on the map.

