Your Travel Map Breaks in the Edge Cases. Here's the Schema That Doesn't.

Your Travel Map Breaks in the Edge Cases. Here's the Schema That Doesn't.

April 11, 2026

TL;DR: Your "visited countries" map isn't tracking countries — it's filling polygons from a specific geometry dataset. If you don't model dataset version + feature IDs + rulesets (territories/disputes) + visit events (date ranges) as first-class concepts, you'll ship a map that randomly misses islands, double-counts trips, and changes after an update. The fix is a rendering-first schema: store travel as events against canonical "place entities," map those entities to versioned map features, and render a ruleset-specific GeoJSON view.

Here are the tickets you'll get if you don't:

  • "Why isn't Kosovo selectable?"
  • "I went to Greenland, why did Denmark light up?"
  • "Only part of the US filled in."
  • "My 2023 map changed after your update."
  • "I visited France, why is French Guiana filled?"

Those aren't UI bugs. They're ID + schema bugs.

Why Your Travel Map Breaks in the Edge Cases

Because maps render polygons (features), not "countries," and your app has to translate messy human travel history into a stable set of fillable shapes.

Your user thinks in places. Your map thinks in FeatureCollection entries with IDs that come from a dataset you didn't invent (Natural Earth, OSM-derived tiles, etc.).

If your model assumes names are keys, ISO codes cover everything, "country" is a universal concept, and geometry never changes, you'll build a map that works in demos and collapses in production.

The Core Rule for Bug-Free Travel Maps

Pick one geometry dataset, pin its version, and treat its feature IDs as a contract. Everything else hangs off that.

This is the rendering-first mindset:

  • Your UI fills map features
  • Your product logic tracks place entities
  • Your users generate visit events

So you store events once, then derive the "fills" for whichever map + ruleset you're rendering.

Store Stable IDs + Versioned Feature Mappings

Store stable IDs + versioned feature mappings, and keep GeoJSON as a dataset artifact — not as the thing your visits point to.

Concretely:

  • Store travel in terms of your canonical place_entity IDs
  • Store geometry in a versioned map_feature table (or external files referenced by it)
  • Store a join table place_feature_map that says: this place entity corresponds to these polygons in Natural Earth vX at resolution Y

If you make visit_event point directly at an ISO code or a polygon, you'll regret it the first time you change geometry source, update Natural Earth, add a disputed region policy, or split territories from sovereign states.

Natural Earth: Your Pragmatic Default

Natural Earth is the pragmatic default for web apps because it's consistent, lightweight, and redistributable.

Your decision isn't "Natural Earth or not." It's:

  • Which Natural Earth layer (Admin-0 countries vs something else)?
  • Which resolution (110m / 50m / 10m)?
  • How will you handle updates?

Use 50m Resolution for Most Interactive Maps

Use 50m for most interactive "visited" maps, because 110m will quietly erase or mangle small places.

  • 110m: fast, but microstates and small islands become "why is it missing?" tickets
  • 50m: solid default for most zoom levels
  • 10m: best detail, but heavier. Worth it if you allow meaningful zoom or care about island chains

The important part: whichever you choose, version it.

Why Names and ISO Codes Fail as Primary Keys

Names are display strings, not identifiers. They change, localize, and alias.

