2026-11-11

From 14,724 Points to 300 per Part

A search for Tokyo in the Tasmap editor returns an administrative boundary with 14,724 vertices across 19 polygon parts. Placed on an empty map, that geometry adds 972.7 KB to the Firestore document, 94.99 percent of the 1 MiB limit for a single document. Placement took 443.4 ms, slow enough to notice, short enough to accept.

Static browsing was healthy. With nothing selected, MapLibre held 16.7 ms per frame while panning and zooming, even at a synthetic 20,000 vertices. Selecting the boundary and dragging it whole produced 3.95 s per frame in the browser test. Dragging a single vertex had a median of 1.57 s. At that speed the shape trails the pointer by seconds.

The profiler put the cost inside the editor's own geometry validation. The renderer was drawing whatever it was handed, and drawing it quickly.

How the measurements were taken

The numbers below come from a measurement lab built beside the product code, so they describe the shipped modules. The lab captured 19 real LocationIQ fixtures: small parks, lake boundaries with holes, long rivers, and administrative areas made of many island parts.

Node 24 microbenchmarks measured normalization, placement into the store, the topology check isValidArea, translateArea, and moveAreaHandle on rings from 50 to 25,000 vertices. Browser tests mounted the real map components in headless Chromium at 1280 by 720 with SwiftShader, then recorded frame intervals and action time while panning, zooming, dragging an area, and dragging one vertex.

Three limits matter when reading the tables. The fixture set was captured once, so it does not represent every place a user might search. The browser used software rasterization, so frame times differ from a real GPU. No production network latency and no Firestore write latency were measured. The shape of the curves and the relative sizes are the parts worth trusting.

The quadratic cost sat in validation

The first curve was the topology check. isValidArea compares every edge against every other edge of a ring, and the cost follows that comparison.

VerticesisValidArea median
1,00036.7 ms
2,000139.8 ms
5,000935.1 ms
10,0003.54 s
25,00022.33 s

At 1,000 vertices the check already missed a 16.7 ms frame. Doubling to 2,000 nearly quadrupled the cost. By 10,000 vertices a single check took seconds, and at 25,000 it took 22.33 s.

That check ran on every drag frame. translateArea at 25,000 vertices took 25.73 s, with 86.8 percent of the time inside isValidArea. moveAreaHandle took 23.82 s. The rest of the frame rebuilt each TasmapPoint, wrote the area back into the MobX store, and asked again whether the moved shape was still a simple polygon. For a boundary of Tokyo's size, one drag frame cost more than the placement that created it.

Part count was a separate problem

Penghu has fewer vertices than Tokyo and took three times as long to appear. 13,551 vertices across 119 parts reached Source to idle in 14.95 s. Tokyo, with 14,724 vertices across 19 parts, took 4.53 s. A synthetic multipolygon with 200 parts of 20 points each took 6.38 s.

Each part of a multipolygon becomes an independent TasmapArea. Penghu arrived as 119 areas, with 119 sets of sources and layers. Part count drove the load time, and the smallest parts contributed almost nothing a reader could see.

A fixed threshold could not serve both cases

The obvious answer was to ask LocationIQ for coarser geometry through polygon_threshold. The measurements closed that route. At 0.001, Okinawa dropped from 1,555 vertices to 555, a 64.3 percent reduction. The same setting took Da-an Forest Park from 263 vertices to 4, a 98.5 percent reduction.

A four-point park is a quadrilateral that no longer describes the park. The threshold is expressed in degrees, so one value has to serve a 200 m park and a 200 km prefecture. The smallest tested threshold, 0.00001, left Okinawa almost unchanged at 0.3 percent and still cut Da-an Forest Park from 263 vertices to 96. The value that thins a prefecture erases the park.

Why the tolerance became a vertex budget

The shipped simplification counts vertices and stops at 300. Every line, and every individual polygon of an area, is capped at that number. Geometry already at or below the cap passes through unchanged, so a small park keeps its original outline and a short road keeps every bend. Above the cap, the geometry is reduced by Visvalingam-Whyatt on the Mercator plane after longitude unwrap. Mercator keeps local angles and distances comparable in both axes at a given latitude, and the unwrap keeps a shape that crosses the antimeridian from stretching across the world.

The reduction only removes vertices. It never moves an existing coordinate and never inserts a new one, so every output point exists in the source data. For one area polygon, the outer ring and all of its holes share the single 300 point budget. Separate polygons keep separate budgets, because each one becomes its own area and each one has to stay editable on its own.

Two filters run before simplification. A part smaller than 0.1 percent of the largest part is dropped as map noise. A hole smaller than 0.02 percent of its outer ring is dropped as a pinhole in the source data. The part filter reduced Penghu from 119 parts to 24 and Tokyo from 19 to 15. The largest part always survives.

The guard that keeps the result editable

