Kiekmap for another place or another language¶
| What you want | What it costs |
|---|---|
| Another German-speaking place | configuration only — no fork, no change to the code |
| Another language | one line in the .env — see below |
That is no accident but a decision that runs through the whole project: nothing place-specific
stands in the code. The extent is fetched from region.json at run time, the map file is a build
artefact, the place index comes out of a build script. Whoever changes something here should keep
that property — it is the reason a second museum needs no second branch.
Another place¶
1. Fixing the region¶
One file only: tiles/region.json.
{
"name": "Musterhausen",
"bbox": [minLon, minLat, maxLon, maxLat],
"center": [lon, lat],
"defaultZoom": 14.8,
"minZoom": 13,
"maxZoom": 15,
"streetChoice": 80
}
streetChoice is the number of streets the contribution panel offers as buttons — the ones nearest
to center. The place index may reach further; whatever lies beyond it is tapped on the map. A
count and not a radius, because that keeps the button budget independent of how densely a place
is built up: 80 streets fit into two questions with at most ten buttons each (see
decisions.md, point 24). If the key is missing, 80 applies.
The value is to be checked, not adopted — how, is in step 3.
The file describes a place and nothing else. Which decades the contribution panel offers stood here once — but that belongs to the collection and follows from it now: what is offered is what the collection spans, but at least the 1920s to the 2010s. A museum that later dates a photo to 1890 gets the 1890s button by itself.
On the length of name. It stands as the heading in the header area, and there it adapts to
the column: the longer it is, the smaller it is set, so that it stays on one line. That does
not go on for ever — it does not get smaller than the line "Pictures from" above it, and below
that it wraps. Measured on 16 August 2026:
| Length of the name | On a 1024 screen | On a 1920 one |
|---|---|---|
| up to 12 characters | one line | one line |
| 13 to 16 characters | wraps | one line |
| more | wraps | wraps |
"Holm" has four, "Klein Nordende" has fourteen. A wrap is not an error but the deliberate
fallback — a truncated place name would be worse. Whoever wants to avoid it shortens the name
("Klein Nordende" instead of "Klein Nordende-Lieth"); the full name belongs in the welcome text
rather than in the heading anyway.
Working out the bounding box. From a centre and the radius you want:
python3 -c "
import math
lat, lon, r = 53.62053, 9.67601, 5.0 # centre, and radius in km
dlat = r / 111.320
dlon = r / (111.320 * math.cos(math.radians(lat)))
print([round(x, 5) for x in (lon-dlon, lat-dlat, lon+dlon, lat+dlat)])
"
Choose generously. The bbox also limits how far the map can be dragged; too tight shows up only
at the kiosk, when somebody zooms out. The tiles are built with a 10 % margin anyway.
Determining the zoom levels. Choose defaultZoom so that the centre of the place fills the
picture:
python3 -c "
import math
lat, map_width_px, wanted_km = 53.62, 1500, 3.0
mpp = 156543.03392 * math.cos(math.radians(lat))
for z in (13, 14, 14.5, 15, 15.5, 16):
m = mpp / 2**z
print(f' z={z:<5} {map_width_px*m/1000:5.2f} km wide')
"
On a 1080p display the map area is about 1500 × 920 px. Choose minZoom so that the whole extent
is visible at once. maxZoom stays at 15 — that is the limit of the Protomaps daily build; beyond
it the map is over-zoomed and still stays sharp.
2. Building the map data and the place index¶
make tiles # vector tiles, fonts and icons for the new region
make places # place index from OpenStreetMap
Both need the internet and run on the development machine, not on the Pi. Order of magnitude for a
municipality with a 5 km radius: 4–5 MB of tiles, 14 MB of fonts and icons, some eight thousand
places (places.json about 1.5 MB).
Addresses make up the largest part — for Holm 7686 of 8513 entries. They make the placing
accurate to the house rather than to the street; without them every photo of a street 800 m long
would get the same point. Whoever wants to keep the place index small can comment the two addr:
lines out in tiles/build-places.py; the interface then skips the house-number step by itself.
make tiles also puts region.json under data/ — the backend reads it there and uses it to
check whether a placing from the contribution panel lies in the region at all. Without that file
the guard does not bite (it then lets everything through instead of refusing for no reason).
3. Checking the street choice¶
The contribution panel asks where a photo was taken, and the main way there is buttons: first the initial letter, then the street, then the house number. There is no search field — the visitor view has no input field at all, because no keyboard stands at the kiosk (see decisions.md, point 24). Whether that way holds up is decided by the place index, and that can be looked at before the first visitor.
Where the streets come from. From make places: the script asks the Overpass API once for
everything inside the bbox and writes it into the place index. Whoever skips this step gets no
error — the panel then says "Please tap the spot on the map" and arms the map by itself. That is a
working way out, but a way out: without the place index there is no house number either, and every
photo of a street 800 m long would get the same point.
How to look instead of guessing. One call returns exactly the list the tree gets:
curl -s localhost:8000/api/places/streets | python3 -c "
import json,sys
names = [p['name'] for p in json.load(sys.stdin)]
print(f'{len(names)} streets to choose from:')
print(' ' + ', '.join(names))
"
Whoever looks at it knows three things: whether the centre of the place is completely in, how many
streets from elsewhere come along, and whether streetChoice fits.
How to choose streetChoice. The value decides how many questions it takes to reach a street.
The groups are calculated, not written down: from at most ten buttons per level the tree follows by
itself. For Holm it looks like this:
Streets to choose from (streetChoice 80) |
80 |
| Buttons on the first level | 10 — A B–D E F–G H I K–L M–R S T–Z |
| of those straight to the street list | 7 |
| with one step in between | 3 — A (15), H (11), I (11) |
Two questions as a rule, three in the exception. That is the target. A more densely built place needs a smaller value, a spread-out one takes a larger — and it can be worked out with the same query above: once more than about a hundred streets come together, the third level becomes the rule and the way to the house number gets long.
What can go wrong, both without an error message:
- The
bboxis set too tight. The place index reaches only as far as it does; edge streets are then missing entirely and stand neither in the search nor on a button. - The
bboxis set too wide. Neighbouring villages come along — and becausestreetChoicetakes the nearest, their streets push the local ones out of the choice. In Holm 486 streets lie in the index and only 80 on the buttons; had the extent been twice as large, streets no photo of the collection ever shows would be among them.
Both can be seen in the list above, before anybody stands in front of it. If you expect a street
name and do not find it, the bbox is the first suspicion, not the code.
4. Putting the coat of arms in¶
What ships is a placeholder, not a coat of arms — a plain shield out of
tools/build_logo.py. Why not a real one is in
decisions.md, point 21.
Replace frontend/public/logo.png with your own — same file name,
nothing else. The picture lies over the top left corner of the map and is at the same time the way
into the admin area. Nowhere in the code does it say what is on it; the label for screen readers is
put together from name in the region.json.
Portrait or landscape makes no difference, the picture is fitted into a square of 4.5 rem. About 400 px on a side is sensible; PNG with transparency looks best on the map.
Do not commit the replaced file. It carries the same name as the placeholder, so it turns up as a changed file. On your own device that is right; in a repository somebody can clone, it passes the coat of arms on — see below.
On the law: two questions that often get merged into one¶
Copyright. A municipal coat of arms is an official work under § 5 (1) UrhG and therefore free of copyright. There is nothing to settle from that side.
The law on arms. Independently of that, using a coat of arms is restricted: it is an official emblem, the municipality governs its use, protected through the right to a name (§ 12 BGB) and the rules on official emblems. Wikipedia points this out explicitly on its coat-of-arms pages too.
Two things follow:
- On your own device the coat of arms of your own place is as a rule unproblematic for a local history museum — if in doubt, ask the municipality.
- In a public repository it is another matter. Whoever publishes the repository passes every file in it to everybody who clones it. Permission for your own museum is not permission for third parties, and a note or an attribution changes nothing about that: this is not about credit but about permission.
That is why a placeholder lies in this repository, and the coat of arms stays a local file — like
the .env and the built map.
5. Checking what belongs to the collection¶
In the .env:
KIEKMAP_EXIF_DATE_MAX_YEAR=1990 # from when an EXIF date counts as the date of the scan
KIEKMAP_ADMIN_PIN_HASH=... # PIN for the admin area
# Details that apply to every photo on import. All three are empty by default.
KIEKMAP_IMPORT_TAGS=["Gebäude"] # keywords for every imported photo
KIEKMAP_IMPORT_CREDIT=Sammlung Heimatmuseum Holm # credit where the file names nobody
KIEKMAP_IMPORT_PROVENANCE=Online-Archiv des Museums, Verzeichnis 01 Orte/
Raise exif_date_max_year if the collection also holds genuine digital photographs — otherwise
they lose the date they were taken. Lower it when nothing but scans is expected. Where the file
names its device, that decides anyway: a scanner never dates, a camera always does. The value
applies only to files with no device recorded.
The three IMPORT_ values are the place for what makes a collection. KIEKMAP_IMPORT_TAGS is a
JSON list; in Holm the stock is buildings, elsewhere it is costumes or ships.
KIEKMAP_IMPORT_PROVENANCE is put verbatim in front of the file path in the import folder and
therefore carries its own separator at the end — so the provenance of a photo leads back to the
file in your own archive.
Whether the import reads the folder names need not be set anywhere: a path element counts as a street when the place index knows it. An archive filed by street and house number is placed by itself; one filed differently is simply left alone.
The PIN hash is produced by:
cd backend && .venv/bin/python -m app.cli pin
The PIN itself is stored nowhere. If KIEKMAP_ADMIN_PIN_HASH is empty, the admin area says so in
plain words instead of rejecting every entry.
6. Checking it all together¶
make dev
- Does the map show the right place in the right extent?
- Can the map not be dragged beyond the region?
- Do the buttons in the contribution panel lead to a real street in two or three steps (see step 3)?
- Switch the wifi off and move the map — the labels have to stay visible.
The last point is the most important. Checking it without looking:
performance.getEntriesByType('resource')
.filter(e => !e.name.startsWith(location.origin) && !e.name.startsWith('data:')).length
// has to be 0
What not to do while doing it¶
No change to the code, no fork, no new branch. Whoever finds while adapting that they do have to
change code has found a bug — the value then belongs in region.json or in the .env, not in a
copy of the project.
Cosmetic residues are the exception: comments in the backend name Holm where an example makes the case concrete. That is documentation, not logic.
Another language¶
One setting, and it stands where every setting of the instance stands:
# .env
KIEKMAP_LANGUAGE=en
Then restart the service. No new build is needed — the frontend fetches the language at startup
through GET /api/config. de and en are allowed; any other value aborts the start instead of
falling back to German in silence.
What that switches is everything a person reads at the device: the visitor view, the admin area, the error messages of the API, the import log, the messages of the backup, the date labels and the number format.
What it does not switch is the map underneath. It labels its places in German whatever the setting says, because the label language is a property of the place rather than of the reader — in Holm the street is called Mühlenweg in every language. For a museum outside the German-speaking area that is the wrong answer, and it is open work: issue #33.
Where the texts are¶
Two catalogues, built the same way:
| Interface | Backend | |
|---|---|---|
| Where | frontend/src/text/ |
backend/app/text/ |
| German | de.ts |
de.py |
| English | en.ts |
en.py |
A missing entry breaks the build, not the museum. In the frontend the type of the catalogues is
typeof de, so tsc refuses an incomplete translation. In the backend they are frozen dataclasses,
and a missing entry is a TypeError at startup.
A third language¶
The same construction carries it without rebuilding: one file per catalogue, one more value in
KIEKMAP_LANGUAGE. At three languages a translation service starts to pay off — see
decisions.md.
The language is a setting of the instance, not a choice for visitors. The device stands in a museum and speaks that museum's language. A switch on the touchscreen would be one more thing to operate for visitors who are often elderly, and no relief.
What stays German in every language¶
| Where | What | Why |
|---|---|---|
| Kinds of place | strasse, gebaeude, flur … |
keys from tiles/build-places.py; what is shown is what t.location.kinds makes of them |
| Street and place names | from OpenStreetMap | a proper name is not translated |
| Older entries in the import log | ImportLog.message |
a record of what the device said at the time |
*.de.md and docs/archive/history.de.md |
docs for the museum and for operation | kept as a translation, see development.md |
The kinds of place are the one case that looks like a trap and is not: German keywords stand in the
database, and the translation is what gets shown. en.ts maps the same keys onto "Street",
"Building" and so on.
The import log is the second. It reports what happened during an import, and the sentence was written and stored at the time. New entries follow the language that is set; the old ones stay as they read.
What is fixed in English and does not travel¶
The folders _done and _problem in the inbox, the backup folder kiekmap-backup/ with
everything in it, and the status of /health. The first two are names in the file system: were
they to follow the setting, changing it would have to rename folders — on the device and on every
stick already written. The third is a machine value that the kiosk service reads.
When splitting things up starts to pay off¶
As long as it is one place per installation, the present cut is the right one: one configuration file, two build scripts, done. More structure would only create work nobody needs.
It gets interesting as soon as one of these cases arrives:
- Several places on one device — a district museum with several municipalities, say.
regionwould then need a database reference instead of a file, and photos would have to be assigned to a region. - One shared collection, several kiosks — the backend would then be central and only the frontend configured per site.
- Regular updates to several museums — it would then pay off to take the configuration out of
the repository entirely, so that a
git pullnever overwrites an adaptation.
Until then: a second museum gets a copy of the repository, changes region.json and the .env,
builds tiles and place index, and is done. It pulls updates with git pull — its own adaptations
lie in files that do not collide.