├── data └── .keep ├── .gitignore ├── pyproject.toml ├── README.md ├── scripts └── prepare_jsonl.py ├── uv.lock └── web └── index.html /data/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | 9 | # Virtual environments 10 | .venv 11 | .python-version 12 | E_4* 13 | *.pmtiles 14 | *.mbtiles 15 | *.json 16 | *.csv 17 | *.zip 18 | *.jsonl 19 | *.geojson 20 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "eesti-hooned" 3 | version = "0.1.0" 4 | description = "Scripts and a front-end to browse Estonian buildings data" 5 | readme = "README.md" 6 | requires-python = ">=3.13" 7 | dependencies = [ 8 | "fiona>=1.10.1", 9 | "pyproj>=3.7.1", 10 | ] 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Estonia Building Age Map 2 | 3 | [Browse it here](https://osmz.ee/hooned/). 4 | 5 | Where to get the data: 6 | 7 | * [ETAK](https://geoportaal.maaamet.ee/est/ruumiandmed/eesti-topograafia-andmekogu/laadi-etak-andmed-alla-p609.html): 8 | open Geoportal / Ruumiandmed, Eesti topograafia andmekogu, Laadi ETAK andmed alla. 9 | Choose "Ehitised" as Esri SHP. 10 | * [EHR](https://livekluster.ehr.ee/ui/ehr/v1/infoportal/reports): 11 | open E-ehitus, choose Infoportaal ja avaandmed, Ehitisregistri avaandmed, Aruanded. 12 | There open "Ehitised", put in your e-mail and choose "CSV" for the file format. Click "Saadan tellimuse" 13 | and wait a couple minutes for an email. 14 | 15 | Unpack the zip file into the `data` directory and rename the file to `ehr-YYMMDD.csv` (with the current date). 16 | Put the ETAK zip there as well (do not unpack, you will regret it). Then run: 17 | 18 | uv run scripts/prepare_jsonl.py > hooned.jsonl 19 | 20 | Then [install Tippecanoe](https://github.com/felt/tippecanoe?tab=readme-ov-file#installation) and prepare the 21 | vector tiles: 22 | 23 | tippecanoe -z13 --drop-densest-as-needed --simplify-only-low-zooms -l hooned -S 6 -M 300000 --generate-ids -o web/hooned.pmtiles -f hooned.jsonl 24 | 25 | Finally, upload the contents of the `web` directory somewhere. 26 | 27 | ## Why the dates are wrong? 28 | 29 | We are using the "Esmase kasutuselevõtu aasta" field from the building register, which is not exactly the date it was first built. 30 | The hint can be translated as _"The year the building was completed or the year its first notice of use or occupancy permit 31 | was entered into the building register. The year of the building's initial commissioning is presumptive if it is derived 32 | from indirect sources, such as historical orthophotos and maps."_ So, it can be incorrect. 33 | Some examples we've found: 34 | 35 | * [Pihlaka 12i, Tallinn](https://livekluster.ehr.ee/ui/ehr/v1/building/101018040) is marked 1999, but before that it was a school building 36 | finished somewhere in 1950s. In 1999 it was converted to a social house. 37 | * [Tuukri 1b, Tallinn](https://livekluster.ehr.ee/ui/ehr/v1/building/120796837) is marked 1972, but it was completely raised after that, 38 | rebuilt from scratch in 2020. There is a [service building](https://livekluster.ehr.ee/ui/ehr/v1/building/220795174) from 2020 39 | at that place, but it's not split into visible buildings. 40 | * [Narva Castle](https://livekluster.ehr.ee/ui/ehr/v1/building/118008962) is marked 1986, despite being built way, way before that (around 1256). 41 | Same for buildings around it. 42 | * [Tallinna Raekoda](https://livekluster.ehr.ee/ui/ehr/v1/building/101036399) and many other buildings on the old town 43 | don't have dates at all. 44 | 45 | ## Acknowledgments 46 | 47 | This code is published under ISC licence by its author, Ilja Zverev. 48 | 49 | Big thank you goes to Maa- ja Ruumiamet, whose geodata packages are over-comprehensive, containing links 50 | to every register imaginable. And for managing all those registers. And publishing them as open data. 51 | 52 | Based on the [2017 map](http://eilat.ee/2017-01-09-tallinna-hoonete-vanus/) by Toomas Eilat. 53 | 54 | For learning about future developments, I recommend [Citify](https://citify.eu/). 55 | -------------------------------------------------------------------------------- /scripts/prepare_jsonl.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import csv 3 | import json 4 | import fiona 5 | import os 6 | import re 7 | import sys 8 | import logging 9 | from pyproj import CRS, Transformer 10 | from collections import namedtuple 11 | from typing import Any 12 | 13 | 14 | # orig_id is the dict key 15 | EhrEntry = namedtuple('EhrEntry', ['nimetus', 'addr', 'year']) 16 | 17 | 18 | def choose_one(options: list[str], path: str) -> str: 19 | if not options: 20 | logging.error('Missing data files') 21 | return '' 22 | 23 | if len(options) == 1: 24 | return os.path.join(path, options[0]) 25 | 26 | for i, fn in enumerate(options, 1): 27 | print(f'{i}. {fn}') 28 | decision = input('Which one: ').strip() 29 | if re.match(r'^\d+$', decision): 30 | d = int(decision) 31 | if d >= 1 and d <= len(options): 32 | return os.path.join(path, options[d-1]) 33 | 34 | return '' 35 | 36 | 37 | def choose_sources() -> tuple[str, str]: 38 | path = os.path.join(os.path.dirname(__file__), '..', 'data') 39 | files = os.listdir(path) 40 | 41 | ehr_files = [f for f in files if re.match(r'ehr.*\.csv', f)] 42 | ehr = choose_one(ehr_files, path) 43 | 44 | etak_files = [f for f in files if re.match(r'ETAK.*\.zip', f)] 45 | etak = choose_one(etak_files, path) 46 | 47 | return ehr, etak 48 | 49 | 50 | if __name__ == '__main__': 51 | logging.basicConfig( 52 | level=logging.INFO, format='%(asctime)s %(message)s', 53 | datefmt='%H:%M:%S') 54 | ehr_file, etak_zip = choose_sources() 55 | if not etak_zip or not ehr_file: 56 | sys.exit(1) 57 | 58 | logging.info('Reading %s', ehr_file) 59 | ehr: dict[int, EhrEntry] = {} 60 | with open(ehr_file, 'r') as f: 61 | r = csv.DictReader(f, delimiter=';') 62 | for line in r: 63 | code = line['ehr_kood'].strip() 64 | if not code or code[0] != '1': 65 | continue 66 | 67 | # If no date, we have no use for it. 68 | # kav_kasutus_kp is a weird thing from old document, 69 | # an intended opening date. 70 | year = line['esmane_kasutus'] # or line['kav_kasutus_kp'] 71 | if not year: 72 | continue 73 | if len(year) > 4: 74 | year = year[:4] 75 | if not re.match(r'^\d{4}$', year): 76 | continue 77 | 78 | ehr[int(code)] = EhrEntry( 79 | nimetus=line['nimetus'] or line['ehitise_tyyp'], 80 | addr=line['taisaadress'], 81 | year=year, 82 | ) 83 | logging.info('Done, read %s records', len(ehr)) 84 | 85 | count = 0 86 | logging.info('Iterating over %s', etak_zip) 87 | with fiona.open('/E_401_hoone_ka.shp', vfs=f'zip://{etak_zip}') as shp: 88 | p_in = CRS.from_proj4(shp.crs.to_proj4()) 89 | p_out = CRS.from_epsg(4326) 90 | transformer = Transformer.from_crs(p_in, p_out) 91 | for hoone in shp: 92 | if hoone['geometry']['type'] != 'Polygon': 93 | continue 94 | 95 | # Find the relevant EHR entry, skip if not found 96 | ehr_gid = hoone['properties'].get('ehr_gid') 97 | if not ehr_gid or not re.match(r'^\d+$', str(ehr_gid)): 98 | continue 99 | ehr_entry = ehr.get(int(ehr_gid)) 100 | if not ehr_entry: 101 | continue 102 | 103 | try: 104 | coords = [] 105 | for ring in hoone['geometry']['coordinates']: 106 | coord = list(zip(*ring)) 107 | x2, y2 = transformer.transform(coord[0], coord[1]) 108 | coords.append(list(zip(y2, x2))) 109 | props = hoone['properties'] 110 | 111 | data: dict[str, Any] = { 112 | 'type': 'Feature', 113 | 'geometry': { 114 | 'type': 'Polygon', 115 | 'coordinates': coords, 116 | }, 117 | 'properties': { 118 | 'type': ehr_entry.nimetus, 119 | 'addr': ehr_entry.addr, 120 | 'year': int(ehr_entry.year), 121 | }, 122 | } 123 | height = props.get('korgus_m') 124 | if height: 125 | data['properties']['height'] = int(height) 126 | print(json.dumps(data, ensure_ascii=False)) 127 | count += 1 128 | except Exception as e: 129 | logging.exception('Failed to write data: %s', e) 130 | break 131 | logging.info('All done, written %s records.', count) 132 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 2 3 | requires-python = ">=3.13" 4 | 5 | [[package]] 6 | name = "attrs" 7 | version = "25.3.0" 8 | source = { registry = "https://pypi.org/simple" } 9 | sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } 10 | wheels = [ 11 | { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, 12 | ] 13 | 14 | [[package]] 15 | name = "certifi" 16 | version = "2025.8.3" 17 | source = { registry = "https://pypi.org/simple" } 18 | sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } 19 | wheels = [ 20 | { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, 21 | ] 22 | 23 | [[package]] 24 | name = "click" 25 | version = "8.2.1" 26 | source = { registry = "https://pypi.org/simple" } 27 | dependencies = [ 28 | { name = "colorama", marker = "sys_platform == 'win32'" }, 29 | ] 30 | sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } 31 | wheels = [ 32 | { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, 33 | ] 34 | 35 | [[package]] 36 | name = "click-plugins" 37 | version = "1.1.1.2" 38 | source = { registry = "https://pypi.org/simple" } 39 | dependencies = [ 40 | { name = "click" }, 41 | ] 42 | sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" } 43 | wheels = [ 44 | { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" }, 45 | ] 46 | 47 | [[package]] 48 | name = "cligj" 49 | version = "0.7.2" 50 | source = { registry = "https://pypi.org/simple" } 51 | dependencies = [ 52 | { name = "click" }, 53 | ] 54 | sdist = { url = "https://files.pythonhosted.org/packages/ea/0d/837dbd5d8430fd0f01ed72c4cfb2f548180f4c68c635df84ce87956cff32/cligj-0.7.2.tar.gz", hash = "sha256:a4bc13d623356b373c2c27c53dbd9c68cae5d526270bfa71f6c6fa69669c6b27", size = 9803, upload-time = "2021-05-28T21:23:27.935Z" } 55 | wheels = [ 56 | { url = "https://files.pythonhosted.org/packages/73/86/43fa9f15c5b9fb6e82620428827cd3c284aa933431405d1bcf5231ae3d3e/cligj-0.7.2-py3-none-any.whl", hash = "sha256:c1ca117dbce1fe20a5809dc96f01e1c2840f6dcc939b3ddbb1111bf330ba82df", size = 7069, upload-time = "2021-05-28T21:23:26.877Z" }, 57 | ] 58 | 59 | [[package]] 60 | name = "colorama" 61 | version = "0.4.6" 62 | source = { registry = "https://pypi.org/simple" } 63 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } 64 | wheels = [ 65 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, 66 | ] 67 | 68 | [[package]] 69 | name = "eesti-hooned" 70 | version = "0.1.0" 71 | source = { virtual = "." } 72 | dependencies = [ 73 | { name = "fiona" }, 74 | { name = "pyproj" }, 75 | ] 76 | 77 | [package.metadata] 78 | requires-dist = [ 79 | { name = "fiona", specifier = ">=1.10.1" }, 80 | { name = "pyproj", specifier = ">=3.7.1" }, 81 | ] 82 | 83 | [[package]] 84 | name = "fiona" 85 | version = "1.10.1" 86 | source = { registry = "https://pypi.org/simple" } 87 | dependencies = [ 88 | { name = "attrs" }, 89 | { name = "certifi" }, 90 | { name = "click" }, 91 | { name = "click-plugins" }, 92 | { name = "cligj" }, 93 | ] 94 | sdist = { url = "https://files.pythonhosted.org/packages/51/e0/71b63839cc609e1d62cea2fc9774aa605ece7ea78af823ff7a8f1c560e72/fiona-1.10.1.tar.gz", hash = "sha256:b00ae357669460c6491caba29c2022ff0acfcbde86a95361ea8ff5cd14a86b68", size = 444606, upload-time = "2024-09-16T20:15:47.074Z" } 95 | wheels = [ 96 | { url = "https://files.pythonhosted.org/packages/c5/e0/665ce969cab6339c19527318534236e5e4184ee03b38cd474497ebd22f4d/fiona-1.10.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:a00b05935c9900678b2ca660026b39efc4e4b916983915d595964eb381763ae7", size = 16106571, upload-time = "2024-09-16T20:15:04.198Z" }, 97 | { url = "https://files.pythonhosted.org/packages/23/c8/150094fbc4220d22217f480cc67b6ee4c2f4324b4b58cd25527cd5905937/fiona-1.10.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f78b781d5bcbbeeddf1d52712f33458775dbb9fd1b2a39882c83618348dd730f", size = 14738178, upload-time = "2024-09-16T20:15:06.848Z" }, 98 | { url = "https://files.pythonhosted.org/packages/20/83/63da54032c0c03d4921b854111e33d3a1dadec5d2b7e741fba6c8c6486a6/fiona-1.10.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29ceeb38e3cd30d91d68858d0817a1bb0c4f96340d334db4b16a99edb0902d35", size = 17221414, upload-time = "2024-09-16T20:15:09.606Z" }, 99 | { url = "https://files.pythonhosted.org/packages/60/14/5ef47002ef19bd5cfbc7a74b21c30ef83f22beb80609314ce0328989ceda/fiona-1.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:15751c90e29cee1e01fcfedf42ab85987e32f0b593cf98d88ed52199ef5ca623", size = 24461486, upload-time = "2024-09-16T20:15:13.399Z" }, 100 | ] 101 | 102 | [[package]] 103 | name = "pyproj" 104 | version = "3.7.1" 105 | source = { registry = "https://pypi.org/simple" } 106 | dependencies = [ 107 | { name = "certifi" }, 108 | ] 109 | sdist = { url = "https://files.pythonhosted.org/packages/67/10/a8480ea27ea4bbe896c168808854d00f2a9b49f95c0319ddcbba693c8a90/pyproj-3.7.1.tar.gz", hash = "sha256:60d72facd7b6b79853f19744779abcd3f804c4e0d4fa8815469db20c9f640a47", size = 226339, upload-time = "2025-02-16T04:28:46.621Z" } 110 | wheels = [ 111 | { url = "https://files.pythonhosted.org/packages/ef/01/984828464c9960036c602753fc0f21f24f0aa9043c18fa3f2f2b66a86340/pyproj-3.7.1-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:5f8d02ef4431dee414d1753d13fa82a21a2f61494737b5f642ea668d76164d6d", size = 6253062, upload-time = "2025-02-16T04:28:27.861Z" }, 112 | { url = "https://files.pythonhosted.org/packages/68/65/6ecdcdc829811a2c160cdfe2f068a009fc572fd4349664f758ccb0853a7c/pyproj-3.7.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:0b853ae99bda66cbe24b4ccfe26d70601d84375940a47f553413d9df570065e0", size = 4660548, upload-time = "2025-02-16T04:28:29.526Z" }, 113 | { url = "https://files.pythonhosted.org/packages/67/da/dda94c4490803679230ba4c17a12f151b307a0d58e8110820405ca2d98db/pyproj-3.7.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83db380c52087f9e9bdd8a527943b2e7324f275881125e39475c4f9277bdeec4", size = 9662464, upload-time = "2025-02-16T04:28:31.437Z" }, 114 | { url = "https://files.pythonhosted.org/packages/6f/57/f61b7d22c91ae1d12ee00ac4c0038714e774ebcd851b9133e5f4f930dd40/pyproj-3.7.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b35ed213892e211a3ce2bea002aa1183e1a2a9b79e51bb3c6b15549a831ae528", size = 9497461, upload-time = "2025-02-16T04:28:33.848Z" }, 115 | { url = "https://files.pythonhosted.org/packages/b7/f6/932128236f79d2ac7d39fe1a19667fdf7155d9a81d31fb9472a7a497790f/pyproj-3.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a8b15b0463d1303bab113d1a6af2860a0d79013c3a66fcc5475ce26ef717fd4f", size = 10708869, upload-time = "2025-02-16T04:28:37.34Z" }, 116 | { url = "https://files.pythonhosted.org/packages/1d/0d/07ac7712994454a254c383c0d08aff9916a2851e6512d59da8dc369b1b02/pyproj-3.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:87229e42b75e89f4dad6459200f92988c5998dfb093c7c631fb48524c86cd5dc", size = 10729260, upload-time = "2025-02-16T04:28:40.639Z" }, 117 | { url = "https://files.pythonhosted.org/packages/b0/d0/9c604bc72c37ba69b867b6df724d6a5af6789e8c375022c952f65b2af558/pyproj-3.7.1-cp313-cp313-win32.whl", hash = "sha256:d666c3a3faaf3b1d7fc4a544059c4eab9d06f84a604b070b7aa2f318e227798e", size = 5855462, upload-time = "2025-02-16T04:28:42.827Z" }, 118 | { url = "https://files.pythonhosted.org/packages/98/df/68a2b7f5fb6400c64aad82d72bcc4bc531775e62eedff993a77c780defd0/pyproj-3.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:d3caac7473be22b6d6e102dde6c46de73b96bc98334e577dfaee9886f102ea2e", size = 6266573, upload-time = "2025-02-16T04:28:44.727Z" }, 119 | ] 120 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |