Aves dataset — refined tasks¶
Source idea (verbatim from the request):
we want to enrich our map with this imense dataset - but allow rich controls (e.g. filtering layers for different marker types) and ensure a proper performance.
What the dataset actually is¶
data/avesmaps.map-features.json is a single ~1.2M-line JSON document with four
top-level keys:
| Key | Lines | Contents |
|---|---|---|
features |
5–638598 | GeoJSON FeatureCollection array |
source_catalog |
638600–648303 | { [sourceId]: { url, label, type, official } } |
feature_sources |
648304–988815 | { "settlement:<uuid>" \| "citymap:<uuid>" \| "path:<uuid>": [{ source_id, reference_kind, pages, note }] } |
in_settlement_places |
988816–EOF | [{ name, settlement, type }] — places inside settlements, no geometry |
Facts verified by inspection, not assumed:
- Geometry is only
PointandLineString. NoPolygon,MultiPolygon,MultiLineString,MultiPointorGeometryCollectionoccurs. feature_typehas six values, not four as first assumed:location(2717),crossing(792),junction(1239),path(5720), plus two missed on first inspection —label(659, text labels for regions/mountains/ rivers/seas etc., geometryPoint, 24 subtypes such asgebirge,fluss,meer,wueste) andpowerline(163, magical ley lines between locations, geometryLineString, single subtypepowerline). Task 1's shards and manifest cover all six.feature_subtypeis the useful axis:metropole,grossstadt,stadt,kleinstadt,dorf,gebaeude,crossing,Pfad,Weg,Reichsstrasse,Seeweg,Flussweg,Gebirgspass, …layeris unreliable. It carries the German display groupings (Metropolen,Großstädte,Städte,Dörfer,Kreuzungen,Pfade,Meerwege,Flusswege,Wüstenpfade, …) but a large tail of features has nolayerkey at all — e.g. theGebirgspass/Karawanenrouteblock around line 600000. This is why the filter is keyed onfeature_subtype.- Property shape is not uniform. A
metropolecarrieswiki_settlement,coat,images[],political.hierarchy[],wiki_url,territory_*; a synthesized crossing carries eight scalars. Apathmay instead carrytransport_domainandallowed_transports[]. - Coordinates max out at ~1022 on both axes, and
public/tiles/avesmaps/0/is a 4×4 grid of 256px tiles = 1024×1024px atz = 2(=minZoom). The transform is therefore probablymap.unproject([x, y], 2), but this is an inference and must be verified — see task 2. - Image/coat URLs are host-relative to avesmaps.de (
/uploads/wappen/…,/uploads/siedlungen/…), not to this app'spublic/uploads/.
Blocking decision (not resolved)¶
The request was to import features into the map's POI list as editable, persisted entries. That is not viable against the current persistence without a separate decision, for two concrete reasons:
projects.server.tsreads and rewrites all ofdata/projects.jsonon every single POI mutation. Folding tens of thousands of features intomap.poismakes every marker edit a multi-megabyte rewrite.PointOfInterestinapp/lib/poi.tsmodels a singlepositiononly. ALineStringroute has no representation in it at all.
Tasks 1–4 below are deliberately scoped so they do not depend on how this is answered. See the open questions in task 1.
1. Split the Aves dataset into typed shards with a taxonomy manifest¶
Goal: The dataset is available as a set of small files that can be read individually, instead of one document that must be parsed whole.
Scope
- In: A repeatable ingest step that reads
data/avesmaps.map-features.jsonand emits shards plus a manifest describing the taxonomy and per-shard counts. - In: Shards are split so that a single
feature_subtypecan be loaded without touching the rest. - In:
source_catalog,feature_sourcesandin_settlement_placesare emitted as their own files, unchanged in meaning. - Out: Any rendering, filtering or UI. Nothing in
app/changes. - Out: Coordinate conversion — shards keep source coordinates verbatim (task 2).
- Out: Writing anything into
data/projects.json.
Acceptance criteria
- A documented command regenerates every derived file from the source file,
and is idempotent — running it twice produces byte-identical output.
pnpm ingest:avesmapsrunsscripts/ingest-avesmaps-data.ts; it clears and rewritesdata/avesmaps/on every run and emits no timestamps or other non-deterministic fields. - No emitted shard exceeds 5 MB.
Each subtype's features are chunked to a 3.5 MB target and every written
file's actual size is checked against a 5 MB hard limit, throwing if
exceeded.
feature_sources(~8 MB unsplit) also gets this treatment — chunked by dict entry intofeature-sources.1/2/3.json— since it hit the limit in practice;source_catalogandin_settlement_placesuse the same chunking helper but fit in one file each. - A manifest file lists every
feature_type, everyfeature_subtypeunder it, a human-readable label, the feature count, and the shard path. Seedata/avesmaps/manifest.json→featureTypes[].subtypes[]. - Manifest counts sum to the total feature count of the source file, and the
feature_typevalues in the manifest are exactlylocation,crossing,junction,path,label,powerline— six, not the four assumed before inspection (see the taxonomy note above). The script throws if any feature has afeature_typeoutside that set, or if the manifest total doesn't match the source feature count. - Every feature in every shard retains its original
id/public_id, sofeature_sourceslookups still resolve. Features are grouped and written unchanged, no field stripping. - Whether the source file stays in the repo or is treated as an input
artifact is decided and written down.
Decided:
data/is already entirely git-ignored (see.gitignore), so both the source file and everything under the newdata/avesmaps/directory are generated/local artifacts, not committed — consistent withpublic/tiles/andbuild/client/tiles/. No.gitignorechange was needed.
Affected areas
data/avesmaps.map-features.json— the input.package.json— the ingest command lives alongsidebuild/typecheck.app/lib/*.server.ts— not touched by this task; per its own scope, task 1 makes noapp/changes. A server-only reader for the shards is for whichever task first needs to load them (task 2).
Open questions
Should the derived shards be committed, or generated duringResolved above —pnpm buildand git-ignored likebuild/client/tiles/?data/is already git-ignored wholesale, so this didn't need a build-step decision.- Does the answer to the blocking decision above (editable POIs vs. read-only reference layer) change what the shards need to contain? If features become editable, the shards are a seed and need a stable import key; if not, they are the runtime source of truth.
2. Render Aves point features on the map, with the coordinate transform verified¶
Goal: Opening a map backed by the avesmaps tile set shows the dataset's
settlements sitting on the correct spots on the tiles.
Scope
- In: Point features only (
location,crossing,junction). - In: Establishing and verifying the transform from dataset coordinates to
Leaflet
CRS.Simplepositions. - In: Viewport culling — only features intersecting the current view are
rendered — and a per-subtype minimum zoom so dense classes such as
dorfandcrossingdo not render when zoomed out. - Out: Filter UI (task 3), LineStrings (task 4), detail panel (task 5), clustering (task 6).
- Out: Editing, dragging, deleting dataset features.
- Out: Maps backed by
kind: "upload"sources or other tile sets.
Acceptance criteria
- Gareth (source coordinates
551.625, 532.969) renders on the Gareth settlement drawn in the tile imagery, at every zoom level fromminZoomtomaxZoom, with no drift when zooming. - At least two further settlements far apart on the map are checked the same way and land correctly, ruling out a coincidental fit.
- The transform and the evidence for it are written down; if the 1024×1024 /
z=2inference is wrong, the actual basis is recorded instead. - Dataset features are visually distinguishable from user-created POIs.
- Panning and zooming across the whole map stays responsive; the number of markers in the DOM stays bounded regardless of how far out you zoom.
- Existing user POIs, POI placement, and POI editing behave exactly as before on the same map.
- A map whose source is an upload, or a tile set other than
avesmaps, is unaffected and shows no dataset features.
Affected areas
app/lib/tile-sets.ts— theavesmapsgrid tile set the dataset is aligned to; the association between a tile set and its dataset belongs near here. AddeddatasetId?: string, set to"avesmaps"on that entry.app/lib/avesmaps-dataset.ts— new shared module: theAvesmapsPointFeaturetype and the per-feature_subtypeminimum-zoom table.app/lib/avesmaps-dataset.server.ts— new server module reading the task 1 shards for thelocation,crossingandjunctionfeature types.app/components/map.tsx— newAvesmapsFeatureLayer, rendered besidePoiLayerfor grid tile sets that carry adatasetId; culls to the current viewport bounds plus a per-subtype minimum zoom.app/routes/project-map.tsx— the loader now returnsdatasetFeatures(empty unless the map's tile set hasdatasetId === "avesmaps").app/lib/poi.ts— untouched; the dataset layer reusesUNKNOWN_POI_TYPE.colorfor a muted fill so it reads as "reference data", not a user POI.
The transform, verified
map.unproject([x, tileSize * 2**minZoom - y], minZoom), i.e. for the
avesmaps tile set (tileSize: 256, minZoom: 2): lng = x / 4,
lat = y / 4 - 256.
This is the 1024×1024 / z=2 basis from the note above, plus one correction:
dataset y turns out to increase northward (mathematical convention, origin
at the south edge), not downward from the top as guessed — so on top of
CRS.Simple's own lat = -y/scale negation, y must first be flipped within
the 1024px-tall native grid (y' = 1024 - y) before unprojecting.
Evidence (no other source file in this workspace fixes the coordinate space, so this was checked against two independent things):
- The avesmaps.de source itself.
js/config.jsin valentin-schwind/avesmaps definesTILE_SIZE = 256,IMG_WIDTH = IMG_HEIGHT = 1024, confirming the 1024×1024 basis.js/app/bootstrap.jsseeds its default view fromconst AVESMAPS_DEFAULT_MAP_CENTER = [497.28, 520.5]; // [lat, lng] = [y, x]— i.e. their own map assigns datasetytolatandxtolngdirectly, with their ownL.map({ crs: L.CRS.Simple, minZoom: 0, bounds: [[0,0],[1024,1024]] }). Combined withCRS.Simple's built-inlat = -y/scale, matching their direct (unnegated) assignment requires the extra flip captured above. - Five named settlements already pinned by hand in this app's own
data/projects.json(projectaventurien, mapKontinent-Karte), compared against the same names' dataset coordinates:
| Settlement | dataset x, y |
transform gives lat, lng |
actual pin lat, lng |
|---|---|---|---|
| Gareth | 551.625, 532.969 | -122.758, 137.906 | -122.747, 137.896 |
| Perricum | 631.28485, 507.28485 | -129.179, 157.821 | -129.170, 157.808 |
| Trallop | 591.07812, 662.84375 | -90.289, 147.770 | -90.248, 147.787 |
| Greifenfurt | 507.52209, 584.85355 | -109.787, 126.881 | -109.696, 126.953 |
| Wehrheim | 558.5, 572.74307 | -112.814, 139.625 | -112.734, 139.619 |
All five land within ~0.1 map units of the hand-placed pin (consistent with the imprecision of clicking a point on screen), across settlements spread across the whole map — ruling out a coincidental fit for any single one.
Open questions
Confirmation of the coordinate space is pending an answer from avesmaps.de.Resolved above via the avesmaps.de source and the five-pin cross-check.IsNo — resolved above: datasetymeasured downward from the top (SVG convention)?yincreases northward, and the required flip is1024 - y, not a no-op.
3. Filter Aves features by subtype¶
Goal: A user can choose which classes of dataset feature are on the map.
Scope
- In: A control listing every
feature_subtype, grouped under itsfeature_typeheader, driven by the manifest from task 1 — not a hardcoded list. - In: Per-subtype toggles, plus toggling a whole group at once.
- In: The selection survives a page reload for that map.
- Out: Reordering layers, opacity, or per-layer styling.
- Out: Filtering user-created POIs.
- Out: Free-text search.
Acceptance criteria
- Every subtype present in the manifest appears exactly once in the control,
under its
feature_typegroup, with its feature count shown. - Turning a subtype off removes exactly those features and leaves all others on the map.
- Toggling a group toggles every subtype under it.
- Reloading the page restores the previous selection for that map; a different map is unaffected.
- A subtype that is off is never fetched or parsed on the client.
- Toggling a subtype does not reset the map's pan or zoom.
- The control is reachable in view mode, not only edit mode.
Affected areas
app/lib/avesmaps-dataset.ts— addedAvesmapsManifest/AvesmapsManifestFeatureType/AvesmapsManifestSubtypeandgetAvesmapsLayerKey(featureType, subtype). The key is"<feature_type>:<feature_subtype>", not the bare subtype: the manifest'sjunctionfeature_type reuses thecrossingfeature_subtype for a different (and much larger) set of features, so the two need to be toggled and fetched independently.app/lib/avesmaps-dataset.server.ts— addedgetAvesmapsManifest()(a point-feature-only,shards-stripped view for the client) andgetAvesmapsPointFeaturesByLayerKeys(layerKeys). The previously-exportedgetAvesmapsPointFeatures()(task 2) is now a privateloadAllAvesmapsPointFeatures()— the full parsed set stays cached server-side, but only the requested layers are ever put on the wire.app/routes/avesmaps-features.tsx— new resource route (GET /avesmaps/features?layers=<key>,<key>,…) the client fetches from whenever the enabled layer set changes. Registered inapp/routes.ts.app/routes/project-map.tsx— the loader now returnsdatasetManifestinstead of the full feature list. The component owns the enabled-layers state (useAvesmapsLayerSelection) and re-fetcher.load()s/avesmaps/featureswhen it changes;datasetFetcher.data.features(not a loader field) is what reaches<Map>. The layer control renders as a floating panel over the map, positioned outside themode === "edit"sidebar so it shows in view mode too.app/components/avesmaps-layer-control.tsx— new:useAvesmapsLayerSelection(the localStorage-backed on/off state, keyedavesmaps.layers.<mapId>) andAvesmapsLayerControl(the grouped-checkbox panel UI).app/components/map.tsx— unchanged by this task;AvesmapsFeatureLayeralready just renders whateverfeaturesprop it's given (task 2), so server-side layer filtering "just works" underneath it.
Decisions
- Persistence is
localStorage, keyedavesmaps.layers.<mapId>(resolves the open question below): sufficient for now, doesn't growdata/projects.json, and doesn't need to be shareable. - The control lists
location,crossingandjunctiononly (task 2's scope) — notpath,labelorpowerline, which nothing renders yet. Task 4 extendsgetAvesmapsManifest()'s filter to includepathonce route rendering exists, which is what "join the same filter taxonomy" refers to. - "Off" is stored as the disabled set, not the enabled set, so a manifest that gains a new subtype later defaults it to visible instead of hidden.
Open questions
Where does the selection persist — URL search params,Resolved above:localStorage, or the map record indata/projects.json?localStorage.
4. Render Aves route features¶
Goal: Roads, paths, river routes and sea routes are drawn on the map.
Scope
- In:
LineStringfeatures (feature_type: "path") rendered as polylines. - In: Route subtypes join the same filter taxonomy from task 3.
- In: The same viewport culling and per-subtype zoom thresholds as task 2.
- Out: Route editing, drawing new routes, snapping to crossings/junctions.
- Out: Pathfinding, and any use of
transport_domain/allowed_transportsbeyond storing them. - Out: Connecting routes to the
crossing/junctionpoints as a graph.
Acceptance criteria
- Route features render as lines aligned to the roads and rivers in the tile imagery, using the same verified transform as task 2.
- Route subtypes (
Reichsstrasse,Seeweg,Flussweg,Gebirgspass,Weg,Pfad, …) are individually toggleable via the task 3 control. - Route subtypes are visually distinguishable from one another.
- Panning across a dense region with all route subtypes enabled stays responsive.
- Clicking a route does not interfere with POI placement in edit mode.
Affected areas
app/lib/avesmaps-dataset.ts— addedAvesmapsRouteFeature, extended the per-subtype minimum-zoom table with the 8pathsubtypes (no key collision with the point subtypes), and addedgetAvesmapsPathColor(subtype).app/lib/avesmaps-dataset.server.ts— addedloadAllAvesmapsRouteFeatures()/getAvesmapsRouteFeaturesByLayerKeys(), reading the task 1pathshards and keeping theirLineStringcoordinatesverbatim aspoints.getAvesmapsManifest()now also includes thepathfeature type (was point-only through task 3) — the decision task 3 flagged as pending this task.app/routes/avesmaps-features.tsx— the resource route now returnsroutesalongsidefeatures, filtered by the samelayersparam.app/components/map.tsx— newAvesmapsRouteLayer: same transform, viewport culling and per-subtype minimum zoom asAvesmapsFeatureLayer, but culls each route by whether its bounding box overlaps the viewport (not a single point), and renders<Polyline interactive={false}>so a route never intercepts a click meant for POI placement or deselection.app/routes/project-map.tsx— passesdatasetFetcher.data.routesthrough to<Map>asdatasetRoutes.app/lib/poi.ts— untouched; line geometry has no representation there and didn't need one —AvesmapsRouteFeature(a dataset-only, read-only type) lives inavesmaps-dataset.tsinstead.
Open questions
- None.
5. Aves feature detail panel¶
Goal: Clicking a dataset feature shows what the source knows about it.
Scope
- In: A panel showing name, class, the
wiki_settlementfields, thepolitical.hierarchychain, the coat of arms, theimages[]gallery, and the source references resolved throughfeature_sources→source_catalog. - In: Handling the non-uniform property shape — a synthesized crossing has almost none of these fields.
- In: Deciding and implementing how the host-relative
/uploads/…image URLs are resolved. - Out: Editing any of this data.
- Out:
in_settlement_places(no geometry; needs its own decision).
Acceptance criteria
- Clicking Gareth shows its population, ruler, region, description, wiki link, coat of arms, image gallery, and its four-level political hierarchy.
- Clicking a synthesized crossing shows a panel with no empty sections and no broken image placeholders.
- Source references render with the source label and page numbers, and link
out where
source_catalogprovides a URL. - External URLs in the data cannot inject markup or script into the panel,
and non-
http(s)URLs are not rendered as links. - Images do not block the panel from rendering, and a missing image does not break the layout.
- The panel does not conflict with the existing POI detail surface in
project-map.tsx.
Affected areas
app/lib/avesmaps-dataset.ts— addedAvesmapsFeatureDetail,AvesmapsPoliticalEntity,AvesmapsFeatureSourceReference,resolveAvesmapsAssetUrl()andisSafeHttpUrl().app/lib/avesmaps-dataset.server.ts—loadAllAvesmapsPointFeatures()(task 2) now also fills aMap<id, rawFeature>cache, so the full non-uniform properties are available without a second file scan. AddedgetAvesmapsFeatureDetail(id), plus private readers forfeature-sources.*.json/source-catalog.json(paths come from the manifest'sauxiliaryblock, not hardcoded) and agetAvesmapsFeatureSourceshelper that looks features up assettlement:<id>— crossings/junctions resolve to[], which the panel renders as no "Sources" section.app/routes/avesmaps-feature-detail.tsx— new resource route (GET /avesmaps/features/:id), 404s for an unknown id. Registered inapp/routes.ts.app/components/avesmaps-feature-detail-panel.tsx— newAvesmapsFeatureDetailPanel; every section (settlement fields, political hierarchy, images, sources) is conditionally rendered only when it has data. ASafeImagewrapper hides itself ononErrorinstead of showing a broken image icon.app/components/map.tsx—AvesmapsFeatureLayer'sCircleMarkers now take anonSelectRequest, mirroring howPoiMarkeralready does it.app/routes/project-map.tsx— newselectedDatasetFeatureIdstate and auseFetcherthat loads the detail route when it changes. Selecting a dataset feature clears the selected POI and vice versa (mutual exclusion, not two panels stacking), and only does anything in view mode — dataset features aren't editable, so there's nothing to show them for in edit mode.
Decisions
- Image/coat URLs are hotlinked directly as
https://avesmaps.de<path>(resolveAvesmapsAssetUrl), not mirrored into this app'spublic/uploads/. This resolves the open question below: mirroring a third party's asset library is out of scope for a read-only reference layer, and the acceptance criteria already require handling a missing/blocked image gracefully, whichSafeImage'sonErrorhandles regardless of why the image failed to load. - XSS/link-injection defense is two-layered:
isSafeHttpUrlrejects any URL whose scheme isn'thttp/httpsbefore it's ever put in the detail JSON, and the panel only ever puts a URL in anhrefattribute — it never usesdangerouslySetInnerHTML, so React's normal text escaping covers names, descriptions and source notes (which can contain wiki-style[[...]]markup) automatically.
Open questions
Are the avesmaps.de image URLs publicly fetchable and are we permitted to hotlink them, or must they be mirrored intoResolved above: hotlinked, with graceful failure if that turns out to be wrong.public/uploads/?- What is
in_settlement_placesfor — a future submap feature, or a list to show inside a settlement's detail panel? Still open; out of this task's scope.
6. Cluster dense point features¶
Goal: Zoomed-out views of dense regions stay readable and fast.
Scope
- In: Clustering for point features, replacing or complementing the per-subtype zoom thresholds from task 2.
- Out: Clustering routes.
- Out: Clustering user-created POIs.
Acceptance criteria
- With every point subtype enabled, the map is interactive at all zoom levels and marker count in the DOM stays bounded.
- A cluster shows how many features it contains and expands on click.
- Clusters respect the active subtype filter — a disabled subtype is not counted.
- Any new dependency is added with
pnpmand is client-only, consistent with how Leaflet is lazy-loaded.
Affected areas
app/components/map.tsx— the dataset point layer.package.json— no clustering dependency exists today.
Open questions
- None.
Recommended order¶
Task 1 first. Nothing else can be built while the dataset is a single
1.2M-line document — task 2 needs shards to load, task 3 needs the manifest to
build its control from, and task 5 needs feature_sources addressable on its
own. It is also the only task that changes no application code, so it can land
without touching the map.
Then 2 (proves the transform — everything visual is wrong until this is right), 3, 4, 5, 6.