A reducer that only optimizes triangle area can fold a coastline through itself or push an outer ring into a hole. That failure is worse than slowness. A self-intersecting ring fails isValidArea, and updateAreaGeometry rejects the drag, so the area renders but cannot be moved.

A vertex is therefore removed only when three conditions hold. The new chord between its neighbours must not cross another edge of the polygon. The triangle being removed must not contain another live vertex. The ring's signed area must neither collapse to zero nor change sign. When any condition fails, the vertex stays.

Scanning the polygon for every candidate would reintroduce the quadratic cost. A uniform grid over the polygon in the Mercator plane indexes live edges by the cells their bounding boxes touch, and live vertices by the single cell each one occupies. A candidate queries only the cells around its chord and its triangle. Stale edge references are rejected by checking the live links, and the grid is rebuilt every 512 removals so a cell cannot fill with dead entries.

The finished polygon is validated once more. A hole that fails is dropped. A polygon that fails falls back to its original rings, and a part that cannot be made valid is removed. Whatever reaches the store passes isValidArea, so it renders and can be selected, dragged, and edited.

Validation now follows the gesture

The second fix was in the editor. A translation is rigid: every vertex moves by the same offset, so the rings keep their relative arrangement. translateArea now runs an O(N) check. Coordinates must be finite, latitude must stay inside the projection limit, and every ring must keep a non-zero area.

A single-point edit changes only the two edges attached to that vertex, so moveAreaHandle checks those two edges against every other edge of the ring and of the holes, O(N) intersection tests. Holes still get the containment checks a single-vertex move can affect.

The full O(N^2) check still runs on commit, on import, and when a hand-drawn polygon is finished. Translation and single-point drags no longer pay for it on every frame. A multi-vertex drag that moves hidden vertices still uses the full check on each frame, with damping. Rotating an area, resizing a rectangle or ellipse, and adjusting a circle still use the full check, because those gestures move many vertices in ways a two-edge test cannot describe.

The honest result at 25,000 vertices: the full check still takes 10.27 s. translateArea fell from 25.73 s to 958.9 ms, and moveAreaHandle fell from 23.82 s to 868.9 ms. What remains is rebuilding tens of thousands of TasmapPoint objects and updating MobX. In the drag path, geometry validation is no longer the cost, and a real boundary no longer reaches that size.

What the real samples look like now

SampleVertices beforeVertices afterParts beforeParts afterFirestore beforeFirestore afterShare beforeShare after
Tokyo14,7241,9811915972.7 KB135.9 KB94.99%13.28%
Penghu13,5514,24111924935.3 KB288.2 KB91.34%28.14%
Lake Biwa12,99130011860.6 KB20.2 KB84.05%1.98%
Sun Moon Lake, 1 hole8323001154.6 KB20.2 KB5.33%1.97%
Okinawa1,5551,16266104.1 KB78.9 KB10.17%7.70%
Da-an Forest Park2632631117.5 KB17.5 KB1.71%1.71%
SamplePlacement beforePlacement afterSource to idle beforeSource to idle afterVertex drag beforeVertex drag after
Tokyo443.4 ms55.3 ms4.53 s634.3 ms1.57 s83.4 ms
Penghu449.9 ms129.0 ms14.95 s1.82 s16.7 ms16.7 ms

Da-an Forest Park stays at 263 vertices and 17.5 KB, untouched by the cap. Lake Biwa lands on 300 vertices and keeps 3 of its original 23 holes. Sun Moon Lake lands on 300 vertices with its single hole. Both are about 20 KB.

Penghu's vertex drag did not change, because dragging was never its problem. Its load time was, and the part filter took it from 14.95 s to 1.82 s. Lake Biwa places in 11.6 ms and Sun Moon Lake in 8.48 ms. Okinawa places in 34.4 ms.

What the cap does not fix

The 300 point cap buys headroom. It does not remove the 1 MiB ceiling on a single Firestore document. A map with a dozen large boundaries, a long article, and imported areas can still accumulate. The cap changes how quickly a map gets into trouble.

Control points have a limit of their own. A synthetic area with 20,000 vertices still takes 3.12 s per vertex drag after the local validation change, because the browser has to render and update that many control points through React and MapLibre. Real large boundaries no longer reach that number per polygon, since each polygon stops at 300 points. Imported geometry does not pass through the search normalization, so it keeps its source detail up to the import limits, and a very dense import can still produce a slow drag.

This is why a searched administrative boundary in Tasmap can be selected and dragged after it lands. The geometry arrives reduced, valid, and split into parts a person can work with, and the editor checks only what a given gesture can break.

For the general shape of the problem, polygon simplification explained covers what can be removed and what has to stay. For the ring rules, hollow polygons in map design covers containment in more detail.

Tasmap is at tasmap.app.

← back to blog