Even "ISO fixes it" is only partially true:

  • ISO 3166-1 covers sovereign states, but territories are messy (many are ISO 3166-1 or 3166-2; some expectations won't match ISO reality)
  • Disputed / partially recognized regions often don't fit cleanly
  • Your geometry dataset may carry multiple codes (iso_a2, adm0_a3, sov_a3) with dataset-specific exceptions

So you need:

  1. Your own canonical "place" ID
  2. A mapping to dataset-specific feature IDs
  3. A ruleset that decides what's fillable and how it's grouped

The Rendering-First Schema That Survives Reality

A minimal, rendering-first model has six core tables:

  1. map_dataset
  2. map_feature
  3. place_entity
  4. place_feature_map
  5. policy_ruleset (optional but you'll want it)
  6. visit_event

Here's how each solves a specific failure mode.

Model Versioned Map Datasets

A map dataset is a versioned contract that defines which features exist and how they're identified.

map_dataset
- id
- provider (e.g., natural_earth)
- product (e.g., admin_0_countries)
- resolution (e.g., 50m)
- version (pin it: date tag, NE release, or your own import hash)
- source_url
- imported_at

Why this matters: "we updated the map" should not rewrite history. If your dataset changes, you add a new map_dataset row.

Store Map Features Without Partial Fills

Store one row per renderable feature, not per polygon part, and keep a stable feature_key.

map_feature
- id
- map_dataset_id
- feature_key (the dataset's stable identifier)
- properties (snapshot of key attributes: codes, names, sovereignty fields)
- geometry (GeoJSON MultiPolygon) or geometry_ref (path to object storage)

Use a Stable Dataset Field as Your Feature Key

Use a field that's stable in the dataset you pinned, and don't pretend it's universal.

Common approaches:

  • feature_key = ne_admin0.adm0_a3 (common in NE workflows)
  • or feature_key = ne_id (if present and stable in your import)

What you do not do:

  • feature_key = name
  • feature_key = iso_a2 (tempting, but it will fail on edge cases and dataset quirks)

The win here is operational: your renderer can always say "fill feature X in dataset version Y."

Place Entities: Your Product's Canonical Places

A place entity is your product's canonical concept of a place a user can claim they visited — independent of how any map dataset draws or labels it.

You need it because:

  • "France" (place) might map to multiple features depending on rules
  • "Greenland" (place) might be treated as separate from Denmark
  • "Kosovo" (place) might be included in one ruleset and excluded in another
  • Geometry datasets change; the place entity should not
place_entity
- id (your stable UUID)
- canonical_name (e.g., "Greenland")
- place_type (e.g., sovereign_state, territory, disputed, region)
- parent_place_id (optional: e.g., Greenland → Denmark)
- valid_from, valid_to (optional: for historical entities)

ISO Codes Become Attributes, Not Keys

iso_a2, iso_a3, un_m49, etc. can live on place_entity or on a place_identifier table. But your primary reference for visits should be place_entity_id.

Connect Places to Polygons With a Join Table

place_feature_map
- place_entity_id
- map_dataset_id
- map_feature_id
- role (e.g., primary, alt, disputed_overlay)

This is where you fix multi-polygons and prevent the classic "Only Alaska lit up" bug. Your renderer should fill at the feature level, not at the "one polygon ring" level.

Model Visited Trips as Immutable Events

Store visit events as immutable facts, then derive stats.

visit_event
- id
- user_id
- place_entity_id
- start_date (nullable if unknown)
- end_date (nullable; enforce end_date >= start_date)
- visit_type (e.g., landed, stayed, transit, drove_through)
- source (optional: user_entered, imported)
- notes
- created_at, updated_at

Avoid Overlap Bugs With Raw Events

You don't try to keep a single "trip per country per year" row. You keep raw events, then compute timeline views with normalization:

  • Merge overlapping/adjacent ranges per (user_id, place_entity_id) when calculating "days visited"
  • Keep "ever visited" as a derived boolean: exists(visit_event where place_entity_id = X)

If you store pre-aggregated counters, you'll ship double-counted days, negative days after edits, and year filters that drift.

Handle Territories vs Sovereign States Explicitly

Make territories first-class place entities, then decide whether you roll them up in each ruleset.

This handles both expectations:

  • "I visited Greenland (not Denmark)"
  • "I visited Denmark (and I don't care about Greenland)"

The Data Model Pattern

  • place_entity: Greenland (place_type=territory, parent_place=Denmark)
  • place_entity: Denmark (place_type=sovereign_state)
  • Each maps to its own features in place_feature_map

The Product Policy Decision

Your UI can pick a ruleset:

  • Sovereign-only: territories don't count separately; optionally roll up under parent
  • Expanded: territories are selectable and count independently

Both use the same underlying events.

Handle Disputed Regions With Policy Rulesets

You can't "solve" geopolitics. You can model it explicitly.

The practical move is a policy ruleset that controls inclusion and grouping.

policy_ruleset
- id
- name (e.g., un_members, iso_3166, expanded_de_facto)
- description

ruleset_place
- policy_ruleset_id
- place_entity_id
- is_included (boolean)
- counts_as_place_entity_id (optional roll-up: e.g., Crimea counts as Ukraine)
- display_name_override (optional)

This lets you answer "Kosovo missing" with: "You're using the UN members ruleset. Switch to Expanded." Not a custom one-off hack in your renderer.

Handle Country Rebrands Without Rewriting History

Never let a map dataset update rewrite a user's past.

You do that with stable place_entity IDs that don't change when the name changes, plus optional validity windows and aliases.

place_alias
- place_entity_id
- alias (e.g., "Swaziland")
- locale (optional)
- valid_from, valid_to (optional)

For splits/merges (Sudan/South Sudan, Serbia/Montenegro), model historical entities as separate place_entity rows with validity windows, or treat only modern entities as selectable.

The key: don't key anything to today's display name.

Render Maps as a Deterministic Pipeline

Render is: (ruleset → places → features → GeoJSON).

Here's the practical flow:

  1. Select a ruleset (e.g., expanded_de_facto)
  2. Select a map dataset version (e.g., natural_earth/admin_0/50m@2025-01-15)
  3. Resolve visits → effective places (apply ruleset mapping)
  4. Resolve effective places → map features (join through place_feature_map)
  5. Emit a FeatureCollection with properties your front-end can fill on

You can precompute this as a cached view per (user_id, ruleset_id, map_dataset_id) if needed. The important part is that the renderer never guesses. It follows the contract you pinned.

Real Failure Modes This Schema Prevents

This prevents the ones that waste your time because they look like "random bugs" but are really "your model lied."

"Only part of the country lights up"

Cause: you mapped to a geometry part instead of the dataset feature.
Fix: fill by map_feature_id, and ensure the feature is MultiPolygon where appropriate.

"Territory filled the sovereign (or vice versa)"

Cause: you stored only ISO and assumed "France" implies all overseas departments.
Fix: separate place_entity for territories + explicit roll-up policies.

"Kosovo/Taiwan/Palestine missing"

Cause: your keyspace doesn't include them or you implicitly followed one political definition.
Fix: ruleset-driven inclusion.

"My past map changed after you updated the map"

Cause: geometry dataset updated and your IDs weren't pinned.
Fix: map_dataset.version + place_feature_map per dataset.

"2023 count is wrong"

Cause: overlap in date ranges; edits create duplicates.
Fix: event sourcing + range merge during calculation.

Export Events + Place IDs + Ruleset Context

Export events + place IDs + ruleset context, not just a painted map.

A clean export includes:

  • place_entity_id (your stable ID)
  • canonical_name
  • optional ISO fields
  • each visit_event with dates + type
  • the ruleset used for any aggregate stats

Why this matters: users don't want your screenshot. They want their history in a form that won't rot.

Edge Cases Kill Products, Not Big Features

If you're building a travel map tracker, you don't need a prettier SVG. You need a model that doesn't page you at 2am because someone visited Martinique and your map started a sovereignty debate.

This is what founders miss: the big roadmap items aren't what kill you. The edge cases do. They show up as "quick fixes," then become a permanent support category.

SmartLine Handles Your Phone's Edge Cases

SmartLine exists for the same reason: founders shouldn't personally handle every edge case in their day.

Our AI assistant screens calls before they reach you, extracts the who/why/urgency, and gives you a clean summary so you decide what deserves your attention. Just like this schema "screens" territories, disputes, dataset changes, and overlapping trips upfront — so they don't interrupt your roadmap later.

If your phone is still your last unmodeled edge case, SmartLine's AI-powered phone assistant handles inbound call screening, provides call summaries and transcripts, and sends push notifications for urgent items that need your attention.

Implementation Priority: The Quickest "No Bugs" Win

Do these three things in order:

  1. Pick Natural Earth Admin-0 (50m) and pin a dataset version
  2. Implement place_entity + visit_event (events against place IDs, not names)
  3. Implement place_feature_map and make rendering strictly dataset-versioned

That's the spine. Everything else is policy and UI.