An interactive map of a live AIS snapshot of 4,993 vessels in and around Iranian waters: the Persian Gulf, Strait of Hormuz, Gulf of Oman, northern Arabian Sea and the Caspian Sea.
The repository contains the raw snapshot (JSON + CSV) and a single self-contained HTML page that renders, filters and audits it. There is no build step and no server-side code.
iran_ships.json 1.8 MB 4,993 vessel records (source of truth, read by map.html)
iran_ships.csv 0.4 MB the same records flattened to 21 columns
map.html 31 KB Leaflet viewer: filters, course vectors, coverage grid
map.html fetches iran_ships.json from the same folder, and browsers block fetch() on file:// URLs. Serve the folder instead:
py -m http.server 8000 # or: python3 -m http.server 8000
# or: npx serve .Then open http://localhost:8000/map.html.
If you do open the file directly, the page detects the failed fetch and falls back to a file picker. Choose iran_ships.json manually and everything works the same.
Leaflet 1.9.4 and Leaflet.markercluster 1.5.3 are loaded from unpkg, so the first load needs a network connection (as do the OpenStreetMap tiles).
Ship type. One colour per MarineTraffic SHIPTYPE, each with a checkbox and a live count. Tugs & special craft (1,463) and fishing vessels (1,070) dominate the snapshot; tankers (660) and cargo (706) are the commercially interesting ones.
Speed & motion. A dual-handle range slider over 0-55 knots, plus All / Stopped / Moving / Fast presets. Vessels reporting no speed at all (288 records) are toggled separately so they are never silently dropped. Enabling course vectors draws a schematic arrow per moving vessel, length scaled by speed and angle taken from COURSE (falling back to HEADING).
Destination. The reported destination field is messy, so entries are folded to a canonical key: AE FJR, AEFJR and AE-FJR all collapse into one bucket, and the dropdown shows the most common spelling with a [n spellings] marker. Known placeholders (CLASS B, 0, N/A, UNKNOWN, and so on) are treated as no destination. Optional on-map labels are capped at 350 in-view labels, and the panel tells you how many were suppressed.
Flag. 111 distinct flags, sorted by frequency. Iran (657), Saudi Arabia (543), Kuwait (466), Qatar (400) and the UAE (377) lead, followed by the usual open registries (Panama, Liberia, Marshall Islands).
Search. Free-text match across ship name, SHIP_ID and destination.
Coverage check. The panel's last section is about the data, not the ships. It draws the scrape bounding box and, optionally, the XYZ tile grid the snapshot was collected on, with a per-tile vessel count. Tiles are coloured grey when they returned nothing and red at or above 500 vessels, which is the point where a tile response is likely truncated by an upstream cap rather than genuinely empty of traffic. The hint line summarises tiles requested, empty tiles, saturated tiles and vessels that landed outside the tile range.
Clicking any marker opens a detail table (type, flag, position, speed, course, heading, destination, status, dimensions, DWT, report age) with a link through to the vessel's MarineTraffic page.
A single point-in-time snapshot collected on 2026-08-15. ELAPSED (minutes since each vessel's last AIS report) has a median of 16 minutes, so most positions were fresh at collection time; the tail runs to about 24 hours for sporadic reporters.
Extent of the records:
| min | max | |
|---|---|---|
| latitude | 21.90 | 40.67 |
| longitude | 46.93 | 65.43 |
The scrape bounding box declared in map.html is 23.0-40.5 N, 43.0-64.5 E. A handful of records fall outside it, because the tile that covers a corner of the box extends past its edge.
Every record carries LAT, LON, SPEED, COURSE, HEADING, ELAPSED, SHIPNAME, SHIPTYPE and SHIP_ID. The rest depend on what the upstream tile returned, so JSON objects have varying key sets; the CSV pads the missing ones with empty strings.
| Field | Present | Meaning |
|---|---|---|
LAT, LON |
4,993 | Position, decimal degrees, as strings |
SPEED |
4,705 | Tenths of a knot, so 524 is 52.4 kn. null where the tile carried no speed |
COURSE |
4,222 | Course over ground, degrees |
HEADING |
2,286 | Compass heading, degrees; often null for small craft |
ELAPSED |
4,993 | Minutes since the vessel's last AIS report |
SHIPNAME |
4,993 | Name, or an MMSI-like number for Class B transponders, or [SAT-AIS] |
SHIPTYPE |
4,993 | Coarse type code, 0-9 (see below) |
SHIP_ID |
4,993 | MarineTraffic vessel id; unique across all 4,993 records |
FLAG |
4,843 | ISO 3166-1 alpha-2 country code |
LENGTH, WIDTH |
4,842 | Overall dimensions in metres; 0 when unreported |
DESTINATION |
4,836 | Free text, frequently a placeholder |
L_FORE, W_LEFT |
4,835 | Antenna offset from bow / port side, metres |
GT_SHIPTYPE |
2,510 | Fine-grained type code |
DWT |
2,488 | Deadweight tonnage |
ROT |
1,517 | Rate of turn |
TYPE_IMG, TYPE_NAME, STATUS_NAME |
150 | Only on satellite-AIS records |
SHIPTYPE codes and their counts in this snapshot:
| Code | Type | Count |
|---|---|---|
| 3 | Tugs & Special Craft | 1,463 |
| 2 | Fishing | 1,070 |
| 7 | Cargo Vessel | 706 |
| 8 | Tanker | 660 |
| 0 | Unspecified | 507 |
| 9 | Pleasure Craft | 313 |
| 1 | Navigation Aids | 201 |
| 6 | Passenger Vessel | 63 |
| 4 | High Speed Craft | 10 |
| 5 | Military | 0 |
- Speed is in tenths of a knot. Forgetting to divide by 10 is the single most common mistake with this data.
map.htmldoes it inknots(). - 1,806 records report
CLASS Bas their destination. That is not a place; it is what the source puts in the field for Class B transponders. Another 746 report an empty destination. Treat both as unknown, which is what the viewer does. - 150 records are satellite-AIS (
SHIPNAMEis[SAT-AIS]). These have a base64 blob instead of a numericSHIP_ID, carryTYPE_NAME/STATUS_NAMEthat terrestrial records lack, and are missingFLAGand dimensions. They are the reason theFLAGcount stops at 4,843. - All values are strings, or
null. Nothing is numeric in the JSON. Cast before comparing. - Class B / small-craft names are often bare MMSI-like numbers (
447804000, sometimes with a trailing number). Don't assumeSHIPNAMEis human-readable. SHIP_IDis unique here, so it works as a primary key for this snapshot, but it is a MarineTraffic-internal id, not an IMO or MMSI number.- The CSV is UTF-8 with a BOM and lists the 21 columns alphabetically. Read it with
encoding='utf-8-sig'in Python, or the first column name comes back asCOURSE.
The snapshot was scraped tile-by-tile over an XYZ grid covering the bounding box, at MarineTraffic zoom 9 (XYZ zoom 8). map.html hard-codes the same constants at the top of its script block so the coverage overlay reproduces the exact grid that was requested:
var BBOX = { north: 40.5, south: 23.0, west: 43.0, east: 64.5 };
var XYZ_ZOOM = 8; // MARINE_Z (9) - 1
var TILE_SATURATION = 500; // per-tile count that hints at an API capThe scraper itself (update.py) is not part of this repository, only its output and the constants needed to interpret that output. If you regenerate the data with a different bounding box or zoom, update those three constants to match, or the coverage overlay will describe a grid that was never requested.
To refresh the map with your own snapshot, replace iran_ships.json with a JSON array of objects using the field names above. Records missing usable coordinates are filtered out at load and reported in the panel subtitle.
This is one snapshot of self-reported AIS data, not a surveillance product. AIS can be switched off, misconfigured or spoofed; coverage is uneven, and an empty tile in the coverage grid means nothing was returned, which is not the same as nothing was there. Vessel type, destination and dimensions are entered by crews and are frequently stale or wrong. Anything derived from this data deserves a second source.
GPL-3.0, see LICENSE. Ship data © MarineTraffic; base map tiles © OpenStreetMap contributors.